fix: count down to a game's reset, not to UTC midnight

A source that prints "August 19, 2026" and no time is stored at 00:00Z,
which is a placeholder for "somewhere in this day" rather than a claim
about when the day opens. The countdown read it literally, which turns it
into exactly that claim — and the UTC day is nobody's. Wuthering Waves
events dated the 19th were still running three hours after we had retired
them, because a European player's day opens at 04:00 on a UTC+1 server;
Asia was four hours out the other way and America nine. Sixty-two of the
eighty-six published events were affected, across ten of thirteen games,
so the reader was reading a wrong number far more often than a right one.

A day-precision boundary now resolves to the reset that opens that
game-day on the reader's server — the same grid daily.ts keys every tick
by, and the only clock we hold for a game. That is a reading of the date
the source printed, not a time invented for it: nothing stored moves, no
event ID moves, and the parsers still refuse to guess.

Two boundaries are never re-anchored. A regionEnds value is already the
instant a source stated per server, so anchoring it would throw a fact
away. And an event the reader typed in was resolved by readerInstant in
their own timezone, which is a stated time too — theirs.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Lucas Winther
2026-08-18 21:48:29 +02:00
co-authored by Claude Opus 5
parent 00c3f5e2da
commit 4334988ecf
6 changed files with 248 additions and 13 deletions
+18 -1
View File
@@ -115,7 +115,7 @@ src/client/ React app, service worker, manifest
zoom.ts — the timeline's scale ladder; pure
scripts/ build-feed.ts, build-static.ts, parse-fixture.ts (offline), refresh-sources.ts (fetches)
serve.ts static server + /api/health
test/ 591 tests
test/ 599 tests
fixtures/<game>/ raw HTML + .expected.json per source — pinned, kept forever
snapshots/ current page per source, rewritten by refresh — see its README
```
@@ -137,6 +137,19 @@ These come from how gacha games actually schedule things, and they cause most bu
`endsAt: null` and `endPrecision: "unknown"`. **Never invent a plausible date to satisfy a
non-null type.** This is the worst failure mode this codebase has, because the user's entire
reason for visiting is trusting the end date.
- **A date with no time of day is stored as 00:00Z, and that is a placeholder, not an instant.**
Most sources print `August 19, 2026` and nothing else, so `dates.ts` returns `precision: "day"` at
UTC midnight because it has to return something. Counting down to it literally turns the
placeholder into a claim the source never made — that the day opens in UTC — and retires an event
up to nine hours before the game does, while the reader is standing in the game watching a longer
timer. So `clockFor` (`src/shared/time.ts`) resolves a day-precision boundary to the reset that
opens that game-day on the reader's server, via `dayStartMs`: the same clock `daily.ts` keys every
tick by, and the only fact we hold about a game's day. Two boundaries are never re-anchored — a
`regionEnds` value, which exists precisely because the source stated an instant per server, and an
event the reader typed in, which `readerInstant` already resolved in their own timezone. This is a
*reading* of the printed date, not an invented time, and it changes nothing stored: the feed,
every event ID and the parsers are untouched, so it is one resolution at the point where the
region is finally known.
- **Patch cycles are ~6 weeks.** Any event over 180 days is a parse error, not a long event. The
validator and the tests both reject it.
@@ -193,6 +206,10 @@ Two more key spaces have the same property, for the same reason:
- **Game-day keys** (`dayKey`) are `YYYY-MM-DD` in *server-reset space*, not UTC — the day rolls at
04:00 local server time. They are storage keys *and* they are compared with `<` and sorted, so the
format is fixed. Changing the reset hour or the offsets moves every reader's streak by a day.
The clock those keys are cut on — `RESET_HOUR_LOCAL`, `serverOffsetUtc`, `resetHourFor`,
`resetShiftMs` — lives in `time.ts`, not `daily.ts`, because the countdown resolves day-precision
boundaries on the same grid (§ Domain rules). Ticks are no longer its only caller, so a change
there now moves a reader's streak **and** every undated end date at once.
A game whose server map differs lists the affected regions in `resetOffsets` (`games.ts`) —
Endfield serves Europe off the Americas machine, so `europe` is UTC-5 there and its reset is
09:00 UTC, not 03:00. Keep that override **per region**: a blanket per-game offset drags the