A game's Codes tab lists the codes still to use first; a code redeemed on every server played, or past its stated expiry, is greyed in a "Redeemed or expired" section below. A date-only expiry passes once that date has ended everywhere. Settings → Your games marks a followed game with its count of unseen, still usable codes until its Codes tab is opened. Co-Authored-By: Claude Opus 5.5 <[email protected]>
1478 lines
52 KiB
TypeScript
1478 lines
52 KiB
TypeScript
import { EFFORT, type Effort } from "../shared/effort.ts";
|
|
import { manyServers, serverSuffix } from "./state/reader/servers.ts";
|
|
import { mintAccountId } from "./state/reader/ids.ts";
|
|
import { trackedGames } from "./state/reader/updates.ts";
|
|
import { freshness, type SourceHealth } from "../shared/feed.ts";
|
|
import { formatInZone } from "./state/displayTime.ts";
|
|
import { aliasesFrom, RETIRED_IDS } from "./state/retiredIds.ts";
|
|
import { PinnedRoutine } from "./components/redesign/PinnedRoutine.tsx";
|
|
import { DisplayTimeContext } from "./state/displayTime.ts";
|
|
import type { CodesFeed } from "../shared/codes.ts";
|
|
import { useEffect, useMemo, useRef, useState } from "react";
|
|
import {
|
|
fetchFeed,
|
|
fetchCodesFeed,
|
|
refreshOutcome,
|
|
type FeedState,
|
|
type RefreshOutcome,
|
|
} from "./api.ts";
|
|
import { createReaderStore, type ReaderStore } from "./state/reader/store.ts";
|
|
import { openReader } from "./state/reader/open.ts";
|
|
import { ReaderContext, useReader } from "./state/reader/useReader.ts";
|
|
import { saveBackup } from "./state/reader/backups.ts";
|
|
import {
|
|
pairKey,
|
|
type ReaderState,
|
|
type ArchiveRecord,
|
|
} from "./state/reader/schema.ts";
|
|
import { actionMeta, recordOf } from "./state/reader/actions.ts";
|
|
import {
|
|
readerEventRows,
|
|
type ReaderEventRow,
|
|
} from "./state/reader/eventRows.ts";
|
|
import { routineRows } from "./state/reader/routines.ts";
|
|
import { setupReader } from "./state/reader/onboarding.ts";
|
|
import { resolveReaderClock, readerClockFor } from "../shared/readerClock.ts";
|
|
import {
|
|
CustomGame,
|
|
CustomEvent,
|
|
type CustomGames,
|
|
type CustomEvents,
|
|
mintCustomGameId,
|
|
mintCustomEventId,
|
|
recordFor,
|
|
} from "../shared/custom.ts";
|
|
import { metaFor } from "../shared/games.ts";
|
|
import { readGoal } from "../shared/goal.ts";
|
|
import { isDaily, resolveDaily } from "../shared/daily.ts";
|
|
import { orderGames } from "./state/gameOrder.ts";
|
|
import { advanceFocus } from "./state/lens.ts";
|
|
import { restorePrefsValue } from "./state/usePrefs.ts";
|
|
import { readableAmounts } from "./state/useAmounts.ts";
|
|
import { validRecords, type EventDraft } from "./state/useCustom.ts";
|
|
import { useTheme } from "./state/theme.ts";
|
|
import {
|
|
appearanceTokens,
|
|
applyAppearance,
|
|
readableOnSurfaces,
|
|
} from "./state/appearance.ts";
|
|
import { GameMetaProvider } from "./state/gameMeta.tsx";
|
|
import { useAppUpdate } from "./state/useAppUpdate.ts";
|
|
import { ENTRIES, NOTES, newestId, unseen } from "../shared/changelog.ts";
|
|
import {
|
|
ChecklistEvent,
|
|
LedgerColumns,
|
|
UrgencyLegend,
|
|
NextDeadline,
|
|
} from "./components/redesign/ChecklistEvent.tsx";
|
|
import { EventDetail } from "./components/EventDetail.tsx";
|
|
|
|
import { FocusPicker } from "./components/redesign/FocusPicker.tsx";
|
|
import { YourOwn } from "./components/YourOwn.tsx";
|
|
import { AboutPage } from "./components/redesign/AboutPage.tsx";
|
|
import { parseRoute, routeHash } from "./state/route.ts";
|
|
|
|
import { UpdateNotice } from "./components/UpdateNotice.tsx";
|
|
import {
|
|
AppNavigation,
|
|
mainViewPatch,
|
|
type AppPage,
|
|
} from "./components/redesign/AppNavigation.tsx";
|
|
import {
|
|
SettingsLayout,
|
|
type SettingsPage,
|
|
} from "./components/redesign/SettingsLayout.tsx";
|
|
import { ChangelogPage } from "./components/redesign/ChangelogPage.tsx";
|
|
import { GameSettings } from "./components/redesign/GameSettings.tsx";
|
|
import { RoutinesPage } from "./components/redesign/RoutinesPage.tsx";
|
|
import { DailyPanel } from "./components/redesign/DailyPanel.tsx";
|
|
import { RoutineGroup } from "./components/redesign/RoutineGroup.tsx";
|
|
import {
|
|
TimelinePage,
|
|
timelineLook,
|
|
} from "./components/redesign/TimelinePage.tsx";
|
|
import { focusBarShown } from "./components/redesign/FocusPicker.tsx";
|
|
import { ArchiveDetail } from "./components/redesign/ArchiveDetail.tsx";
|
|
import { ArchivePage } from "./components/redesign/ArchivePage.tsx";
|
|
import { BackupPage } from "./components/redesign/BackupPage.tsx";
|
|
import { AppearancePage } from "./components/redesign/AppearancePage.tsx";
|
|
import { TimeSettings } from "./components/redesign/TimeSettings.tsx";
|
|
import { UpdatesPanel } from "./components/redesign/UpdatesPanel.tsx";
|
|
import { DataSourcesPage } from "./components/redesign/DataSourcesPage.tsx";
|
|
import { FeedFooter } from "./components/redesign/FeedFooter.tsx";
|
|
import { SetupWizard } from "./components/redesign/SetupWizard.tsx";
|
|
import {
|
|
ReturningTour,
|
|
TOUR_DONE,
|
|
tourMode,
|
|
} from "./components/redesign/ReturningTour.tsx";
|
|
|
|
/** The upcoming section's sub-line: "3 events in 2 games". */
|
|
function upcomingSummary(rows: readonly { event: { game: string } }[]): string {
|
|
const games = new Set(rows.map((r) => r.event.game)).size;
|
|
return `${rows.length} ${rows.length === 1 ? "event" : "events"} in ${games} ${games === 1 ? "game" : "games"}`;
|
|
}
|
|
|
|
export function ReaderApp() {
|
|
const [codes, setCodes] = useState<CodesFeed | null>(null);
|
|
const [feed, setFeed] = useState<FeedState>({ status: "loading" });
|
|
const [store, setStore] = useState<ReaderStore | null>(null);
|
|
const [error, setError] = useState("");
|
|
useEffect(() => {
|
|
const ac = new AbortController();
|
|
void fetchCodesFeed(ac.signal)
|
|
.then(setCodes)
|
|
.catch(() => {});
|
|
void fetchFeed(ac.signal)
|
|
.then(async (feed) => {
|
|
const opened = await openReader(
|
|
localStorage,
|
|
[
|
|
...new Set([
|
|
...feed.sources.map((s) => s.game),
|
|
...feed.events.map((e) => e.game),
|
|
]),
|
|
],
|
|
new Date().toISOString(),
|
|
mintAccountId,
|
|
);
|
|
if (ac.signal.aborted) return;
|
|
if (!opened.ok) {
|
|
setError(
|
|
opened.reason === "restore"
|
|
? "Your interrupted restore could not be recovered. Free browser storage and reload; saved data has been retained."
|
|
: "Your saved data could not be opened safely. It has been retained. Check browser storage access and reload.",
|
|
);
|
|
return;
|
|
}
|
|
const created = createReaderStore(localStorage, opened.state);
|
|
setStore(created);
|
|
setFeed({ status: "ready", feed });
|
|
})
|
|
.catch((e) => {
|
|
if (!ac.signal.aborted)
|
|
setError(
|
|
e instanceof Error ? e.message : "Events could not be loaded.",
|
|
);
|
|
});
|
|
return () => ac.abort();
|
|
}, []);
|
|
useEffect(() => () => store?.dispose(), [store]);
|
|
if (error)
|
|
return (
|
|
<main className="app-shell reader-ui">
|
|
<h1>Event Clock could not open</h1>
|
|
<p>{error}</p>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
try {
|
|
const raw: Record<string, string> = {};
|
|
for (let i = 0; i < localStorage.length; i++) {
|
|
const key = localStorage.key(i);
|
|
if (key?.startsWith("gacha-tracker:"))
|
|
raw[key] = localStorage.getItem(key)!;
|
|
}
|
|
const url = URL.createObjectURL(
|
|
new Blob(
|
|
[
|
|
JSON.stringify(
|
|
{
|
|
format: "gacha-tracker-raw-recovery",
|
|
savedAt: new Date().toISOString(),
|
|
raw,
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
],
|
|
{ type: "application/json" },
|
|
),
|
|
);
|
|
const a = document.createElement("a");
|
|
a.href = url;
|
|
a.download = "event-clock-recovery.json";
|
|
a.click();
|
|
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
} catch {
|
|
setError(
|
|
"Browser storage cannot be read. Your stored data has not been changed; enable storage access and reload.",
|
|
);
|
|
}
|
|
}}
|
|
>
|
|
Export saved data for recovery
|
|
</button>
|
|
<button type="button" onClick={() => location.reload()}>
|
|
Try again
|
|
</button>
|
|
</main>
|
|
);
|
|
if (feed.status !== "ready" || store === null)
|
|
return (
|
|
<main className="app-shell reader-ui">
|
|
<p>Loading events…</p>
|
|
</main>
|
|
);
|
|
return (
|
|
<ReaderContext value={store}>
|
|
<ReaderContent
|
|
codes={codes}
|
|
onCodes={setCodes}
|
|
feed={feed.feed}
|
|
onFeed={(next) => setFeed({ status: "ready", feed: next })}
|
|
/>
|
|
</ReaderContext>
|
|
);
|
|
}
|
|
function ReaderContent({
|
|
feed,
|
|
onFeed,
|
|
codes,
|
|
onCodes,
|
|
}: {
|
|
onCodes: (codes: CodesFeed) => void;
|
|
codes: CodesFeed | null;
|
|
feed: Extract<FeedState, { status: "ready" }>["feed"];
|
|
onFeed: (feed: Extract<FeedState, { status: "ready" }>["feed"]) => void;
|
|
}) {
|
|
const { state, dispatch, saveError } = useReader();
|
|
const prefs = restorePrefsValue(state.prefs);
|
|
const theme = useTheme(prefs.theme);
|
|
const tokens = useMemo(
|
|
() => appearanceTokens(state.prefs, theme),
|
|
[state.prefs, theme],
|
|
);
|
|
const [now, setNow] = useState(Date.now());
|
|
const [online, setOnline] = useState(navigator.onLine);
|
|
const [hashTick, setHashTick] = useState(0);
|
|
// A reload returns to the page in the address bar; with none, the default view.
|
|
const [startRoute] = useState(() =>
|
|
typeof location === "undefined" ? null : parseRoute(location.hash),
|
|
);
|
|
const [page, setPage] = useState<AppPage>(startRoute?.page ?? prefs.view);
|
|
const [settingsPage, setSettingsPage] = useState<SettingsPage>(
|
|
startRoute?.settings ?? "appearance",
|
|
);
|
|
// A new tab starts at its top. Without this the window kept the last page's
|
|
// scroll, so leaving a scrolled Settings page opened the Checklist part-way
|
|
// down. The first render is skipped so a reload's restored scroll stands.
|
|
const firstPage = useRef(true);
|
|
useEffect(() => {
|
|
if (firstPage.current) {
|
|
firstPage.current = false;
|
|
return;
|
|
}
|
|
window.scrollTo(0, 0);
|
|
}, [page]);
|
|
// Everyone takes the tour once before using the app; a replay from Settings
|
|
// is optional. A returning reader who has not taken it starts on Relaxed.
|
|
const tour = prefs.onboarded
|
|
? tourMode(state.prefs.redesignTourSeen)
|
|
: "done";
|
|
// At most once per page load, and never after this tab saw the tour
|
|
// finished: if another tab or a stale write ever put an old marker back,
|
|
// the reader is not moved to Relaxed and sent round the tour again.
|
|
const tourSettled = useRef(false);
|
|
if (tour === "done" && prefs.onboarded) tourSettled.current = true;
|
|
useEffect(() => {
|
|
if (tour !== "unseen" || tourSettled.current) return;
|
|
tourSettled.current = true;
|
|
void dispatch({
|
|
type: "prefs",
|
|
patch: { interface: "relaxed", redesignTourSeen: "returning" },
|
|
...actionMeta(),
|
|
});
|
|
}, [tour]);
|
|
const touring = tour === "new" || tour === "returning" || tour === "replay";
|
|
const tourForced = tour === "new" || tour === "returning";
|
|
const [gameTab, setGameTab] = useState<"setup" | "codes">("setup");
|
|
const [codesGame, setCodesGame] = useState<string | undefined>(
|
|
startRoute?.game,
|
|
);
|
|
useEffect(() => {
|
|
if (!prefs.onboarded || typeof location === "undefined") return;
|
|
const hash = routeHash({
|
|
page,
|
|
settings: settingsPage,
|
|
game: settingsPage === "games" ? codesGame : undefined,
|
|
});
|
|
// Replaced, not pushed: the address follows the page without adding a
|
|
// history entry for every tab the reader clicks.
|
|
if (location.hash !== hash) history.replaceState(null, "", hash);
|
|
}, [page, settingsPage, codesGame, prefs.onboarded, hashTick]);
|
|
useEffect(() => {
|
|
const follow = () => {
|
|
const route = parseRoute(location.hash);
|
|
// An address naming no page keeps the current one, and says so.
|
|
if (!route) return setHashTick((n) => n + 1);
|
|
setPage(route.page);
|
|
if (route.settings) setSettingsPage(route.settings);
|
|
setCodesGame(route.game);
|
|
};
|
|
window.addEventListener("hashchange", follow);
|
|
return () => window.removeEventListener("hashchange", follow);
|
|
}, []);
|
|
const [archivedOpen, setArchivedOpen] = useState<ArchiveRecord | null>(null);
|
|
const [open, setOpen] = useState<ReaderEventRow | null>(null);
|
|
const [transition, setTransition] = useState<{
|
|
key: string;
|
|
accountId: string;
|
|
eventId: string;
|
|
title: string;
|
|
label: string;
|
|
phase: "hold" | "fade" | "hidden";
|
|
} | null>(null);
|
|
const [backupError, setBackupError] = useState("");
|
|
const appUpdate = useAppUpdate();
|
|
const zone =
|
|
typeof state.prefs.timezone === "string" ? state.prefs.timezone : null;
|
|
const customGames = useMemo(
|
|
() => validRecords(state.customGames, CustomGame),
|
|
[state.customGames],
|
|
);
|
|
const customEvents = useMemo(
|
|
() => validRecords(state.customEvents, CustomEvent),
|
|
[state.customEvents],
|
|
);
|
|
const meta = useMemo(
|
|
() => (id: string) => {
|
|
const original = metaFor(id, customGames);
|
|
const override = recordOf(state.prefs.gameColors)[id];
|
|
return {
|
|
...original,
|
|
hue: readableOnSurfaces(
|
|
typeof override === "string" ? override : original.hue,
|
|
[tokens.ground, tokens.surface],
|
|
4.5,
|
|
),
|
|
};
|
|
},
|
|
[customGames, state.prefs.gameColors, tokens],
|
|
);
|
|
useEffect(() => {
|
|
applyAppearance(state.prefs, theme);
|
|
}, [state.prefs, theme]);
|
|
useEffect(() => {
|
|
const timer = setInterval(() => setNow(Date.now()), 1000),
|
|
update = () => setOnline(navigator.onLine);
|
|
window.addEventListener("online", update);
|
|
window.addEventListener("offline", update);
|
|
return () => {
|
|
clearInterval(timer);
|
|
window.removeEventListener("online", update);
|
|
window.removeEventListener("offline", update);
|
|
};
|
|
}, []);
|
|
useEffect(() => {
|
|
const table = { ...RETIRED_IDS, ...aliasesFrom(feed.events) };
|
|
if (
|
|
Object.values(state.data).some((d) =>
|
|
[
|
|
d.progress,
|
|
d.ignored,
|
|
...Object.values(d.daily),
|
|
...Object.values(d.amounts),
|
|
].some((map) =>
|
|
Object.keys(table).some((id) => Object.hasOwn(map, id)),
|
|
),
|
|
) ||
|
|
[
|
|
...Object.values(state.archive),
|
|
...Object.values(state.seenEvents),
|
|
].some((r) => Object.hasOwn(table, r.event.id))
|
|
)
|
|
void dispatch({ type: "aliases", table, ...actionMeta() });
|
|
}, [feed.events, state.data, state.archive, state.seenEvents, dispatch]);
|
|
const all = useMemo(
|
|
() => readerEventRows(state, feed.events, now),
|
|
[
|
|
state.accounts,
|
|
state.customEvents,
|
|
state.seenEvents,
|
|
feed.events,
|
|
Math.floor(now / 60000),
|
|
],
|
|
);
|
|
useEffect(() => {
|
|
if (!prefs.onboarded) return;
|
|
const records = all
|
|
.filter(
|
|
(r) =>
|
|
!r.clock.ended ||
|
|
state.data[r.accountId]?.progress[r.event.id] ||
|
|
state.data[r.accountId]?.ignored[r.event.id],
|
|
)
|
|
.filter((r) => {
|
|
const previous = state.seenEvents[pairKey(r.accountId, r.event.id)];
|
|
return (
|
|
!previous ||
|
|
JSON.stringify(previous.event) !== JSON.stringify(r.event) ||
|
|
previous.clockRevision !== r.clockRevision
|
|
);
|
|
})
|
|
.map((r) => ({
|
|
accountId: r.accountId,
|
|
event: r.event,
|
|
firstSeenAt: new Date(now).toISOString(),
|
|
lastSeenAt: new Date(now).toISOString(),
|
|
clockRevision: r.clockRevision,
|
|
}));
|
|
if (records.length)
|
|
void dispatch({ type: "seenEvents", records, ...actionMeta() }).then(() =>
|
|
dispatch({ type: "reconcile", ...actionMeta() }),
|
|
);
|
|
const entries = Object.values(state.accounts)
|
|
.filter((a) => state.eventSeen[a.id] === undefined)
|
|
.map((a) => ({
|
|
id: a.id,
|
|
ids: feed.events.filter((e) => e.game === a.game).map((e) => e.id),
|
|
}));
|
|
if (entries.length)
|
|
void dispatch({ type: "seenBatch", entries, ...actionMeta() });
|
|
}, [all, feed.events, state.eventSeen, prefs.onboarded, dispatch]);
|
|
useEffect(() => {
|
|
if (!prefs.onboarded) return;
|
|
const needs = Object.values(state.seenEvents).some((s) => {
|
|
const a = state.accounts[s.accountId];
|
|
return (
|
|
a &&
|
|
!a.retiredAt &&
|
|
!state.archive[pairKey(a.id, s.event.id)] &&
|
|
(readerClockFor(s.event, a, now).ended ||
|
|
recordOf(state.data[a.id]?.progress[s.event.id]).status === "done" ||
|
|
state.data[a.id]?.ignored[s.event.id])
|
|
);
|
|
});
|
|
if (needs) void dispatch({ type: "reconcile", ...actionMeta() });
|
|
}, [
|
|
prefs.onboarded,
|
|
state.seenEvents,
|
|
state.archive,
|
|
state.data,
|
|
Math.floor(now / 60000),
|
|
dispatch,
|
|
]);
|
|
useEffect(() => {
|
|
if (!prefs.onboarded) return;
|
|
const timer = setTimeout(() => {
|
|
const r = saveBackup(localStorage, state, new Date().toISOString());
|
|
setBackupError(
|
|
r.ok
|
|
? ""
|
|
: "Automatic backup could not be saved. Export a copy in Settings → Backup.",
|
|
);
|
|
}, 1200);
|
|
return () => clearTimeout(timer);
|
|
}, [state.revision, prefs.onboarded]);
|
|
useEffect(() => {
|
|
if (!transition) return;
|
|
const t = setTimeout(
|
|
() => setTransition((t) => (t ? { ...t, phase: "fade" } : null)),
|
|
1500,
|
|
);
|
|
const collapse = setTimeout(
|
|
() => setTransition((t) => (t ? { ...t, phase: "hidden" } : null)),
|
|
1750,
|
|
);
|
|
const undo = setTimeout(() => setTransition(null), 8000);
|
|
return () => {
|
|
clearTimeout(t);
|
|
clearTimeout(collapse);
|
|
clearTimeout(undo);
|
|
};
|
|
}, [transition?.key]);
|
|
const changePage = (next: AppPage) => {
|
|
setPage(next);
|
|
const patch = mainViewPatch(next);
|
|
if (Object.keys(patch).length)
|
|
void dispatch({ type: "prefs", patch, ...actionMeta() });
|
|
};
|
|
/** Ask for the newest published feed and codes; shared by the footer and Data & sources. */
|
|
const refreshFeed = async (): Promise<RefreshOutcome> => {
|
|
try {
|
|
const [next, nextCodes] = await Promise.all([
|
|
fetchFeed(undefined, { fresh: true }),
|
|
fetchCodesFeed(undefined, { fresh: true }).catch(() => null),
|
|
]);
|
|
if (nextCodes) onCodes(nextCodes);
|
|
const result = refreshOutcome(feed, next);
|
|
if (result === "updated") onFeed(next);
|
|
if (!nextCodes) return "partial";
|
|
return result === "same" &&
|
|
nextCodes &&
|
|
JSON.stringify(codes) !== JSON.stringify(nextCodes)
|
|
? "updated"
|
|
: result;
|
|
} catch {
|
|
return "failed";
|
|
}
|
|
};
|
|
const showSettings = (next: SettingsPage) => {
|
|
setSettingsPage(next);
|
|
setPage("settings");
|
|
};
|
|
const accounts = Object.values(state.accounts).filter(
|
|
(a) =>
|
|
!a.retiredAt &&
|
|
!prefs.hiddenGames.includes(a.game) &&
|
|
(a.intent.events || Object.values(a.routines).some((r) => r.followed)),
|
|
);
|
|
const ordered = orderGames(
|
|
[...new Set(accounts.map((a) => a.game))],
|
|
prefs.gameOrder,
|
|
(id) => meta(id).name,
|
|
);
|
|
// A view without its focus bar shows every game: a focus chosen elsewhere
|
|
// must never filter a page that offers no way to see or clear it.
|
|
const focus =
|
|
focusBarShown(state.prefs, page) && ordered.includes(prefs.focusGame ?? "")
|
|
? prefs.focusGame
|
|
: null;
|
|
const progress = (r: ReaderEventRow) =>
|
|
recordOf(state.data[r.accountId]?.progress[r.event.id]);
|
|
const outstanding = all.filter(
|
|
(r) =>
|
|
!r.clock.ended &&
|
|
!state.archive[pairKey(r.accountId, r.event.id)] &&
|
|
progress(r).status !== "done" &&
|
|
!state.data[r.accountId]?.ignored[r.event.id],
|
|
);
|
|
const scoped = outstanding.filter(
|
|
(r) => focus === null || r.event.game === focus,
|
|
);
|
|
const counts = Object.fromEntries(
|
|
ordered.map((game) => [
|
|
game,
|
|
outstanding.filter((r) => r.event.game === game).length,
|
|
]),
|
|
);
|
|
const pins = accounts
|
|
.filter((a) => focus === null || a.game === focus)
|
|
.flatMap((a) => routineRows(state, a.id, now))
|
|
.filter((r) => r.placement.followed && r.placement.checklist);
|
|
const separate = state.prefs.dailyLayout !== "grouped";
|
|
const visible = all.filter(
|
|
(r) =>
|
|
(focus === null || r.event.game === focus) &&
|
|
!r.clock.ended &&
|
|
(!r.clock.upcoming || prefs.showUpcoming) &&
|
|
(!state.archive[pairKey(r.accountId, r.event.id)] ||
|
|
(transition?.key === pairKey(r.accountId, r.event.id) &&
|
|
transition.phase !== "hidden")),
|
|
);
|
|
if (prefs.sort === "doing")
|
|
visible.sort(
|
|
(a, b) =>
|
|
Number(progress(b).status === "doing") -
|
|
Number(progress(a).status === "doing") ||
|
|
(a.clock.endsMs ?? Infinity) - (b.clock.endsMs ?? Infinity),
|
|
);
|
|
const focusBar = (
|
|
<FocusPicker
|
|
games={ordered}
|
|
focus={focus}
|
|
counts={counts}
|
|
total={outstanding.length}
|
|
next={advanceFocus(focus, ordered)}
|
|
onFocus={(focusGame) =>
|
|
void dispatch({ type: "prefs", patch: { focusGame }, ...actionMeta() })
|
|
}
|
|
onAdvance={() =>
|
|
void dispatch({
|
|
type: "prefs",
|
|
patch: { focusGame: advanceFocus(focus, ordered) },
|
|
...actionMeta(),
|
|
})
|
|
}
|
|
/>
|
|
);
|
|
const outcome = async (row: ReaderEventRow, kind: "done" | "ignored") => {
|
|
const r = await dispatch({
|
|
type: "outcome",
|
|
accountId: row.accountId,
|
|
eventId: row.event.id,
|
|
outcome: kind,
|
|
...actionMeta(),
|
|
});
|
|
if (r.ok) {
|
|
setOpen(null);
|
|
setTransition({
|
|
key: pairKey(row.accountId, row.event.id),
|
|
accountId: row.accountId,
|
|
eventId: row.event.id,
|
|
title: row.event.title,
|
|
label: kind === "done" ? "Completed" : "Ignored",
|
|
phase: "hold",
|
|
});
|
|
}
|
|
};
|
|
const renderRow = (row: ReaderEventRow) => {
|
|
const key = pairKey(row.accountId, row.event.id),
|
|
p = progress(row);
|
|
return (
|
|
<li
|
|
key={key}
|
|
className={
|
|
transition?.key === key && transition.phase === "fade"
|
|
? "archive-fading"
|
|
: ""
|
|
}
|
|
>
|
|
<ChecklistEvent
|
|
row={row}
|
|
now={now}
|
|
doing={p.status === "doing"}
|
|
server={manyServers(state, row.event.game)}
|
|
compact={prefs.density === "compact"}
|
|
onOpen={() => setOpen(row)}
|
|
/>
|
|
{transition?.key === key && transition.phase !== "hidden" && (
|
|
<p className="archive-confirm" role="status">
|
|
{transition.label} · Moved to Archive
|
|
</p>
|
|
)}
|
|
</li>
|
|
);
|
|
};
|
|
const mainUpdates = (
|
|
<>
|
|
<UpdatesPanel
|
|
codes={codes}
|
|
onCodes={(game) => {
|
|
setGameTab("codes");
|
|
setCodesGame(game);
|
|
showSettings("games");
|
|
}}
|
|
state={state}
|
|
feed={feed}
|
|
now={now}
|
|
onSources={() => showSettings("sources")}
|
|
onAction={dispatch}
|
|
/>
|
|
{unseen(
|
|
ENTRIES,
|
|
prefs.changelogSeen,
|
|
new Date(now).toISOString().slice(0, 10),
|
|
).length > 0 && (
|
|
<div className="release-update update-row">
|
|
<strong>What's new in Event Clock</strong>
|
|
<button onClick={() => showSettings("changelog")}>
|
|
Read the release notes
|
|
</button>
|
|
<button
|
|
onClick={() =>
|
|
void dispatch({
|
|
type: "prefs",
|
|
patch: { changelogSeen: newestId(ENTRIES) },
|
|
...actionMeta(),
|
|
})
|
|
}
|
|
>
|
|
Got it
|
|
</button>
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
const ownProps = readerOwnProps(
|
|
state,
|
|
customGames,
|
|
customEvents,
|
|
dispatch,
|
|
setOpen,
|
|
all,
|
|
now,
|
|
);
|
|
return (
|
|
<DisplayTimeContext value={zone}>
|
|
<GameMetaProvider value={meta}>
|
|
<main
|
|
className="app-shell reader-ui"
|
|
data-touring={touring || undefined}
|
|
>
|
|
{/* While the tour is forced the page behind shows each stop but
|
|
takes no clicks or focus. `display: contents` keeps the layout
|
|
as if this wrapper were not here. */}
|
|
<div className="tour-behind" inert={tourForced}>
|
|
<header className="top">
|
|
{prefs.onboarded ? (
|
|
<button
|
|
type="button"
|
|
className="mark"
|
|
aria-label="Event Clock — go to your default view"
|
|
onClick={() => changePage(prefs.view)}
|
|
>
|
|
Event<span>Clock</span>
|
|
</button>
|
|
) : (
|
|
<p className="mark">
|
|
Event<span>Clock</span>
|
|
</p>
|
|
)}
|
|
{prefs.onboarded && (
|
|
<AppNavigation
|
|
page={page}
|
|
onPage={changePage}
|
|
counts={{
|
|
soon: outstanding.filter((r) => r.clock.live).length,
|
|
routines: accounts
|
|
.flatMap((a) => routineRows(state, a.id, now))
|
|
.filter((r) => r.placement.followed && !r.status.met)
|
|
.length,
|
|
archive: Object.keys(state.archive).length,
|
|
}}
|
|
/>
|
|
)}
|
|
</header>
|
|
{saveError && !saveError.ok && (
|
|
<p role="alert" className="save-error">
|
|
Your last change could not be saved ({saveError.reason}). Your
|
|
previous data remains available. Try again or export it in
|
|
Backup.
|
|
</p>
|
|
)}
|
|
{backupError && (
|
|
<p role="status" className="help">
|
|
{backupError}
|
|
</p>
|
|
)}
|
|
{!prefs.onboarded ? (
|
|
<SetupWizard
|
|
state={state}
|
|
meta={meta}
|
|
onFinish={async (draft) => {
|
|
const next = setupReader(
|
|
draft,
|
|
state,
|
|
new Date().toISOString(),
|
|
mintAccountId,
|
|
);
|
|
for (const account of Object.values(next.accounts)) {
|
|
const result = await dispatch({
|
|
type: "account",
|
|
account,
|
|
...actionMeta(),
|
|
});
|
|
if (!result.ok) return;
|
|
}
|
|
// No navigation here: the tour opens next and leads the way,
|
|
// ending on the chosen view.
|
|
// Only what setup changed, so a setup finished in a second,
|
|
// stale tab leaves alone every setting setup does not set.
|
|
await dispatch({
|
|
type: "prefs",
|
|
patch: Object.fromEntries(
|
|
Object.entries(next.prefs).filter(
|
|
([k, v]) =>
|
|
JSON.stringify(v) !== JSON.stringify(state.prefs[k]),
|
|
),
|
|
),
|
|
...actionMeta(),
|
|
});
|
|
}}
|
|
/>
|
|
) : (
|
|
<>
|
|
{page === "soon" && (
|
|
<div className="checklist-layout">
|
|
<aside className="checklist-rail">
|
|
<div className="rail">
|
|
{focusBarShown(state.prefs, "soon") && focusBar}
|
|
<NextDeadline
|
|
row={scoped.find((r) => r.clock.live)}
|
|
then={scoped.filter((r) => r.clock.live).slice(1, 4)}
|
|
servers={(game) => manyServers(state, game)}
|
|
onOpen={setOpen}
|
|
/>
|
|
{separate && (
|
|
<DailyPanel
|
|
onManage={() => changePage("routines")}
|
|
rows={pins.filter((r) => r.cadence === "day")}
|
|
state={state}
|
|
onAction={dispatch}
|
|
now={now}
|
|
/>
|
|
)}
|
|
<section className="pinned-routines">
|
|
<div className="pinned-heading">
|
|
<div>
|
|
<h2>Routines</h2>
|
|
<p className="sub">Showing only pinned</p>
|
|
</div>
|
|
<button onClick={() => changePage("routines")}>
|
|
Edit pins
|
|
</button>
|
|
</div>
|
|
{accounts
|
|
.filter((a) => focus === null || a.game === focus)
|
|
.map((a) => {
|
|
const rows = pins.filter(
|
|
(r) =>
|
|
r.accountId === a.id &&
|
|
(!separate || r.cadence !== "day"),
|
|
);
|
|
return rows.length ? (
|
|
<RoutineGroup
|
|
key={
|
|
a.id +
|
|
focus +
|
|
String(state.prefs.routineDisclosure)
|
|
}
|
|
title={`${meta(a.game).short}${serverSuffix(state, a.game, a.region)}`}
|
|
hue={meta(a.game).hue}
|
|
done={rows.filter((r) => r.status.met).length}
|
|
total={rows.length}
|
|
summary={
|
|
rows.every((r) => r.status.met)
|
|
? `All ${rows.length} done`
|
|
: `${rows.filter((r) => r.status.met).length} of ${rows.length} completed`
|
|
}
|
|
policy={
|
|
state.prefs.routineDisclosure === "always"
|
|
? "always"
|
|
: state.prefs.routineDisclosure ===
|
|
"expanded"
|
|
? "expanded"
|
|
: "collapsed"
|
|
}
|
|
focused={focus !== null}
|
|
>
|
|
{rows.map((row) => (
|
|
<PinnedRoutine
|
|
key={row.id}
|
|
row={row}
|
|
hue={meta(a.game).hue}
|
|
onAction={dispatch}
|
|
now={now}
|
|
/>
|
|
))}
|
|
</RoutineGroup>
|
|
) : null;
|
|
})}
|
|
<button
|
|
type="button"
|
|
className="quiet"
|
|
onClick={() => changePage("routines")}
|
|
>
|
|
Manage routines
|
|
</button>
|
|
</section>
|
|
</div>
|
|
</aside>
|
|
<div className="checklist-main">
|
|
{mainUpdates}
|
|
<div className="list-heading running-heading">
|
|
<h1>Running now</h1>
|
|
<div className="list-tools">
|
|
<div className="segmented">
|
|
{["standard", "compact"].map((d) => (
|
|
<button
|
|
type="button"
|
|
key={d}
|
|
aria-pressed={prefs.density === d}
|
|
onClick={() =>
|
|
void dispatch({
|
|
type: "prefs",
|
|
patch: { density: d },
|
|
...actionMeta(),
|
|
})
|
|
}
|
|
>
|
|
{d === "standard" ? "Standard" : "Compact"}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<div className="segmented" aria-label="Sort events">
|
|
{(["ending", "doing"] as const).map((sort) => (
|
|
<button
|
|
key={sort}
|
|
aria-pressed={prefs.sort === sort}
|
|
onClick={() =>
|
|
void dispatch({
|
|
type: "prefs",
|
|
patch: { sort },
|
|
...actionMeta(),
|
|
})
|
|
}
|
|
>
|
|
{sort === "ending"
|
|
? "Ending soonest"
|
|
: "Doing first"}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
{/* One line under the title and the buttons: the count
|
|
on the left, live's legend on the right. */}
|
|
<div className="heading-meta">
|
|
<p className="sub">
|
|
{visible.filter((r) => r.clock.live).length} events
|
|
in {new Set(visible.map((r) => r.event.game)).size}{" "}
|
|
games · sorted by what ends first
|
|
{!online ? " · offline" : ""}
|
|
</p>
|
|
<UrgencyLegend />
|
|
</div>
|
|
</div>
|
|
{visible.length ? (
|
|
<>
|
|
{prefs.density === "compact" && <LedgerColumns />}
|
|
<ul className="event-list">
|
|
{visible
|
|
.filter((r) => !r.clock.upcoming)
|
|
.map(renderRow)}
|
|
</ul>
|
|
{visible.some((r) => r.clock.upcoming) && (
|
|
<section className="upcoming-events">
|
|
<div className="list-heading">
|
|
<div>
|
|
<p className="eyebrow">Coming up</p>
|
|
<h2>Not started yet</h2>
|
|
<p className="sub">
|
|
{upcomingSummary(
|
|
visible.filter((r) => r.clock.upcoming),
|
|
)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
{prefs.density === "compact" && (
|
|
<LedgerColumns upcoming />
|
|
)}
|
|
<ul className="event-list">
|
|
{visible
|
|
.filter((r) => r.clock.upcoming)
|
|
.map(renderRow)}
|
|
</ul>
|
|
</section>
|
|
)}
|
|
</>
|
|
) : (
|
|
<p className="empty-state">
|
|
No events to show. Choose another game or change
|
|
tracking in Settings → Your games.
|
|
</p>
|
|
)}
|
|
<p className="list-foot">
|
|
Done or ignored events leave this list and wait in the{" "}
|
|
<button onClick={() => changePage("archive")}>
|
|
Archive
|
|
</button>
|
|
.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{page === "timeline" && (
|
|
<>
|
|
{mainUpdates}
|
|
<TimelinePage
|
|
state={state}
|
|
focus={focus}
|
|
focusBar={
|
|
focusBarShown(state.prefs, "timeline") ? focusBar : null
|
|
}
|
|
rows={visible}
|
|
now={now}
|
|
onAction={dispatch}
|
|
onOpen={setOpen}
|
|
/>
|
|
</>
|
|
)}
|
|
{page === "routines" && (
|
|
<RoutinesPage state={state} onAction={dispatch} now={now} />
|
|
)}
|
|
{page === "archive" && (
|
|
<ArchivePage
|
|
state={state}
|
|
onAction={dispatch}
|
|
now={now}
|
|
onOpen={setArchivedOpen}
|
|
/>
|
|
)}
|
|
{page === "settings" && (
|
|
<SettingsLayout
|
|
state={state}
|
|
codes={codes}
|
|
game={codesGame}
|
|
page={settingsPage}
|
|
onPage={(p) => {
|
|
setCodesGame(undefined);
|
|
setSettingsPage(p);
|
|
}}
|
|
onGame={(g) => {
|
|
setCodesGame(g);
|
|
setGameTab("setup");
|
|
setSettingsPage("games");
|
|
}}
|
|
>
|
|
{settingsPage === "appearance" ||
|
|
(settingsPage === "games" && !codesGame) ? (
|
|
<AppearancePage
|
|
state={state}
|
|
onAction={dispatch}
|
|
onReplayTour={() => {
|
|
void dispatch({
|
|
type: "prefs",
|
|
patch: { redesignTourSeen: "replay" },
|
|
...actionMeta(),
|
|
});
|
|
}}
|
|
/>
|
|
) : settingsPage === "games" && codesGame ? (
|
|
<GameSettings
|
|
key={`${codesGame}-${gameTab}`}
|
|
initialTab={gameTab}
|
|
codes={codes}
|
|
initialGame={codesGame}
|
|
state={state}
|
|
sources={feed.sources}
|
|
onAction={dispatch}
|
|
/>
|
|
) : settingsPage === "time" ? (
|
|
<TimeSettings
|
|
state={state}
|
|
now={now}
|
|
onAction={dispatch}
|
|
/>
|
|
) : settingsPage === "sources" ? (
|
|
<DataSourcesPage
|
|
sources={feed.sources.filter((s) =>
|
|
trackedGames(state).has(s.game),
|
|
)}
|
|
now={now}
|
|
timezone={zone}
|
|
online={online}
|
|
onRefresh={refreshFeed}
|
|
showUpcoming={prefs.showUpcoming}
|
|
detectDaily={prefs.detectDaily}
|
|
standardPulls={state.prefs.standardPulls === true}
|
|
onPrefs={(patch) =>
|
|
void dispatch({
|
|
type: "prefs",
|
|
patch,
|
|
...actionMeta(),
|
|
})
|
|
}
|
|
/>
|
|
) : settingsPage === "backup" ? (
|
|
<BackupPage state={state} />
|
|
) : settingsPage === "changelog" ? (
|
|
<ChangelogPage notes={NOTES} entries={ENTRIES} />
|
|
) : settingsPage === "about" ? (
|
|
<AboutPage sources={feed.sources} />
|
|
) : settingsPage === "own" ? (
|
|
<section>
|
|
<h1>Your own events and games</h1>
|
|
<p className="help">
|
|
Only what you added yourself. Routines live on the
|
|
Routines tab.
|
|
</p>
|
|
<YourOwn {...ownProps} />
|
|
</section>
|
|
) : null}
|
|
</SettingsLayout>
|
|
)}
|
|
<FeedFooter
|
|
sources={feed.sources}
|
|
now={now}
|
|
online={online}
|
|
timezone={zone}
|
|
onAbout={() => showSettings("about")}
|
|
onRefresh={refreshFeed}
|
|
/>
|
|
</>
|
|
)}
|
|
{transition && (
|
|
<aside className="archive-toast" role="status">
|
|
{transition.label} “{transition.title}” · Moved to Archive{" "}
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
void dispatch({
|
|
type: "restore",
|
|
accountId: transition.accountId,
|
|
eventId: transition.eventId,
|
|
...actionMeta(),
|
|
});
|
|
setTransition(null);
|
|
}}
|
|
>
|
|
Undo
|
|
</button>
|
|
</aside>
|
|
)}
|
|
{archivedOpen && (
|
|
<ArchiveDetail
|
|
record={archivedOpen}
|
|
state={state}
|
|
onAction={dispatch}
|
|
onClose={() => setArchivedOpen(null)}
|
|
/>
|
|
)}
|
|
{open && (
|
|
<ReaderDetail
|
|
sources={feed.sources}
|
|
onSources={() => {
|
|
setOpen(null);
|
|
showSettings("sources");
|
|
}}
|
|
row={open}
|
|
state={state}
|
|
now={now}
|
|
timezone={zone}
|
|
onAction={dispatch}
|
|
onOutcome={(kind) => void outcome(open, kind)}
|
|
onClose={() => setOpen(null)}
|
|
ownProps={ownProps}
|
|
/>
|
|
)}
|
|
{appUpdate.available && (
|
|
<UpdateNotice
|
|
applying={appUpdate.applying}
|
|
onApply={appUpdate.apply}
|
|
onDismiss={appUpdate.dismiss}
|
|
/>
|
|
)}
|
|
</div>
|
|
{touring && (
|
|
<>
|
|
<ReturningTour
|
|
intro={tour === "replay" ? undefined : tour}
|
|
closable={!tourForced}
|
|
looks={{
|
|
interface: prefs.interface ?? "structured",
|
|
timelineLook: timelineLook(state.prefs),
|
|
}}
|
|
onLook={(patch) =>
|
|
void dispatch({ type: "prefs", patch, ...actionMeta() })
|
|
}
|
|
onStep={(stop) => {
|
|
setOpen(null);
|
|
setArchivedOpen(null);
|
|
if (stop !== "codes") {
|
|
if (stop === "settings") showSettings("appearance");
|
|
else setPage(stop);
|
|
return;
|
|
}
|
|
// The Codes tab of a tracked game that has codes, else the
|
|
// Checklist, where new codes are announced.
|
|
const game = accounts
|
|
.map((a) => a.game)
|
|
.find((g) => codes?.codes.some((c) => c.game === g));
|
|
if (!game) {
|
|
setPage("soon");
|
|
return;
|
|
}
|
|
setGameTab("codes");
|
|
setCodesGame(game);
|
|
showSettings("games");
|
|
}}
|
|
onFinish={() => {
|
|
void dispatch({
|
|
type: "prefs",
|
|
patch: { redesignTourSeen: TOUR_DONE },
|
|
...actionMeta(),
|
|
});
|
|
setPage(prefs.view);
|
|
}}
|
|
/>
|
|
</>
|
|
)}
|
|
</main>
|
|
</GameMetaProvider>
|
|
</DisplayTimeContext>
|
|
);
|
|
}
|
|
function readerOwnProps(
|
|
state: ReaderState,
|
|
games: CustomGames,
|
|
events: CustomEvents,
|
|
onAction: import("./components/redesign/GameSettings.tsx").ActionHandler,
|
|
onOpen: (row: ReaderEventRow) => void,
|
|
rows: ReaderEventRow[],
|
|
now: number,
|
|
): React.ComponentProps<typeof YourOwn> {
|
|
const lanes = [...new Set(Object.values(state.accounts).map((a) => a.game))];
|
|
const saveEvent = (id: string, draft: EventDraft) => {
|
|
const at = new Date().toISOString();
|
|
void onAction({
|
|
type: "record",
|
|
collection: "customEvents",
|
|
id,
|
|
value: {
|
|
...recordOf(state.customEvents[id]),
|
|
...draft,
|
|
id,
|
|
startPrecision: draft.startHasTime ? "exact" : "day",
|
|
endPrecision:
|
|
draft.endsAt === null
|
|
? "unknown"
|
|
: draft.endHasTime
|
|
? "exact"
|
|
: "day",
|
|
summary: draft.summary?.trim() || null,
|
|
at: events[id]?.at ?? at,
|
|
updatedAt: at,
|
|
},
|
|
...actionMeta(),
|
|
});
|
|
};
|
|
return {
|
|
games,
|
|
events,
|
|
lanes,
|
|
now,
|
|
onOpen: (id) => {
|
|
const row =
|
|
rows.find((r) => r.event.id === id) ||
|
|
rows.find((r) => r.event.id.startsWith(id + "#"));
|
|
if (row) onOpen(row);
|
|
},
|
|
onAddGame: (name, hue) => {
|
|
const at = new Date().toISOString(),
|
|
id = mintCustomGameId(name, Object.keys(games));
|
|
void onAction({
|
|
type: "record",
|
|
collection: "customGames",
|
|
id,
|
|
value: { id, name, hue, at, updatedAt: at },
|
|
...actionMeta(),
|
|
}).then(async () => {
|
|
const { createAccount } = await import("./state/reader/accounts.ts");
|
|
await onAction({
|
|
type: "account",
|
|
account: createAccount(
|
|
id,
|
|
restorePrefsValue(state.prefs).region,
|
|
at,
|
|
mintAccountId(),
|
|
),
|
|
...actionMeta(),
|
|
});
|
|
});
|
|
},
|
|
onEditGame: (id, name, hue) =>
|
|
void onAction({
|
|
type: "record",
|
|
collection: "customGames",
|
|
id,
|
|
value: {
|
|
...recordOf(state.customGames[id]),
|
|
name,
|
|
hue,
|
|
updatedAt: new Date().toISOString(),
|
|
},
|
|
...actionMeta(),
|
|
}),
|
|
onRemoveGame: (id) => {
|
|
const blockedBy = Object.values(events).filter(
|
|
(e) => e.game === id,
|
|
).length;
|
|
if (blockedBy) return { removed: false, blockedBy };
|
|
void onAction({
|
|
type: "record",
|
|
collection: "customGames",
|
|
id,
|
|
value: null,
|
|
...actionMeta(),
|
|
});
|
|
for (const a of Object.values(state.accounts).filter(
|
|
(a) => a.game === id,
|
|
))
|
|
void onAction({
|
|
type: "account",
|
|
account: { ...a, retiredAt: new Date().toISOString() },
|
|
...actionMeta(),
|
|
});
|
|
return { removed: true, blockedBy: 0 };
|
|
},
|
|
onAddEvent: (draft) => saveEvent(mintCustomEventId(), draft),
|
|
};
|
|
}
|
|
function ReaderDetail({
|
|
row,
|
|
state,
|
|
now,
|
|
timezone,
|
|
onAction,
|
|
onOutcome,
|
|
onClose,
|
|
ownProps,
|
|
sources,
|
|
onSources,
|
|
}: {
|
|
sources: SourceHealth[];
|
|
onSources: () => void;
|
|
row: ReaderEventRow;
|
|
state: ReaderState;
|
|
now: number;
|
|
timezone: string | null;
|
|
onAction: import("./components/redesign/GameSettings.tsx").ActionHandler;
|
|
onOutcome: (outcome: "done" | "ignored") => void;
|
|
onClose: () => void;
|
|
ownProps: React.ComponentProps<typeof YourOwn>;
|
|
}) {
|
|
const a = state.accounts[row.accountId]!;
|
|
const timing = resolveReaderClock(a, now);
|
|
const data = state.data[a.id]!;
|
|
const p = recordOf(data.progress[row.event.id]);
|
|
const raw = recordOf(data.daily[timing.revision]?.[row.event.id]);
|
|
const days = Array.isArray(raw.days)
|
|
? raw.days.filter((d): d is string => typeof d === "string")
|
|
: [];
|
|
const goal = readGoal(p.goal);
|
|
const patch = (patch: Record<string, unknown>) =>
|
|
void onAction({
|
|
type: "progress",
|
|
accountId: a.id,
|
|
itemId: row.event.id,
|
|
patch,
|
|
...actionMeta(),
|
|
});
|
|
const own = recordFor(ownProps.events, row.event.id);
|
|
const stale = freshness(
|
|
sources.filter((s) => s.game === row.event.game),
|
|
now,
|
|
).stale;
|
|
return (
|
|
<EventDetail
|
|
sourceNotice={
|
|
stale.length ? (
|
|
<div className="detail-source-notice">
|
|
<p>Event data may be out of date.</p>
|
|
{stale[0]!.sources.map((s) => (
|
|
<p className="help" key={s.sourceId}>
|
|
{s.sourceId} ·
|
|
{s.lastConfirmedAt
|
|
? `Last checked ${formatInZone(s.lastConfirmedAt, true, timezone)}`
|
|
: "No successful source check recorded"}
|
|
{s.contentChangedAt
|
|
? ` · content changed ${formatInZone(s.contentChangedAt, true, timezone)}`
|
|
: ""}
|
|
</p>
|
|
))}
|
|
<button type="button" onClick={onSources}>
|
|
Details & reasons
|
|
</button>
|
|
</div>
|
|
) : undefined
|
|
}
|
|
row={{ ...row, clock: readerClockFor(row.event, a, now) }}
|
|
completed={p.status === "done"}
|
|
ignored={Boolean(data.ignored[row.event.id])}
|
|
status={
|
|
p.status === "doing" || p.status === "done" ? p.status : undefined
|
|
}
|
|
effort={
|
|
typeof p.effort === "string" && Object.hasOwn(EFFORT, p.effort)
|
|
? (p.effort as Effort)
|
|
: undefined
|
|
}
|
|
note={typeof p.note === "string" ? p.note : ""}
|
|
region={a.region}
|
|
timing={timing}
|
|
timezone={timezone}
|
|
now={now}
|
|
daily={
|
|
goal !== undefined ||
|
|
resolveDaily(
|
|
row.event,
|
|
typeof p.daily === "boolean" ? p.daily : undefined,
|
|
state.prefs.detectDaily === true,
|
|
)
|
|
}
|
|
detectedDaily={state.prefs.detectDaily === true && isDaily(row.event)}
|
|
dailyDays={days}
|
|
goal={goal}
|
|
hasStoredGoal={p.goal !== undefined}
|
|
amounts={readableAmounts(
|
|
data.amounts[timing.revision]?.[row.event.id] as
|
|
Record<string, unknown> | undefined,
|
|
)}
|
|
onSetAmount={(id, period, value) =>
|
|
void onAction({
|
|
type: "amount",
|
|
accountId: a.id,
|
|
revisionId: timing.revision,
|
|
itemId: id,
|
|
period,
|
|
value,
|
|
...actionMeta(),
|
|
})
|
|
}
|
|
onDaily={(_id, daily, clearsGoal) =>
|
|
patch({
|
|
...((clearsGoal ?? false) ? { goal: null } : {}),
|
|
daily: daily ?? null,
|
|
})
|
|
}
|
|
onGoal={(_id, goal) =>
|
|
patch({ goal: goal ?? null, ...(goal ? { daily: null } : {}) })
|
|
}
|
|
onToggleDay={(id, day) =>
|
|
void onAction({
|
|
type: "day",
|
|
accountId: a.id,
|
|
revisionId: timing.revision,
|
|
itemId: id,
|
|
day,
|
|
checked: !days.includes(day),
|
|
...actionMeta(),
|
|
})
|
|
}
|
|
onIgnore={() => onOutcome("ignored")}
|
|
onStatus={(_id, status) => {
|
|
if (status === "done") onOutcome("done");
|
|
else patch({ status: status ?? null });
|
|
}}
|
|
onEffort={(_id, effort) => patch({ effort: effort ?? null })}
|
|
onNote={(_id, note) => patch({ note })}
|
|
onClose={onClose}
|
|
own={
|
|
own
|
|
? {
|
|
record: own,
|
|
lanes: ownProps.lanes,
|
|
games: ownProps.games,
|
|
onSave: (id, draft) =>
|
|
void onAction({
|
|
type: "record",
|
|
collection: "customEvents",
|
|
id,
|
|
value: {
|
|
...recordOf(state.customEvents[id]),
|
|
...draft,
|
|
startPrecision: draft.startHasTime ? "exact" : "day",
|
|
endPrecision:
|
|
draft.endsAt === null
|
|
? "unknown"
|
|
: draft.endHasTime
|
|
? "exact"
|
|
: "day",
|
|
summary: draft.summary?.trim() || null,
|
|
updatedAt: new Date().toISOString(),
|
|
},
|
|
...actionMeta(),
|
|
}),
|
|
onDelete: (id) => {
|
|
void onAction({
|
|
type: "record",
|
|
collection: "customEvents",
|
|
id,
|
|
value: null,
|
|
...actionMeta(),
|
|
});
|
|
onClose();
|
|
},
|
|
strandedBy: () =>
|
|
Object.keys(data.progress).filter((id) =>
|
|
id.startsWith(own.id + "#"),
|
|
).length,
|
|
}
|
|
: undefined
|
|
}
|
|
/>
|
|
);
|
|
}
|