Skip to content
appsbynate
← productivity

Watch and Read

A local-first list of films and books — what you mean to get to, what you finished, and what you thought of it — kept in one browser with no account and no lookups.

Section
productivity
Year
2026
Role
Solo project
Stack
React, TypeScript, Vite, Vitest, localStorage

Summary

One list for two media. Films and books share a queue — Now Playing — and a finished shelf, with a rating out of five, a one-line summary and as many notes as you care to write. Search runs across titles, people, genres, summaries and your own notes at once. Everything lives under a single localStorage key: no account, no backend, no metadata lookups, and an export button that is therefore the only backup there is. The look is a cinema marquee after dark — bulbs, cyan neon, one magenta accent.

Story

The decision the whole app rests on is refusing to split the two media. An entry has a kind of movie or book and a single creator field, labelled “Director” or “Author” at render time. Forking that into director and author would have forked the editor, the search index and the row along with it, and the app is only interesting because the two lists are one list.

The second decision was letting status own the date. finishedAt is stamped when an entry becomes done and cleared when it goes back to the queue, enforced in exactly two places — the reducer’s withStatus and storage’s normalize — so no other code path can write a finish date onto something unfinished. Sorting then falls out of it: finished entries order by when they were finished, queued ones by when they were added, with the title as a stable tiebreak.

Routing is the hash and nothing else. location.hash is the single source of truth, navigation writes to it and a hashchange reads it back, which is a router in twelve lines and — unplanned but welcome — completely indifferent to being served under a path prefix.

Lessons

  • Reads never throw. Corrupt or unavailable storage falls back to empty rather than blanking the page mid-render, and a malformed library is repaired entry by entry — anything with an id, a title and a valid kind survives, the rest of its fields get defaults — instead of the whole file being dropped.
  • Writes fail quietly too. Private browsing and a full quota are caught, and the session keeps working in memory.
  • Import merges rather than replaces, keyed by id with the incoming entry winning, because the destructive version of that button needs a confirmation dialog and the non-destructive one doesn’t.
  • A rating is 1–5 or nothing, and clicking the star you’re already on clears it — the only route back to unrated, which makes it a feature and not a quirk.
  • Glow is rationed to three things: the sign, the active tab, a filled star. A fourth flattens the effect. Constraints in a stylesheet need writing down or the next change quietly spends them.