diff --git a/src/client/App.tsx b/src/client/App.tsx
index b86604d..3cb4f68 100644
--- a/src/client/App.tsx
+++ b/src/client/App.tsx
@@ -1,8 +1,9 @@
import { useEffect, useMemo, useState } from "react";
import { fetchFeed, type FeedState } from "./api.ts";
import { Controls } from "./components/Controls.tsx";
+import { Dailies } from "./components/Dailies.tsx";
import { EventDetail } from "./components/EventDetail.tsx";
-import { EventRow, type RowEvent } from "./components/EventRow.tsx";
+import { EventRow, type DailyBadge, type RowEvent } from "./components/EventRow.tsx";
import { NextUp } from "./components/NextUp.tsx";
import { Timeline } from "./components/Timeline.tsx";
import { Welcome } from "./components/Welcome.tsx";
@@ -12,8 +13,10 @@ import { Toast } from "./components/Toast.tsx";
import { KEYS } from "./state/storage.ts";
import { useMarkSet } from "./state/useMarkSet.ts";
import { useProgress } from "./state/useProgress.ts";
+import { useDailyLog, type DailyLogMap } from "./state/useDailyLog.ts";
import { usePrefs } from "./state/usePrefs.ts";
import { clockFor, DAY, endingSoonestFirst, formatRemaining } from "../shared/time.ts";
+import { dailySummary, isDaily } from "../shared/daily.ts";
import type { GameId } from "../shared/schema.ts";
type View = "soon" | "calendar";
@@ -62,10 +65,24 @@ export function App() {
const { prefs, update, toggleGame } = usePrefs();
const ignored = useMarkSet(KEYS.ignored);
const prog = useProgress();
+ const daily = useDailyLog();
// "Completed" is now one status among several; the rest of the UI still asks
// this question a lot, so keep a cheap shorthand.
const isDone = (id: string) => prog.progress[id]?.status === "done";
+ /** Today's state for a repeating event, or undefined if it does not repeat. */
+ const dailyBadge = (row: RowEvent): DailyBadge | undefined => {
+ if (!isDaily(row.event)) return undefined;
+ const summary = dailySummary({
+ startsMs: row.clock.startsMs,
+ endsMs: row.clock.endsMs,
+ region: prefs.region,
+ now,
+ logged: daily.daysFor(row.event.id),
+ });
+ return { doneToday: summary.doneToday, remaining: summary.remaining };
+ };
+
const toggleIgnored = (id: string, title: string) => {
const wasIgnored = ignored.marks[id] !== undefined;
ignored.toggle(id);
@@ -112,7 +129,15 @@ export function App() {
.filter((r) => prefs.showIgnored || ignored.marks[r.event.id] === undefined)
.filter((r) => prefs.showCompleted || !isDone(r.event.id))
.sort(endingSoonestFirst),
- [allRows, prefs.hiddenGames, prefs.showCompleted, prefs.showIgnored, prog.progress, ignored.marks],
+ [
+ allRows,
+ prefs.hiddenGames,
+ prefs.showCompleted,
+ prefs.showIgnored,
+ prog.progress,
+ daily.logs,
+ ignored.marks,
+ ],
);
const live = visible.filter((r) => r.clock.live);
@@ -208,6 +233,16 @@ export function App() {
<>
+ {/* The chores no wiki publishes, and the only thing on this page
+ that expires tonight rather than next patch. */}
+ !prefs.hiddenGames.includes(g))}
+ region={prefs.region}
+ now={now}
+ daysFor={daily.daysFor}
+ onToggleDay={daily.toggleDay}
+ />
+
{live.length > 0 && (
ignored.toggle(id)}
onOpen={setOpenId}
@@ -244,6 +280,7 @@ export function App() {
completed={isDone(row.event.id)}
status={prog.progress[row.event.id]?.status}
effort={prog.progress[row.event.id]?.effort}
+ daily={dailyBadge(row)}
ignored={ignored.marks[row.event.id] !== undefined}
onRestore={(id) => ignored.toggle(id)}
onOpen={setOpenId}
@@ -269,8 +306,12 @@ export function App() {
onToggleGame={toggleGame}
onUpdate={update}
ignoredCount={Object.keys(ignored.marks).length}
- onExport={() => exportProgress(prog.progress, ignored.marks, prefs)}
- onImport={(file) => void importProgress(file, prog.merge, ignored.merge)}
+ onExport={() =>
+ exportProgress(prog.progress, daily.logs, ignored.marks, prefs)
+ }
+ onImport={(file) =>
+ void importProgress(file, prog.merge, daily.merge, ignored.merge)
+ }
/>
{!online && (
@@ -305,6 +346,10 @@ export function App() {
status={prog.progress[openRow.event.id]?.status}
effort={prog.progress[openRow.event.id]?.effort}
note={prog.progress[openRow.event.id]?.note ?? ""}
+ region={prefs.region}
+ now={now}
+ dailyDays={daily.daysFor(openRow.event.id)}
+ onToggleDay={daily.toggleDay}
onStatus={prog.setStatus}
onEffort={prog.setEffort}
onNote={prog.setNote}
@@ -350,6 +395,7 @@ function Section({
function exportProgress(
progress: Record,
+ daily: DailyLogMap,
ignored: Record,
prefs: unknown,
) {
@@ -361,6 +407,9 @@ function exportProgress(
version: 1,
exportedAt: new Date().toISOString(),
progress,
+ // Streaks live nowhere else — not on a server, not in the feed — so
+ // an export that omitted them would quietly be a lossy backup.
+ daily,
ignored,
prefs,
},
@@ -381,6 +430,7 @@ function exportProgress(
async function importProgress(
file: File,
mergeProgress: (c: Record) => void,
+ mergeDaily: (c: DailyLogMap) => void,
mergeIgnored: (c: Record) => void,
) {
try {
@@ -389,6 +439,7 @@ async function importProgress(
format?: string;
progress?: unknown;
completions?: unknown;
+ daily?: unknown;
ignored?: unknown;
};
if (data.format !== "gacha-tracker-export") {
@@ -412,6 +463,10 @@ async function importProgress(
),
);
}
+ // An export written before daily checklists existed simply has no `daily`
+ // key; that is not an error, it just leaves the streaks it never held.
+ const d = data.daily;
+ if (typeof d === "object" && d !== null) mergeDaily(d as DailyLogMap);
if (i !== null) mergeIgnored(i);
} catch {
alert("That file couldn't be read. Export a fresh copy and try again.");
diff --git a/src/client/components/Controls.tsx b/src/client/components/Controls.tsx
index e3e33aa..d40d3c5 100644
--- a/src/client/components/Controls.tsx
+++ b/src/client/components/Controls.tsx
@@ -104,8 +104,9 @@ export function Controls({
Your progress
- Completed events are saved in this browser only — there is no account.
- Move them to another device with a file.
+ What you've finished, and every daily you've ticked off, are saved in
+ this browser only — there is no account. Move them to another device
+ with a file.
+ {/* A repeating event's real deadline is tonight's reset, not the
+ end date the countdown shows, so the row says both. */}
+ {daily !== undefined && (
+
+ {daily.doneToday
+ ? "daily · done today"
+ : daily.remaining === null
+ ? "daily · not today"
+ : `daily · ${daily.remaining} left`}
+
+ )}
{status === "doing" && (
doing
@@ -158,8 +188,8 @@ export function EventRow({
does not any more: "done" was never the only thing a reader wants
to say about an event, and a tick they can hit by accident on the
way to opening it is a bad trade. The row opens the sheet, where
- status, effort and notes all live; this is just the affordance
- saying so.
+ status, effort, notes and a daily checklist all live; this is just
+ the affordance saying so.
The one exception is a revealed ignored row, where undo is a real
action with nowhere better to sit. */}
diff --git a/src/client/state/storage.ts b/src/client/state/storage.ts
index df02847..81a56f5 100644
--- a/src/client/state/storage.ts
+++ b/src/client/state/storage.ts
@@ -18,6 +18,11 @@ export const KEYS = {
*/
completions: `${NS}:completions`,
progress: `${NS}:progress`,
+ /**
+ * Which game-days of a repeating event the reader has ticked off. Separate
+ * from `progress` because it is a growing list per event, not one record.
+ */
+ daily: `${NS}:daily`,
ignored: `${NS}:ignored`,
prefs: `${NS}:prefs`,
} as const;
diff --git a/src/client/state/useDailyLog.ts b/src/client/state/useDailyLog.ts
new file mode 100644
index 0000000..60f1efa
--- /dev/null
+++ b/src/client/state/useDailyLog.ts
@@ -0,0 +1,78 @@
+import { useCallback, useEffect, useState } from "react";
+import { KEYS, readJson, writeJson } from "./storage.ts";
+
+export interface DailyLog {
+ /** Game-day keys (`YYYY-MM-DD`) the reader has ticked off, oldest first. */
+ days: string[];
+ /** When the log was last touched. Used to resolve import conflicts. */
+ at: string;
+}
+
+export type DailyLogMap = Record;
+
+/**
+ * Which days of a repeating event the reader has done.
+ *
+ * Kept apart from `progress` rather than nested inside it: progress is one
+ * record per event and this is a growing list per event, and merging on import
+ * means different things for the two (last-write-wins versus union). A daily
+ * log is also the only store here that can lose *work* rather than a
+ * preference — a fortnight's login streak exists nowhere else — so nothing in
+ * this module ever removes a day the reader did not remove themselves.
+ */
+export function useDailyLog() {
+ const [logs, setLogs] = useState(() =>
+ readJson(KEYS.daily, {}),
+ );
+
+ useEffect(() => {
+ writeJson(KEYS.daily, logs);
+ }, [logs]);
+
+ const toggleDay = useCallback((id: string, day: string) => {
+ setLogs((prev) => {
+ const current = prev[id]?.days ?? [];
+ const next = current.includes(day)
+ ? current.filter((d) => d !== day)
+ : [...current, day].sort();
+ if (next.length === 0) {
+ const { [id]: _removed, ...rest } = prev;
+ return rest;
+ }
+ return { ...prev, [id]: { days: next, at: new Date().toISOString() } };
+ });
+ }, []);
+
+ const daysFor = useCallback(
+ (id: string): string[] => logs[id]?.days ?? [],
+ [logs],
+ );
+
+ /**
+ * Union merge on import: every day either side recorded is a day the reader
+ * actually played, so keeping both is the only answer that cannot lose one.
+ */
+ const merge = useCallback((incoming: DailyLogMap) => {
+ setLogs((prev) => {
+ const next = { ...prev };
+ for (const [id, log] of Object.entries(incoming)) {
+ // An imported file is untrusted input; a malformed entry is skipped
+ // rather than allowed to take the store down with it.
+ if (!Array.isArray(log?.days)) continue;
+ const days = log.days.filter((d): d is string => typeof d === "string");
+ if (days.length === 0) continue;
+ const union = [...new Set([...(next[id]?.days ?? []), ...days])].sort();
+ const at = next[id]?.at;
+ const incomingAt =
+ typeof log.at === "string" ? log.at : new Date().toISOString();
+ next[id] = {
+ days: union,
+ at: at === undefined || incomingAt > at ? incomingAt : at,
+ };
+ }
+ return next;
+ });
+ }, []);
+
+ return { logs, toggleDay, daysFor, merge };
+}
diff --git a/src/shared/daily.ts b/src/shared/daily.ts
new file mode 100644
index 0000000..e57deab
--- /dev/null
+++ b/src/shared/daily.ts
@@ -0,0 +1,214 @@
+import type { GachaEvent, GameId, Region } from "./schema.ts";
+import { DAY, HOUR, REGION_RESET_UTC_OFFSET } from "./time.ts";
+
+/**
+ * Events you have to come back to every day.
+ *
+ * A login campaign is not one job with a deadline — it is twenty small jobs on
+ * twenty separate deadlines, and missing one is unrecoverable in a way that
+ * being late on a story chapter is not. The rest of the app measures a single
+ * window shrinking; this measures a repeating one, and counts how many are
+ * left.
+ *
+ * Everything here is pure and takes its clock as an argument, for the same
+ * reason the parsers do: a function that reads `Date.now()` cannot be tested
+ * against a fixed instant.
+ */
+
+/**
+ * Gacha servers roll the day at 04:00 local server time, not midnight — a
+ * player finishing at 02:00 is still on the previous day's dailies. Getting
+ * this wrong ticks the wrong box for four hours every night.
+ */
+export const RESET_HOUR_LOCAL = 4;
+
+/**
+ * A 180-day event is already a parse error (see CLAUDE.md § Domain rules), so
+ * this only ever fires on data that is wrong; it exists so a bad end date
+ * cannot make the client allocate an unbounded array.
+ */
+const MAX_DAYS = 200;
+
+/**
+ * Phrases a source uses for "come back every day". Deliberately narrow: a
+ * false positive puts a checklist on an event that does not want one, which is
+ * noise the reader has to work out and dismiss.
+ */
+const DAILY_PHRASES = [
+ /\bdaily\b/i,
+ /\bdailies\b/i,
+ /\bevery day\b/i,
+ /\beach day\b/i,
+ /\bcheck[- ]?in\b/i,
+ /\bsign[- ]?in\b/i,
+ /\blog[- ]?in (?:bonus|reward|event|campaign)/i,
+ /\b7[- ]day\b/i,
+ /\bconsecutive days?\b/i,
+];
+
+/** The fields dailiness is decided from — everything else is irrelevant. */
+export type DailyCandidate = Pick;
+
+/**
+ * Whether an event wants a daily checklist.
+ *
+ * Read off the event as published. Nothing is inferred from a game's habits or
+ * an event's length: a source that never says "daily" gets no checklist, which
+ * is the same skip-rather-than-guess rule the parsers follow.
+ */
+export function isDaily(event: DailyCandidate): boolean {
+ if (event.type === "login") return true;
+ const text = `${event.title} ${event.summary ?? ""}`;
+ return DAILY_PHRASES.some((re) => re.test(text));
+}
+
+/**
+ * Key for a game's standing daily chore — commissions, sanity, the routine
+ * that exists whether or not an event is running.
+ *
+ * No source publishes these, so they are not feed events and never will be;
+ * they are a fixed list the client knows about. The two-segment shape cannot
+ * collide with an event ID, which is always `game:slug:date`, and like every
+ * other ID here it is a localStorage key: changing it drops a reader's streak
+ * with nothing server-side to restore from.
+ */
+export function dailiesId(game: GameId): string {
+ return `dailies:${game}`;
+}
+
+/** 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;
+}
+
+/**
+ * Which game-day an instant falls in, as `YYYY-MM-DD`.
+ *
+ * These are storage keys, and they are compared with `<` elsewhere in this
+ * module, so the format is fixed and sortable on purpose.
+ */
+export function dayKey(ms: number, region: Region): string {
+ return new Date(ms + shift(region)).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);
+}
+
+/** How long the reader has left to do today's dailies. */
+export function msUntilReset(ms: number, region: Region): number {
+ return nextResetMs(ms, region) - ms;
+}
+
+/**
+ * Every game-day the event is claimable on, oldest first.
+ *
+ * Returns null when the end is unannounced. A daily event with no end date has
+ * an unknown number of days left, and inventing one to fill a checklist would
+ * be exactly the fabrication `endsAt: null` exists to prevent.
+ */
+export function dailyDays(
+ startsMs: number,
+ endsMs: number | null,
+ region: Region,
+): string[] | null {
+ if (endsMs === null) return null;
+
+ const out: string[] = [];
+ // The end instant belongs to the previous day when it lands exactly on a
+ // 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)];
+
+ while (cursor <= last && out.length < MAX_DAYS) {
+ out.push(dayKey(cursor, region));
+ cursor = nextResetMs(cursor, region);
+ }
+ return out;
+}
+
+export interface DailySummary {
+ /** Every claimable day, oldest first. Null when the end is unannounced. */
+ days: string[] | null;
+ /** Today's key, whether or not the event is running. */
+ today: string;
+ todayInWindow: boolean;
+ doneToday: boolean;
+ /** Days ticked off inside the window. */
+ logged: number;
+ /** Days still claimable, today included. Null when the end is unannounced. */
+ remaining: number | null;
+ /** Past days that went unticked. Null when the end is unannounced. */
+ missed: number | null;
+ /** Consecutive ticked days up to today (or up to yesterday, if today is untouched). */
+ streak: number;
+ msUntilReset: number;
+}
+
+/**
+ * Where the reader is with a repeating event.
+ *
+ * `logged` is the reader's own record and is never second-guessed here: a day
+ * they ticked stays ticked even if it falls outside the window the feed now
+ * claims, because a source quietly moving a date must not silently erase what
+ * somebody did.
+ */
+export function dailySummary(input: {
+ startsMs: number;
+ endsMs: number | null;
+ region: Region;
+ 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 ticked = new Set(logged);
+
+ const inWindow = days === null ? logged.slice() : days.filter((d) => ticked.has(d));
+ const todayInWindow = days === null ? now >= startsMs : days.includes(today);
+
+ return {
+ days,
+ today,
+ todayInWindow,
+ doneToday: ticked.has(today),
+ logged: inWindow.length,
+ remaining: days === null ? null : days.filter((d) => d >= today).length,
+ missed:
+ days === null
+ ? null
+ : days.filter((d) => d < today && !ticked.has(d)).length,
+ streak: streakOf(logged, today),
+ msUntilReset: msUntilReset(now, region),
+ };
+}
+
+/**
+ * Consecutive ticked days ending today.
+ *
+ * Counts back from yesterday when today has not been done yet, so a run built
+ * over a fortnight does not read as broken every morning before the reader has
+ * logged in.
+ *
+ * Works on day keys rather than instants so the standing per-game chores and
+ * an event's checklist can share one definition of a streak.
+ */
+export function streakOf(logged: readonly string[], today: string): number {
+ const ticked = new Set(logged);
+ const at = Date.parse(`${today}T00:00:00Z`);
+ let cursor = ticked.has(today) ? at : at - DAY;
+ let streak = 0;
+ while (streak < MAX_DAYS && ticked.has(keyOf(cursor))) {
+ streak += 1;
+ cursor -= DAY;
+ }
+ return streak;
+}
+
+function keyOf(ms: number): string {
+ return new Date(ms).toISOString().slice(0, 10);
+}
diff --git a/src/shared/games.ts b/src/shared/games.ts
index d7a3474..e7987a3 100644
--- a/src/shared/games.ts
+++ b/src/shared/games.ts
@@ -13,16 +13,23 @@ export interface GameMeta {
hue: string;
/** Who makes it. Credited in the colophon, derived rather than hardcoded. */
studio: string;
+ /**
+ * What the game's standing daily chore actually consists of, in the terms
+ * the game itself uses. Deliberately the routine every player recognises —
+ * this is a reminder, not a guide, and a wrong specific would be worse than
+ * no hint at all.
+ */
+ dailyTasks: string;
}
export const GAMES: Record = {
- genshin: { id: "genshin", name: "Genshin Impact", short: "Genshin", hue: "#4EA8DE" , studio: "HoYoverse" },
- hsr: { id: "hsr", name: "Honkai: Star Rail", short: "Star Rail", hue: "#7B8CFF" , studio: "HoYoverse" },
- zzz: { id: "zzz", name: "Zenless Zone Zero", short: "ZZZ", hue: "#F2A03D" , studio: "HoYoverse" },
- wuwa: { id: "wuwa", name: "Wuthering Waves", short: "Wuwa", hue: "#3DD6A0" , studio: "Kuro Games" },
- arknights: { id: "arknights", name: "Arknights", short: "Arknights", hue: "#9AA3B8" , studio: "Hypergryph" },
- endfield: { id: "endfield", name: "Arknights: Endfield", short: "Endfield", hue: "#E8635A" , studio: "Hypergryph" },
- nte: { id: "nte", name: "Neverness to Everness", short: "NTE", hue: "#C77DFF" , studio: "Hotta Studio" },
+ genshin: { id: "genshin", name: "Genshin Impact", short: "Genshin", hue: "#4EA8DE" , studio: "HoYoverse", dailyTasks: "Commissions, resin" },
+ hsr: { id: "hsr", name: "Honkai: Star Rail", short: "Star Rail", hue: "#7B8CFF" , studio: "HoYoverse", dailyTasks: "Daily training, Trailblaze Power" },
+ 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" },
+ nte: { id: "nte", name: "Neverness to Everness", short: "NTE", hue: "#C77DFF" , studio: "Hotta Studio", dailyTasks: "Daily tasks" },
};
export const GAME_LIST: GameMeta[] = Object.values(GAMES);
diff --git a/test/daily.test.ts b/test/daily.test.ts
new file mode 100644
index 0000000..98e7a4f
--- /dev/null
+++ b/test/daily.test.ts
@@ -0,0 +1,225 @@
+import { describe, expect, test } from "bun:test";
+import {
+ dailiesId,
+ dailyDays,
+ dailySummary,
+ dayKey,
+ isDaily,
+ msUntilReset,
+ nextResetMs,
+ streakOf,
+} from "../src/shared/daily.ts";
+import { DAY, HOUR } from "../src/shared/time.ts";
+
+const at = (iso: string) => Date.parse(iso);
+
+describe("isDaily", () => {
+ const base = { type: "other" as const, title: "", summary: null };
+
+ test("a login campaign always repeats", () => {
+ expect(isDaily({ ...base, type: "login", title: "Traveler's Log" })).toBe(true);
+ });
+
+ test("reads the phrasing sources actually use", () => {
+ for (const title of [
+ "Daily Check-In Rewards",
+ "Sign-in Event",
+ "7-Day Login Bonus",
+ "Log-in Campaign: Frostlands",
+ ]) {
+ expect(isDaily({ ...base, title })).toBe(true);
+ }
+ });
+
+ test("finds it in the summary when the title is a codename", () => {
+ expect(
+ isDaily({
+ ...base,
+ title: "Mutual Aid in Bloom",
+ summary: "Complete a task each day to earn Primogems.",
+ }),
+ ).toBe(true);
+ });
+
+ test("an ordinary event gets no checklist", () => {
+ // A false positive puts a twenty-box checklist on a story chapter, which
+ // the reader then has to work out and dismiss.
+ expect(
+ isDaily({
+ ...base,
+ type: "banner",
+ title: "Mutual Aid in Bloom",
+ summary: "Limited character banner rerun.",
+ }),
+ ).toBe(false);
+ expect(isDaily({ ...base, type: "challenge", title: "Spiral Abyss" })).toBe(false);
+ });
+});
+
+describe("dayKey", () => {
+ test("the game day rolls at 04:00 server time, not midnight", () => {
+ // Asia is UTC+8, so its 04:00 reset is 20:00 UTC the day before. Someone
+ // playing at 02:00 local is still on the previous day's dailies, and a
+ // naive UTC date would tick the wrong box for four hours every night.
+ expect(dayKey(at("2026-08-15T19:59:00Z"), "asia")).toBe("2026-08-15");
+ expect(dayKey(at("2026-08-15T20:00:00Z"), "asia")).toBe("2026-08-16");
+ });
+
+ test("each region rolls at its own instant", () => {
+ const instant = at("2026-08-16T02:00:00Z");
+ // Europe (UTC+1) resets at 03:00 UTC, so 02:00 is still the 15th there,
+ // while Asia rolled over six hours earlier.
+ expect(dayKey(instant, "europe")).toBe("2026-08-15");
+ expect(dayKey(instant, "asia")).toBe("2026-08-16");
+ });
+
+ test("survives a UTC month boundary", () => {
+ expect(dayKey(at("2026-09-01T00:30:00Z"), "america")).toBe("2026-08-31");
+ });
+});
+
+describe("nextResetMs", () => {
+ test("is the next reset strictly after the instant given", () => {
+ const reset = at("2026-08-15T20:00:00Z"); // asia
+ expect(nextResetMs(reset - 1, "asia")).toBe(reset);
+ // Standing exactly on a reset, the next one is tomorrow's — otherwise the
+ // countdown would read "0s" for a whole tick.
+ expect(nextResetMs(reset, "asia")).toBe(reset + DAY);
+ });
+
+ test("msUntilReset never exceeds a day", () => {
+ for (const region of ["asia", "america", "europe"] as const) {
+ const left = msUntilReset(at("2026-08-15T11:22:33Z"), region);
+ expect(left).toBeGreaterThan(0);
+ expect(left).toBeLessThanOrEqual(DAY);
+ }
+ });
+});
+
+describe("dailyDays", () => {
+ const start = at("2026-08-12T20:00:00Z"); // an asia reset instant
+
+ test("one entry per claimable day", () => {
+ const days = dailyDays(start, start + 7 * DAY, "asia");
+ expect(days).toEqual([
+ "2026-08-13",
+ "2026-08-14",
+ "2026-08-15",
+ "2026-08-16",
+ "2026-08-17",
+ "2026-08-18",
+ "2026-08-19",
+ ]);
+ });
+
+ test("an end landing on a reset gives you nothing that day", () => {
+ // Ending at 04:00 means the final day was never claimable; listing it
+ // would show a box that can only ever be a miss.
+ const days = dailyDays(start, start + 3 * DAY, "asia");
+ expect(days).toHaveLength(3);
+ expect(days?.at(-1)).toBe("2026-08-15");
+ });
+
+ test("an unannounced end yields no checklist rather than a made-up one", () => {
+ // This is the endsAt: null rule. Filling twenty boxes from a guessed end
+ // date is exactly the fabrication the schema exists to prevent.
+ expect(dailyDays(start, null, "asia")).toBeNull();
+ });
+
+ test("a nonsense window still returns something finite", () => {
+ expect(dailyDays(start, start + 400 * DAY, "asia")).toHaveLength(200);
+ expect(dailyDays(start, start - DAY, "asia")).toEqual(["2026-08-13"]);
+ });
+});
+
+describe("dailySummary", () => {
+ const start = at("2026-08-12T20:00:00Z");
+ const week = { startsMs: start, endsMs: start + 7 * DAY, region: "asia" as const };
+
+ test("counts what is left including today", () => {
+ const s = dailySummary({
+ ...week,
+ now: at("2026-08-15T12:00:00Z"),
+ logged: ["2026-08-13", "2026-08-15"],
+ });
+ expect(s.today).toBe("2026-08-15");
+ expect(s.doneToday).toBe(true);
+ expect(s.logged).toBe(2);
+ expect(s.remaining).toBe(5); // 15th through 19th
+ expect(s.missed).toBe(1); // the 14th
+ });
+
+ test("a day the reader missed is reported, not hidden", () => {
+ const s = dailySummary({
+ ...week,
+ now: at("2026-08-16T12:00:00Z"),
+ logged: [],
+ });
+ expect(s.doneToday).toBe(false);
+ expect(s.missed).toBe(3);
+ expect(s.remaining).toBe(4);
+ });
+
+ test("an unannounced end still counts the ticks", () => {
+ const s = dailySummary({
+ ...week,
+ endsMs: null,
+ now: at("2026-08-16T12:00:00Z"),
+ logged: ["2026-08-14", "2026-08-15"],
+ });
+ expect(s.days).toBeNull();
+ expect(s.remaining).toBeNull();
+ expect(s.missed).toBeNull();
+ expect(s.logged).toBe(2);
+ });
+
+ test("ticks outside the published window are never discarded", () => {
+ // If a source quietly moves a date, the reader's own record of having
+ // played still stands — nothing else holds a copy of it.
+ const s = dailySummary({
+ ...week,
+ endsMs: null,
+ now: at("2026-08-16T12:00:00Z"),
+ logged: ["2020-01-01"],
+ });
+ expect(s.logged).toBe(1);
+ });
+
+ test("resets are reported against the reader's own region", () => {
+ const evening = at("2026-08-15T21:00:00Z");
+ expect(dailySummary({ ...week, now: evening, logged: [] }).msUntilReset).toBe(
+ 23 * HOUR,
+ );
+ });
+});
+
+describe("streakOf", () => {
+ test("counts back from yesterday when today is not done yet", () => {
+ // Otherwise a fortnight's run reads as broken every morning, which is the
+ // one time the number actually matters to the reader.
+ expect(
+ streakOf(["2026-08-10", "2026-08-11", "2026-08-12"], "2026-08-13"),
+ ).toBe(3);
+ });
+
+ test("includes today once it is done", () => {
+ expect(streakOf(["2026-08-12", "2026-08-13"], "2026-08-13")).toBe(2);
+ });
+
+ test("a gap ends the run", () => {
+ expect(streakOf(["2026-08-09", "2026-08-11"], "2026-08-12")).toBe(1);
+ expect(streakOf([], "2026-08-12")).toBe(0);
+ });
+
+ test("skips a month boundary correctly", () => {
+ expect(streakOf(["2026-07-31", "2026-08-01"], "2026-08-01")).toBe(2);
+ });
+});
+
+describe("dailiesId", () => {
+ test("cannot collide with an event ID", () => {
+ // Event IDs are `game:slug:date`; these are deliberately two segments.
+ expect(dailiesId("genshin")).toBe("dailies:genshin");
+ expect(dailiesId("genshin").split(":")).toHaveLength(2);
+ });
+});