Commit Graph
125 Commits
Author SHA1 Message Date
Lucas WintherandClaude Opus 5.5 b17b52941e fix(ui): let list titles wrap to two lines instead of one
CI / Typecheck, test, feed (push) Successful in 15s
CI / Build site (push) Successful in 8s
CI / Container image (push) Successful in 38s
On a phone a single truncated line cut most titles mid-word, and the
title is what a reader scans the list for.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-01 03:44:52 +02:00
Lucas WintherandClaude Opus 5.5 97d4786646 fix(ui): draw the 'It repeats daily' plus instead of typing it
The fullwidth plus is in neither Chakra Petch nor Public Sans, so it fell
back to whatever font the device had and showed as an empty box on some.
It is now an SVG stroked like the other icons.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-01 03:44:35 +02:00
Lucas WintherandClaude Opus 5.5 66d6748070 fix(theme): lift game hues that are unreadable on the dark ground
readableHue returned every dark hue untouched on the claim that all of
them already cleared 4.5:1 there. Five did not, and Fate's navy was
1.8:1, so its name under the headline deadline was nearly invisible.
Those five are now lifted in lightness only, keeping hue and saturation;
the fifteen that pass are unchanged. The owner approved relaxing the
"dark theme does not move" rule to this, and AGENTS.md and PRD F15 say so.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-01 03:44:20 +02:00
Lucas Winther 941d8095ea fix(ui): use effective pull date in colophon to avoid false pull overdue diagnoses
CI / Typecheck, test, feed (push) Successful in 15s
CI / Build site (push) Successful in 8s
CI / Container image (push) Successful in 30s
Derive source pull date from confirmation and recent content updates, preventing recently updated sources from displaying as pulled never or pull overdue.
2026-09-18 09:33:56 +02:00
Lucas Winther 347d4ddad8 feat(ui): differentiate pull vs site update blame in colophon staleness notice
Show whether staleness is due to crawler checks or site updates in Colophon, and add an expandable per-source breakdown.
2026-09-18 08:55:10 +02:00
Lucas Winther b73c253de2 feat(ui): add "Export all" and "Import all" buttons for moving hosts
CI / Typecheck, test, feed (push) Successful in 13s
CI / Build site (push) Successful in 8s
CI / Container image (push) Successful in 31s
2026-09-17 10:20:07 +02:00
Lucas Winther b36047d218 feat(state): support full export and preferences restoration 2026-09-17 10:19:59 +02:00
Lucas WintherandClaude Opus 5 393ea594ea Only warn about staleness for games the reader has switched on
The footer's staleness notice is an instruction, and the only remedy it
offers is "go and check that game's source page". That is not something a
reader can act on for a game they turned off and cannot see a single row
of — and with nineteen lanes, naming eleven they do not play buries the one
they do. A new lane arrives switched off (F8), so an untouched install was
being warned about most of the catalogue.

Scoped in two places, not one: the named list, and the count the
summarising branch measures against. Without the second, that branch would
never fire for a reader with most of the calendar off and they would get a
list where a sentence was the readable answer.

The notice now also states whose games it counted, in the words NextUp
already uses for the same set. Narrowing what the footer measured while
still printing "nothing has refreshed" would turn a claim about four games
into one about eighteen, in the one paragraph on the page whose job is
being trusted about age.

The headline age and the credits stay whole: the first is a fact about the
feed and feeds the bug form, the second is attribution owed to every source
we read regardless of what is on screen.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-30 12:56:00 +02:00
Lucas WintherandClaude Opus 5 dd8d7577b3 Watch the pref reach the screen, not just the function
Review pointed out that `dailyGroups` was well covered and the wiring to it
was not: passing the wrong pref from App, binding the checkbox to a
neighbour, or hardcoding `true` in Dailies each shipped with the suite
green. Three of those are now caught by rendering the strip and the settings
panel rather than the function behind them — verified by performing the
mutations, not by reading the code.

The fourth, App handing Dailies the wrong pref, is still uncovered: no test
renders App, and closing it needs a feed and a click library this project
does not have.

Also pins the default. Flipping that one line silently empties Today's
dailies for every existing reader, which is the whole argument of the
comment above it, and nothing was watching — `defaults` is exported so the
value and the upgrade path can both be asserted.

And DATA-MODEL's enumeration of the prefs blob had gone stale; AGENTS.md
names it as the doc to update when a stored key moves.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-28 05:43:04 +02:00
Lucas WintherandClaude Opus 5 4277c28e50 Let a reader switch off the chores the app invented
Today's dailies carries two different things: each game's standing chore —
"Commissions, resin", a fixed list nobody publishes — and any event with a
checklist. The first is the app guessing at a routine the reader never asked
for, and it was the only part of that strip with no way out.

So the switch removes exactly that. Events keep their checklists whatever
their source, including ones the reader added themselves and marked daily,
which was the requirement most at risk of being filtered away by a switch
aimed at something else.

Nothing is discarded. The ticks live under `dailies:<game>` and nothing here
reads or writes them, so switching back on restores every logged day and
every streak — the same promise `detectDaily` already makes. Defaulted on,
because everyone has these today and a setting that silently removes
something on upgrade is worse than one nobody notices.

Gating where the chore is built rather than where it is drawn means the
counts follow for free: "N still waiting on you today" is derived from the
items, and a game left with nothing contributes no group at all, so the
strip's own empty guard drops it rather than leaving a heading with no rows.

Named for what it removes. A switch called "dailies" would read as broken
while the strip stayed on screen showing the reader's own events.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-28 05:34:08 +02:00
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 3a3b7d9c2f Give a reader their own events back
An event of theirs that had ended was on no surface at all. Every list and
the board drop a row once its end has passed, and settings only counted the
events a game held rather than naming them — so a one-off became unreachable
the day it finished: impossible to edit, and impossible to delete out of a
store nothing else can see. Repeating events escaped only because their
occurrences roll forward.

So settings names them now, under the game they were filed against, each row
opening the same detail sheet a row on the front page does. Nothing about
how they are managed changes; what was missing was the way back to them.

Two things the index has to get right or it leaves the same hole it closes.
The lists hold occurrences, never rules, so a repeating rule's own id opens
nothing — nearestOccurrence bridges that, and answers for a finished series
too by falling back to the first occurrence, since a rule whose `until` has
passed would otherwise be exactly as stuck. And an event filed under a game
we track has no row of theirs to nest under; the form allows that, so those
get their own heading rather than trailing the list and reading as though
they belonged to whichever game came last.

Each row says why it is not on the front page — ended and when, or its
cadence — because a list of bare titles leaves you guessing which of two
entries is the dead one.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-28 05:14:47 +02:00
Lucas WintherandClaude Opus 5 33f7c77704 Let a preset actually be saved
Review found the headline feature unreachable. `valid` still demanded an end
date unconditionally, while every sibling rule had been gated on
`datedWindow`. A fresh form starts with endKnown true and endDate empty, so
picking weekly hid both the field and the checkbox and left Save disabled
with nothing on screen a reader could do about it. Editing an existing event
was fine, which is why no test caught it: every static render drives the form
through `initial`, and cadenceOf never returns a preset for an event that has
an end.

So the three states that needed a click to reach are now pure functions that
do not. endStated pins exactly that case. repeatOf takes the cadence and
answers for all five, which also makes `until` surviving the preset path
provable — mutating it away previously left the suite green.

Two more from the same review. The unknown-end note keyed off the cadence, so
custom with the repeat set to never promised a countdown that would never
arrive; it keys off the rule now, which is what actually decides. And a delay
whose start and end disagree about having a time of day can never land a
whole unit from the anchor — unstatable, not merely unstated — so it says so
rather than disabling Save in silence.

"State it myself" was also a one-way door: nothing ever cleared it, so the
measured reading could not be recovered without abandoning the form.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-28 05:04:39 +02:00
Lucas WintherandClaude Opus 5 6dad359b2a Ask the cadence first, then only the dates it needs
The repeat controls sat after the dates and applied to whatever was there,
which meant a weekly chore still had to answer an end date it has no honest
answer to. Asking first inverts that: the cadence decides which dates are
even questions.

A preset carries no window. Weekly means the week is the window, so there is
no end to type and no ignorance to admit — five fields instead of eight, and
it stores exactly what the model already renders as back-to-back
occurrences. A dated recurring event is therefore a custom, which is where
the forever/delay control now lives; a one-off shows no repeat machinery at
all.

cadenceOf derives which of the five a saved event opens in, so a rule made
before this control existed opens in whichever answer describes it. Nothing
about the schema moved.

Switching cadence hides the end date rather than clearing it: hiding a field
and quietly discarding what is in it is how a form loses somebody's work
when they change their mind back.

The duplicate note was found by rendering the form and looking at it. Both
of the older notes explain the end-date field, so a preset — which has no
such field — was showing two sentences that said nearly the same thing.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-28 05:04:39 +02:00
Lucas WintherandClaude Opus 5 4c3aa4c7e3 Call it a cycle, not "every N days"
"Every 26 days" is ordinary English for an interval, but it sits directly
beneath a start and an end — a duration — and a reader who has just been
thinking in durations reads it as another one. Naming the shape of the
repetition is what separates them, and "cycle" is the word this genre
already uses.

A cycle of one unit is named rather than numbered, because nobody says "a
1-week cycle"; a longer one takes the singular unit, since a hyphenated
"26-day" is an adjective and not a count. The article follows how the
number sounds — an 8-day cycle, an 11-day, an 84-day — which is the kind of
wrong that reads as sloppiness rather than as a bug.

The manual control becomes "Cycle length" for the same reason: it is asking
for the length of the cycle, not for a duration the reader already gave.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-28 05:04:39 +02:00
Lucas WintherandClaude Opus 5 1f1d608e95 Ask how it repeats, not how often
Three answers — never, forever, or after a delay — because "how often" is
rarely the question a reader has. They know it comes back the moment it
ends, or that it comes back after a wait. The cadence follows from that and
from dates they have already typed, so the form reports what it measured
instead of asking for a number, and says it out loud rather than filling a
field silently.

A delay is expressed by where it lands: the next opening is the wait added
to the close, so a delay cannot produce a rule that comes round before it
ends. Only a hand-stated cadence can, and stating one by hand stays
available — measuring is the convenience, not a cage.

Both readings are shown for a delay, since a week's wait after a week's
window is a fortnightly rule and seeing that spelled out is how a wrong
number gets caught.

contiguousOpening is the load-bearing detail. An end given as a date is
stored as 23:59:59, so a successor opening "the moment it closes" opens at
the following midnight, a second later. Comparing against the stored end
instead read every day-precision rule as having a gap it does not have —
and the form has to measure the instants it will save, not the ones the
record happens to hold, or it opens describing a rule it would not write.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-28 05:04:39 +02:00
Lucas WintherandClaude Opus 5 d731c3a367 Drop a comment block pasted twice
Copy-paste artefact from the review fix wave: the nine lines explaining why
the bar's right edge is clamped appeared twice, back to back. Cosmetic, but
this file's whole style is careful commentary and a doubled paragraph reads
as an editing mistake in the middle of it.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-28 05:04:39 +02:00
Lucas WintherandClaude Opus 5 e3c4fae1b9 Correct the PRD's claim of an until control, and stop the form erasing one
The PRD said a reader's rule could optionally stop on a date; the until
control was deliberately descoped from the form during planning, and the
PRD was written as though it shipped. Corrected the PRD to describe what
actually ships, and noted that the field still exists in the schema —
reachable today only by importing a file that carries one.

That gap had a second-order bug behind it: because the edit form rebuilt
`repeat` from scratch on every save, hard-coding `until: null`, a rule that
already carried a non-null `until` had it silently reset to eternal on any
edit at all, including a pure title fix. Extracted the `repeat`-building
logic into `repeatFrom`, which now carries `initial?.repeat?.until` forward,
and is exported so the behaviour is provable without a submit nothing in
this suite's renderToStaticMarkup tests can click.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-28 05:04:39 +02:00
Lucas WintherandClaude Opus 5 d433eeee50 Only claim a derived boundary is a source's when boundaryMs treats it that way
The day-precision note ("The source gave a date but no time of day, so this
counts down to that day's server reset...") rendered for any event with
endPrecision "day" and a stated end, with no check on where the date came
from. For a reader's own event that is false three times over: there is no
source (sourceUrl is null, sourceId is "you"), nobody gave a date — for a
repeating occurrence with no stated end the app derived it from the interval
— and it does not count down to a server reset, since boundaryMs only applies
that shift when extractionMethod === "parser". Gated the note on the same
condition so the copy and the countdown agree.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-28 05:04:39 +02:00
Lucas WintherandClaude Opus 5 d1ffac4b9f Clamp an expanded bar to the board, and count it in the start markers
Two related ways an occurrence that only arrived through Timeline's expand
prop could disagree with the board it was drawn onto:

- `right` was computed but never clamped to chartWidth, while `left` was
  clamped to 0. boardWindow's max is derived from `plotted` alone, so a base
  row can never exceed it — but occurrencesOf admits any occurrence starting
  at or before the window's edge, and one with no stated end then runs a full
  interval past it. Inside overflow-auto that grows the pane's scrollWidth,
  so the reader scrolls into empty space with no gridlines or axis. The spec
  says a rule may fill the board but must never enlarge it; `right` is now
  clamped the same way `left` already was.

- startMarkers read from `plotted` rather than `drawn`, so a "3 events start
  <day>" label under-counted whatever expand had added to that day. Pointed
  at `drawn`.

The existing "boardWindow is not widened by expansion" test only pinned
boardWindow's own purity and never rendered Timeline, despite its comment
claiming the ordering was "asserted structurally in the component below" —
no such assertion existed, and moving the expand call above boardWindow left
the suite green. Added a render-level regression test that compares the
rendered chart width with and without an expand returning far-future
occurrences; confirmed it fails if expand is called before boardWindow.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-28 05:04:39 +02:00
Lucas WintherandClaude Opus 5 b0523a7cb9 Resolve any occurrence back to its rule, and count what a repeat adds strands
The lists only ever build allRows from a rule's first two occurrences
(LIST_OCCURRENCES), but the timeline draws every occurrence the board window
admits. Clicking a bar past the second set openId to an id allRows could not
resolve, so the detail sheet silently failed to open and per-occurrence
completion was unreachable for those bars. occurrenceForId parses the day an
occurrence id names and regenerates just that neighbourhood via occurrencesOf,
so App.tsx can resolve through the rule when the direct lookup misses.

Separately, strandedBy's `if (record.repeat === null) return 0` hid the
warning on exactly the transition where it matters most: a reader marks a
plain event done, then edits it to add a repeat, and the save re-keys every
row from `id` to `id#<date>` with no notice that the mark under the bare id
is about to become unreachable. strandedOccurrences checks the record's own
id when it has no repeat yet, and its next occurrences once it does.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-28 05:04:39 +02:00
Lucas WintherandClaude Opus 5 14b221a627 Give the cadence line the gap its siblings have
Every other paragraph in that stack carries an mt-*; this one did not, so
"every 2 weeks" sat flush against the bottom of the window table with no
gap. Static-markup tests assert on content, not spacing, so nothing caught
it — the Task 10 review did, by reading the surrounding rhythm.

Reading cadenceLabel once into a local rather than calling it at both the
guard and the render also drops the non-null assertion the second call
needed.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-28 05:04:39 +02:00
Lucas WintherandClaude Opus 5 c5c578ae7e Say how often, and say what a reschedule costs
Occurrence ids carry their own start day, so moving the anchor or the
interval re-keys every occurrence and the marks under the old ids stop
being reachable. Nothing is rewritten — removeEvent makes the same trade,
and useMarkSet never removes because nothing else holds a copy — but the
reader is told the count first, the way removeGame reports blockedBy
instead of cascading.

Informs, never blocks. Renaming still costs nothing: the token is random
precisely so fixing a typo never moves an id, and movesOccurrences is what
keeps the warning off a rename and off a bare change of `until`.

cadenceLabel sits beside the form's own vocabulary so the sheet cannot
describe a rule differently from the control that set it.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-28 05:04:39 +02:00
Lucas WintherandClaude Opus 5 c161c4a0dc Let the form state a repeat
Defaults to never, so the form a reader already knows is unchanged until
they reach for this.

The unknown-end note had to change with it. "It'll show with no countdown
and no daily checklist" is true of an unbounded event and false once an
interval bounds it — and leaving it there would talk a reader out of the
simplest way to record a weekly reset. With a rule set it says each one
runs until the next one opens.

Refusal reuses comesRoundEarly rather than restating it, so the form cannot
drift from the schema it has to agree with.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-28 05:04:39 +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 181eceac6f Let the timeline draw a rule's whole rhythm
The lists carry two occurrences because they answer "what ends soonest";
the board exists to show rhythm, so it draws every occurrence in view.

expand is called with the settled window rather than returning rows up
front, and the ordering is the point: boardWindow takes its max from the
ends it is handed, so feeding expanded occurrences back into it would widen
the window, generate more, and widen it again — a rule with no until would
never terminate. Compute the range from the base rows, then expand into it.

Merged rows are re-sorted only when there is something to merge, so a
reader with no repeating events sees identical behaviour.

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 f3f5524ffc refactor: give the region and the theme a settings line each
They were one group called "Reading", grouped on "both are how do I read
this?" — which summarised as "Europe · Dark", two unrelated answers joined
by a dot, under a name for neither of them, in a panel whose whole premise
is that a closed line answers its own group.

The region is not a reading preference. Region-scoped ends, a date printed
without a time, and every daily streak are all cut on that server's clock,
so it is the one control in this panel that can make a countdown wrong —
and it was filed behind a word for the theme. Split, each line answers one
question, and the region group says what it governs when opened.

The eyebrow above each pill row goes with it, since the group summary now
asks the question the eyebrow was repeating; aria-label keeps the
accessible name, which is what stops six unlabelled buttons in a row from
being all a screen reader hears. The summary assertions are looked up by
group name rather than by index, so splitting a group no longer moves every
assertion below it onto a neighbour.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-20 07:25:05 +02:00
Lucas WintherandClaude Opus 5 35b3ab8e89 fix: let the settings panel use the width of the page
Looked at on a 1440px screen it was the one block on the page not using
the column it sits in. The rows were held to max-w-3xl inside an 1150px
shell, but the section's own rule spans the shell — so every hairline
inside the panel stopped a third of the column short of the one above it,
between a two-column checklist and a three-column footer that both go
full width. The measure was there to keep a name and its state reading as
one line, but that pairing is what "running now" already does with a
title and its countdown directly above, so full width is the house
pattern rather than a stretch. Prose inside a group keeps its measure.

The game list was the worse half: eighteen rows of four controls at
max-w-md is a thousand pixels of ribbon down the left with two thirds of
the screen empty beside it. It takes two columns past lg once there are
nine or more games, flowing down then across so the numbers still read in
a straight line and an arrow still swaps a row with its neighbour. Below
nine, and on a phone, it is unchanged.

The last group also loses its bottom rule, which was a second hairline a
few pixels above the footer's own.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-20 07:24:38 +02:00
Lucas WintherandClaude Opus 5 0b5aa12173 refactor: delete the section hint nothing could reach
The "Running now" header had a hint reading "next after this ends in …", and the
commit before this one fixed a real defect in the string it produced — an
unannounced end became the word "ended". Both were beside the point: the branch
cannot render at all.

`Section` shows `action ?? hint`. The hint needed a second live row to have
anything to say, and two live rows are two visible rows, which is exactly the
condition that puts the sort control in the same slot. So the action was present
whenever the hint was, and won every time.

What it would have said is already on the page. The headline panel's "Then" list
names the deadlines behind the closest one and counts each of them down, which is
the same answer with more room. So the hint slot goes, `followingDeadlineMs` goes
with it as its only caller, and `Section` is left with the one control slot it
actually uses.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-20 06:39:37 +02:00
Lucas WintherandClaude Opus 5 026b1315d3 docs: the focus bar's games are the reader's order, not the feed's
Left over from before `orderGames` existed. The prop says feed order and App has
been handing it `enabled`, which is the reader's order filtered — so the comment
described the one thing that stopped being true when every game-listing surface
started going through the same rule.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-20 06:37:41 +02:00
Lucas WintherandClaude Opus 5 f9ef21a366 fix: an imported backup no longer rolls progress back
The progress store merged like the mark stores do, keeping whichever copy had
the earlier timestamp. That is right for a mark — `at` is when the reader made
it, membership is the whole fact, and the oldest stamp is the truest one — and
wrong here, because this record *is* the data. A status, an effort, a note and
whether an event repeats all live in it, and `at` says when one of those last
changed.

So the earlier copy winning discarded every edit made after it, in both of the
directions an import actually happens in: restoring a backup taken before an
evening's work undid the evening, and importing an old file onto a device with
newer progress rolled the device back. Neither is recoverable — there is no
account and no server holding a second copy.

The later record wins now. Nothing is removed in either direction, and taking
the maximum of two timestamps is as order-independent and idempotent as the rule
it replaces. `mergeProgress` came out of the hook to be tested, which is also
how the ignored store's opposite rule is now written down rather than assumed.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-20 06:37:11 +02:00
Lucas WintherandClaude Opus 5 7bb7dc843c refactor: the event row takes its clock instead of reading one
Every other surface here is handed `now` — the headline, the board, the dailies
strip, the detail sheet — for the reason the parsers are: a function that reads
the clock cannot be rendered against a fixed instant, so nothing about it can be
asserted. The list row was the one exception, calling the wall clock twice for
its window caption and its "starts in".

Two things follow from fixing it. Those numbers were counted from a different
instant than the `clock` sitting beside them in the same row, which is a
disagreement nobody would ever notice and nobody could ever prove. And the
countdown is now testable: rendering one row at two instants gives the two
answers the injected clock implies, which is what the new test pins.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-20 06:33:42 +02:00
Lucas WintherandClaude Opus 5 fb390c1af8 feat: settings become five groups that state their own answer
The panel was one open block of everything in two columns. That reads fine at
four games and stops working at eighteen: the game list alone is eighteen rows
of four controls each, and it sat above the checkbox somebody had scrolled down
here to tick. Nothing was findable and everything was in the way.

Collapsing it into groups is only half an answer, though — a group showing
nothing but its name turns "what is my region set to?" into a click, which
trades one kind of friction for another. So each summary carries its group's
current state: "Europe · Dark", "17 of 18 on · A–Z", "plus finished, not
started". Closed, the panel is a five-line report of how the app is configured;
opening one is for changing an answer rather than reading it. The states are
derived from `prefs` at render, never stored, so they cannot drift from the
controls they describe.

Native `<details>`, for the reason the reorder arrows are ordinary buttons:
keyboard and screen reader reach it with no second implementation. `summary` is
not an `a`, `button` or `[tabindex]`, so it needed its own focus-visible rule —
the shared one does not reach it.

Two things fell out along the way. The region and appearance rows had no
accessible name at all, so a screen reader read six unlabelled buttons in a row;
they and the board's split pills are one `PillGroup` now, with the `role="group"`
the board's own controls already carry. And the empty states that point at
"Show events that haven't started" now name the group holding it, which is what
makes shipping the groups closed safe.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-20 06:29:28 +02:00
Lucas WintherandClaude Opus 5 14f6f2f0dc fix: make the progress export actually reach the disk
Export is the whole of this app's backup story. Progress, streaks, ignores and
every game and event the reader typed in live in one browser and nowhere else —
no account, no server, nothing to restore from — so the download either happens
or the data was never backed up.

Two things made it a coin toss. The anchor was never in the document, which is
not reliably clickable, and the object URL was revoked in the same task as the
click, which can pull the blob away before the download has read it. Both fail
silently and look exactly like a successful export.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-20 06:14:12 +02:00
Lucas WintherandClaude Opus 5 1e7a7abc64 perf: group the timeline's lanes in one pass
Stacking by game rebuilt a lane's whole array for every row it added, which is
quadratic in the lane's length. That is cheap at three events and not at a
reader with fourteen games switched on and the future plotted — and it is not
paid once, because the board re-renders on every clock tick.

Appending into the array instead is the same output: `Map` preserves insertion
order, so lanes still arrive in the order their first row did and the rows
inside one keep the order they were given, which is what the existing tests
pin.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-20 06:13:24 +02:00
Lucas WintherandClaude Opus 5 4fe563dcbb fix: stop the running-now header counting down to "ended"
The section header said "next after this ends in {live[1]}", which got the
question wrong in both of the ways lens.ts exists to prevent.

An event whose end was never announced has no time remaining, and the `?? 0`
that stood in for it made `formatRemaining` return its expiry string — so a
second row with `endsAt: null` had the header reading "next after this ends in
ended". That is the one rule this product is built on, inverted: an unknown end
announced as an expiry.

And `live[1]` is the second *row*, not the second deadline. The list is sorted
by whatever mode the reader picked, so under "doing first" the header named
whatever they were second-most partway through — the same mistake firstToExpire
was written to keep out of the headline, one row further down.

`followingDeadlineMs` asks the deadlines instead and returns null when there is
no second dated end, and the header then says nothing at all.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-20 06:12:38 +02:00
Lucas WintherandClaude Opus 5 447ea25632 build: let tsc find the bindings nothing reads
This project has no linter and does not want one, but `strict` says nothing
about a binding that is simply never read — and that is the one kind of dead
code a reader cannot tell apart from a deliberate seam. `tsc` already walks
every file, so it may as well answer the question.

Three had accumulated: an unread `type View` import in App, and a
`useGameMeta()` resolver in Controls and YourOwn, both of which draw their
colours from somewhere else — the game-order list has its own resolver, and
YourOwn reads the reader's own records directly. None of them were wrong, they
were just leftovers that read as intent.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-20 06:11:26 +02:00
Lucas WintherandClaude Opus 5 74c1f74e87 feat: reorder games from settings
The games chip row becomes a row per game, because a reorder affordance needs
somewhere to put a handle and two arrows, and at fourteen games a list reads
better than a wrapped row of pills anyway.

Reordering lives here and nowhere else. The focus bar and the dailies strip are
the fastest tap targets in the app, and a drag target sitting on top of a tick
target costs somebody a streak the first time it misfires — so the live surfaces
stay drag-free and this is the screen you visit on purpose.

Both affordances ship. Touch fires no drag events at all, so the arrows are the
mechanism and the handle is the pointer fast path; being ordinary buttons is
also what makes the whole thing reachable by keyboard and screen reader without
a second implementation of the same interaction. An arrow at either end is
disabled rather than removed, because a control that disappears on the first row
slides the other one under the finger aiming at it.

A move writes back the whole displayed list: the indices are positions on
screen, so applying them to a stored order that names only some lanes would move
the wrong game. Reset writes the field away rather than storing an empty order.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-20 06:01:56 +02:00
Lucas WintherandClaude Opus 5 721c4c90fb feat: every list of games follows the reader's order, dailies included
The focus bar, the settings list, the timeline's lanes and the dailies strip all
go through `orderGames` now, resolved once in App. `games` itself stays in feed
order on purpose: `adoptNewLanes` diffs it and `knownGames` is seeded from it,
so ordering it at source would let a display preference reach the code that
decides which of a reader's games get hidden — a reordering bug would become a
game-silently-switched-off bug.

The dailies strip also groups. It was `[...chores, ...repeating]`, which put
Genshin's commissions and Genshin's own login event at opposite ends with a
dozen games between them; a game's chore and its repeating events are now
adjacent, chore first, events in the order they arrived. Collapsed that is
adjacency alone — no per-game headings, because this is the part of the page
answerable in ten seconds and a heading each would make it the tallest block on
it, pushing "next to expire" down the page.

Two things moved to where they belong. Skipping a standing chore for a lane the
reader invented is `dailyGroups`' rule, not the call site's: filtering those
lanes out in App also cost a reader's own game its place in the order its events
group under. And the expanded catch-up panel is its own exported component, so
which days it offers and whose clock they were cut on are testable rather than
trapped behind a `useState`.

`Welcome` drops its own comparator for the shared rule — the picker runs before
any stored order, so it asks for the A–Z case and cannot drift from the four
surfaces behind it.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-20 06:01:37 +02:00
Lucas WintherandClaude Opus 5 bf7959c916 feat: let a day already gone be ticked
People play at midnight and tick at breakfast, or forget for a week and come
back. A log that only accepts today goes wrong on its first bad evening and is
never trusted again.

An event whose end was published already allowed this — its checklist draws the
whole run and past pips are clickable. `catchUpDays` answers the two cases
`dailyDays` cannot: a standing chore, which has no start or end because it is a
routine rather than an event, and an event with `endsAt: null`, where the
checklist could draw no run at all and so offered only today. That second one
is the worse of the two, because it looks like a working checklist.

Never a day past today. A tick claims you did it, and nobody can have done
tomorrow, so a future day is absent rather than rendered and disabled — a
control for a claim that cannot be true. An event's strip starts when the event
did, capped at the same fortnight so a login campaign that opened in March does
not become a wall of pips.

The window bounds display and never data: a tick older than it stays logged and
keeps counting toward the streak. `DayPip` moves to its own module rather than
being copied, so there is one answer to what a missed day looks like.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-20 06:01:08 +02:00
Lucas WintherandClaude Opus 5 88d5ea2e43 feat: decide the order games are listed in
Nothing did. Every surface that lists a game rendered App's `games`, which is
the order lanes first appear in the feed — whichever game happened to hold the
first event row. It is arbitrary, it moves as events come and go, and it was
the same complaint the first-run picker had.

So `orderGames` is one rule in one place, and `prefs.gameOrder` is where the
reader's own answer goes. Absent means they have never placed a game rather
than an empty order, which is the distinction `knownGames` already draws and
the same trap: every install predating the field is in that state, and reading
it the other way would hand them a blank list. They get A–Z by the name on
screen instead.

Two properties carry the weight, and both are tested rather than asserted. The
result is always a permutation of the lanes given, because a game dropped
here looks exactly like a game switched off and switching it on would not
bring it back. And a lane the order does not name trails the ones it does, so
a game we add later never lands in the middle of a hand-made order and a
retired source keeps its slot for when it returns.

Nothing reads it yet.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-20 06:00:32 +02:00
Lucas WintherandClaude Opus 5 7b6ee62235 feat: sort the first-run game picker alphabetically
The picker took `available` in feed order — whichever game happened to hold
the first event row — which means something everywhere else in the app and
nothing on this screen, where the reader is scanning for the two or three
names they already know.

Sorted on the name printed on the button rather than the LaneId, since the id
is not what a reader sees, and through localeCompare rather than `<`, because
a code-point sort files hololive Dreams after every capitalised game instead
of between Genshin and Honkai.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-20 05:34:00 +02:00
Lucas WintherandClaude Opus 5 2cb4dd214c feat: "show events that haven't started" governs both views
It was the board's alone, sitting between two app-wide filters and
carrying a line of small print to explain why it was different. It is the
same events answering the same question in either view, so it is now one
switch: the board plots them, and the checklist keeps its "Not started
yet" section.

That means the section is off by default, which is a visible change for
every existing reader — deliberate, and the same argument the board makes.
This app answers what expires next; on fourteen lanes the queued patches
are more rows than the thing they came for.

`timelineUpcoming` becomes `showUpcoming`, because the old name would now
be false, and `adoptRenamed` carries a stored answer across on load.
Nothing would be lost by dropping it — `prefs` is one blob under one key,
not a key space — but a reader who had switched the future on would find
it off with no explanation, and they should not have to say a thing
twice. A stored new name always wins, so it cannot overwrite a fresher
answer with a stale one.

Gating the section alone would have opened a hole: nothing running and
everything held back rendered an empty column, because the "nothing to
show" line keyed off there being no rows at all rather than none listed.
It now counts what is held back and names the switch, as the board does.

The split pills stay the board's: the checklist gives these a section
with a heading either way, so there is nothing there to mix them into.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-19 06:20:14 +02:00
Lucas WintherandClaude Opus 5 d3066aefdc feat(focus): the game chips answer the pointer, as the dailies do
They are the same object twice — a pill carrying a game's colour, pressed
or not, that you tap — so they get the same hover rather than a second
one: a ring in the game's hue, a one-pixel lift, and the label coming up
to full ink while the chip is not the pressed one.

Sharing it meant `.tick-chip` becoming `.hue-chip`, and dropping the
`data-done` attribute it keyed off. Both chips were already saying the
same thing in `aria-pressed`, and a second attribute repeating it is one
more thing to keep in step — a rule that reads the announced state cannot
disagree with it.

The focus strip needed a pixel of headroom for that lift. `scroll-x` sets
`overflow-x: auto`, which computes the vertical axis to `auto` as well,
so a chip that rises loses that pixel and its ring to the scroller's
edge; `pt-1` is the room, and `mt` gives the same total gap back.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-19 06:13:48 +02:00
Lucas WintherandClaude Opus 5 3db93a1ccf feat(timeline): offer both readings of a board with the future on it
"In their own group" is the shape of a patch — what is on now, and what
is queued behind it. "Mixed in" is the question a Gantt chart exists for:
what runs out first, whether or not it has opened. On real data that is
not a cosmetic difference — mixed, five events currently running sort
below an Arknights rerun that has not started, because it closes before
they do.

Which is why it is a pair of pills and not a checkbox: neither answer is
the absence of the other, and "mixed in" is a different order rather than
the heading switched off. It sits under the row that puts the future on
the board at all, and only while that row is ticked — a choice about
arranging unstarted events is unanswerable with none of them on screen.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-19 06:05:58 +02:00
Lucas WintherandClaude Opus 5 9dde82801a feat(prefs): remember which reading of the board the reader wants
Whether unstarted events keep to their own block or sit in one deadline
order with the running ones. Both are right for somebody, so it is the
reader's answer and it survives a reload like the rest of the board's
shape does.

Defaults to the block, which is the board as it was before the choice
existed. Only read while `timelineUpcoming` is on — with nothing
unstarted plotted there is no block to keep apart — but stored either
way, so switching that back on restores what they said rather than a
default.

The Controls test builds a whole `Prefs`, so a new required field lands
there in the same commit or nothing typechecks in between.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-19 06:05:31 +02:00
Lucas WintherandClaude Opus 5 6029e9ef10 feat(timeline): let a lane be one deadline queue, started or not
`endingSoonestFirst` holds every unstarted event behind every running
one. That is right for a checklist — you cannot do a thing that has not
opened — and it is one answer of two for a board, which is asked what
runs out first. An event opening on Friday and closing on Sunday is a
nearer deadline than one running now until October, and that order can
never show it.

So `byDeadline` is the same comparator with that clause removed, and
`timelineLanes` takes a `split` flag choosing between them. It defaults
to the old behaviour, so nothing moves yet.

The flag is also the one case where lane mode reorders inside a lane,
which the module otherwise refuses to do. Deliberate: the given order is
live-first whatever the reader chose in the list, so honouring it would
draw exactly the block this was asked not to draw.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-19 05:59:43 +02:00
Lucas WintherandClaude Opus 5 c0e523bdc4 feat(timeline): name the line where the running bars stop
A dashed edge and a thinner wash say "this bar has not started". They do
not say where the running ones ended, so a board with the future switched
on had to be decoded bar by bar to answer the question a reader opens it
with — what is on now.

So the boundary gets a label, and it is the same object as a lane's name:
a small eyebrow pinned to the left edge, surviving any scroll position. In
muted ink rather than a hue, because a hue on this board means "whose
event is this" and this is not about a game. One per lane, since "where
does this stop running?" is a different answer for each of them.

Where it goes is a single index rather than a per-row test, because there
is only one boundary: every order this board can be given puts live rows
before upcoming ones, and `splitAt` is exported so that is a test rather
than a comment.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-19 05:55:16 +02:00
Lucas WintherandClaude Opus 5 a66803384a refactor(timeline): move the unstarted-events switch into settings
It shipped as a pill in the board's own header, beside the stacking and
scale controls. That is the wrong company: those two reshape what is
already on the board, which is why they are reached for while reading it,
and this one decides what is on it at all — the same question
"Show events I've finished" and "Show events I'm ignoring" answer, from
the panel where both of those live.

It is the only one of the three scoped to a single view, so the row says
so instead of reading as a promise about the whole app. The board loses
the count the pill carried; with nothing left running it still says how
many are waiting and names the setting, and the page header has said
"N live · N upcoming" all along.

Controls had no tests at all, so the three view filters get some: what
each checkbox is bound to is invisible in a diff and obvious only to the
reader it happens to.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-19 05:46:38 +02:00