diff --git a/AGENTS.md b/AGENTS.md index 4be5090..1fa6f02 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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// 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 diff --git a/docs/DATA-MODEL.md b/docs/DATA-MODEL.md index 0ef2875..bb91ff1 100644 --- a/docs/DATA-MODEL.md +++ b/docs/DATA-MODEL.md @@ -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 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 `docs/INGESTION.md` § Scoring. It records how firmly the sources pinned the event down. @@ -392,7 +409,7 @@ to: | 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 | -| 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 | | `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 | diff --git a/docs/INGESTION.md b/docs/INGESTION.md index 8e6be25..8e8293c 100644 --- a/docs/INGESTION.md +++ b/docs/INGESTION.md @@ -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 | | `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 and reports no end. diff --git a/docs/PRD.md b/docs/PRD.md index 02764bc..df9535c 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -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 until a human approves it. - 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 product exists to prevent. diff --git a/src/shared/time.ts b/src/shared/time.ts index 727706b..430b394 100644 --- a/src/shared/time.ts +++ b/src/shared/time.ts @@ -1,6 +1,6 @@ import type { DisplayEvent, LaneId } from "./custom.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: @@ -81,6 +81,14 @@ export function resetShiftMs(region: Region, game?: LaneId): number { 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( timeZoneOffsetMinutes: number = -new Date().getTimezoneOffset(), ): Region { @@ -99,17 +107,86 @@ export function guessRegion( */ export type EndBearing = Pick< 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. */ export function effectiveEnd( event: EndBearing, region: Region, ): string | null { - if (event.endsAt === null) return null; - if (!event.regionScoped || event.regionEnds === null) return event.endsAt; - return event.regionEnds[region] ?? event.endsAt; + return endBoundary(event, region)?.iso ?? null; +} + +/** 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, + 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"; @@ -173,13 +250,13 @@ export interface EventClock { } export function clockFor( - event: EndBearing & Pick, + event: Clockable, region: Region, now: number, ): EventClock { - const startsMs = Date.parse(event.startsAt); - const end = effectiveEnd(event, region); - const endsMs = end === null ? null : Date.parse(end); + const startsMs = boundaryMs(event.startsAt, event.startPrecision, event, region); + const end = endBoundary(event, region); + const endsMs = end === null ? null : boundaryMs(end.iso, end.precision, event, region); const msRemaining = endsMs === null ? null : endsMs - now; const upcoming = now < startsMs; diff --git a/test/time.test.ts b/test/time.test.ts index 08aab04..62058eb 100644 --- a/test/time.test.ts +++ b/test/time.test.ts @@ -1,8 +1,10 @@ import { describe, expect, test } from "bun:test"; import type { GachaEvent } from "../src/shared/schema.ts"; +import { dailyDays, dayKey } from "../src/shared/daily.ts"; import { clockFor, DAY, + dayStartMs, endingSoonestFirst, formatRemaining, HOUR, @@ -59,10 +61,13 @@ describe("urgency", () => { describe("clockFor", () => { 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); expect(c.live).toBe(true); - expect(c.progress).toBeCloseTo(0.55, 2); - expect(c.msRemaining).toBe(Date.parse("2026-08-20T00:00:00.000Z") - NOW); + expect(c.progress).toBeCloseTo(0.5375, 3); + expect(c.msRemaining).toBe(Date.parse("2026-08-20T03:00:00.000Z") - NOW); }); 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", () => { + // 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( event({ 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 = {}) => + 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"); + } + } + }); +});