9 Commits
Author SHA1 Message Date
Lucas WintherandClaude Opus 5 86ba2ccc1d Make a stored id always name a row
Review found the new settings row opening nothing for a rule whose `until`
precedes its `startsAt`. That parses — nothing ties the two together — and
yields no occurrence at all, so nextOccurrences and the anchor fallback both
come back empty, nearestOccurrence returns null, and openRow cannot resolve
a bare rule id. It was the undeletable record this index exists to rescue,
now with a button that lies about it.

So resolution moves out of App into displayEventFor, with the rule itself as
the floor: a stored id always names something the reader can edit and
delete, whatever the rule does or does not generate. Exported because
nothing here can click and no test renders App, which is exactly how a dead
button shipped — the chain is now covered without a DOM.

Two of the caption tests could not fail, proven by mutation rather than
read: deleting the whole "starts" branch and removing the null-end guard
both left the suite green, because `not.toContain("ended")` was never
watching the branch at risk. They assert what the caption says now.

And a repeating series that has stopped said only how often it repeats — in
the one place whose job is explaining why an event is on no other surface. A
healthy cadence explains nothing; it says when it stopped.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-28 05:25:37 +02:00
Lucas WintherandClaude Opus 5 60041134ee Resolve an occurrence back to the rule behind it
The detail sheet looked its record up by the row's id. For an occurrence
that id carries a #date suffix and is not a key in the store, so `own` came
back undefined and the edit and delete buttons vanished on every recurring
row — and a save would have reached editEvent with an id it could not find
and quietly done nothing.

The suffix is deliberate: marks key off the occurrence so each time round
carries its own completion. There is still only one record to edit.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-28 05:04:39 +02:00
Lucas WintherandClaude Opus 5 f7f67fd305 Expand a rule into rows, two at a time
The lists exist to answer "what ends soonest", so a rule contributes the
occurrence that has not finished and the one after it — no more, however
often it repeats. A weekly rule would otherwise put thirteen rows into the
list F1 is built to keep short.

occurrencesIn answers the timeline's different question and covers a whole
range. It skips non-repeating events deliberately: those are already in
rows, and returning them twice would double every plain event on the board.

useCustom takes the clock as an argument now, because the rows have to
change as time passes — the occurrence on screen rolls to the next one when
the current finishes.

CustomForms.tsx now passes repeat: null when saving a draft — EventDraft
gained the field but the form has no repeat control yet, so every event it
saves stays a single occurrence.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-28 05:04:39 +02:00
Lucas WintherandClaude Opus 5 c76a0b4e70 Project an occurrence into the shape every view reads
The rule supplies what the thing is; the occurrence supplies which time
round. Nothing downstream is told which it is looking at, which is what
lets sort, focus, lanes, filters, progress, ignores and the daily
checklist work with no narrowing at any call site.

The end is always resolved here and never null. A row still carrying the
unresolved form would render live-with-unknown-end forever, which is the
failure the whole design exists to avoid.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-28 05:04:39 +02:00
Lucas WintherandClaude Opus 5 1535c2d920 Let a reader's event carry a repeat rule
.default(null) rather than a bare .nullable(), and the distinction is the
whole commit: a record written before this field existed has no `repeat`
key, a bare .nullable() rejects a missing key, and useCustom reads through
validRecords — which drops what fails and persists only the survivors. The
stricter form would have erased every reader's custom events on first
launch with no server-side copy.

Also refuses a window that comes round before it closes, since two live
occurrences of one rule leave "what ends soonest" without an answer. Only
when an end is stated; with none the window runs to the next opening and
cannot overlap.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-28 05:04:39 +02:00
Lucas WintherandClaude Opus 5 239e970471 test: pin that retiring a game cannot delete a reader's own events
Nothing in the client prunes reader data today — no store filters against the
feed, knownGames only appends, metaFor renders a lane whose game is gone — so a
retired source, page or game leaves completion marks, streaks and hand-entered
events untouched. That was true by construction and pinned nowhere.

The load path is what makes it worth pinning. useCustom reads through
validRecords, which drops a record that fails its schema, and the survivors are
what the next write persists. A record that stops parsing is therefore not
hidden until someone notices — it is deleted from the device, permanently, by
the act of opening the app, with no server-side copy to recover it from.

Which makes CustomEvent.game being z.string() rather than GameId the whole
safety property, and it currently reads like validation someone forgot. Anyone
narrowing it to the enum would be tightening a schema and arming every future
game removal to erase reader data on next launch. The tests state the premise,
the survival, and that one unreadable neighbour still does not take the rest
down; the field says why it is loose.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-19 04:40:04 +02:00
Lucas WintherandClaude Opus 5 6de1434115 test(custom): pin the import gate
A file being imported is not necessarily one this reader wrote. Extracts the
record validator so both the store read and the import path share it, and covers
what it has to guarantee: a partly-corrupt file costs the reader only the broken
records, a hue that is not a hex colour never reaches a style attribute, an
export written before F13 is a file with nothing of its own rather than an
error, and an event whose dates contradict themselves does not land.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-17 18:25:41 +02:00
Lucas WintherandClaude Opus 5 d046758671 feat(custom): store the reader's games and events, and back them up
Adds the two localStorage stores behind PRD F13 and joins their events to the
feed's in App, so they sort, filter, focus, expire and tick through exactly the
same code paths rather than a parallel set.

Three decisions worth naming:

- Export carries customGames and customEvents. These exist in one browser and
  nowhere else — not in the feed, not on a server — so an export without them
  would be a backup that loses the half the reader typed. Import merges by id
  like every other set and never removes.
- A date is read in the reader's timezone, and a bare end date means the end of
  that day. Someone who types 20 Aug means the 20th where they are; the feed's
  00:00Z day boundaries are a parser declining to guess a time the source never
  printed, which is a different situation from being told directly.
- An impossible date is refused rather than rolled over, because Date.parse
  turns 30 February into 2 March and a silently shifted date is the failure this
  product exists to prevent.

Deleting a game is refused while it still holds events, and deleting an event
leaves its marks and logged days alone — reaching into three stores on one tap
is how a misclick costs somebody a streak.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-17 18:19:59 +02:00
Lucas WintherandClaude Opus 5 3702ee7a4c feat(custom): model reader-authored games and events
The data layer for PRD F13, with no UI and no behaviour change yet.

src/shared/custom.ts defines the two key spaces, their schemas, and the
projection into what the views read. Two properties are the point of it:

- A reader's event id is random, not derived from their title. They can type a
  scraped event's exact name and date, which under ${game}:${slug}:${date} is a
  byte-identical key — one completion mark and one streak silently shared by two
  events. Randomness also means renaming their own event never moves its id.
- A reader's event carries no sourceUrl, so a hand-entered date can never be
  attributed to a source, and claims no region split, because they entered one
  instant and inventing three would fabricate two of them.

The rest is widening what was GameId-shaped into a lane that may be one of
theirs: clockFor takes the boundary fields structurally so their events run on
the identical countdown rather than a second one, day keys fall back to the
regional default for a lane with no server map, and gameMeta becomes a context
resolver so metaFor stays pure and total — a lane can outlive its game when an
import carries an event whose game did not come with it.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-17 18:16:21 +02:00