0014. Achievements evaluated in the renderer, only unlocks persisted
- Status: Accepted
- Date: 2026-10-06
Context
Achievements reward reading and organizing the library. Most are a count over data the renderer already holds in its data store (books finished, books in the library, lists, ratings, tags); a few depend on the moment a book is opened (at night, early, in right-to-left). The app keeps no reading history, and the maintainer did not want one for this: streaks, "N books in a row" and long-session achievements are therefore out of scope.
Decision
- The rules live in the renderer (
src/lib/achievements.ts), pure and unit-tested, over the store's slices. The main process owns no rule: it only stores which achievements were earned and when (achievementstable, migration 6) and announces new ones withdata:changed. - Only unlocks are persisted. An achievement stays earned when the books that earned it are removed or the library is cleared, and its date never changes (
INSERT OR IGNORE). - The renderer asks the main process to record an unlock (
achievements:unlock), which accepts only ids ofACHIEVEMENT_IDS. The renderer is trusted for when (as it already is for reading progress), not for what. - Event achievements are decided from the clock and the reading direction when the reader opens a book, with no stored history.
- A toast announces what is earned after the window loads; many at once are summed up in one.
Consequences
- No write path needs to know about achievements, and a new rule is one entry in
ACHIEVEMENT_RULESplus its texts and badge. - Achievements are earned while a window is open: books added by a start-up resynchronization are counted the next time the root layout evaluates, i.e. at once, since it runs on every change of the library.
- An achievement earned on one machine isn't carried by the JSON export: a restored library earns them again by the same rules, except the event ones.
- A renderer could unlock anything; acceptable for a local, single-user app with no competitive element.
Alternatives considered
- Evaluating in the main process after every write: a hook in each handler and a second copy of the filtering helpers (
isFinished(), tags) insrc/main/, for no benefit. - Deriving everything and persisting nothing: removing books would lose achievements, and event achievements couldn't exist.
- A reading-history table for streaks and marathons: declined for now; it would be a new migration and a new ADR.