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 { GameFocus } from "./components/GameFocus.tsx"; import { EventDetail } from "./components/EventDetail.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"; import { Colophon } from "./components/Colophon.tsx"; import { Legend } from "./components/Legend.tsx"; import { Toast } from "./components/Toast.tsx"; import { UpdateNotice } from "./components/UpdateNotice.tsx"; import { KEYS } from "./state/storage.ts"; import { useAppUpdate } from "./state/useAppUpdate.ts"; import { useMarkSet } from "./state/useMarkSet.ts"; import { useProgress } from "./state/useProgress.ts"; import { useDailyLog, type DailyLogMap } from "./state/useDailyLog.ts"; import { adoptNewLanes, usePrefs, type View } from "./state/usePrefs.ts"; import { snapDayWidth } from "./state/zoom.ts"; import { useCustom } from "./state/useCustom.ts"; import { compareRows, SORT_MODES, type Activity, type SortMode } from "./state/sort.ts"; import { advanceFocus, countByGame, nextToExpire, outstanding, resolveFocus, } from "./state/lens.ts"; import { clockFor, formatRemaining } from "../shared/time.ts"; import { dailySummary, isDaily, resolveDaily } from "../shared/daily.ts"; import { GameMetaProvider, type MetaResolver } from "./state/gameMeta.tsx"; import { metaOnTheme, useTheme } from "./state/theme.ts"; import { isCustomGameId, type CustomEvents, type CustomGames, type LaneId, } from "../shared/custom.ts"; import { metaFor } from "../shared/games.ts"; /** * How many deadlines the headline carries. * * One was the whole panel, and one is what a reader who has just finished it * needs replacing. Three is what they asked for: enough to plan an evening * around, few enough that the closest one still owns the page. */ const HEADLINE_DEADLINES = 3; /** * How many rows a section shows before it offers the rest. * * Two games already run to twenty-one live events and every game added doubles * down on that, which is the point at which a list stops being read at all. * This truncates the *view* and nothing else: the order is untouched, the * hidden rows are still counted in the header, still on the timeline, and one * tap away here. */ const LIST_CAP = 6; /** * Connection state. Offline is not an error here — the service worker serves * the last feed it saw and countdowns run off the local clock — but it does * change what the reader can trust, so it is surfaced rather than hidden. */ function useOnline(): boolean { const [online, setOnline] = useState(() => typeof navigator === "undefined" ? true : navigator.onLine, ); useEffect(() => { const up = () => setOnline(true); const down = () => setOnline(false); window.addEventListener("online", up); window.addEventListener("offline", down); return () => { window.removeEventListener("online", up); window.removeEventListener("offline", down); }; }, []); return online; } /** Ticks once a second so countdowns stay honest without re-fetching. */ function useNow(intervalMs = 1000): number { const [now, setNow] = useState(() => Date.now()); useEffect(() => { const id = setInterval(() => setNow(Date.now()), intervalMs); return () => clearInterval(id); }, [intervalMs]); return now; } export function App() { const [state, setState] = useState({ status: "loading" }); const [openId, setOpenId] = useState(null); // The event most recently ignored, so it can be put back without hunting for // a row that just disappeared. const [lastIgnored, setLastIgnored] = useState<{ id: string; title: string } | null>(null); const now = useNow(); const online = useOnline(); const { prefs, update, toggleGame } = usePrefs(); // Their answer from the first run, or their last tap on the tabs. Reading it // from `prefs` is what stops a reload putting a timeline reader back on the // list they did not choose. const view = prefs.view; const ignored = useMarkSet(KEYS.ignored); const prog = useProgress(); const daily = useDailyLog(); const custom = useCustom(); // Colour only: which ground the page is drawn on, written to the document by // the hook. Nothing else in the app asks what it is — the tokens in // styles.css answer for every component — except the hues below. const theme = useTheme(prefs.theme); /** * How every lane in this tree is named and coloured. * * App owns it because App is the only thing holding the reader's own games, * and hands it down rather than letting components import a lookup that can * only ever answer for the tracked ones. * * It is also where a hue meets the theme. A hue is data — ours in `games.ts`, * theirs in their browser — and all of it was picked against the dark ground, * so on paper the bright ones need darkening to stay readable. Doing it here * means every lane label, chip, rail and bar in the tree gets the adjusted * answer without a single component knowing a theme exists. */ const gameMeta = useMemo( () => (id) => metaOnTheme(metaFor(id, custom.games), theme), [custom.games, theme], ); // "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"; /** * How far into an event the reader is, for ordering only. * * Ticking a day off a repeating event counts as "doing it" without them * having to also set the status — the tick already said so, and asking twice * is how a sort ends up lying about what you were in the middle of. */ const activityOf = (id: string): Activity => { const status = prog.progress[id]?.status; if (status === "done") return "done"; if (status === "doing") return "doing"; return daily.daysFor(id).length > 0 ? "doing" : "idle"; }; /** * Whether an event repeats, the reader's own answer included. Detection reads * the source's wording; they can overrule it either way. */ const repeatsDaily = (row: RowEvent): boolean => resolveDaily( row.event, prog.progress[row.event.id]?.daily, prefs.detectDaily, ); /** Today's state for a repeating event, or undefined if it does not repeat. */ const dailyBadge = (row: RowEvent): DailyBadge | undefined => { if (!repeatsDaily(row)) return undefined; const summary = dailySummary({ startsMs: row.clock.startsMs, endsMs: row.clock.endsMs, region: prefs.region, game: row.event.game, now, logged: daily.daysFor(row.event.id), }); return { doneToday: summary.doneToday, remaining: summary.remaining }; }; const isIgnored = (id: string) => ignored.marks[id] !== undefined; const toggleIgnored = (id: string, title: string) => { const wasIgnored = isIgnored(id); ignored.toggle(id); setLastIgnored(wasIgnored ? null : { id, title }); }; useEffect(() => { const ac = new AbortController(); fetchFeed(ac.signal) .then((feed) => setState({ status: "ready", feed })) .catch((err: unknown) => { if (ac.signal.aborted) return; setState({ status: "error", message: err instanceof Error ? err.message : "Could not load events.", }); }); return () => ac.abort(); }, []); const allRows = useMemo(() => { if (state.status !== "ready") return []; // The reader's own events are events. They sort, filter, focus, expire and // tick exactly like scraped ones — what sets them apart is only that // nothing is claimed about where their dates came from. return [ ...state.feed.events.filter((e) => e.status === "published"), ...custom.rows, ].map((event) => ({ event, clock: clockFor(event, prefs.region, now) })); // `now` intentionally excluded: recomputing every clock each second is // wasteful, and the countdown text re-renders from `now` anyway. // eslint-disable-next-line react-hooks/exhaustive-deps }, [state, custom.rows, prefs.region, Math.floor(now / 60_000)]); // Feed lanes come from rows, the reader's from the games themselves — so a // game they just created shows up in the filters before it holds anything. // It is still not a scraped game with an empty feed: it has no source row, no // freshness badge and no colophon credit. const games = useMemo( () => [...new Set([...allRows.map((r) => r.event.game), ...custom.lanes])], [allRows, custom.lanes], ); /** * A lane the reader has never been offered starts switched off. * * Adding a source is our decision, not theirs, and a reader who plays two * games did not ask for the other twelve. So a lane that is new to *them* * is recorded and hidden, and the games chips in settings are where they * take it up — the one place that lists every lane, on or off. * * The seeding branch is the whole reason this is safe: an existing reader * has no `knownGames` at all, and treating that as "has been offered * nothing" would switch off every game they already read. Absent means * unrecorded, so the first pass records what is already on their screen and * changes nothing else. */ useEffect(() => { if (state.status !== "ready") return; const patch = adoptNewLanes(games, prefs.knownGames, prefs.hiddenGames); if (patch !== null) update(patch); }, [state.status, games, prefs.knownGames, prefs.hiddenGames, update]); /** Games the reader plays, in feed order. The focus bar rotates through these. */ const enabled = useMemo( () => games.filter((g) => !prefs.hiddenGames.includes(g)), [games, prefs.hiddenGames], ); // A focus on a game they have since switched off is ignored, not obeyed — // otherwise the page is blank for a reason that lives in a panel at the // bottom. The stored value is left alone so switching the game back on // restores where they were. const focus = resolveFocus(prefs.focusGame, enabled); /** * Everything the reader could be looking at, before focus narrows it. The * focus chips count off this, so a chip can say what is waiting in a game * that is not the one currently on screen. */ const inScope = useMemo( () => allRows .filter((r) => !prefs.hiddenGames.includes(r.event.game)) .filter((r) => !r.clock.ended) // Ignored events are gone from both views unless deliberately revealed // — that is the whole point of ignoring one. .filter((r) => prefs.showIgnored || !isIgnored(r.event.id)) .filter((r) => prefs.showCompleted || !isDone(r.event.id)), [ allRows, prefs.hiddenGames, prefs.showCompleted, prefs.showIgnored, prog.progress, ignored.marks, ], ); const visible = useMemo( () => inScope .filter((r) => focus === null || r.event.game === focus) // Sorting only ever groups: both modes fall back to soonest-ending // inside a group, so choosing one never costs the deadline order. .sort(compareRows(prefs.sort, activityOf)), [inScope, focus, prefs.sort, prog.progress, daily.logs], ); const live = visible.filter((r) => r.clock.live); const upcoming = visible.filter((r) => r.clock.upcoming); /** * What the page is telling the reader to *do*, as opposed to what it is * letting them look at. * * The headline and the dailies strip are both instructions, so both drop * events the reader has finished or ignored — being pointed at a job you * already did is the bug whether the pointer is a countdown or a checkbox. * `showCompleted` deliberately does not reach this: that preference says keep * them on screen, not keep nagging me about them. */ const todo = outstanding(live, isDone, isIgnored); const headline = nextToExpire(todo, HEADLINE_DEADLINES); // Counted across every game the reader plays, not just the focused one — a // chip has to say what is waiting behind it to be worth tapping. const scopedTodo = useMemo( () => outstanding(inScope, isDone, isIgnored), [inScope, prog.progress, ignored.marks], ); const perGame = useMemo(() => countByGame(scopedTodo), [scopedTodo]); const openRow = allRows.find((r) => r.event.id === openId) ?? null; /** One row, wired up. Both lists render the same thing from the same props. */ const renderRow = (row: RowEvent) => ( ignored.toggle(id)} onOpen={setOpenId} /> ); if (state.status === "loading") { return

Loading events…

; } if (state.status === "error") { return (

Events unavailable

{state.message}

); } // First run: ask which games before showing a calendar full of ones they // don't play. Stored as hiddenGames (the inverse); what happens to a game // added *later* is decided by `knownGames` above, not by this shape. if (!prefs.onboarded) { return ( update({ onboarded: true, hiddenGames: games.filter((g) => !chosen.includes(g)), view: chosenView, }) } /> ); } /** * Working through games one at a time, which is how someone with four of them * actually plays: clear one, move on. * * It goes at the top of the column it filters, and past `lg` the checklist's * column is the rail: the bar rides with the deadlines and the dailies it * narrows, pinned and still above every one of them, instead of spending the * full width of a wide screen on a row of chips and pushing "next to expire" * — the answer the reader came for — down the page to make room. On a phone, * and on the timeline, which has no rail, that same rule puts it back at the * top of the page. Rendered once per view, never twice on one page. */ const focusBar = ( update({ focusGame })} onAdvance={() => update({ focusGame: advanceFocus(focus, enabled) })} /> ); return (

EVENTCLOCK

{live.length} live · {upcoming.length} upcoming {!online && ( offline )}

{/* The id is a stored value (`prefs.view`) and the label is copy, so they are allowed to drift: renaming the tab must not silently move every reader to the other view. */} {( [ ["soon", "Checklist"], ["timeline", "Timeline"], ] as const ).map(([id, label]) => ( ))}
{view === "soon" ? ( /* * Two columns once the screen has room for them, and the split is the * one this codebase already draws everywhere else: what the page is * *telling* the reader to do on the left, what it is *showing* them on * the right. The deadlines and tonight's dailies are instructions, they * are short, and they are what the reader came for — so on a wide * screen they stop scrolling away and stay pinned beside the list, with * the focus bar that narrows them at the top of the same column. * * Below `lg` this is one column in exactly the old order, because on a * phone the same argument produces the same answer: put them first. */
{live.length > 0 && (
1 ? `next after this ends in ${formatRemaining( live[1]?.clock.msRemaining ?? 0, )}` : undefined } action={ visible.length > 1 ? ( update({ sort })} /> ) : undefined } >
)} {upcoming.length > 0 && (
1 ? ( update({ sort })} /> ) : undefined } >
)} {visible.length === 0 && (

{focus !== null ? `Nothing running in ${gameMeta(focus).name}. Try another game, or show all of them.` : "Nothing to show. Every game is switched off, or you've finished everything and hidden completed events."}

)}
) : ( <> {focusBar} update({ timelineDayWidth })} group={prefs.timelineGroup} onGroup={(timelineGroup) => update({ timelineGroup })} // The board holds these back itself rather than being handed a // shorter list, so it can say how many are waiting when there is // nothing else left to draw. The switch is in settings. showUpcoming={prefs.timelineUpcoming} splitUpcoming={prefs.timelineSplitUpcoming} onOpen={setOpenId} isDone={isDone} /> )} exportProgress(prog.progress, daily.logs, ignored.marks, prefs, { games: custom.games, events: custom.events, }) } onImport={(file) => void importProgress( file, prog.merge, daily.merge, ignored.merge, custom.merge, ) } /> {!online && (

You're offline. These are the events last downloaded {" "} {formatRemaining(now - Date.parse(state.feed.generatedAt))} ago, and countdowns are still running. Anything rescheduled since then won't show until you reconnect.

)} {lastIgnored !== null && ( { ignored.toggle(lastIgnored.id); setLastIgnored(null); }} onDismiss={() => setLastIgnored(null)} /> )} {openRow !== null && ( toggleIgnored(id, openRow.event.title)} onClose={() => setOpenId(null)} own={ custom.events[openRow.event.id] === undefined ? undefined : { record: custom.events[openRow.event.id]!, lanes: games, games: custom.games, onSave: custom.editEvent, onDelete: custom.removeEvent, } } /> )}
); } /** * Every screen goes through here, which is why the update notice lives here * rather than beside the list: a reader who is being told "events unavailable" * or is still picking their games needs the offer at least as much as one * reading a calendar — a bundle too old for the feed it just downloaded * (`fetchFeed`'s schemaVersion refusal) lands on exactly that error screen, and * a reload is the fix. */ function Shell({ children }: { children: React.ReactNode }) { const update = useAppUpdate(); return (
{children} {update.available && ( )}
); } function Section({ title, hint, legend, action, children, }: { title: string; hint?: string | undefined; legend?: boolean | undefined; /** A control that belongs to this section, e.g. how it is ordered. */ action?: React.ReactNode | undefined; children: React.ReactNode; }) { return (

{title}

{action ?? (hint !== undefined &&

{hint}

)}
{legend === true && } {children}
); } /** * A list of events, capped at a length someone will actually read. * * The reader who asked for this had two games switched on and twenty-one live * events, and said the list stopped being usable — so the default view shows a * handful and offers the rest. What it must never do is *reorder*: this slices * the front off a list that is already in the order the reader chose, so the * deadline guarantee holds for what is shown and what is hidden alike. * * Expanding is per-visit rather than a stored preference: it is an action taken * while reading one list, not a statement about how they want the app to work. */ function EventList({ rows, render, }: { rows: RowEvent[]; render: (row: RowEvent) => React.ReactNode; }) { const [showAll, setShowAll] = useState(false); const shown = showAll ? rows : rows.slice(0, LIST_CAP); const hidden = rows.length - shown.length; return ( <>
    {shown.map(render)}
{rows.length > LIST_CAP && ( )} ); } /** * Order the list by deadline, or by what the reader is partway through. * * Sits in the list's own header rather than down in settings: ordering is a * thing you reach for while looking at the list, not a preference you go and * configure. */ function SortControl({ value, onChange, }: { value: SortMode; onChange: (mode: SortMode) => void; }) { return (
{SORT_MODES.map((mode) => { const on = value === mode.id; return ( ); })}
); } function exportProgress( progress: Record, daily: DailyLogMap, ignored: Record, prefs: unknown, own: { games: CustomGames; events: CustomEvents }, ) { const blob = new Blob( [ JSON.stringify( { format: "gacha-tracker-export", 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, // The reader's own games and events exist nowhere else at all — not // in the feed, not on a server. An export without them is a backup // that quietly loses the half they typed themselves. customGames: own.games, customEvents: own.events, prefs, }, null, 2, ), ], { type: "application/json" }, ); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `event-clock-progress-${new Date().toISOString().slice(0, 10)}.json`; a.click(); URL.revokeObjectURL(url); } async function importProgress( file: File, mergeProgress: (c: Record) => void, mergeDaily: (c: DailyLogMap) => void, mergeIgnored: (c: Record) => void, mergeCustom: (games: unknown, events: unknown) => void, ) { try { const parsed: unknown = JSON.parse(await file.text()); const data = parsed as { format?: string; progress?: unknown; completions?: unknown; daily?: unknown; ignored?: unknown; customGames?: unknown; customEvents?: unknown; }; if (data.format !== "gacha-tracker-export") { alert("That file isn't an Event Clock export."); return; } const asMarks = (v: unknown) => typeof v === "object" && v !== null ? (v as Record) : null; // Accept exports from before progress replaced completions: membership // there meant "done", so map it forward rather than dropping it. const p = asMarks(data.progress); const legacy = asMarks(data.completions); const i = asMarks(data.ignored); if (p !== null) mergeProgress(p); else if (legacy !== null) { mergeProgress( Object.fromEntries( Object.entries(legacy).map(([id, m]) => [id, { ...m, status: "done" }]), ), ); } // 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); // Additive keys: an export written before F13 has neither, which is a file // from a device that had none rather than an error. An event and the game // it belongs to always travel together, so this can never land a lane with // nothing to name it. mergeCustom(data.customGames, data.customEvents); } catch { alert("That file couldn't be read. Export a fresh copy and try again."); } }