feat: track events that repeat daily, and each game's dailies
A login campaign is not one job with a deadline. It is twenty small jobs on twenty separate deadlines, and a day you miss is gone whatever you do afterwards — which a single "done" tick cannot express. Repeating events now get a checklist: today's tick, a strip of every day in the run showing what you got and what you missed, the streak, and how many chances are left. Past days stay editable, because people tick up later and a checklist you cannot correct stops being trusted after the first mistake. Alongside it sits today's dailies — commissions, sanity, daily training — one tick per game, keyed `dailies:<game>`, since no source publishes those and they are the only thing on the page that expires tonight rather than next patch. Days roll at 04:00 server time per region, not midnight: finishing at 02:00 is still yesterday, and a naive UTC date would tick the wrong box for four hours every night. Dailiness is read off what the source published — a login event type, or "daily"/"check-in"/"7-day" wording — never from a game's habits or an event's length. That adds no schema field, so the feed contract and every event ID are untouched. An unannounced end yields a tick count, not a checklist of invented length, and a tick is never removed except by the reader, including ticks outside the window the feed now claims: a source quietly moving a date must not erase a fortnight's streak that exists nowhere else. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
ad3647bee2
commit
ccc5d369bd
+59
-4
@@ -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() {
|
||||
<>
|
||||
<NextUp row={next} onOpen={setOpenId} />
|
||||
|
||||
{/* The chores no wiki publishes, and the only thing on this page
|
||||
that expires tonight rather than next patch. */}
|
||||
<Dailies
|
||||
games={games.filter((g) => !prefs.hiddenGames.includes(g))}
|
||||
region={prefs.region}
|
||||
now={now}
|
||||
daysFor={daily.daysFor}
|
||||
onToggleDay={daily.toggleDay}
|
||||
/>
|
||||
|
||||
{live.length > 0 && (
|
||||
<Section
|
||||
legend
|
||||
@@ -227,6 +262,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}
|
||||
@@ -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<string, unknown>,
|
||||
daily: DailyLogMap,
|
||||
ignored: Record<string, { at: string }>,
|
||||
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<string, { at: string }>) => void,
|
||||
mergeDaily: (c: DailyLogMap) => void,
|
||||
mergeIgnored: (c: Record<string, { at: string }>) => 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.");
|
||||
|
||||
@@ -104,8 +104,9 @@ export function Controls({
|
||||
<div className="mt-6 border-t border-hairline pt-4">
|
||||
<p className="eyebrow">Your progress</p>
|
||||
<p className="mt-1.5 max-w-md text-xs leading-relaxed text-faint">
|
||||
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.
|
||||
</p>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { dailiesId, dayKey, msUntilReset, streakOf } from "../../shared/daily.ts";
|
||||
import { gameMeta } from "../../shared/games.ts";
|
||||
import type { GameId, Region } from "../../shared/schema.ts";
|
||||
import { formatRemaining } from "../../shared/time.ts";
|
||||
|
||||
/**
|
||||
* The chores no source publishes.
|
||||
*
|
||||
* Commissions, sanity, daily training — the routine that runs whether or not
|
||||
* an event is on. They are the most-missed thing in every one of these games
|
||||
* and they appear on no wiki page, so they are a fixed client-side list rather
|
||||
* than feed data. Ticking one is stored in exactly the same day log an event's
|
||||
* checklist uses, so streaks and exports work the same way for both.
|
||||
*
|
||||
* Sits above the event list because it is the one part of the page that is
|
||||
* answerable in ten seconds and expires tonight.
|
||||
*/
|
||||
export function Dailies({
|
||||
games,
|
||||
region,
|
||||
now,
|
||||
daysFor,
|
||||
onToggleDay,
|
||||
}: {
|
||||
games: GameId[];
|
||||
region: Region;
|
||||
now: number;
|
||||
daysFor: (id: string) => string[];
|
||||
onToggleDay: (id: string, day: string) => void;
|
||||
}) {
|
||||
if (games.length === 0) return null;
|
||||
|
||||
const today = dayKey(now, region);
|
||||
const done = games.filter((g) => daysFor(dailiesId(g)).includes(today));
|
||||
|
||||
return (
|
||||
<section className="border-b border-hairline px-4 py-4">
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<h2 className="eyebrow">
|
||||
Today's dailies · {done.length}/{games.length}
|
||||
</h2>
|
||||
<p className="tnum text-[0.6875rem] text-faint">
|
||||
resets in {formatRemaining(msUntilReset(now, region))}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ul className="mt-2.5 flex flex-wrap gap-1.5">
|
||||
{games.map((id) => {
|
||||
const game = gameMeta(id);
|
||||
const key = dailiesId(id);
|
||||
const days = daysFor(key);
|
||||
const isDone = days.includes(today);
|
||||
const streak = streakOf(days, today);
|
||||
|
||||
return (
|
||||
<li key={id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggleDay(key, today)}
|
||||
aria-pressed={isDone}
|
||||
aria-label={`${game.name} dailies — ${game.dailyTasks}${
|
||||
isDone ? ", done today" : ", not done today"
|
||||
}`}
|
||||
title={game.dailyTasks}
|
||||
className="flex items-center gap-2 rounded-full border px-3 py-1.5 text-xs font-medium transition-colors"
|
||||
style={{
|
||||
borderColor: isDone ? game.hue : "var(--color-hairline)",
|
||||
color: isDone ? game.hue : "var(--color-faint)",
|
||||
background: isDone
|
||||
? `color-mix(in srgb, ${game.hue} 14%, transparent)`
|
||||
: "transparent",
|
||||
}}
|
||||
>
|
||||
<svg viewBox="0 0 16 16" aria-hidden className="size-3">
|
||||
<path
|
||||
d="M2.5 8.5l3.5 3.5 7.5-8"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
opacity={isDone ? 1 : 0.3}
|
||||
/>
|
||||
</svg>
|
||||
{game.short}
|
||||
{streak > 1 && (
|
||||
<span className="tnum text-[0.625rem] opacity-70">
|
||||
{streak}d
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
<p className="mt-2 text-[0.6875rem] leading-relaxed text-faint">
|
||||
{done.length === games.length
|
||||
? "All done. Nothing else expires tonight."
|
||||
: `${waiting(games.length - done.length)} still waiting on you today.`}
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function waiting(n: number): string {
|
||||
return n === 1 ? "One game" : `${n} games`;
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="mt-5 rounded-xl border border-hairline p-4">
|
||||
<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))}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggleDay(today)}
|
||||
disabled={!started}
|
||||
aria-pressed={doneToday}
|
||||
className={`mt-3 flex w-full items-center gap-3 rounded-lg border px-3 py-2.5 text-left transition-colors ${
|
||||
doneToday
|
||||
? "border-near/60 bg-near/10 text-near"
|
||||
: "border-hairline text-ink hover:border-faint"
|
||||
} ${started ? "cursor-pointer" : "cursor-not-allowed opacity-50"}`}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className={`grid size-6 shrink-0 place-items-center rounded-md border ${
|
||||
doneToday ? "border-transparent bg-near/25" : "border-hairline"
|
||||
}`}
|
||||
>
|
||||
<svg viewBox="0 0 16 16" className="size-3.5">
|
||||
<path
|
||||
d="M2.5 8.5l3.5 3.5 7.5-8"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
opacity={doneToday ? 1 : 0.25}
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
<span className="text-sm font-medium">
|
||||
{!started
|
||||
? "Not started yet"
|
||||
: doneToday
|
||||
? "Done today"
|
||||
: "Do today's"}
|
||||
</span>
|
||||
{summary.streak > 1 && (
|
||||
<span className="ml-auto text-[0.6875rem] text-faint">
|
||||
{summary.streak}-day streak
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{days === null ? (
|
||||
<p className="mt-3 text-xs leading-relaxed text-faint">
|
||||
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.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="mt-3 flex flex-wrap gap-1">
|
||||
{days.map((day) => (
|
||||
<DayPip
|
||||
key={day}
|
||||
day={day}
|
||||
today={today}
|
||||
done={logged.includes(day)}
|
||||
onToggle={() => onToggleDay(day)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-2.5 text-xs leading-relaxed text-faint">
|
||||
{caption(summary, todayInWindow)}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
disabled={isFuture}
|
||||
aria-pressed={done}
|
||||
aria-label={`${label}${done ? ", done" : ", not done"}`}
|
||||
title={label}
|
||||
className={`tnum size-6 rounded-[5px] border text-[0.625rem] leading-none transition-colors ${
|
||||
done
|
||||
? "border-transparent bg-near/25 text-near"
|
||||
: isFuture
|
||||
? "border-hairline/60 text-faint/50"
|
||||
: "border-hairline text-faint hover:border-faint"
|
||||
} ${isToday ? "ring-1 ring-ink/40" : ""} ${
|
||||
isFuture ? "cursor-default" : "cursor-pointer"
|
||||
}`}
|
||||
>
|
||||
{day.slice(8)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
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(" · ")}.`;
|
||||
}
|
||||
@@ -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({
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* 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) && (
|
||||
<DailyChecklist
|
||||
startsMs={clock.startsMs}
|
||||
endsMs={clock.endsMs}
|
||||
region={region}
|
||||
now={now}
|
||||
logged={dailyDays}
|
||||
onToggleDay={(day) => onToggleDay(event.id, day)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ProgressControls
|
||||
status={status}
|
||||
effort={effort}
|
||||
|
||||
@@ -14,11 +14,20 @@ export interface RowEvent {
|
||||
clock: EventClock;
|
||||
}
|
||||
|
||||
/** What a repeating event needs to say in a list: today, and how many left. */
|
||||
export interface DailyBadge {
|
||||
doneToday: boolean;
|
||||
/** Days left including today. Null when the end is unannounced. */
|
||||
remaining: number | null;
|
||||
}
|
||||
|
||||
interface EventRowProps {
|
||||
row: RowEvent;
|
||||
completed: boolean;
|
||||
status?: Status | undefined;
|
||||
effort?: Effort | undefined;
|
||||
/** Present only on events that repeat daily. */
|
||||
daily?: DailyBadge | undefined;
|
||||
/** Only ever true when the reader has chosen to reveal ignored events. */
|
||||
ignored?: boolean | undefined;
|
||||
onRestore?: ((id: string) => void) | undefined;
|
||||
@@ -30,6 +39,7 @@ export function EventRow({
|
||||
completed,
|
||||
status,
|
||||
effort,
|
||||
daily,
|
||||
ignored = false,
|
||||
onRestore,
|
||||
onOpen,
|
||||
@@ -102,8 +112,28 @@ export function EventRow({
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{(status === "doing" || effort !== undefined || risk !== "fine") && (
|
||||
{(status === "doing" ||
|
||||
effort !== undefined ||
|
||||
daily !== undefined ||
|
||||
risk !== "fine") && (
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-1.5">
|
||||
{/* A repeating event's real deadline is tonight's reset, not the
|
||||
end date the countdown shows, so the row says both. */}
|
||||
{daily !== undefined && (
|
||||
<span
|
||||
className={`rounded-[3px] px-1.5 py-px text-[0.625rem] font-medium ${
|
||||
daily.doneToday
|
||||
? "bg-near/15 text-near"
|
||||
: "bg-soon/15 text-soon"
|
||||
}`}
|
||||
>
|
||||
{daily.doneToday
|
||||
? "daily · done today"
|
||||
: daily.remaining === null
|
||||
? "daily · not today"
|
||||
: `daily · ${daily.remaining} left`}
|
||||
</span>
|
||||
)}
|
||||
{status === "doing" && (
|
||||
<span className="rounded-[3px] bg-near/15 px-1.5 py-px text-[0.625rem] font-medium text-near">
|
||||
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. */}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string, DailyLog>;
|
||||
|
||||
/**
|
||||
* 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<DailyLogMap>(() =>
|
||||
readJson<DailyLogMap>(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 };
|
||||
}
|
||||
@@ -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<GachaEvent, "type" | "title" | "summary">;
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
+14
-7
@@ -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<GameId, GameMeta> = {
|
||||
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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user