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:
co-authored by
Claude Opus 5
parent
00c3f5e2da
commit
4334988ecf
@@ -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
|
||||
|
||||
+18
-1
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
+86
-9
@@ -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<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";
|
||||
@@ -173,13 +250,13 @@ export interface EventClock {
|
||||
}
|
||||
|
||||
export function clockFor(
|
||||
event: EndBearing & Pick<DisplayEvent, "startsAt">,
|
||||
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;
|
||||
|
||||
+112
-2
@@ -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<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");
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user