The pipeline change made a set of sentences false, and they are the only
record of why things are the way they are, so they move with it: the CI
section and deploy target in README, the cron and BASE_PATH notes in
ARCHITECTURE, the documented User-Agent in INGESTION, and the contact
URL in SOURCES.
Two comments in source were false in the same way and are fixed here
rather than left for the next reader. `serve.ts` said compression was
"the whole difference between the Docker image and the deployed site" —
the image *is* the deployed site now, so that negotiation is the only
compression any reader gets. `robots.ts` justified product-token
matching with "our contact URL contains the string StereotypicalCat",
which it no longer does; the hazard is the arbitrary substring, not that
one, so the comment now says so and the regression test keeps the URL
that actually caused it.
What is deliberately NOT rewritten: game8.co's refusal stays in the past
tense with the Gitea result marked unverified, in AGENTS.md, README,
SOURCES and FEEDBACK alike. A new runner address is the variable that
failure turns on, so it may well be served — but "may well be" is not
evidence, and the four places that would have to change are named
together so they change together once a cycle has actually run.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Add karendar.com as the event source for Punishing: Gray Raven (PGR).
Karendar publishes a dedicated Global event calendar with exact UTC
timestamps in clean server-rendered HTML.
- Register 'pgr' in GameId and GAMES registry (#CC292B, Kuro Games).
- Add parseWeekdayDayMonthYearUtc in src/ingest/dates.ts with unit tests.
- Implement karendarParser in src/ingest/parsers/karendar.ts reading
'week', 'ongoing', and 'upcoming' sections, mapping indefinite/permanent
ends to endsAt: null, and categorizing tags to EventType.
- Register pgr-karendar-events in src/ingest/adapters/index.ts.
- Add pinned fixture, expected output, and comprehensive test suites.
- Update documentation in AGENTS.md, README.md, docs/INGESTION.md,
and docs/SOURCES.md.
Add genshin-impact.fandom.com as a second source for Genshin Impact,
which has been fixture-backed in CI because Game8 CloudFront-blocks the
Actions runner with 202s.
- Add fifth Fandom template parser in src/ingest/parsers/fandom.ts
extracting titles from caption display text, stripping dated subpage
suffixes, and checking currency via latestBoundaryMs.
- Register genshin-fandom-events in src/ingest/adapters/index.ts and
set genshin-game8-events priority: 10 so existing event IDs survive
near-match ties, preventing localStorage completion marks from
orphaning.
- Explicitly sort adapter groups by priority descending in
scripts/build-feed.ts to align with parseGame.
- Pin fixtures and add test coverage in test/adapters/game8.test.ts.
- Update AGENTS.md, README.md, docs/INGESTION.md, and docs/SOURCES.md.
The build log prints a count per source, and CI now declines to fail on
a page that states its own emptiness — which leaves that source showing
an unexplained 0, reading as exactly the fault the gate just decided it
was not. The zero we are content with is the one that has to say why.
It is also the only line that would ever prompt anyone to ask whether a
lane quiet for a month is a game between patches or a wiki that reworded
the sentence a statesNoEvents check is still matching. Nothing else in
the pipeline can tell those apart.
The test count in the layout block was stale at 898 and moves with these
two commits, so it is corrected here.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Infinity Nikki's wiki prints "There are no Events in this category"
between versions, and the refresh runner has honoured that since
2026-09-03 — it stores the empty parse as the source's real answer
rather than letting a quiet lane reach the broken tier. The feed build
never asked, so the same page arrived at CI as parsedCount 0,
indistinguishable from a parser that has stopped reading a redesigned
page, and brokenSources failed every build while every refresh stayed
green.
The runner's verdict cannot travel on its own: only a parser has seen
the page, and by the time brokenSources runs there is nothing left but
the feed. So the fact rides on SourceHealth, defaulted so an older feed
the service worker cached keeps validating and reads as the strict
answer.
Both ends now ask it the same way — of an empty parse only, from the
page's own words only — because a redesign yields zero rows too, and
excusing that is the silently emptied calendar the gate exists for.
The rule sits in a module rather than in build-feed.ts, which writes
public/ and so runs a build if a test imports it. That is how the two
ends drifted apart with nothing to catch it.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The wiki emptied both its Current and Upcoming tables on 2026-09-03 — 2.7's
events had ended and 2.8 was not listed yet — and said so in words: "There
are no Events in this category". Nothing else moved; Past Events still
carries twenty tables of the same shape.
`isInfinityNikkiEventPage` looked for a *populated* table, so it read that
as a redesign. The source spent four cycles reporting "the source has
likely been redesigned" at a page nobody had touched, reached the broken
tier, and went on serving a snapshot whose every row expired on 27 August.
A check that reads data cannot tell a rewrite from a quiet week. It now
reads the section headings, which an empty table does not take with it —
either heading rather than both, since requiring the pair would fail the
source over a renamed heading it does not even read, and one already
separates this page from the other three Fandom templates. docs/INGESTION.md
asked for structural checks all along; this one had drifted into content.
`statesNoEvents` then reports the page's own declaration, so the runner can
tell an answer from a failure. It insists on the statement rather than an
empty table, because a redesign yields an empty table too.
The emptied page is pinned as a fixture: an empty answer is a shape, and it
is the one that used to read as a redesign.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
A count of zero has two causes and nothing downstream can tell them apart:
a source whose selectors all broke, and a game that is simply between
patches. Both parse to nothing, so `canParse` plus a row count cannot
separate them — and the pipeline currently has to assume the worst, which
is right for a redesign and wrong for a quiet week.
`statesNoEvents` is the seam for the one thing that can settle it: the
page's own words. Optional, because most pages say nothing either way, and
absent means the strict gate stands.
No parser implements it yet and nothing reads it — the contract widens here
so the parser and the refresh gate can land as their own changes.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
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]>
CI failed on Infinity Nikki yielding no events, and was wrong to. Its
snapshot parses to six events; every one of them had ended by the morning
the build ran. The parser is fine — `--now 2026-08-14` gives six, today
gives none — and the page simply has nothing current left on it.
eventCount is counted after expired events are dropped, so "this parser has
stopped reading a redesigned page" and "this page's events have all
finished" arrived as the same zero. Only the first means our code is wrong,
and only the first should redden a build. So the feed now records what each
document yields parsed as of its own capture date, before expiry, and the
check fails on that instead.
Nikki cannot refresh itself out of this, either: docs/SOURCES.md records
that Fandom refuses the Actions runner. A lane with an empty calendar is a
real problem, but it is a refresh problem, so it is reported on the build
log and left visible rather than thrown.
parsedCount is nullable and defaulted, never required: the client validates
the whole feed with safeParse and the service worker serves the last feed it
downloaded, so a required field would have made every cached feed fail
validation and taken the offline promise with it. Null also covers a source
whose bytes were never confirmed live — there is no date to parse "as of",
and a build is not failed on missing information.
The rule moved to shared/feed.ts. A test did pin the old one, by grepping
the workflow for the string — which proved the check existed, never that it
was right.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
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]>
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]>
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]>
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]>
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]>
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]>
"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]>
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]>
The repeat control is becoming three answers — never, forever, or after a
delay — and in the last two the form can work the cadence out from dates
the reader has already given. Both reduce to one question: which unit and
interval steps from the anchor to the instant the next occurrence should
open. Forever passes the instant this one closes; a delay pushes it out.
Searched with addUnits rather than divided out of a millisecond span,
because a month is not a fixed number of days and a week across a DST
transition is 167 or 169 hours. Largest honest unit wins, so 1 July to
1 August is "every month" rather than "every 31 days", which would drift
out of step by February.
repeatModeOf derives which state a saved rule is in rather than storing it,
since "forever" and "a delay of zero" are the same rule and remembering
which button produced it would change nothing about a single occurrence.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
The rule supplies what the thing is; the occurrence supplies which time
round. Nothing downstream is told which it is looking at, which is what
lets sort, focus, lanes, filters, progress, ignores and the daily
checklist work with no narrowing at any call site.
The end is always resolved here and never null. A row still carrying the
unresolved form would render live-with-unknown-end forever, which is the
failure the whole design exists to avoid.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
An occurrence with no stated end runs until the next one opens. That is the
boundary a bare rotation was missing — docs/SOURCES.md declines to publish
arustats' Abyss openings precisely because nothing bounded them — and here
it is entailed by the interval the reader typed rather than invented for
them. The store still holds endsAt: null; only this projection resolves it.
nextOccurrences returns what has not finished rather than what is running,
so a rule between cycles answers "opens Saturday" instead of vanishing for
its whole off week.
The "ancient anchor" test's expected occurrences now include 31 August: with
a six-year-old anchor at 09:00 and a query window opening at midnight, that
day's occurrence (no stated end, so it runs until 1 September 09:00 opens
the next) genuinely overlaps the window's first nine hours — the same edge
rule the sibling "overlapping at either edge" test exists to prove. The
brief's original expected list omitted it.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
.default(null) rather than a bare .nullable(), and the distinction is the
whole commit: a record written before this field existed has no `repeat`
key, a bare .nullable() rejects a missing key, and useCustom reads through
validRecords — which drops what fails and persists only the survivors. The
stricter form would have erased every reader's custom events on first
launch with no server-side copy.
Also refuses a window that comes round before it closes, since two live
occurrences of one rule leave "what ends soonest" without an answer. Only
when an end is stated; with none the window runs to the next opening and
cannot overlap.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
myevent:<token>#<YYYY-MM-DD>. The token says which recurring thing, the
local day says which time round, and marks, ignores, progress and daily
ticks all key off the whole string — so an occurrence carries its own
completion and its own streak rather than sharing the rule's.
'#' is outside [a-z0-9] and therefore outside CustomEventId, so an
occurrence cannot be written back into the store or survive an import.
That is the guardrail rather than a code path anybody has to remember, and
a test pins it in both directions.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The app can express a window closing and a day repeating; daily.ts counts
in days and stops there. This is the arithmetic under the rung between
them.
Local wall-clock rather than milliseconds, because a reader's own event is
local throughout — a weekly reset set for 09:00 stays at 09:00 across a DST
transition, where adding 7*DAY would move it an hour and drag every later
occurrence with it. Months clamp to the last valid day rather than letting
setMonth roll 31 February into 3 March, which is the same silent shift
readerInstant already refuses on the way in.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Sixteen events, from a page whose URL never needs editing: /en-us/hi3/timeline
answers 307 to the live version, so the site names its own current version
server-side and the runner follows it. That is the stable route the
marisaimpact.com page was declined for lacking, and pinning a version here
would publish a finished schedule as current the day the game moves on — a
test asserts the registered URL carries no version.
The boundaries are estimated week-bucket edges rather than announced dates,
which is the objection marisaimpact.com was declined on and is taken here
deliberately: the game had no source at all, and approximate six-week windows
were judged better than an empty lane. Confidence records it in the data.
Verified against the live page rather than only against the expected file:
all sixteen rendered grid-column spans agree with the JSON, which independently
confirms the week mapping and that endWeek is exclusive, and sixteen bars on
the page yield sixteen events with nothing silently dropped. The two 7-Day
Login events get their own test, since collapsing them into one ID is the
failure this parser is most likely to regress into.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
First parser here that reads no markup: the site is Next.js and server-renders
its whole schedule into a __NEXT_DATA__ blob, so reading that JSON is both
simpler and safer than the rendered grid, whose bars carry their geometry in
inline grid-column styles and whose titles arrive HTML-escaped.
The dates it yields are estimates, which nothing else in this project
publishes. The page schedules by week bucket — events carry startWeek/endWeek
integers, no event states a date of its own, and the grid is headed
"ESTIMATED WEEK". ESTIMATE_CONFIDENCE (0.4) is what carries that fact into the
data rather than leaving it in a comment: mergeEvents prefers the higher
number, so a source that states real dates outranks this one automatically and
nobody has to remember to retire it.
Two details that would otherwise cost data. endWeek is exclusive and runs one
past the grid to mean "to the end of the version"; an index beyond that is
skipped rather than clamped, because pinning an unreadable bar to the version's
edge would invent the boundary. And titleTop/titleMid must be joined when the
first ends in a colon — v9.0 runs two "7-Day Login:" events from week one, and
taking titleTop alone gives both the same title, the same start and therefore
the same event ID.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The lane needs a GameId before anything can publish into it, and that enum
value becomes the first segment of every completion key this game will ever
have — so it is worth landing on its own rather than inside an adapter change.
No resetOffsets and no resetHourLocal. The source we are about to add states
its times as 0:0:0 placeholders on a week grid and names no timezone, so there
is no clock to read a reset out of. Setting one now would move real readers'
day keys later on nothing but a guess; the p5x and ba entries take the same
silence to the same answer.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
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]>
`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]>
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]>
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]>
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]>
`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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>