fix(daily): reset Endfield's European day on the server it's on

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) <[email protected]>
This commit is contained in:
Lucas Winther
2026-08-16 20:37:47 +02:00
co-authored by Claude Opus 5
parent 6c5546ba53
commit e6e4f7085a
10 changed files with 259 additions and 64 deletions
+8 -1
View File
@@ -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/<game>/ 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
+1 -1
View File
@@ -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
+32
View File
@@ -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:
+1
View File
@@ -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),
});
+48 -34
View File
@@ -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 (
<section className="border-b border-hairline px-4 py-4">
@@ -50,45 +70,39 @@ export function Dailies({
Today's dailies · {complete}/{total}
</h2>
<p className="tnum text-[0.6875rem] text-faint">
resets in {formatRemaining(msUntilReset(now, region))}
{mixed ? "next reset in " : "resets in "}
{formatRemaining(soonest)}
</p>
</div>
<ul className="mt-2.5 flex flex-wrap gap-1.5">
{games.map((id) => {
const game = gameMeta(id);
const key = dailiesId(id);
return (
<li key={key}>
{chores.map((chore) => (
<li key={chore.key}>
<TickChip
label={game.short}
hue={game.hue}
title={game.dailyTasks}
ariaLabel={`${game.name} dailies — ${game.dailyTasks}`}
days={daysFor(key)}
today={today}
onToggle={() => onToggleDay(key, today)}
label={chore.game.short}
hue={chore.game.hue}
title={chore.game.dailyTasks}
ariaLabel={`${chore.game.name} dailies — ${chore.game.dailyTasks}`}
days={daysFor(chore.key)}
today={chore.today}
onToggle={() => onToggleDay(chore.key, chore.today)}
/>
</li>
);
})}
))}
{events.map((event) => {
const game = gameMeta(event.game);
return (
<li key={event.id}>
{repeating.map((row) => (
<li key={row.key}>
<TickChip
label={event.title}
hue={game.hue}
title={`${game.name} — ${event.title}`}
ariaLabel={`${event.title} (${game.name})`}
days={daysFor(event.id)}
today={today}
onToggle={() => onToggleDay(event.id, today)}
label={row.event.title}
hue={row.game.hue}
title={`${row.game.name} — ${row.event.title}`}
ariaLabel={`${row.event.title} (${row.game.name})`}
days={daysFor(row.key)}
today={row.today}
onToggle={() => onToggleDay(row.key, row.today)}
/>
</li>
);
})}
))}
</ul>
<p className="mt-2 text-[0.6875rem] leading-relaxed text-faint">
+6 -3
View File
@@ -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({
<div className="flex items-baseline justify-between gap-3">
<p className="eyebrow">Daily checklist</p>
<p className="tnum text-[0.6875rem] text-faint">
resets in {formatRemaining(msUntilReset(now, region))}
resets in {formatRemaining(msUntilReset(now, region, game))}
</p>
</div>
+1
View File
@@ -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)}
+51 -17
View File
@@ -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),
};
}
+25 -2
View File
@@ -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<Record<Region, number>> | undefined;
}
export const GAMES: Record<GameId, GameMeta> = {
@@ -28,7 +46,12 @@ export const GAMES: Record<GameId, GameMeta> = {
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" },
};
+80
View File
@@ -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