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.

+ + ); + })} + + +

+ {done.length === games.length + ? "All done. Nothing else expires tonight." + : `${waiting(games.length - done.length)} still waiting on you today.`} +

+
+ ); +} + +function waiting(n: number): string { + return n === 1 ? "One game" : `${n} games`; +} diff --git a/src/client/components/DailyChecklist.tsx b/src/client/components/DailyChecklist.tsx new file mode 100644 index 0000000..c5f4e96 --- /dev/null +++ b/src/client/components/DailyChecklist.tsx @@ -0,0 +1,182 @@ +import { + dailySummary, + msUntilReset, + type DailySummary, +} from "../../shared/daily.ts"; +import type { Region } from "../../shared/schema.ts"; +import { formatRemaining } from "../../shared/time.ts"; + +/** + * The checklist for an event you have to come back to every day. + * + * A single "done" tick is the wrong shape for these: the job is not finished + * or unfinished, it is finished *today*, and yesterday's is gone whatever you + * do now. So this shows the whole run as a strip of days — what you got, what + * you missed, and how many chances are left — with today's tick as the one + * prominent control. + * + * Past days stay clickable on purpose. People log in and tick up later, and a + * checklist that cannot be corrected stops being trusted after the first + * mistake. + */ +export function DailyChecklist({ + startsMs, + endsMs, + region, + now, + logged, + onToggleDay, +}: { + startsMs: number; + endsMs: number | null; + region: Region; + now: number; + logged: string[]; + onToggleDay: (day: string) => void; +}) { + const summary = dailySummary({ startsMs, endsMs, region, now, logged }); + const { days, today, doneToday, todayInWindow } = summary; + const started = now >= startsMs; + + return ( +
+
+

Daily checklist

+

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

+
+ + + + {days === null ? ( +

+ The source hasn't announced an end date, so how many days are left is + unknown. Your ticks are still counted — {summary.logged} so far. +

+ ) : ( + <> +
+ {days.map((day) => ( + onToggleDay(day)} + /> + ))} +
+

+ {caption(summary, todayInWindow)} +

+ + )} +
+ ); +} + +/** + * One day. Future days are dimmed but not disabled-looking, past misses read as + * empty rather than as an error — a missed daily is information, not a telling + * off. + */ +function DayPip({ + day, + today, + done, + onToggle, +}: { + day: string; + today: string; + done: boolean; + onToggle: () => void; +}) { + const isToday = day === today; + const isFuture = day > today; + const label = new Date(`${day}T00:00:00Z`).toLocaleDateString(undefined, { + day: "numeric", + month: "short", + }); + + return ( + + ); +} + +function caption(summary: DailySummary, todayInWindow: boolean): string { + const { days, logged, remaining, missed } = summary; + if (days === null || remaining === null) return `${logged} days ticked off.`; + + const parts = [`${logged} of ${days.length} days`]; + if (remaining > 0) { + parts.push( + todayInWindow + ? `${remaining} left including today` + : `${remaining} to come`, + ); + } else { + parts.push("the run is over"); + } + if (missed !== null && missed > 0) parts.push(`${missed} missed`); + return `${parts.join(" · ")}.`; +} diff --git a/src/client/components/EventDetail.tsx b/src/client/components/EventDetail.tsx index fe9e45b..589417d 100644 --- a/src/client/components/EventDetail.tsx +++ b/src/client/components/EventDetail.tsx @@ -5,6 +5,9 @@ import type { RowEvent } from "./EventRow.tsx"; import { pressure, pressureReason, type Effort } from "../../shared/effort.ts"; import type { Status } from "../state/useProgress.ts"; import { ProgressControls } from "./ProgressControls.tsx"; +import { DailyChecklist } from "./DailyChecklist.tsx"; +import { isDaily } from "../../shared/daily.ts"; +import type { Region } from "../../shared/schema.ts"; import { Meter, URGENCY_COLOR } from "./Meter.tsx"; export function EventDetail({ @@ -14,6 +17,10 @@ export function EventDetail({ status, effort, note, + region, + now, + dailyDays, + onToggleDay, onToggle, onIgnore, onStatus, @@ -27,6 +34,11 @@ export function EventDetail({ status: Status | undefined; effort: Effort | undefined; note: string; + region: Region; + now: number; + /** Days already ticked off, for events that repeat. */ + dailyDays: string[]; + onToggleDay: (id: string, day: string) => void; onToggle: (id: string) => void; onIgnore: (id: string) => void; onStatus: (id: string, s: Status | undefined) => void; @@ -118,6 +130,20 @@ export function EventDetail({

)} + {/* A repeating event gets the checklist instead of nothing but a + "mark done" — its work is spread over every day of the run, and one + tick cannot express that. */} + {isDaily(event) && ( + onToggleDay(event.id, day)} + /> + )} + void) | undefined; @@ -30,6 +39,7 @@ export function EventRow({ completed, status, effort, + daily, ignored = false, onRestore, onOpen, @@ -102,8 +112,28 @@ export function EventRow({ - {(status === "doing" || effort !== undefined || risk !== "fine") && ( + {(status === "doing" || + effort !== undefined || + daily !== undefined || + risk !== "fine") && (
+ {/* 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); + }); +});