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 zoom.ts — the timeline's scale ladder; pure
scripts/ build-feed.ts, build-static.ts, parse-fixture.ts (offline), refresh-sources.ts (fetches) scripts/ build-feed.ts, build-static.ts, parse-fixture.ts (offline), refresh-sources.ts (fetches)
serve.ts static server + /api/health serve.ts static server + /api/health
test/ 591 tests test/ 599 tests
fixtures/<game>/ raw HTML + .expected.json per source — pinned, kept forever fixtures/<game>/ raw HTML + .expected.json per source — pinned, kept forever
snapshots/ current page per source, rewritten by refresh — see its README 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 `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 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. 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 - **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. 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 - **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 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. 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`) — 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 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 09:00 UTC, not 03:00. Keep that override **per region**: a blanket per-game offset drags the
+18 -1
View File
@@ -79,6 +79,23 @@ true`, with `regionEnds` carrying the three resolved UTC instants. The client pi
user's stored region (PRD F5). Collapsing these into a single timestamp loses up to 13 hours of user's stored region (PRD F5). Collapsing these into a single timestamp loses up to 13 hours of
accuracy and will make the countdown wrong for two thirds of users. accuracy and will make the countdown wrong for two thirds of users.
**`startPrecision` / `endPrecision`, and what 00:00Z means.** A source that prints a calendar date
and no time of day gets `"day"` precision, and the instant stored alongside it is that date at
00:00Z. That timestamp is a **placeholder for "somewhere in this day", not a claim that the day
begins at UTC midnight** — the parser has declined to invent a time, exactly as it declines to
invent a date. Nothing downstream may read it as an instant: `clockFor` in `src/shared/time.ts`
resolves a day-precision boundary to the reset that opens that game-day on the reader's server
(`dayStartMs`), which is the same clock `daily.ts` keys every tick by, and the countdown and the
detail sheet both run off that. Read literally instead, the stored value expires an event up to nine
hours early — the whole of Asia and Europe — which is how a Wuthering Waves event dated "August 19"
was called over three hours before the game ended it.
Two boundaries are exempt, for the same reason in both directions. A `regionEnds` value is taken
verbatim: that map only exists because a source published a timer per server, so it is already the
instant, and re-anchoring it would throw a stated fact away. And an event the reader typed in
(`extractionMethod: "manual"`) is taken verbatim too: `readerInstant` resolved it to the instant
*they* meant, in their own timezone, when they entered it.
**`confidence`** is assigned by the parser and adjusted during merge and reconciliation — see **`confidence`** is assigned by the parser and adjusted during merge and reconciliation — see
`docs/INGESTION.md` § Scoring. It records how firmly the sources pinned the event down. `docs/INGESTION.md` § Scoring. It records how firmly the sources pinned the event down.
@@ -392,7 +409,7 @@ to:
| Rule | Why | | Rule | Why |
|---|---| |---|---|
| `endsAt` null pairs with `endPrecision: "unknown"` | The same invariant `GachaEvent` enforces. "I don't know when this ends" is a supported answer for a reader too, and a required one — otherwise entering an unannounced event forces them to invent a date | | `endsAt` null pairs with `endPrecision: "unknown"` | The same invariant `GachaEvent` enforces. "I don't know when this ends" is a supported answer for a reader too, and a required one — otherwise entering an unannounced event forces them to invent a date |
| A date with no time is `"day"` precision, a date with one is `"exact"` | So the UI's existing "accurate to the day only" note is honest about their input as well | | A date with no time is `"day"` precision, a date with one is `"exact"` | So the UI's existing "accurate to the day only" note is honest about their input as well. Their day-precision boundary is still *their* instant, though — `readerInstant` puts a start at 00:00 and an end at 23:59:59 in their own timezone, so unlike a parser's placeholder it is never re-anchored to a server reset |
| `endsAt` must be after `startsAt` | A backwards interval is a typo whoever made it | | `endsAt` must be after `startsAt` | A backwards interval is a typo whoever made it |
| `hue` must match `#rrggbb` | It reaches a `style` attribute, and an imported file is not necessarily one the reader wrote | | `hue` must match `#rrggbb` | It reaches a `style` attribute, and an imported file is not necessarily one the reader wrote |
| `regionScoped` is always false | They entered one instant, not a per-region map. Claiming otherwise would fabricate three timestamps out of one | | `regionScoped` is always false | They entered one instant, not a per-region map. Claiming otherwise would fabricate three timestamps out of one |
+8
View File
@@ -142,6 +142,14 @@ All live in `src/ingest/dates.ts`, each returning null rather than inferring any
| `parseSlashClockZone` | `08/17/2026 8:00PM (JST)` (one boundary per column, 12-hour clock, **named** zone) | hololive Dreams | | `parseSlashClockZone` | `08/17/2026 8:00PM (JST)` (one boundary per column, 12-hour clock, **named** zone) | hololive Dreams |
| `parseOpenRange` | `Jul. 24, 2026 - End of 4.6`, `July 10, 2026 - Permanent` | Star Rail, Wuthering Waves | | `parseOpenRange` | `Jul. 24, 2026 - End of 4.6`, `July 10, 2026 - Permanent` | Star Rail, Wuthering Waves |
**A day-precision result is 00:00Z, and that is a placeholder rather than a time.** Every reader
above returns `precision: "day"` when the source printed no clock, and stores the date at UTC
midnight because it has to store *something*. It is not a statement that the event begins or ends
then, and nothing may count down to it literally: `clockFor` resolves a day-precision boundary to
that game-day's server reset for the reader's region (`docs/DATA-MODEL.md` § Field notes). The
parsers are unaffected by this and must stay so — resolving here would need a region the parser does
not have, and would bake one reader's server into the stored feed.
`parseOpenRange` is tried last because it is the most permissive — it accepts any leading full date `parseOpenRange` is tried last because it is the most permissive — it accepts any leading full date
and reports no end. and reports no end.
+6
View File
@@ -287,6 +287,12 @@ The app's entire value is that the dates are right. Therefore:
- An event whose confidence is below threshold, or whose sources disagree, is not published at all - An event whose confidence is below threshold, or whose sources disagree, is not published at all
until a human approves it. until a human approves it.
- Every event links to its source so a skeptical user can verify in one click. - Every event links to its source so a skeptical user can verify in one click.
- **A date with no time is resolved on the game's clock, never on UTC's.** Most sources print
"August 19, 2026" and no time of day, which is stored as 00:00Z — a placeholder, not an instant.
The countdown reads it as that game-day's server reset for the reader's region, because a literal
reading retires an event up to nine hours before the game does and the reader is standing in the
game while we say it. This is a reading of the date the source printed, not a time invented for
it, and the detail sheet says as much.
An empty calendar is a recoverable disappointment. A confidently wrong end date is the failure this An empty calendar is a recoverable disappointment. A confidently wrong end date is the failure this
product exists to prevent. product exists to prevent.
+86 -9
View File
@@ -1,6 +1,6 @@
import type { DisplayEvent, LaneId } from "./custom.ts"; import type { DisplayEvent, LaneId } from "./custom.ts";
import { GAMES } from "./games.ts"; import { GAMES } from "./games.ts";
import type { GameId, Region } from "./schema.ts"; import type { GameId, Precision, Region } from "./schema.ts";
/** /**
* Time is this product's entire subject, so the vocabulary lives in one place: * Time is this product's entire subject, so the vocabulary lives in one place:
@@ -81,6 +81,14 @@ export function resetShiftMs(region: Region, game?: LaneId): number {
return serverOffsetUtc(region, game) * HOUR - resetHourFor(game) * HOUR; return serverOffsetUtc(region, game) * HOUR - resetHourFor(game) * HOUR;
} }
/**
* The instant the game-day labelled `day` (`YYYY-MM-DD`) opens on — the inverse
* of `dayKey`.
*/
export function dayStartMs(day: string, region: Region, game?: LaneId): number {
return Date.parse(`${day}T00:00:00.000Z`) - resetShiftMs(region, game);
}
export function guessRegion( export function guessRegion(
timeZoneOffsetMinutes: number = -new Date().getTimezoneOffset(), timeZoneOffsetMinutes: number = -new Date().getTimezoneOffset(),
): Region { ): Region {
@@ -99,17 +107,86 @@ export function guessRegion(
*/ */
export type EndBearing = Pick< export type EndBearing = Pick<
DisplayEvent, DisplayEvent,
"endsAt" | "regionScoped" | "regionEnds" "endsAt" | "endPrecision" | "regionScoped" | "regionEnds"
>; >;
/**
* The end to show this user, honouring a region-scoped event, with the
* precision that boundary was actually stated to.
*
* A `regionEnds` value is read as `exact` whatever `endPrecision` says about the
* fallback: the map only exists because the source published a timer per server
* (wiki.gg does; see AGENTS.md § Working on parsers), so there is a real instant
* in it and nothing left to resolve.
*/
function endBoundary(
event: EndBearing,
region: Region,
): { iso: string; precision: Precision } | null {
const stated =
event.regionScoped && event.regionEnds !== null
? event.regionEnds[region]
: undefined;
if (stated !== undefined && stated !== null) {
return { iso: stated, precision: "exact" };
}
return event.endsAt === null
? null
: { iso: event.endsAt, precision: event.endPrecision };
}
/** The end instant to show this user, honouring a region-scoped event. */ /** The end instant to show this user, honouring a region-scoped event. */
export function effectiveEnd( export function effectiveEnd(
event: EndBearing, event: EndBearing,
region: Region, region: Region,
): string | null { ): string | null {
if (event.endsAt === null) return null; return endBoundary(event, region)?.iso ?? null;
if (!event.regionScoped || event.regionEnds === null) return event.endsAt; }
return event.regionEnds[region] ?? event.endsAt;
/** Everything the clock reads off an event. */
export type Clockable = EndBearing &
Pick<
DisplayEvent,
"startsAt" | "startPrecision" | "game" | "extractionMethod"
>;
/**
* The instant a stated boundary actually falls on.
*
* An `exact` boundary is already an instant and is returned untouched. A `day`
* one is not an instant at all: the source printed a calendar date and no time,
* and `dates.ts` stores the only faithful reading of that — 00:00Z, a placeholder
* for "somewhere in this day" rather than a claim about when the day starts.
*
* Counting down to that placeholder turns it into exactly the claim it was not:
* that the day begins at UTC midnight, which is nobody's day. It is wrong for
* every reader by their server's distance from UTC, and always in the direction
* of expiring an event early for the two regions that run ahead of it. Wuthering
* Waves events dated "August 19, 2026" were still running three hours after this
* countdown had retired them, because a European player's day opens at 04:00 on
* a UTC+1 server.
*
* So a day-precision boundary resolves to the reset that opens that game-day on
* the clock the game actually rolls on — the one fact we do hold about a game's
* day, and the same one `daily.ts` already keys every tick by. That is a reading
* of the date the source printed, not a time invented for it: the calendar day
* is still the source's, and an end whose day is genuinely unannounced is still
* `null`.
*
* A reader's own event (PRD F13) is left alone even at day precision. Its
* boundary is not a parser declining to guess — `readerInstant` resolved it to
* the instant they meant, in their own timezone, when they typed it.
*/
function boundaryMs(
iso: string,
precision: Precision,
event: Pick<Clockable, "game" | "extractionMethod">,
region: Region,
): number {
if (precision !== "day" || event.extractionMethod !== "parser") {
return Date.parse(iso);
}
return dayStartMs(iso.slice(0, 10), region, event.game);
} }
export type Urgency = "expired" | "critical" | "soon" | "near" | "calm"; export type Urgency = "expired" | "critical" | "soon" | "near" | "calm";
@@ -173,13 +250,13 @@ export interface EventClock {
} }
export function clockFor( export function clockFor(
event: EndBearing & Pick<DisplayEvent, "startsAt">, event: Clockable,
region: Region, region: Region,
now: number, now: number,
): EventClock { ): EventClock {
const startsMs = Date.parse(event.startsAt); const startsMs = boundaryMs(event.startsAt, event.startPrecision, event, region);
const end = effectiveEnd(event, region); const end = endBoundary(event, region);
const endsMs = end === null ? null : Date.parse(end); const endsMs = end === null ? null : boundaryMs(end.iso, end.precision, event, region);
const msRemaining = endsMs === null ? null : endsMs - now; const msRemaining = endsMs === null ? null : endsMs - now;
const upcoming = now < startsMs; const upcoming = now < startsMs;
+112 -2
View File
@@ -1,8 +1,10 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import type { GachaEvent } from "../src/shared/schema.ts"; import type { GachaEvent } from "../src/shared/schema.ts";
import { dailyDays, dayKey } from "../src/shared/daily.ts";
import { import {
clockFor, clockFor,
DAY, DAY,
dayStartMs,
endingSoonestFirst, endingSoonestFirst,
formatRemaining, formatRemaining,
HOUR, HOUR,
@@ -59,10 +61,13 @@ describe("urgency", () => {
describe("clockFor", () => { describe("clockFor", () => {
test("reports progress through the window", () => { test("reports progress through the window", () => {
// Both boundaries are day-precision, so both land on the Europe reset
// (04:00 on a UTC+1 server = 03:00Z) rather than on UTC midnight. The
// window is still ten days; it just starts and finishes three hours later.
const c = clockFor(event(), "europe", NOW); const c = clockFor(event(), "europe", NOW);
expect(c.live).toBe(true); expect(c.live).toBe(true);
expect(c.progress).toBeCloseTo(0.55, 2); expect(c.progress).toBeCloseTo(0.5375, 3);
expect(c.msRemaining).toBe(Date.parse("2026-08-20T00:00:00.000Z") - NOW); expect(c.msRemaining).toBe(Date.parse("2026-08-20T03:00:00.000Z") - NOW);
}); });
test("an unannounced end is never urgent and has no progress", () => { test("an unannounced end is never urgent and has no progress", () => {
@@ -80,6 +85,9 @@ describe("clockFor", () => {
}); });
test("resolves a region-scoped end to the reader's region", () => { test("resolves a region-scoped end to the reader's region", () => {
// Note the event is day-precision: a `regionEnds` value is still taken
// verbatim, because the map only exists when a source printed a timer per
// server. Re-anchoring it to a reset would throw that instant away.
const c = clockFor( const c = clockFor(
event({ event({
regionScoped: true, regionScoped: true,
@@ -122,3 +130,105 @@ describe("endingSoonestFirst", () => {
]); ]);
}); });
}); });
describe("a day-precision boundary", () => {
/**
* The bug this describes: a source that prints "August 19, 2026" and no time
* is stored as 00:00Z, and counting down to that literally expires the event
* at the moment the *UTC* day opens. A European Wuthering Waves player's day
* opens at 04:00 on a UTC+1 server, so the app called an event over three
* hours before the game did — and the game was the one the reader believed.
*/
const dated = (endsAt: string, overrides: Partial<GachaEvent> = {}) =>
event({ endsAt, endPrecision: "day", ...overrides });
test("resolves to the game's reset, not to UTC midnight", () => {
const c = clockFor(dated("2026-08-19T00:00:00.000Z"), "europe", NOW);
expect(c.endsMs).toBe(Date.parse("2026-08-19T03:00:00.000Z"));
expect(c.endsMs! - Date.parse("2026-08-19T00:00:00.000Z")).toBe(3 * HOUR);
});
test("lands on a different instant in each region, from the same date", () => {
const at = (region: "asia" | "america" | "europe") =>
clockFor(dated("2026-08-19T00:00:00.000Z"), region, NOW).endsMs;
// 04:00 local on UTC+8, UTC-5 and UTC+1 servers respectively. Reading the
// printed date as UTC is wrong for all three, and by up to nine hours.
expect(at("asia")).toBe(Date.parse("2026-08-18T20:00:00.000Z"));
expect(at("america")).toBe(Date.parse("2026-08-19T09:00:00.000Z"));
expect(at("europe")).toBe(Date.parse("2026-08-19T03:00:00.000Z"));
});
test("follows a game that states its own server map or reset hour", () => {
// Endfield serves Europe off the Americas machine (UTC-5), and Reverse:
// 1999 rolls at 05:00 on a single UTC-5 server. Both already move day keys;
// an end date printed for those games moves with them.
const end = "2026-08-19T00:00:00.000Z";
expect(clockFor(dated(end, { game: "endfield" }), "europe", NOW).endsMs).toBe(
Date.parse("2026-08-19T09:00:00.000Z"),
);
expect(clockFor(dated(end, { game: "r1999" }), "europe", NOW).endsMs).toBe(
Date.parse("2026-08-19T10:00:00.000Z"),
);
});
test("leaves an exact boundary exactly where the source put it", () => {
const c = clockFor(
event({
startsAt: "2026-08-10T11:00:00.000Z",
startPrecision: "exact",
endsAt: "2026-08-19T10:59:59.000Z",
endPrecision: "exact",
}),
"europe",
NOW,
);
expect(c.startsMs).toBe(Date.parse("2026-08-10T11:00:00.000Z"));
expect(c.endsMs).toBe(Date.parse("2026-08-19T10:59:59.000Z"));
});
test("leaves an event the reader typed in alone", () => {
// `readerInstant` already resolved this to the instant they meant, in their
// own timezone — the end of the day they named, not a parser declining to
// guess a time. Anchoring it would move a date they stated themselves.
const typed = "2026-08-19T21:59:59.000Z";
const c = clockFor(
dated(typed, { extractionMethod: "manual", sourceId: "you" }),
"europe",
NOW,
);
expect(c.endsMs).toBe(Date.parse(typed));
});
test("an unannounced end is still unannounced", () => {
const c = clockFor(event({ endsAt: null, endPrecision: "unknown" }), "asia", NOW);
expect(c.endsMs).toBeNull();
});
test("puts the checklist on the days the reader can actually claim", () => {
// The window now starts on a reset, so the first pip is the day the source
// named. Read as UTC midnight it opened three hours early, which put a
// phantom pip on the day *before* the event for every region ahead of UTC.
const c = clockFor(
event({ startsAt: "2026-08-10T00:00:00.000Z", endsAt: "2026-08-13T00:00:00.000Z" }),
"europe",
NOW,
);
expect(dailyDays(c.startsMs, c.endsMs, "europe", "genshin")).toEqual([
"2026-08-10",
"2026-08-11",
"2026-08-12",
]);
});
});
describe("dayStartMs", () => {
test("is the instant dayKey names, for every region and game", () => {
for (const region of ["asia", "america", "europe"] as const) {
for (const game of [undefined, "genshin", "endfield", "r1999"] as const) {
const start = dayStartMs("2026-08-19", region, game);
expect(dayKey(start, region, game)).toBe("2026-08-19");
expect(dayKey(start - 1, region, game)).toBe("2026-08-18");
}
}
});
});