Files
gacha-event-tracker/src/client/App.tsx
T
Lucas WintherandClaude Opus 5.5 228ce9132d feat(dailies): offer a game's built-in weekly tasks, and show them once on
The strip asks once, in one line, about a game on screen that has
built-ins. Yes puts them beside its dailies, drawn and counted like the
reader's own tasks, with a tooltip giving the source's date.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:09:25 +02:00

1066 lines
40 KiB
TypeScript

import { useCallback, 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 { useTasks } from "./state/useTasks.ts";
import { useAmounts } from "./state/useAmounts.ts";
import { adoptNewLanes, usePrefs, type Prefs } from "./state/usePrefs.ts";
import { buildExportData, parseImportData, triggerDownload } from "./state/export.ts";
import { snapDayWidth } from "./state/zoom.ts";
import { useCustom, type EventDraft } 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 { goalStatus, readGoal, type GoalStatus } from "../shared/goal.ts";
import { taskStatus, type TaskShape } from "../shared/task.ts";
import { activeRoutines, routineToAsk } from "../shared/routines.ts";
import { strandedOccurrences } from "../shared/recurrence.ts";
import { orderGames } from "./state/gameOrder.ts";
import { GameMetaProvider, type MetaResolver } from "./state/gameMeta.tsx";
import { metaOnTheme, useTheme } from "./state/theme.ts";
import {
displayEventFor,
recordFor,
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<FeedState>({ status: "loading" });
const [openId, setOpenId] = useState<string | null>(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, restorePrefs } = 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 tasks = useTasks();
const amounts = useAmounts();
const mergeExtra = (incomingTasks: unknown, incomingAmounts: unknown) => {
tasks.merge(incomingTasks);
amounts.merge(incomingAmounts);
};
const custom = useCustom(now);
// 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<MetaResolver>(
() => (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";
};
/** The reader's check-in goal for an event, if they set a readable one. */
const goalOf = (id: string) => readGoal(prog.progress[id]?.goal);
/**
* Whether an event repeats, the reader's own answer included. Detection reads
* the source's wording; they can overrule it either way. A goal settles it
* outright: the reader has told us they come back to this one.
*/
const repeatsDaily = (row: RowEvent): boolean =>
goalOf(row.event.id) !== undefined ||
resolveDaily(
row.event,
prog.progress[row.event.id]?.daily,
prefs.detectDaily,
);
/** Where the reader is against an event's goal, or undefined without one. */
const goalStatusOf = (row: RowEvent): GoalStatus | undefined => {
const goal = goalOf(row.event.id);
if (goal === undefined) return undefined;
return goalStatus({
goal,
startsMs: row.clock.startsMs,
endsMs: row.clock.endsMs,
region: prefs.region,
game: row.event.game,
now,
logged: daily.daysFor(row.event.id),
amounts: amounts.amountsFor(row.event.id),
});
};
/**
* Built-ins the reader turned on, less any they made their own — then theirs
* and the built-ins together, as the strip draws them.
*/
const copiedFrom = new Set(
tasks.tasks.flatMap((t) => (t.from === undefined ? [] : [t.from])),
);
const routines = activeRoutines(prefs.routineGames, copiedFrom);
const stripTasks = [...tasks.tasks, ...routines];
const taskStatusOf = (task: TaskShape): GoalStatus =>
taskStatus({
task,
region: prefs.region,
now,
logged: daily.daysFor(task.id),
amounts: amounts.amountsFor(task.id),
});
/** 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<RowEvent[]>(() => {
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<LaneId[]>(
() => [...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]);
/**
* Every lane in the order the reader reads them in.
*
* `games` above stays the lane-*identity* list: `adoptNewLanes` diffs it to
* decide which games arrive switched off and `knownGames` is seeded from it,
* so reordering it at source would let a display preference reach the logic
* that hides a reader's games. Ordering is applied here instead, once, and
* handed to every surface that shows a game.
*/
const ordered = useMemo(
() => orderGames(games, prefs.gameOrder, (id) => gameMeta(id).name),
[games, prefs.gameOrder, gameMeta],
);
/** Games the reader plays, in their order. The focus bar rotates through these. */
const enabled = useMemo(
() => ordered.filter((g) => !prefs.hiddenGames.includes(g)),
[ordered, 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);
/**
* The filters that decide whether a row is on screen at all.
*
* Extracted from `inScope` so the timeline's expanded occurrences pass
* through the same four questions. Restating them there would be a second
* copy that drifts, and each drift is a row the reader told us to hide
* appearing on the board.
*/
const inScopeOf = useCallback(
(rows: RowEvent[]) =>
rows
.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)),
[prefs.hiddenGames, prefs.showCompleted, prefs.showIgnored, prog.progress, ignored.marks],
);
/**
* 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(() => inScopeOf(allRows), [allRows, inScopeOf]);
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],
);
/**
* Fills the timeline's settled window with the rest of a rule's rhythm.
*
* Passed to `<Timeline>` as `expand` rather than inlined there — a hook
* cannot be called inside JSX. Filtered through `inScopeOf` and the focus
* chip so an expanded occurrence obeys exactly what a base row obeys: a
* hidden game, an ignored event, or a finished one stays off the board.
*/
const expandOccurrences = useCallback(
(min: number, max: number) =>
inScopeOf(
custom
.occurrencesIn(min, max)
.map((event) => ({ event, clock: clockFor(event, prefs.region, now) })),
).filter((r) => focus === null || r.event.game === focus),
// `now` is deliberately coarse here, as it is for `allRows`:
// re-expanding every rule each second would be wasted work.
// eslint-disable-next-line react-hooks/exhaustive-deps
[custom.occurrencesIn, inScopeOf, focus, prefs.region, Math.floor(now / 60_000)],
);
const live = visible.filter((r) => r.clock.live);
const upcoming = visible.filter((r) => r.clock.upcoming);
/**
* The unstarted events the checklist actually lists.
*
* `upcoming` stays the full count either way, because the page header states
* it — a section that is simply absent is indistinguishable from a quiet
* fortnight, and this app does not leave a reader to infer what it is not
* showing them.
*/
const listedUpcoming = prefs.showUpcoming ? 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]);
/**
* The lists only ever build `allRows` from a rule's first two occurrences
* (`LIST_OCCURRENCES`), but the timeline draws every occurrence the board
* window admits — so a bar past the second names an id `allRows` cannot
* resolve. Resolved through the rule behind it rather than dropped, or every
* later bar would be a dead click with no way to reach per-occurrence
* completion.
*/
const openRow = (() => {
const hit = allRows.find((r) => r.event.id === openId) ?? null;
if (hit !== null || openId === null) return hit;
const event = displayEventFor(custom.events, openId, now);
if (event === null) return null;
return { event, clock: clockFor(event, prefs.region, now) };
})();
/** One row, wired up. Both lists render the same thing from the same props. */
const renderRow = (row: RowEvent) => (
<EventRow
key={row.event.id}
row={row}
now={now}
completed={isDone(row.event.id)}
status={prog.progress[row.event.id]?.status}
effort={prog.progress[row.event.id]?.effort}
daily={dailyBadge(row)}
ignored={isIgnored(row.event.id)}
onRestore={(id) => ignored.toggle(id)}
onOpen={setOpenId}
/>
);
if (state.status === "loading") {
return <Shell><p className="px-4 py-16 text-sm text-muted">Loading events…</p></Shell>;
}
if (state.status === "error") {
return (
<Shell>
<div className="px-4 py-16">
<p className="eyebrow text-critical">Events unavailable</p>
<p className="mt-2 max-w-sm text-sm leading-relaxed text-muted">
{state.message}
</p>
</div>
</Shell>
);
}
// 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 (
<GameMetaProvider value={gameMeta}>
<Shell>
<Welcome
available={ordered}
onConfirm={(chosen, chosenView) =>
update({
onboarded: true,
hiddenGames: games.filter((g) => !chosen.includes(g)),
view: chosenView,
})
}
/>
</Shell>
</GameMetaProvider>
);
}
/**
* 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 = (
<GameFocus
games={enabled}
focus={focus}
counts={perGame}
total={scopedTodo.length}
next={advanceFocus(focus, enabled)}
onFocus={(focusGame) => update({ focusGame })}
onAdvance={() => update({ focusGame: advanceFocus(focus, enabled) })}
/>
);
return (
<GameMetaProvider value={gameMeta}>
<Shell>
<header className="flex items-center justify-between gap-4 border-b border-hairline px-4 pb-3 pt-5">
<div>
<p className="font-display text-[0.9375rem] font-bold tracking-[0.02em] lg:text-lg">
EVENT<span className="text-near">CLOCK</span>
</p>
<p className="mt-0.5 flex items-center gap-1.5 text-xs text-faint">
{live.length} live · {upcoming.length} upcoming
{!online && (
<span className="inline-flex items-center gap-1 text-soon">
<span aria-hidden className="size-1.5 rounded-full bg-soon" />
offline
</span>
)}
</p>
</div>
<div
role="tablist"
aria-label="View"
className="flex rounded-lg border border-hairline p-0.5"
>
{/* 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]) => (
<button
key={id}
role="tab"
aria-selected={view === id}
onClick={() => update({ view: id })}
className={`rounded-[6px] px-2.5 py-1.5 text-xs font-medium transition-colors ${
view === id ? "bg-raised text-ink" : "text-faint hover:text-muted"
}`}
>
{label}
</button>
))}
</div>
</header>
{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.
*/
<div className="lg:grid lg:grid-cols-[20rem_minmax(0,1fr)] xl:grid-cols-[22rem_minmax(0,1fr)]">
<aside>
{/* The rule belongs to the panel, not to the column: the deadlines
and the dailies are short and the list beside them is long, so a
full-height divider would spend most of its length walling off
an empty gap. It travels with the panel as that pins. */}
<div className="scroll-pane lg:sticky lg:top-0 lg:max-h-screen lg:overflow-y-auto lg:border-r lg:border-hairline">
{focusBar}
<NextUp
rows={headline}
focused={focus === null ? null : gameMeta(focus).name}
onOpen={setOpenId}
/>
{/* The chores no wiki publishes, and the only thing on this page
that expires tonight rather than next patch. */}
{/* Standing chores are a tracked-game notion: there is no routine we
could name on behalf of a game the reader invented, so their lanes
contribute repeating events here but no chore of their own. That
exclusion is `dailyGroups`' to make, not this call site's — filtering
the lanes out here would also cost a reader's own game its place in
the order their repeating events are grouped under. */}
<Dailies
showChores={prefs.showChores}
games={focus === null ? enabled : [focus]}
events={todo.filter(repeatsDaily).map((r) => r.event)}
region={prefs.region}
now={now}
daysFor={daily.daysFor}
onToggleDay={daily.toggleDay}
goalFor={(id) => {
const row = todo.find((r) => r.event.id === id);
return row === undefined ? undefined : goalStatusOf(row);
}}
tasks={stripTasks}
taskStatusFor={taskStatusOf}
ask={(() => {
const game = routineToAsk(
focus === null ? enabled : [focus],
prefs.routineGames,
prefs.routinesAsked,
);
if (game === null) return undefined;
return {
game,
// Either answer is recorded, so the question is asked once.
onAnswer: (yes: boolean) =>
update({
routinesAsked: [...(prefs.routinesAsked ?? []), game],
...(yes ? { routineGames: [...(prefs.routineGames ?? []), game] } : {}),
}),
};
})()}
onSetAmount={amounts.setAmount}
amountFor={(key, period) => amounts.amountsFor(key)[period]?.value}
/>
</div>
</aside>
<div className="min-w-0">
{live.length > 0 && (
<Section
legend
title="Running now"
action={
visible.length > 1 ? (
<SortControl
value={prefs.sort}
onChange={(sort) => update({ sort })}
/>
) : undefined
}
>
<EventList rows={live} render={renderRow} />
</Section>
)}
{listedUpcoming.length > 0 && (
<Section
title="Not started yet"
// The ordering control lives with the first list on the page, so
// it is never missing when there is something to order.
action={
live.length === 0 && listedUpcoming.length > 1 ? (
<SortControl
value={prefs.sort}
onChange={(sort) => update({ sort })}
/>
) : undefined
}
>
<EventList rows={listedUpcoming} render={renderRow} />
</Section>
)}
{/* Nothing listed is three different situations, and the reader can
only act on the one they are actually in. Held-back events come
first because that one has a switch behind it. */}
{live.length === 0 && listedUpcoming.length === 0 && (
<p className="px-4 py-12 text-sm leading-relaxed text-muted">
{upcoming.length > 0
? `Nothing running right now. ${
upcoming.length === 1
? "One event has"
: `${upcoming.length} events have`
} not started yet — switch on “Show events that haven't started”, under “What you see” in settings below.`
: 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."}
</p>
)}
</div>
</div>
) : (
<>
{focusBar}
<Timeline
rows={visible}
now={now}
// Snapped here rather than trusted: a stored number arrives from an
// export written by another version of the ladder, or from a file a
// reader edited, and a board one pixel wide is not a preference.
dayWidth={snapDayWidth(prefs.timelineDayWidth)}
onZoom={(timelineDayWidth) => 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.showUpcoming}
splitUpcoming={prefs.timelineSplitUpcoming}
// Lanes stack in the reader's game order, so their main game is the
// top lane rather than whichever one held the first row.
gameOrder={ordered}
onOpen={setOpenId}
isDone={isDone}
expand={expandOccurrences}
/>
</>
)}
<Controls
games={ordered}
prefs={prefs}
onToggleGame={toggleGame}
onUpdate={update}
ignoredCount={Object.keys(ignored.marks).length}
own={{
games: custom.games,
events: custom.events,
// Every lane, not just theirs: a source can miss an event in a game
// we do track, and that is the same job with the same form.
lanes: games,
onAddGame: custom.addGame,
onEditGame: custom.editGame,
onRemoveGame: custom.removeGame,
onAddEvent: custom.addEvent,
tasks: tasks.tasks,
onAddTask: tasks.addTask,
onEditTask: tasks.editTask,
onRemoveTask: tasks.removeTask,
now,
// The index lists rules; the sheet opens rows. A repeating rule's
// own id is never a row — the lists hold its occurrences — so it is
// resolved to whichever occurrence is nearest before opening.
// The stored id is enough: `displayEventFor` resolves a rule to
// whichever occurrence the sheet can show, and to the rule itself
// when it has none.
onOpen: setOpenId,
}}
onExport={() =>
exportProgress(prog.progress, daily.logs, ignored.marks, {
games: custom.games,
events: custom.events,
tasks: tasks.raw,
amounts: amounts.amounts,
})
}
onImport={(file) =>
void importProgress(
file,
prog.merge,
daily.merge,
ignored.merge,
custom.merge,
mergeExtra,
)
}
onExportAll={() =>
exportAll(prog.progress, daily.logs, ignored.marks, prefs, {
games: custom.games,
events: custom.events,
tasks: tasks.raw,
amounts: amounts.amounts,
})
}
onImportAll={(file) =>
void importProgress(
file,
prog.merge,
daily.merge,
ignored.merge,
custom.merge,
mergeExtra,
restorePrefs,
)
}
/>
{!online && (
<p className="border-t border-hairline px-4 py-3 text-xs leading-relaxed text-soon">
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.
</p>
)}
{/* `hiddenGames` rather than `enabled`: the footer needs to know what the
reader turned *off*, and a lane absent from both is one the feed has
and this reader has never been offered — still not theirs to chase. */}
<Colophon
sources={state.feed.sources}
now={now}
hiddenGames={prefs.hiddenGames}
/>
{lastIgnored !== null && (
<Toast
message={`Ignored "${lastIgnored.title}"`}
actionLabel="Undo"
onAction={() => {
ignored.toggle(lastIgnored.id);
setLastIgnored(null);
}}
onDismiss={() => setLastIgnored(null)}
/>
)}
{openRow !== null && (
<EventDetail
row={openRow}
completed={isDone(openRow.event.id)}
ignored={isIgnored(openRow.event.id)}
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}
daily={repeatsDaily(openRow)}
detectedDaily={prefs.detectDaily && isDaily(openRow.event)}
dailyDays={daily.daysFor(openRow.event.id)}
goal={goalOf(openRow.event.id)}
hasStoredGoal={prog.progress[openRow.event.id]?.goal !== undefined}
amounts={amounts.amountsFor(openRow.event.id)}
onSetAmount={amounts.setAmount}
onDaily={prog.setDaily}
onGoal={prog.setGoal}
onToggleDay={daily.toggleDay}
onStatus={prog.setStatus}
onEffort={prog.setEffort}
onNote={prog.setNote}
onIgnore={(id) => toggleIgnored(id, openRow.event.title)}
onClose={() => setOpenId(null)}
own={(() => {
// The row may be one occurrence of a rule. Marks key off the
// occurrence; the record to edit is the rule behind it.
const record = recordFor(custom.events, openRow.event.id);
if (record === undefined) return undefined;
return {
record,
lanes: games,
games: custom.games,
onSave: (_id: string, draft: EventDraft) =>
custom.editEvent(record.id, draft),
onDelete: () => custom.removeEvent(record.id),
strandedBy: () =>
strandedOccurrences(
record,
now,
(id) =>
prog.progress[id] !== undefined ||
(daily.logs[id]?.days.length ?? 0) > 0,
),
};
})()}
/>
)}
</Shell>
</GameMetaProvider>
);
}
/**
* 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 (
<div className="mx-auto min-h-full max-w-2xl border-hairline sm:border-x lg:max-w-5xl xl:max-w-6xl 2xl:max-w-7xl">
{children}
{update.available && (
<UpdateNotice
applying={update.applying}
onApply={update.apply}
onDismiss={update.dismiss}
/>
)}
</div>
);
}
/**
* A titled block of the checklist, with room for one control on the right.
*
* There was a `hint` slot beside `action` — a line of prose for a section with
* no control — rendered as `action ?? hint`. Nothing could ever reach it: its
* only caller was "Running now", whose hint needed two live rows, and two live
* rows are two visible rows, which is exactly when the sort control appears and
* wins. What it would have said is on the page anyway, in the "Then" list of the
* headline panel, which names those deadlines and counts them down.
*/
function Section({
title,
legend,
action,
children,
}: {
title: string;
legend?: boolean | undefined;
/** A control that belongs to this section, e.g. how it is ordered. */
action?: React.ReactNode | undefined;
children: React.ReactNode;
}) {
return (
<section className="pt-5">
<div className="flex items-baseline justify-between gap-3 px-4 pb-2">
<h2 className="eyebrow">{title}</h2>
{action}
</div>
{legend === true && <Legend />}
{children}
</section>
);
}
/**
* 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 (
<>
<ul className="border-t border-hairline">{shown.map(render)}</ul>
{rows.length > LIST_CAP && (
<button
type="button"
onClick={() => setShowAll((v) => !v)}
className="w-full border-b border-hairline px-4 py-3 text-left text-xs font-medium text-muted transition-colors duration-150 hover:text-ink"
>
{showAll ? "Show fewer" : `Show all ${rows.length}`}
{!showAll && (
<span className="text-faint">{` · ${hidden} more below the cut`}</span>
)}
</button>
)}
</>
);
}
/**
* 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 (
<div role="group" aria-label="Sort events" className="flex gap-1">
{SORT_MODES.map((mode) => {
const on = value === mode.id;
return (
<button
key={mode.id}
type="button"
onClick={() => onChange(mode.id)}
aria-pressed={on}
title={mode.hint}
className={`rounded-full border px-2.5 py-1 text-[0.6875rem] font-medium transition-colors ${
on
? "border-ink/60 text-ink"
: "border-transparent text-faint hover:text-muted"
}`}
>
{mode.label}
</button>
);
})}
</div>
);
}
function exportProgress(
progress: Record<string, unknown>,
daily: DailyLogMap,
ignored: Record<string, { at: string }>,
own: Own,
) {
const data = buildExportData(progress, daily, ignored, own);
triggerDownload(
data,
`event-clock-progress-${new Date().toISOString().slice(0, 10)}.json`,
);
}
function exportAll(
progress: Record<string, unknown>,
daily: DailyLogMap,
ignored: Record<string, { at: string }>,
prefs: Prefs,
own: Own,
) {
const data = buildExportData(progress, daily, ignored, own, prefs);
triggerDownload(
data,
`event-clock-backup-${new Date().toISOString().slice(0, 10)}.json`,
);
}
/** Everything reader-authored that travels in an export beside progress. */
type Own = Parameters<typeof buildExportData>[3];
async function importProgress(
file: File,
mergeProgress: (c: Record<string, { at: string }>) => void,
mergeDaily: (c: DailyLogMap) => void,
mergeIgnored: (c: Record<string, { at: string }>) => void,
mergeCustom: (games: unknown, events: unknown) => void,
mergeExtra: (tasks: unknown, amounts: unknown) => void,
restorePrefs?: ((prefs: unknown) => void) | undefined,
) {
try {
const text = await file.text();
const data = parseImportData(JSON.parse(text));
if (data === null) {
alert("That file isn't an Event Clock export.");
return;
}
if (data.progress !== null) mergeProgress(data.progress);
if (data.daily !== null) mergeDaily(data.daily);
if (data.ignored !== null) mergeIgnored(data.ignored);
mergeCustom(data.customGames, data.customEvents);
mergeExtra(data.tasks, data.amounts);
if (restorePrefs !== undefined) {
if (data.prefs !== null) {
restorePrefs(data.prefs);
} else {
alert("Progress was imported, but this file did not include settings.");
}
}
} catch {
alert("That file couldn't be read. Export a fresh copy and try again.");
}
}