Commit Graph
305 Commits
Author SHA1 Message Date
Lucas WintherandClaude Opus 5 6dad359b2a Ask the cadence first, then only the dates it needs
The repeat controls sat after the dates and applied to whatever was there,
which meant a weekly chore still had to answer an end date it has no honest
answer to. Asking first inverts that: the cadence decides which dates are
even questions.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-28 05:04:39 +02:00
Lucas WintherandClaude Opus 5 c93f85fb6d Record what recurrence changed, and what it unblocked
Three sentences were false once a rule could bound an occurrence. DATA-MODEL
gains the derived occurrence key and what a reschedule costs; F13 gains the
rule and the reason an occurrence need not state its end.

SOURCES' arustats note stays, with the answer beside it: the reason
scheduleBosses is unread was a design gap, that gap is closed, and reading
it is now a parser change. Nothing on the ingest side has moved — this note
is what stops the next person re-deriving why.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-28 05:04:39 +02:00
Lucas WintherandClaude Opus 5 dfb00aaa86 Correct a Task 4 expectation that contradicted its own rule
The "ancient anchor" test expected three occurrences and omitted 31 August.
With a daily rule anchored at 09:00 and no stated end, the 31st runs to
09:00 on the 1st — inside a window that opens at midnight on the 1st. The
brief's own rule is that an occurrence overlapping either edge is included,
so four is right and three was the plan disagreeing with itself.

Found by the Task 4 implementer, which corrected the test and left the
implementation alone. Verified by running occurrencesOf directly.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-28 05:04:39 +02:00
Lucas WintherandClaude Opus 5 50121602db Derive the occurrences a rule stands for
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]>
2026-08-28 05:04:39 +02:00
Lucas WintherandClaude Opus 5 ee43270b2b Move the rename-stability test to where it can fail
Task 2's version called occurrenceId twice with identical arguments, which
any pure function satisfies — occurrenceId never takes a title, so it could
not fail the thing it claimed to pin. Task 5 passes the whole rule into
asOccurrenceEvent, so a rename is a real input there and the assertion has
something to bite on.

Found by the Task 2 review.

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

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

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-28 05:04:39 +02:00
Lucas WintherandClaude Opus 5 12dbbe3228 Fix vacuous tests in occurrence id suite
Replace the local-vs-UTC test with a timestamp that actually straddles UTC
day boundary (00:30 local on 2 September = 22:30Z on the 1st). The original
test was vacuous because both readings agreed on the same calendar day.

Delete the rename-stability test which was a tautology — occurrenceId never
takes a title, so any pure function satisfied it. The real assertion belongs
in a later task where the whole rule is passed in and a rename can be
exercised.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-28 05:04:39 +02:00
Lucas WintherandClaude Opus 5 661fc1719b Derive an id per occurrence, unstorable by construction
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]>
2026-08-28 05:04:39 +02:00
Lucas WintherandClaude Opus 5 dcb9be9f4a Step a calendar by days, weeks or months
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]>
2026-08-28 05:04:39 +02:00
Lucas WintherandClaude Opus 5 5acd64996b Move the movesOccurrences tests to the task that writes it
Pre-flight scan finding. The function is implemented in Task 2 and its four
tests sat in Task 4's block, so Task 2 would have shipped untested code and
Task 4 would have tested a function it did not write. Task 2's stated count
of 23 already assumed the corrected placement.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-28 05:04:39 +02:00
github-actions[bot] 81ed1013c0 chore(data): refresh source snapshots
Automated fetch from Refresh sources run 33133155344.
2026-08-28 01:32:44 +00:00
github-actions[bot] fdb0597564 chore(data): refresh source snapshots
Automated fetch from Refresh sources run 33093949043.
2026-08-27 16:35:24 +00:00
Lucas WintherandClaude Opus 5 c07e37a4a7 Plan recurring custom events, task by task
Eleven tasks from the design, each with its own test cycle and commit.
Ordered so the schema field lands before anything derives from it and the
store migration is proved before a single row is expanded.

Three things the plan had to settle that the spec left to the implementer.
The overlap check is one exported predicate, because a form that restates
its schema's rule drifts from it and starts refusing saves that would
succeed. The timeline's expansion needs App to pass the same four scope
filters the lists use, so the predicate is extracted rather than copied.
And custom-ui.test.tsx renders statically with no testing-library in the
project at all, so anything needing a click is stated as a pure function
and tested directly rather than through a harness that does not exist.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-27 02:10:05 +02:00
Lucas WintherandClaude Opus 5 98cd950e8e Design recurring events, and plan the sourced ones
The app can express a window closing and a day repeating, and nothing in
between — daily.ts counts in days and stops there. That gap is already
costing parsed data: arustats scheduleBosses is read and discarded because
"a recurring rotation with no end is not a deadline" (docs/SOURCES.md:740).

A rule fixes exactly that. Content repeating every fourteen days ends, at
the latest, when its next occurrence opens — a boundary entailed by the
interval rather than invented for a form. So an occurrence may state no end
and still be a deadline, which is the distinction the spec turns on.

Proposes it first on the reader's own events (F13), where the rule is typed
rather than fetched: no parser, no review gate, no GachaEvent change, and a
tested recurrence model in shared/ for the ingest side to adopt later.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-27 01:53:43 +02:00
Lucas WintherandClaude Opus 5 f36dfcf733 chore(data): add the first Honkai Impact 3rd snapshot
Fetched by the runner rather than by hand, so the whole path is evidenced:
robots allowed at Crawl-delay 1, the 307 followed to the live version, canParse
satisfied, eventCount 16. The lane now builds from live bytes instead of
falling back to the checked-in fixture.

Kept out of the source commit because a snapshot is data this repository
rewrites on a schedule, and the fixture is the pinned copy that proves the
parser.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-27 01:28:24 +02:00
Lucas WintherandClaude Opus 5 7864ba6241 Record the Honkai Impact 3rd source and what its dates are worth
SOURCES.md § 13 declined this game's only known source partly because every
boundary was a week bucket, and we have now built a different source with that
same objection standing. Leaving that contradiction unwritten is how a later
pass rediscovers the trade the expensive way, so § 14 records the decision
itself: the conduct evidence, which three of § 13's four objections arustats
answers, which one it does not, and that any source stating a date per event
retires it. § 13 is marked superseded rather than rewritten — its verdict on
marisaimpact.com still stands and was not re-tested.

AGENTS.md gets the same warning where a parser author will hit it, because the
estimate is not visible from the code: the numbers look like every other
day-precision date in the project.

Also corrects the counts this game made false — nine parsers, twenty sources,
nineteen games. The "stopped working at eighteen" passages are left alone: they
date the settings-panel redesign, and bumping them would falsify the history
rather than update a count.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-27 01:28:05 +02:00
Lucas WintherandClaude Opus 5 c272d9e93d Publish Honkai Impact 3rd events from arustats.com
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]>
2026-08-27 01:27:49 +02:00
Lucas WintherandClaude Opus 5 8735222adc Add a parser for arustats.com's version timeline
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]>
2026-08-27 01:27:25 +02:00
Lucas WintherandClaude Opus 5 fc2a1e2c9f Add Honkai Impact 3rd as a game
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]>
2026-08-27 01:27:07 +02:00
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