Commit Graph
270 Commits
Author SHA1 Message Date
github-actions[bot] bf14b2f1ea chore(data): refresh source snapshots
Automated fetch from Refresh sources run 33004794613.
2026-08-26 19:22:51 +00:00
github-actions[bot] cf96be121c chore(data): refresh source snapshots
Automated fetch from Refresh sources run 32935917997.
2026-08-26 05:55:24 +00:00
Lucas Winther 1505202aee chore: manually refresh source snapshots. 2026-08-26 06:13:56 +02:00
github-actions[bot] b8f956be6b chore(data): refresh source snapshots
Automated fetch from Refresh sources run 32880251300.
2026-08-25 17:51:08 +00:00
github-actions[bot] 5902c39d68 chore(data): refresh source snapshots
Automated fetch from Refresh sources run 32814584039.
2026-08-25 05:53:19 +00:00
github-actions[bot] 58a014b329 chore(data): refresh source snapshots
Automated fetch from Refresh sources run 32759140412.
2026-08-24 17:53:11 +00:00
github-actions[bot] 6aab460c67 chore(data): refresh source snapshots
Automated fetch from Refresh sources run 32695451084.
2026-08-24 05:59:10 +00:00
github-actions[bot] 86a486b2ac chore(data): refresh source snapshots
Automated fetch from Refresh sources run 32655597178.
2026-08-23 17:41:06 +00:00
github-actions[bot] faf230e2c9 chore(data): refresh source snapshots
Automated fetch from Refresh sources run 32621373232.
2026-08-23 05:51:15 +00:00
Lucas Winther 78ef06371e chore: refresh source snapshots. 2026-08-23 01:10:02 +02:00
Lucas WintherandClaude Opus 5 b19bc61ecf chore(data): refresh the Endfield Game8 snapshot
Fetched under `--force`, authorised interactively — the held snapshot was the
1.4 page and predates the parser fixes, so the lane was serving one event while
the page listed a version's worth. It now reads 8, and the source's failure
count is back to zero.

`Sanity Supply` is not among them, and was in the previous snapshot. It has left
the upcoming table for the current-events card grid, which the parser still
cannot read, and wiki.gg does not list it — so an event starting 2026-08-26 is
unpublished until that shape is supported. Endfield's lane is 13 events rather
than the 14 it should be.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-23 01:07:40 +02:00
Lucas WintherandClaude Opus 5 dbcd14eb70 docs: record the TBA drop and what a one-row source means
Two things the last two commits taught, written down where the next person
looking at a parser will hit them.

The date table now carries the slash-dated open range, and the prose beside it
says the year is required and why — that reader is tried last, so a guess there
reaches the feed unchallenged. It also names the second copy of the open-end
vocabulary, because the two lists being out of step is not a hypothetical
failure any more.

The second is the one worth having on record. `eventCount` in a snapshot's meta
is not a count of the page. Endfield's Game8 lane read 1 for a week — one row of
an *upcoming* table, while four live events sat in a card grid the parser cannot
read — so the first time the page moved, the lane read 0 and the gate reported a
shape change. Neither number described the page. A count far below what the page
shows is a shape being skipped, and it is worth finding before it reaches zero
and the gate has to guess on your behalf.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-23 01:07:30 +02:00
Lucas WintherandClaude Opus 5 0307d24b92 fix(ingest): keep an unannounced end out of the event blurb
The open-end vocabulary is written down twice and the two copies have to agree.
`parseOpenRange` decides whether a row is datable; `RANGE_PREFIX` strips the
range off the front of the cell so `proseAfterDates` can recover the blurb
behind it. A word the first knows and the second does not still yields the
event — with the leftover end tacked onto the summary a reader actually sees.

That is what happened to all eight Endfield rows the previous commit recovered:
`- TBA Sign-in to get extra pulls for Typhoeus!`. Adding `tba` beside `tbd`
fixes it, and a comment now says why the lists travel together, since the next
one of these will be found the same way — by reading generated fixture output.

Pins the 1.5 page beside the 1.4 one rather than replacing it. Fixtures are
permanent and these are different table shapes, not different data: 1.4 is a
single dated row, 1.5 is nine rows of open ends. The group name gains the
fixture path now that one adapter has two.

Nine rows, eight events. `Ridgeline Flows of Autumn Sign-In` reads
`Period: TBA` with no start at all, so it stays undatable and dropped.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-23 01:07:21 +02:00
Lucas WintherandClaude Opus 5 843e27b4f1 fix(ingest): read an open range written in slash dates
`parseOpenRange` only ever read a month-name start, because it delegates to
`parseMonthDayYear`. So `Jul. 24, 2026 - End of 4.6` parsed and
`09/02/2026 - TBA` did not.

Game8 re-cut Endfield's upcoming table from version 1.4 to 1.5 and wrote the
whole new schedule in the second notation. All nine rows became undatable and
were dropped in silence: the source went from one event to zero, the parse gate
kept the previous snapshot, and the run reported a shape change on a page whose
shape had not changed. Nine announced events were invisible with no error
anywhere — the failure § Working on parsers calls the dangerous one.

Which notation a page uses is house style, not a statement about how certain the
source is, so both are read. The year stays mandatory in either: this is the
last reader tried, so nothing downstream would catch a guess, and Game8's
year-less summary rows (`08/12 - 08/24`) must keep returning null rather than
becoming confidently dated events.

The two-digit year pivot moves out of `parseShortSlashRange` into `pivotYear`
rather than being written a second time.

Diffed across every pinned fixture and all nineteen live snapshots: no event
moved.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-23 01:06:52 +02:00
Lucas Winther af6689365b chore: refresh source snapshots. 2026-08-23 00:45:35 +02:00
github-actions[bot] f903ddff47 chore(data): refresh source snapshots
Automated fetch from Refresh sources run 32588485487.
2026-08-22 17:41:52 +00:00
github-actions[bot] 23d957ed91 chore(data): refresh source snapshots
Automated fetch from Refresh sources run 32555287427.
2026-08-22 05:48:39 +00:00
github-actions[bot] 0c1f9d37d0 chore(data): refresh source snapshots
Automated fetch from Refresh sources run 32510320597.
2026-08-21 17:51:58 +00:00
github-actions[bot] 4b7f088190 chore(data): refresh source snapshots
Automated fetch from Refresh sources run 32452196232.
2026-08-21 05:53:26 +00:00
Lucas WintherandClaude Opus 5 9df558a54b docs: record Game8's events-summary block and the narrower h4 rule
Both files stated the h4 rule as a flat prohibition — an unrecognised h4
must never claim the event title — which is no longer what the code does.
They now state the narrower claim: an h4 may fill an empty event title but
never take one, with the reason each page needs it. Genshin's labels arrive
after its h3 has claimed the slot; Wuthering Waves' block sits under a
section heading that leaves the slot empty.

INGESTION.md gains the block as shape 9, and the part that is easy to get
wrong on the way back: its heading has to be *in* the section vocabulary
rather than merely fall through, because an unrecognised heading is read as
an event name and would spend the title on the section.

AGENTS.md also gains a bullet for where a missing blurb hides. Two
templates now put unlock conditions in the cell where a description
belongs, and in both the real prose is in a per-event section further down
the page — under an h3 on one and an h4 on the other.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-20 22:38:54 +02:00
Lucas WintherandClaude Opus 5 67bbeaf28c fix(ingest): read the Game8 block that holds the event blurbs
Every Wuthering Waves event published with no description, and two of them
did not publish at all. The page has always carried both: below the Ongoing
and Upcoming tables it repeats each live event as its own sub-section,
titled by an h4, over an `Event Duration` row and a paragraph of prose —
the shape Persona 5 uses, one heading level down. Two things kept the whole
block unreadable, and the second is why the first was never noticed.

`RANGE_LABEL` matched a bare `duration` and not `Event Duration`, so
`readLabelledDates` returned null for all ten of those tables and no
candidate was ever pushed — which is also why `summaryAfter`, whose only
job is that trailing paragraph, never ran. The label is now qualified the
way START_LABEL and END_LABEL already allow.

The other was the h4 rule. An unrecognised h4 could never name an event,
because Genshin uses h4 for sub-headings *inside* one ("Availability
Period") and letting a label claim the title renames the event to whatever
sits above its date table. But an h4 is all Wuthering Waves offers. Both
pages are served by the narrower rule: an h4 may fill an empty title, never
take one. Genshin's h3 has already claimed the slot before its labels
arrive; here the block sits under a section heading, so the slot is empty.
That only holds if the heading is recognised rather than fallen through,
since an unrecognised one is read as an event name and would spend the
title on the section — hence `events summary` in the vocabulary.

`dedupe` needed nothing. It already blends a missing summary onto the
better-dated copy for Persona 5 and never blends a date, which is what
keeps Gifts of Starpath's end on August 19 despite the block spelling it
"Auguts".

The fixture goes from ten events to twelve — `Recaptured: Action
Highlights` and `Mingshen Notices` are dated nowhere else on the page — and
nine of the ten blanks fill in. Ascendant Aces stays blank because
`Unlocked by default` is the only prose Game8 ever wrote for it, which is
what `isRequirementOnly` is for, and a test pins it there. Diffed across
every pinned fixture and all nine live Game8 snapshots: the only other
movement is Gifts of Drifting Mist gaining its blurb, and no date, no
count and no other source moved.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-20 22:38:41 +02:00
Lucas Winther ae916a3f76 chore: refresh source snapshots. 2026-08-20 22:18:26 +02:00
github-actions[bot] a3a325997f chore(data): refresh source snapshots
Automated fetch from Refresh sources run 32399945226.
2026-08-20 17:52:10 +00:00
github-actions[bot] bf5e844a1b chore(data): refresh source snapshots
Automated fetch from Refresh sources run 32337263086.
2026-08-20 05:52:49 +00: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 abed3f86fa perf: preload the feed from the shell
`fetchFeed` cannot start until main.js has downloaded, parsed and mounted React,
so the one request this page exists to make was the last one to leave. Nothing
about the URL depends on the bundle, so the preload scanner can have it in flight
in the first few milliseconds instead.

Measured on a 4G/4x-CPU profile with a cold cache and gzip on: the events reach
the screen at ~470ms instead of ~570ms, and the loading state stops being
something a reader can see rather than merely being brief. First contentful paint
is unchanged within noise — the win is that the feed is off the critical path
entirely, arriving at 216ms while the bundle is still downloading.

`crossorigin` on that link is not decoration. `as="fetch"` has to match the
credentials mode of the `fetch()` that follows, and a mismatch is not a no-op:
the browser throws the preload away and downloads the feed twice, which is worse
than not preloading at all. Verified by counting requests in a real browser,
because no test here can see it.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-20 06:57:22 +02:00
Lucas WintherandClaude Opus 5 3e30ab07aa perf: compress text responses from the static server
GitHub Pages gzips for us, so the deployed site never had this problem. The
Docker image serves through serve.ts, which did not — so a self-hoster shipped
the bundle at 344 KB where the site sends 100 KB, and the feed at 90 KB where
the site sends 10 KB. Roughly three times the bytes, and on anything slower than
a laptop on wifi three times the download.

Negotiated on `accept-encoding` and applied only to the text types this app
serves; a PNG is left alone rather than spending CPU to grow it. `Vary` goes out
on both answers, because a shared cache that does not know the response depends
on the request header will hand gzipped bytes to a client that never asked.

The compressed bytes are cached in memory and keyed by mtime — these files
change only on deploy, so re-gzipping 344 KB per request is waste, and keying on
mtime rather than holding forever keeps `bun run dev` serving what was just
rebuilt. A failure to compress falls through to the raw file: this is an
optimisation and never a reason to fail a request.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-20 06:55:34 +02:00
Lucas WintherandClaude Opus 5 a7cf59d8e4 perf: bundle React in production mode
`build:js` was shipping React's development build. That cost 222 KB of the
566 KB bundle (61 KB of 164 KB gzipped), ran every element creation through
dev-only validation, and left `StrictMode` double-invoking effects — so
`fetchFeed` fired twice and every reader downloaded the feed twice on every
single load.

On a 4G/4×-CPU profile with the cache cold: first contentful paint 1480ms →
972ms, the feed on screen 1766ms → 1261ms, and two feed requests → one.

The flag is `--production` and not `--define process.env.NODE_ENV='"production"'`,
which is the obvious-looking version and produces a bundle that does not run at
all: the define flips React to its production build while the JSX transform
keeps emitting `jsxDEV`, so the page dies on `z is not a function` with a blank
body. Both variants build clean, typecheck clean and pass all 843 tests, because
nothing in the suite executes the bundle — so AGENTS.md now says to open a
browser before believing this one.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-20 06:52:21 +02:00
Lucas WintherandClaude Opus 5 23ad2ad797 fix: reject a year Date.UTC would silently move
`iso()` read back the month and the day to catch February 30 rolling over to
March 2, and took the year on trust. `Date.UTC` maps years 0–99 into the 1900s,
so a four-digit "0050" came back as 1950 with the month and day intact — past
both existing checks, because nothing about the resulting date is impossible.

Same class of failure as the rollover the guard was written for: a boundary the
source never stated, published as though it had. Reading the year back too is
the skip-rather-than-guess rule this module runs on, applied to the one field
that was not checked. It narrows only — `parseShortSlashRange`'s two-digit pivot
produces full years and is untouched.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-20 06:40:58 +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 5ae559de97 docs: record the game order and the catch-up window
The rules a future change could undo without noticing, written where whoever
touches that area next will read them.

Three of them are traps rather than descriptions. An absent `gameOrder` means
the reader has never placed a game, not an empty order — reading it the other
way hands every existing install a blank list, which is the `knownGames`
mistake again. `games` is left in feed order deliberately, because the code
that hides a reader's games diffs it. And the catch-up window bounds display
and never storage: a fortnight's streak exists on one device and nowhere else,
so nothing prunes the log against a window.

The spec that produced the work goes in alongside, including the helper it
specified and the implementation dropped, so the next reader sees why rather
than wondering.

Also corrects the test count in AGENTS.md, which said 772 and was already
stale before this.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-20 06:02:16 +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 Winther 24832844a6 chore: refresh source snapshots. 2026-08-20 00:25:43 +02:00
Lucas WintherandClaude Opus 5 04f26e0eb8 docs: the runner was switched back, so stop claiming it
Switching refresh.yml back to ubuntu-latest left this file asserting that it
runs on a self-hosted runner "because the address is the legitimate lever". The
lever is still the address; that particular address just was not a better one.

Says so explicitly, because the revert is easy to misread as the idea being
wrong rather than one address not being the one — and the next person here will
otherwise try the same runner again.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-20 00:23:37 +02:00
Lucas Winther 384cd8164e Merge remote-tracking branch 'origin/main' 2026-08-20 00:21:08 +02:00
Lucas WintherandClaude Opus 5 70d4c3ee1e docs: safe-ip fixed neither edge, and the cron flag fixed Fandom
The runner move was recorded here as untested for game8 because that cycle
interval-skipped all nine. The next one asked them: 202 CloudFront, every one.
So safe-ip is challenged by Cloudflare and refused by CloudFront alike and
bought nothing by itself — worth stating plainly, since "try a different runner"
is the first idea anybody has here and it has now been tried.

The Fandom half is fixed, by the cron passing --assume-robots-on-403 rather
than by the address: the next cycle put all four through at 200, two with fresh
bytes.

Also records why game8 cannot have the same treatment. Its 202 is the page
itself being withheld, not a permission we hold and cannot re-read, so there is
nothing for a recorded decision to stand on.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-20 00:20:26 +02:00
Lucas Winther ddd4185a0d Switch back, as cloudfront is problem still on self-hosted IP. 2026-08-20 00:19:40 +02:00