Commit Graph
17 Commits
Author SHA1 Message Date
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 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 4f53b7e153 Cover Timeline's expand merge, dedupe, and showUpcoming filter
Nothing in the suite rendered <Timeline> with an expand prop, so the merge,
dedupe, and showUpcoming-on-extras logic added for the timeline's rhythm was
verified by inspection only. Three cases: an extra expand hands back is
drawn, an occurrence already among the base rows is not drawn a second time
(pinned by comparing whole markup, since a doubled bar reads as one bar
slightly bolder rather than as an obvious duplicate), and an upcoming extra
is held back the same as an upcoming base row when showUpcoming is false.

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 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 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 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 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
Lucas WintherandClaude Opus 5 7a3588fbf6 feat(timeline): hold back unstarted events, and say where they begin
Two halves of one reading. The board answers "how does the time I am in
lay out?", and plotting every lane's next patch stretched the window
weeks past today and squeezed the running bars down to nothing — so it
holds them back, and holds them back itself rather than being handed a
shorter list, because the control has to say *how many*. A board that
quietly withheld a third of the schedule is indistinguishable from a
quiet fortnight, which is the wrong thing for this app to imply by
accident. A board with nothing left running says so and points at the
control instead of reading as an empty calendar.

Switched on, a bar drawn right of the now rule is only implicitly in the
future, and implicitly is not a standard we hold anywhere else a date is
involved. Gacha schedules are not a smooth stream of starts either — a
patch ships six things at once — so the unit is the clump: a dashed rule
per group of starts, labelled in words. Clumps merge by distance on
screen rather than by calendar, since a week apart is one mark at six
pixels a day and two at a hundred and eight, and a merged one states the
span it covers rather than claiming a date the board is not ruling at.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-19 05:31:01 +02:00
Lucas WintherandClaude Opus 5 1c8b71e7bf feat: put the stacking toggle on the board
The board's header said "One lane per game" and left it at that; now it
offers the other stacking too, in the same place and the same pill shape
the list's sort control uses — you reach for it while looking at the
board, not in settings.

Merged, there is no lane heading to say whose event a bar is, and hue
alone cannot answer that once every game shares one stack. So each bar
carries its game's short name, and its tooltip carries the full one. A
bar under 96px has no room for a tag and a title both, and a chopped
game name reads as a broken word — those keep the title and say the game
in the tooltip.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-18 22:16:00 +02:00
Lucas WintherandClaude Opus 5 663196b5ea feat: let the timeline stack by deadline, not only by game
Lanes keep a game's events adjacent, which is what makes the board
readable for someone playing four of them — but a reader with four games
has one queue of deadlines, and lanes scatter it: the thing ending
tonight sits three lanes below the thing ending next month, and no
amount of scrolling puts them side by side. So the stacking becomes a
choice, and the choice is remembered like the scale and the view are.

The merged mode sorts with endingSoonestFirst rather than a bare end
date, so the timeline and the list cannot mean different things by the
same words — and an unannounced end keeps its place behind every dated
one instead of pretending to a deadline.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-18 22:15:22 +02:00
Lucas WintherandClaude Opus 5 b3aea00cbb feat(ui): call the list view Checklist
"Ending soon" described the sort, not the job — and it sat one line above a
sort control whose own option reads "Ending soonest", so the tab and the
ordering looked like the same control said twice. Checklist is what a reader
with four games is doing with it.

The stored id stays "soon". That value is in `prefs.view` on real devices, and
a label is copy: renaming one must never move a reader to the other view.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-18 03:59:52 +02:00
Lucas WintherandClaude Opus 5 c4f6551844 feat(timeline): stop two months back, and give the bars room
Two changes to how the board reads.

The window was drawn from the earliest start, so one long-running event — a
standing login campaign can have been going half a year — bought months of
empty calendar that nobody scrolls back through and that pushed every other
bar off to the right. It now floors at two months, a patch cycle and a half:
far enough that a running event's start is usually still on the board, past
the point where the answer changes what anyone does today. A bar older than
the board keeps its faded left edge rather than being redrawn as though it
started there.

The bars themselves were 28px with 4px between them, which read as a stack of
hairlines rather than a schedule. They are 36px now, with more padding, more
air between events, and more between lanes.

`boardWindow` comes out of the component while it is being changed: it
decides what a reader can and cannot see, which is worth a test rather than a
rendering.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-18 03:54:23 +02:00
Lucas WintherandClaude Opus 5 7804a08863 feat(prefs): ask which view to open on, and remember the answer
`view` was component state, so a reader who preferred the timeline was put
back on the list by every reload — and which view opened at all had been
decided for them twice over: the PRD said calendar, the app shipped the list.
Neither was the reader's answer.

So the first run asks, and the tabs write to `prefs` from then on. The
question ships pre-answered with the list, because a reader cannot choose
between two layouts they have not seen and that is the one that answers "what
expires next" in a look — and each option is drawn rather than described, for
the same reason. The screen says where to change it afterwards, since the tabs
are small text in a corner.

Adds `view` to the prefs key space. Additive and defaulted, so an existing
reader's stored prefs open exactly where they did before.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-18 03:28:03 +02:00
Lucas WintherandClaude Opus 5 41046899f9 feat(next-up): show the next three deadlines, not one
A reader with two games switched on said the list lost its point at
twenty-one rows and asked for the three closest deadlines up front. One row
was also fragile on its own terms: ticking off the headline event left the
panel pointing at something the reader had no context for.

Three equal panels would be a stat grid, and a reader arrives with one
question — so the shape is one answer at full size and two follow-ups under
it, with no meter, summary or badges to turn the panel into a second copy of
the list below it.

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