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
+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),
});
+54 -40
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}>
<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)}
/>
</li>
);
})}
{chores.map((chore) => (
<li key={chore.key}>
<TickChip
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}>
<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)}
/>
</li>
);
})}
{repeating.map((row) => (
<li key={row.key}>
<TickChip
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" },
};