From e6e4f7085a1318fa338d24958a1486b459ba5b77 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Sun, 16 Aug 2026 20:37:47 +0200 Subject: [PATCH] fix(daily): reset Endfield's European day on the server it's on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Endfield has two server groups, not three: Europe is served off the Americas machine on a fixed UTC-5, so a European player's day rolls at 09:00 UTC. We were resetting it six hours early, at 03:00, which ticked the wrong box every morning between those two instants. Adds GameMeta.resetOffsets, a sparse per-region override, and threads an optional `game` through every day-key function. Per region rather than per game on purpose: a blanket offset would drag Asia — which does have its own Endfield server — onto the Americas clock, moving day keys for readers who never had the bug. A regression test pins Asia's output as identical to before. Day keys are localStorage keys, so this re-labels ticks logged between 03:00 and 09:00 UTC by European Endfield players, one day backward. No tick is deleted and past days stay editable, but a streak can read as broken for a day. That is the cost of correcting a wrong reset; leaving it wrong is worse. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 9 ++- docs/ARCHITECTURE.md | 2 +- docs/DATA-MODEL.md | 32 ++++++++ src/client/App.tsx | 1 + src/client/components/Dailies.tsx | 94 ++++++++++++++---------- src/client/components/DailyChecklist.tsx | 9 ++- src/client/components/EventDetail.tsx | 1 + src/shared/daily.ts | 68 ++++++++++++----- src/shared/games.ts | 27 ++++++- test/daily.test.ts | 80 ++++++++++++++++++++ 10 files changed, 259 insertions(+), 64 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a40e5ae..f9a5dcb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -83,7 +83,7 @@ src/client/ React app, service worker, manifest state/ progress, daily log, ignores, prefs, sort — all localStorage scripts/ build-feed.ts, parse-fixture.ts (offline), refresh-sources.ts (fetches) serve.ts static server + /api/health -test/ 257 tests +test/ 301 tests fixtures// raw HTML + .expected.json per source — pinned, kept forever snapshots/ current page per source, rewritten by refresh — see its README ``` @@ -150,6 +150,13 @@ 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. + 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 + regions that do have their own server onto someone else's clock. Every day-key function takes an + optional `game` — **anything reading or writing a tick must pass it**, or it writes under one + clock and reads under another. A day that drops out of `dailyDays` renders no pip, so a tick on it + becomes unreachable; check real fixture windows before changing an offset. The sanitizer at the ingest boundary recomputes an event ID only when a sanitized title actually changed *and* the ID was minted the standard way. If a change to it starts moving IDs on real diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index eee754a..21f17ee 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -72,7 +72,7 @@ src/ shared/ schema.ts zod schemas — the contract, both sides ✓ built time.ts clocks, urgency, region resets, captions ✓ built - games.ts per-game name and hue ✓ built + games.ts per-game name, hue, and reset clock ✓ built feed.ts the /api/events.json wire contract ✓ built client/ ✓ all built main.tsx render + service worker registration diff --git a/docs/DATA-MODEL.md b/docs/DATA-MODEL.md index 1e337ec..dbb2c24 100644 --- a/docs/DATA-MODEL.md +++ b/docs/DATA-MODEL.md @@ -243,6 +243,38 @@ server time, so a player finishing at 02:00 is still on the previous day's daili computed against the reader's chosen region (`RESET_HOUR_LOCAL`, `dayKey`). Keys sort lexicographically, which is what "how many days are left" and streak counting rely on. +**Not every game has a server per region.** `GameMeta.resetOffsets` records the regions where a +game's server clock differs from `REGION_RESET_UTC_OFFSET`. Endfield is the case this exists for: it +has two server groups rather than three, and Europe is served off the Americas machine on a fixed +UTC-5, so a European player's reset is 09:00 UTC — six hours after the HoYoverse/Kuro pattern. +Every day-key function takes an optional `game` for this reason, and **anything reading or writing a +tick must pass it**: a write under one clock and a read under another puts the tick on a day the +reader cannot see. + +| Game | Reset (server local) | Server offset | Reset (UTC) | Copenhagen, summer / winter | +|---|---|---|---|---| +| Genshin, Star Rail, ZZZ, Wuwa, NTE | 04:00 | region (EU = UTC+1) | 03:00 | 05:00 / 04:00 | +| Endfield, Europe | 04:00 | UTC-5 (on the Americas server) | 09:00 | 11:00 / 10:00 | +| Endfield, Asia / Americas | 04:00 | regional default | 20:00 / 09:00 | — | + +These server offsets are **fixed and do not observe DST**, so the reader's local reset time moves by +an hour across the European clock change while the UTC instant stays put. + +The override is deliberately **per region, not per game**. A blanket per-game offset is the wrong +shape: it drags the regions that do have their own server onto somebody else's clock, moving day +keys for readers who never had the bug. List only the regions that actually differ. + +Changing a value in that table is a **data change, not a constant change**: it re-labels the +game-day some already-logged ticks fall in, for readers in that region. Two consequences to check +before shipping one, both of which are invisible at runtime: + +- A tick logged inside the shifted window reads as the adjacent day, which can show as a one-day + break in a streak. Recoverable — past days stay editable. +- If the shift moves a window's boundary, a day can drop out of `dailyDays` entirely. A day that is + not in `dailyDays` renders no pip, so a tick on it is **unreachable**: not deleted, but with no UI + path back to it and nothing server-side to recover from. Check the real fixture windows for the + affected game and region before changing an offset. + Three rules this store keeps, for the same reason the rest of the client does — nothing else holds a copy: diff --git a/src/client/App.tsx b/src/client/App.tsx index 13c1eca..55afa24 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -103,6 +103,7 @@ export function App() { startsMs: row.clock.startsMs, endsMs: row.clock.endsMs, region: prefs.region, + game: row.event.game, now, logged: daily.daysFor(row.event.id), }); diff --git a/src/client/components/Dailies.tsx b/src/client/components/Dailies.tsx index a31effc..bbb4cb1 100644 --- a/src/client/components/Dailies.tsx +++ b/src/client/components/Dailies.tsx @@ -35,13 +35,33 @@ export function Dailies({ daysFor: (id: string) => string[]; onToggleDay: (id: string, day: string) => void; }) { - if (games.length === 0 && events.length === 0) return null; + // Each game rolls on its own server clock, so "today" is asked per game + // rather than once for the section — Endfield's European day can still be + // yesterday's while every HoYo game has already turned over. + const chores = games.map((id) => ({ + key: dailiesId(id), + game: gameMeta(id), + today: dayKey(now, region, id), + resetsIn: msUntilReset(now, region, id), + })); + const repeating = events.map((event) => ({ + key: event.id, + event, + game: gameMeta(event.game), + today: dayKey(now, region, event.game), + resetsIn: msUntilReset(now, region, event.game), + })); - const today = dayKey(now, region); - const doneEvents = events.filter((e) => daysFor(e.id).includes(today)); - const done = games.filter((g) => daysFor(dailiesId(g)).includes(today)); - const total = games.length + events.length; - const complete = done.length + doneEvents.length; + const items = [...chores, ...repeating]; + const total = items.length; + const complete = items.filter((i) => daysFor(i.key).includes(i.today)).length; + + if (total === 0) return null; + + // With mixed reset clocks there is no single "resets in", so the header + // reports the next one to land and says that it is the next one. + const soonest = Math.min(...items.map((i) => i.resetsIn)); + const mixed = new Set(items.map((i) => i.resetsIn)).size > 1; return (
@@ -50,45 +70,39 @@ export function Dailies({ Today's dailies · {complete}/{total}

- resets in {formatRemaining(msUntilReset(now, region))} + {mixed ? "next reset in " : "resets in "} + {formatRemaining(soonest)}

    - {games.map((id) => { - const game = gameMeta(id); - const key = dailiesId(id); - return ( -
  • - onToggleDay(key, today)} - /> -
  • - ); - })} + {chores.map((chore) => ( +
  • + onToggleDay(chore.key, chore.today)} + /> +
  • + ))} - {events.map((event) => { - const game = gameMeta(event.game); - return ( -
  • - onToggleDay(event.id, today)} - /> -
  • - ); - })} + {repeating.map((row) => ( +
  • + onToggleDay(row.key, row.today)} + /> +
  • + ))}

diff --git a/src/client/components/DailyChecklist.tsx b/src/client/components/DailyChecklist.tsx index d0b59db..9406b7a 100644 --- a/src/client/components/DailyChecklist.tsx +++ b/src/client/components/DailyChecklist.tsx @@ -3,7 +3,7 @@ import { msUntilReset, type DailySummary, } from "../../shared/daily.ts"; -import type { Region } from "../../shared/schema.ts"; +import type { GameId, Region } from "../../shared/schema.ts"; import { formatRemaining } from "../../shared/time.ts"; /** @@ -23,6 +23,7 @@ export function DailyChecklist({ startsMs, endsMs, region, + game, now, logged, onToggleDay, @@ -30,11 +31,13 @@ export function DailyChecklist({ startsMs: number; endsMs: number | null; region: Region; + /** Whose reset clock the days are counted on — not every game shares one. */ + game: GameId; now: number; logged: string[]; onToggleDay: (day: string) => void; }) { - const summary = dailySummary({ startsMs, endsMs, region, now, logged }); + const summary = dailySummary({ startsMs, endsMs, region, game, now, logged }); const { days, today, doneToday, todayInWindow } = summary; const started = now >= startsMs; @@ -43,7 +46,7 @@ export function DailyChecklist({

Daily checklist

- resets in {formatRemaining(msUntilReset(now, region))} + resets in {formatRemaining(msUntilReset(now, region, game))}

diff --git a/src/client/components/EventDetail.tsx b/src/client/components/EventDetail.tsx index 103b028..2dfe3cc 100644 --- a/src/client/components/EventDetail.tsx +++ b/src/client/components/EventDetail.tsx @@ -151,6 +151,7 @@ export function EventDetail({ startsMs={clock.startsMs} endsMs={clock.endsMs} region={region} + game={event.game} now={now} logged={dailyDays} onToggleDay={(day) => onToggleDay(event.id, day)} diff --git a/src/shared/daily.ts b/src/shared/daily.ts index e20fad3..8f8bf06 100644 --- a/src/shared/daily.ts +++ b/src/shared/daily.ts @@ -1,3 +1,4 @@ +import { GAMES } from "./games.ts"; import type { GachaEvent, GameId, Region } from "./schema.ts"; import { DAY, HOUR, REGION_RESET_UTC_OFFSET } from "./time.ts"; @@ -113,9 +114,35 @@ export function dailyOverride( return desired === detected ? undefined : desired; } -/** Offset from UTC midnight to this region's reset instant. */ -function shift(region: Region): number { - return REGION_RESET_UTC_OFFSET[region] * HOUR - RESET_HOUR_LOCAL * HOUR; +/** + * The UTC offset of the server clock a reader's day rolls on. + * + * The reader's region is always the question; a game can just answer it + * differently. Most run a server per region and take the default. One that + * serves two regions off a single machine lists the regions that differ in + * `resetOffsets` — Endfield's European players sit on the Americas server, so + * `europe` resolves to UTC-5 there and to UTC+1 everywhere else. + * + * A blanket per-game offset would be the wrong shape: it would drag the regions + * that *do* have their own server onto somebody else's clock, which is a + * different bug in the same place. + */ +export function serverOffsetUtc(region: Region, game?: GameId): number { + const override = game === undefined ? undefined : GAMES[game].resetOffsets?.[region]; + return override ?? REGION_RESET_UTC_OFFSET[region]; +} + +/** + * Offset from UTC midnight to this game's reset instant. + * + * Everything downstream of this is a **localStorage key**. Moving the reset + * hour, a region offset, or a game's own override re-labels the game-day some + * already-logged ticks fall in — at most by one day, and never by deleting one, + * but it is still the reader's streak moving under them. Treat a change here as + * a data change, not a constant. + */ +function shift(region: Region, game?: GameId): number { + return serverOffsetUtc(region, game) * HOUR - RESET_HOUR_LOCAL * HOUR; } /** @@ -123,20 +150,24 @@ function shift(region: Region): number { * * These are storage keys, and they are compared with `<` elsewhere in this * module, so the format is fixed and sortable on purpose. + * + * `game` is optional because a caller that has no particular game in hand — a + * generic "what day is it here?" — still gets the regional answer. Anything + * that reads or writes a tick should pass it. */ -export function dayKey(ms: number, region: Region): string { - return new Date(ms + shift(region)).toISOString().slice(0, 10); +export function dayKey(ms: number, region: Region, game?: GameId): string { + return new Date(ms + shift(region, game)).toISOString().slice(0, 10); } /** The next reset instant strictly after `ms`. */ -export function nextResetMs(ms: number, region: Region): number { - const shifted = ms + shift(region); - return Math.floor(shifted / DAY) * DAY + DAY - shift(region); +export function nextResetMs(ms: number, region: Region, game?: GameId): number { + const s = shift(region, game); + return Math.floor((ms + s) / DAY) * DAY + DAY - s; } /** How long the reader has left to do today's dailies. */ -export function msUntilReset(ms: number, region: Region): number { - return nextResetMs(ms, region) - ms; +export function msUntilReset(ms: number, region: Region, game?: GameId): number { + return nextResetMs(ms, region, game) - ms; } /** @@ -150,6 +181,7 @@ export function dailyDays( startsMs: number, endsMs: number | null, region: Region, + game?: GameId, ): string[] | null { if (endsMs === null) return null; @@ -158,11 +190,11 @@ export function dailyDays( // reset: an event ending at 04:00 gives you nothing on that final day. const last = endsMs - 1; let cursor = startsMs; - if (last < startsMs) return [dayKey(startsMs, region)]; + if (last < startsMs) return [dayKey(startsMs, region, game)]; while (cursor <= last && out.length < MAX_DAYS) { - out.push(dayKey(cursor, region)); - cursor = nextResetMs(cursor, region); + out.push(dayKey(cursor, region, game)); + cursor = nextResetMs(cursor, region, game); } return out; } @@ -197,12 +229,14 @@ export function dailySummary(input: { startsMs: number; endsMs: number | null; region: Region; + /** Whose reset clock this runs on. Omitted falls back to the region's. */ + game?: GameId | undefined; now: number; logged: readonly string[]; }): DailySummary { - const { startsMs, endsMs, region, now, logged } = input; - const days = dailyDays(startsMs, endsMs, region); - const today = dayKey(now, region); + const { startsMs, endsMs, region, game, now, logged } = input; + const days = dailyDays(startsMs, endsMs, region, game); + const today = dayKey(now, region, game); const ticked = new Set(logged); const inWindow = days === null ? logged.slice() : days.filter((d) => ticked.has(d)); @@ -220,7 +254,7 @@ export function dailySummary(input: { ? null : days.filter((d) => d < today && !ticked.has(d)).length, streak: streakOf(logged, today), - msUntilReset: msUntilReset(now, region), + msUntilReset: msUntilReset(now, region, game), }; } diff --git a/src/shared/games.ts b/src/shared/games.ts index e7987a3..89f4b75 100644 --- a/src/shared/games.ts +++ b/src/shared/games.ts @@ -1,4 +1,4 @@ -import type { GameId } from "./schema.ts"; +import type { GameId, Region } from "./schema.ts"; export interface GameMeta { id: GameId; @@ -20,6 +20,24 @@ export interface GameMeta { * no hint at all. */ dailyTasks: string; + /** + * Server clock offsets that differ from the regional default, per region. + * + * Not every game runs one server per region. Where a game serves two of our + * regions off a single machine, the reader's region is still the right + * question — it just gets a different answer for that game than + * `REGION_RESET_UTC_OFFSET` gives. + * + * Deliberately a sparse override rather than a full table: listing only the + * regions that actually differ keeps the diff to the fact that changed, and + * a region absent here keeps the default answer it has always had. + * + * This feeds `dayKey`, which is a **localStorage key**. Adding or changing an + * entry re-labels the game-day some already-logged ticks fall in, for readers + * in that region only — see `src/shared/daily.ts` § shift and + * docs/DATA-MODEL.md. + */ + resetOffsets?: Partial> | undefined; } export const GAMES: Record = { @@ -28,7 +46,12 @@ export const GAMES: Record = { zzz: { id: "zzz", name: "Zenless Zone Zero", short: "ZZZ", hue: "#F2A03D" , studio: "HoYoverse", dailyTasks: "Daily missions, battery" }, wuwa: { id: "wuwa", name: "Wuthering Waves", short: "Wuwa", hue: "#3DD6A0" , studio: "Kuro Games", dailyTasks: "Daily activity, waveplate" }, arknights: { id: "arknights", name: "Arknights", short: "Arknights", hue: "#9AA3B8" , studio: "Hypergryph", dailyTasks: "Daily missions, sanity" }, - endfield: { id: "endfield", name: "Arknights: Endfield", short: "Endfield", hue: "#E8635A" , studio: "Hypergryph", dailyTasks: "Daily missions" }, + // Endfield has two server groups, not three: Europe is served off the same + // machine as the Americas, on a fixed UTC-5. So a European player's day rolls + // at 09:00 UTC — 11:00 in Copenhagen in summer, 10:00 in winter — six hours + // after the HoYo/Kuro pattern above. Asia has its own server and is unchanged, + // and `america` already resolves to -5, so Europe is the only real override. + endfield: { id: "endfield", name: "Arknights: Endfield", short: "Endfield", hue: "#E8635A" , studio: "Hypergryph", dailyTasks: "Daily missions", resetOffsets: { europe: -5 } }, nte: { id: "nte", name: "Neverness to Everness", short: "NTE", hue: "#C77DFF" , studio: "Hotta Studio", dailyTasks: "Daily tasks" }, }; diff --git a/test/daily.test.ts b/test/daily.test.ts index bfa8fc0..a626165 100644 --- a/test/daily.test.ts +++ b/test/daily.test.ts @@ -133,6 +133,86 @@ describe("dayKey", () => { }); }); +describe("a game whose server map differs from the default", () => { + // Endfield has two server groups, not three: Europe is served off the + // Americas machine on a fixed UTC-5, so a European player's 04:00 reset is + // 09:00 UTC — six hours after the HoYo/Kuro Europe pattern. Asia has its own + // server and is unaffected. + const morning = at("2026-08-16T08:00:00Z"); + + test("the override beats the regional default for that region", () => { + expect(dayKey(morning, "europe", "endfield")).toBe("2026-08-15"); + expect(dayKey(morning, "europe", "genshin")).toBe("2026-08-16"); + }); + + test("rolls exactly on 09:00 UTC in Europe", () => { + expect(dayKey(at("2026-08-16T08:59:59Z"), "europe", "endfield")).toBe( + "2026-08-15", + ); + expect(dayKey(at("2026-08-16T09:00:00Z"), "europe", "endfield")).toBe( + "2026-08-16", + ); + expect(nextResetMs(morning, "europe", "endfield")).toBe( + at("2026-08-16T09:00:00Z"), + ); + }); + + test("a region with its own server keeps the answer it always had", () => { + // The override is per region on purpose. A blanket per-game offset would + // drag Asia — which genuinely has its own Endfield server — onto the + // Americas clock, moving a reader's already-logged day keys by eleven + // hours to fix a bug they never had. + for (const region of ["asia", "america"] as const) { + for (const hour of [0, 6, 9, 14, 20, 23]) { + const t = at(`2026-08-16T${String(hour).padStart(2, "0")}:30:00Z`); + expect(dayKey(t, region, "endfield")).toBe(dayKey(t, region)); + } + } + }); + + test("a game with no override still follows its region", () => { + // Europe is UTC+1, so 03:00 UTC. Asia rolled six hours before that. + const instant = at("2026-08-16T02:00:00Z"); + expect(dayKey(instant, "europe", "genshin")).toBe("2026-08-15"); + expect(dayKey(instant, "asia", "genshin")).toBe("2026-08-16"); + // …and matches the answer given with no game at all. + expect(dayKey(instant, "europe", "genshin")).toBe(dayKey(instant, "europe")); + }); + + test("the checklist counts days on the clock that reader is actually on", () => { + const start = at("2026-08-16T09:00:00Z"); // an endfield europe reset + expect(dailyDays(start, start + 3 * DAY, "europe", "endfield")).toEqual([ + "2026-08-16", + "2026-08-17", + "2026-08-18", + ]); + + const summary = dailySummary({ + startsMs: start, + endsMs: start + 3 * DAY, + region: "europe", + game: "endfield", + now: at("2026-08-17T08:00:00Z"), // still the 16th on a UTC-5 server + logged: [], + }); + expect(summary.today).toBe("2026-08-16"); + expect(summary.msUntilReset).toBe(HOUR); + }); + + test("an Asia reader's checklist is byte-identical to before the override", () => { + // The regression this guards: an Endfield event whose end lands between the + // two candidate resets loses its final claimable day if the wrong clock is + // used, and a day that leaves `dailyDays` can never be ticked or untucked + // again — the pip is simply not rendered. + const start = at("2026-08-06T04:00:00Z"); + const end = at("2026-08-19T22:00:00Z"); + expect(dailyDays(start, end, "asia", "endfield")).toEqual( + dailyDays(start, end, "asia"), + ); + expect(dailyDays(start, end, "asia", "endfield")).toContain("2026-08-20"); + }); +}); + describe("nextResetMs", () => { test("is the next reset strictly after the instant given", () => { const reset = at("2026-08-15T20:00:00Z"); // asia