# Reading position: the last place read wins Since 2026-10-12 the place to continue a book is **where it was last read, on any device**, not the furthest place ever reached. The furthest place is still kept, as a hint ("Weiteste Stelle"). This file is the contract between the server (books/views.py `save_progress`), the web reader (static/js/app.js), diora-android (Ops.kt / SyncEngine.kt / native-*.js) and diora-desktop (src/main/ops.js / sync.js). They must apply the same rules. ## The state per book | field | meaning | |---|---| | `scroll_fraction`, `position_anchor` | the continue point (anchor `"block:inner"`, empty for PDFs) | | `read_at` | when the reader was **at** that place, ms since epoch, **server clock** | | `device` | who read there (`"Browser"`, `"diora Desktop"`, `"Handy"`, `"tolino"` …), for "auf X weitergelesen" | | `furthest_fraction`, `furthest_anchor` | the furthest place ever reported (only ever grows) | `last_read` in `/books/` is `read_at` as ISO time (it used to be the time of the last save attempt). ## Writing: `POST /books//progress/` ```json {"scroll_fraction": 0.42, "position_anchor": "187:0.62", "read_at": 1760270000000, "sent_at": 1760270004000, "device": "diora Desktop"} ``` * `read_at` is **the moment the reader arrived at this position** (the last time the visible position changed because of the reader), in the **device's** clock, not the moment of saving. Saving an unchanged position again — every 10 s, on hide, on close, on unload — therefore carries the old time and can never beat a newer position from another device. A device that only *shows* a position it got from the server (just opened, nothing moved) reports the server's `read_at` back unchanged. * `sent_at` is the device clock **when the request leaves** (an outbox sets it at flush time, not when queuing). The server corrects the device's clock error with it: `t = read_at + (server_now − sent_at)`, then `t = min(t, server_now)`. So a tolino whose clock is hours off still orders correctly, and nothing can win "from the future". * The server stores the position if `t >= stored read_at` (or nothing is stored, or `force`). Older writes (an offline device catching up late) are **not** stored as the continue point. * Every write — stored or not — raises `furthest_*` if it is further (same comparison as before: anchor block, then inner fraction; `scroll_fraction` when an anchor is missing). * `force: true` (the explicit "Diese Stelle als Fortschritt setzen") stores regardless, with `t = server_now`. * **Old clients** (no `read_at`): stored only if **strictly further** than the stored position (the old rule, but `>` so an idle old client re-saving its place changes nothing), with `t = server_now`. This keeps not-yet-updated apps from pulling the continue point back. * The response is the state after the write: `{"ok": true, "stored": bool, "scroll_fraction", "position_anchor", "read_at", "device", "furthest_fraction", "furthest_anchor", "server_now"}`. ## Reading `/books/` and `/api/sync/` (`book_progress`) carry all fields of the state, `read_at` in ms (server clock), plus `server_now` (ms) so a client can estimate its clock offset `server_now − device_now`. ## Clients * **Local apply / outbox** (Android, desktop, web cache): the same rule as the server, in server time: a local write converts its `read_at` with the last known offset (0 if none), stores it if newer than or equal to what the local list holds. Two queued writes for the same book: keep the one with the later `read_at` (force stays sticky). After a pull, a pending write is reapplied with that rule: newer than the server's → it shows (and is sent); older → the server's position shows (it's still sent, for `furthest`). * **Never save before the position is restored** after opening a book, and never save a position the reader didn't reach by reading: while a jump is tentative ("Zurück zum Text" after a footnote or link, "Weit gescrollt — Zurückspringen"), the previous position and its `read_at` are what gets saved, until the reader returns (nothing changes) or carries on reading there (then the jump target counts, with its own time). * **Opening a book** uses the newer of server state and local (not yet sent) state, by `read_at`. * **Coming back to an open book** (window/app in front again, or after a pull): if the server holds a newer position from another device than the one shown, offer it: "Auf weitergelesen — Springen". Do not move the page without asking. * If `furthest_*` is clearly ahead of the continue point when a book opens, offer "Weiteste Stelle: 63 % — Springen".