import { useEffect, useRef, useState } from "react"; import { dailiesId, dayKey, msUntilReset, streakOf } from "../../shared/daily.ts"; import { gameMeta } from "../../shared/games.ts"; import type { GachaEvent, GameId, Region } from "../../shared/schema.ts"; import { formatRemaining } from "../../shared/time.ts"; 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. */ export function Dailies({ games, events, region, now, daysFor, onToggleDay, }: { games: GameId[]; /** * 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: GachaEvent[]; region: Region; now: number; daysFor: (id: string) => string[]; onToggleDay: (id: string, day: string) => void; }) { // Each game rolls on its own server clock, so "today" is asked per game // rather than once for the section — Endfield's European day can still be // yesterday's while every HoYo game has already turned over. const chores = games.map((id) => ({ key: dailiesId(id), game: gameMeta(id), today: dayKey(now, region, id), resetsIn: msUntilReset(now, region, id), })); const repeating = events.map((event) => ({ key: event.id, event, game: gameMeta(event.game), today: dayKey(now, region, event.game), resetsIn: msUntilReset(now, region, event.game), })); const items = [...chores, ...repeating]; 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. 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)}

{allDone ? "All done. Nothing else expires tonight." : `${waiting(total - complete)} still waiting on you today.`}

); } /** * 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 — softly when 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. */ 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`; }