"In their own group" is the shape of a patch — what is on now, and what is queued behind it. "Mixed in" is the question a Gantt chart exists for: what runs out first, whether or not it has opened. On real data that is not a cosmetic difference — mixed, five events currently running sort below an Arknights rerun that has not started, because it closes before they do. Which is why it is a pair of pills and not a checkbox: neither answer is the absence of the other, and "mixed in" is a different order rather than the heading switched off. It sits under the row that puts the future on the board at all, and only while that row is ticked — a choice about arranging unstarted events is unanswerable with none of them on screen. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
882 lines
32 KiB
TypeScript
882 lines
32 KiB
TypeScript
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<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 } = 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<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";
|
|
};
|
|
|
|
/**
|
|
* 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<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]);
|
|
|
|
/** 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) => (
|
|
<EventRow
|
|
key={row.event.id}
|
|
row={row}
|
|
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={games}
|
|
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. */}
|
|
<Dailies
|
|
games={(focus === null ? enabled : [focus]).filter(
|
|
(id) => !isCustomGameId(id),
|
|
)}
|
|
events={todo.filter(repeatsDaily).map((r) => r.event)}
|
|
region={prefs.region}
|
|
now={now}
|
|
daysFor={daily.daysFor}
|
|
onToggleDay={daily.toggleDay}
|
|
/>
|
|
</div>
|
|
</aside>
|
|
|
|
<div className="min-w-0">
|
|
{live.length > 0 && (
|
|
<Section
|
|
legend
|
|
title="Running now"
|
|
hint={
|
|
live.length > 1
|
|
? `next after this ends in ${formatRemaining(
|
|
live[1]?.clock.msRemaining ?? 0,
|
|
)}`
|
|
: undefined
|
|
}
|
|
action={
|
|
visible.length > 1 ? (
|
|
<SortControl
|
|
value={prefs.sort}
|
|
onChange={(sort) => update({ sort })}
|
|
/>
|
|
) : undefined
|
|
}
|
|
>
|
|
<EventList rows={live} render={renderRow} />
|
|
</Section>
|
|
)}
|
|
|
|
{upcoming.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 && upcoming.length > 1 ? (
|
|
<SortControl
|
|
value={prefs.sort}
|
|
onChange={(sort) => update({ sort })}
|
|
/>
|
|
) : undefined
|
|
}
|
|
>
|
|
<EventList rows={upcoming} render={renderRow} />
|
|
</Section>
|
|
)}
|
|
|
|
{visible.length === 0 && (
|
|
<p className="px-4 py-12 text-sm leading-relaxed text-muted">
|
|
{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.timelineUpcoming}
|
|
splitUpcoming={prefs.timelineSplitUpcoming}
|
|
onOpen={setOpenId}
|
|
isDone={isDone}
|
|
/>
|
|
</>
|
|
)}
|
|
|
|
<Controls
|
|
games={games}
|
|
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,
|
|
}}
|
|
onExport={() =>
|
|
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 && (
|
|
<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>
|
|
)}
|
|
|
|
<Colophon sources={state.feed.sources} now={now} />
|
|
|
|
{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)}
|
|
onDaily={prog.setDaily}
|
|
onToggleDay={daily.toggleDay}
|
|
onStatus={prog.setStatus}
|
|
onEffort={prog.setEffort}
|
|
onNote={prog.setNote}
|
|
onIgnore={(id) => 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,
|
|
}
|
|
}
|
|
/>
|
|
)}
|
|
</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>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<section className="pt-5">
|
|
<div className="flex items-baseline justify-between gap-3 px-4 pb-2">
|
|
<h2 className="eyebrow">{title}</h2>
|
|
{action ?? (hint !== undefined && <p className="text-xs text-faint">{hint}</p>)}
|
|
</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 }>,
|
|
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<string, { at: string }>) => void,
|
|
mergeDaily: (c: DailyLogMap) => void,
|
|
mergeIgnored: (c: Record<string, { at: string }>) => 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<string, { at: string }>)
|
|
: 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.");
|
|
}
|
|
}
|