import { useEffect, useRef, useState } from "react"; import { catchUpDays, CATCH_UP_DAYS, dailiesId, dayKey, msUntilReset, streakOf, } from "../../shared/daily.ts"; import { useGameMeta } from "../state/gameMeta.tsx"; import { isCustomGameId, type DisplayEvent, type LaneId } from "../../shared/custom.ts"; import type { GameMeta } from "../../shared/games.ts"; import type { GameId, Region } from "../../shared/schema.ts"; import { formatRemaining } from "../../shared/time.ts"; import { DayPip } from "./DayPip.tsx"; import { Fireworks } from "./Fireworks.tsx"; /** * 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. * * Running events that repeat sit here too, so ticking today off never means * opening a sheet to find the checklist. The checklist is still where the whole * run lives — this is just today's line of it. * * Sits above the event list because it is the one part of the page that is * answerable in ten seconds and expires tonight. */ /** One thing to tick: a game's standing chore, or one of its repeating events. */ export interface DailyItem { /** The day-log key — `dailies:` for a chore, the event id otherwise. */ key: string; game: LaneId; label: string; title: string; ariaLabel: string; today: string; resetsIn: number; /** * Where a catch-up strip starts: the event's start, or null for a chore, * which has no start because it is a routine rather than an event. */ notBefore: number | null; } /** A game and everything of its that wants ticking today. */ export interface DailyGroup { game: LaneId; meta: GameMeta; items: DailyItem[]; } /** * A game's dailies, together. * * This used to be `[...chores, ...repeating]` — every game's standing chore, * then every repeating event — which put Genshin's commissions and Genshin's * login event at opposite ends of the strip with a dozen other games between * them. The reader thinks in games: if they marked an event as repeating, it * belongs beside the chores of the game it came from. * * The chore comes first inside a group because it is the one that exists every * day; the events follow **in the order they arrived**, because grouping is not * a licence to re-sort within a group — the same rule `lanes.ts` states for the * timeline's lanes. * * A lane the reader invented contributes events but no chore: there is no * routine we could name on their behalf (`docs/DATA-MODEL.md` § Reader-authored * key spaces). A group with nothing in it is dropped rather than rendered as an * empty heading. * * Pure, and exported so it is tested directly — the pattern `Timeline.tsx` uses * for `boardWindow` and `splitAt`. */ export function dailyGroups( games: readonly LaneId[], events: readonly DisplayEvent[], now: number, region: Region, meta: (id: LaneId) => GameMeta, startOf: (event: DisplayEvent) => number, ): DailyGroup[] { // A lane can arrive through an event without being in `games` — an event on a // game the reader has since switched off, say — and dropping it here would // quietly remove a tickable line. Ordered lanes first, then any straggler in // the order its events came. const lanes: LaneId[] = [...games]; for (const event of events) { if (!lanes.includes(event.game)) lanes.push(event.game); } const groups: DailyGroup[] = []; for (const lane of lanes) { const game = meta(lane); const items: DailyItem[] = []; const today = dayKey(now, region, lane); const resetsIn = msUntilReset(now, region, lane); if (!isCustomGameId(lane)) { items.push({ key: dailiesId(lane as GameId), game: lane, label: game.short, title: game.dailyTasks, ariaLabel: `${game.name} dailies — ${game.dailyTasks}`, today, resetsIn, notBefore: null, }); } for (const event of events) { if (event.game !== lane) continue; items.push({ key: event.id, game: lane, label: event.title, title: `${game.name} — ${event.title}`, ariaLabel: `${event.title} (${game.name})`, today, resetsIn, notBefore: startOf(event), }); } if (items.length > 0) groups.push({ game: lane, meta: game, items }); } return groups; } export function Dailies({ games, events, region, now, daysFor, onToggleDay, }: { /** Every lane the reader is looking at, in their own order. */ games: LaneId[]; /** * Live events that repeat daily — detected, or marked by the reader — and * that the reader has not already finished or ignored. An event they marked * done has no line left to tick, and listing it is the app arguing with them. */ events: DisplayEvent[]; region: Region; now: number; daysFor: (id: string) => string[]; onToggleDay: (id: string, day: string) => void; }) { const gameMeta = useGameMeta(); /** * Whether the strip is showing the last fortnight. * * Per-visit state and deliberately not a stored preference, for the reason * expanding a truncated list is not one: it is something a reader does while * reading — "I did Tuesday, let me say so" — rather than a statement about how * the app should work. */ const [catchUp, setCatchUp] = useState(false); // Each game rolls on its own server clock, so "today" is asked per game // rather than once for the section — Endfield's European day can still be // yesterday's while every HoYo game has already turned over. const groups = dailyGroups( games, events, now, region, gameMeta, (event) => Date.parse(event.startsAt), ); const items = groups.flatMap((group) => group.items); const total = items.length; const complete = items.filter((i) => daysFor(i.key).includes(i.today)).length; const allDone = total > 0 && complete === total; const [burst, setBurst] = useState(0); // Null until the first render has been seen, so arriving at a page where // everything is already ticked is not treated as having just finished it. const previous = useRef<{ total: number; complete: number } | null>(null); useEffect(() => { const was = previous.current; previous.current = { total, complete }; if (was === null || !allDone) return; // Celebrate finishing the last one, and only that. The list also gets // shorter when the reader focuses a single game or marks a repeating event // done, which can land on "all complete" without them having ticked // anything — a burst there is the app congratulating them for filtering. // Backfilling a past day moves no count here, so it never bursts. if (was.total === total && complete > was.complete) setBurst((n) => n + 1); }, [total, complete, allDone]); useEffect(() => { if (burst === 0) return; const id = setTimeout(() => setBurst(0), 1400); return () => clearTimeout(id); }, [burst]); if (total === 0) return null; // With mixed reset clocks there is no single "resets in", so the header // reports the next one to land and says that it is the next one. const soonest = Math.min(...items.map((i) => i.resetsIn)); const mixed = new Set(items.map((i) => i.resetsIn)).size > 1; return (
{burst > 0 && }

Today's dailies · {complete}/{total}

{mixed ? "next reset in " : "resets in "} {formatRemaining(soonest)}

{catchUp ? ( ) : ( /* One wrapping row, with each game's chore and its events adjacent. No per-game headings here: this is the part of the page answerable in ten seconds, and a heading per game would make it the tallest block on it, pushing "next to expire" — the answer the reader came for — down the page. The hue already says which game a chip belongs to. */
    {items.map((item) => (
  • onToggleDay(item.key, item.today)} />
  • ))}
)}

{catchUp ? `Tick a day you did but didn't record. The last ${CATCH_UP_DAYS} days, and nothing later than today.` : allDone ? "All done. Nothing else expires tonight." : `${waiting(total - complete)} still waiting on you today.`}

{/* The way back to a day you already did. It lives on the section rather than on each chip because a chip is a single tick target, and a second control inside one is a mis-tap that costs a streak. */}
); } /** * The last fortnight, for saying you did a day you never ticked. * * Its own component rather than a branch inside the section, so it can be * rendered and asserted on directly — the interesting parts are which days * appear and whose clock they were cut on, and neither is reachable through a * `useState` from a test. * * Expanded there is vertical room, so the grouping becomes a heading per game. * Collapsed it is adjacency alone: the strip is the part of the page answerable * in ten seconds, and a heading per game would make it the tallest block on it. * * Every strip is cut with its own item's game, never the section's — Endfield * serves Europe off the Americas machine, so a tick written under one clock and * read under another is a day the reader loses. */ export function CatchUpPanel({ groups, now, region, daysFor, onToggleDay, }: { groups: DailyGroup[]; now: number; region: Region; daysFor: (id: string) => string[]; onToggleDay: (id: string, day: string) => void; }) { return (
{groups.map((group) => (

{group.meta.name}

{group.items.map((item) => (

{item.label}

{catchUpDays(now, region, item.game, item.notBefore).map((day) => ( onToggleDay(item.key, day)} /> ))}
))}
))}
); } /** * One thing to tick off today. * * The same pill whether it is a game's standing chore or an event that repeats: * to the reader at 23:50 they are the same job, and the distinction between * "the app knows about this" and "a wiki published it" is ours, not theirs. * * The game's hue is on the border in both states — faintly while the job is * outstanding, fully once it is done. A chip that only takes its colour on * completion means the strip you actually scan, the unfinished one, is a row of * identical grey pills with no clue which game each belongs to. * * The outstanding tint is kept low enough to read as a hint rather than a * state: it has to say *which game* without competing with the tick, which is * the only thing on the chip that answers the question the reader came with. * Ticking one should be a visible jump, not a nudge. */ function TickChip({ label, hue, title, ariaLabel, days, today, onToggle, }: { label: string; hue: string; title: string; ariaLabel: string; days: string[]; today: string; onToggle: () => void; }) { const isDone = days.includes(today); const streak = streakOf(days, today); return ( ); } function waiting(n: number): string { return n === 1 ? "One thing" : `${n} things`; }