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
+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)}