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(null); const [feed, setFeed] = useState({ status: "loading" }); const [store, setStore] = useState(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 (

Event Clock could not open

{error}

); if (feed.status !== "ready" || store === null) return (

Loading events…

); return ( setFeed({ status: "ready", feed: next })} /> ); } function ReaderContent({ feed, onFeed, codes, onCodes, }: { onCodes: (codes: CodesFeed) => void; codes: CodesFeed | null; feed: Extract["feed"]; onFeed: (feed: Extract["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(startRoute?.page ?? prefs.view); const [settingsPage, setSettingsPage] = useState( 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( 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(null); const [open, setOpen] = useState(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 => { 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 = ( 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 (
  • setOpen(row)} /> {transition?.key === key && transition.phase !== "hidden" && (

    {transition.label} · Moved to Archive

    )}
  • ); }; const mainUpdates = ( <> { 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 && (
    What's new in Event Clock
    )} ); const ownProps = readerOwnProps( state, customGames, customEvents, dispatch, setOpen, all, now, ); return (
    {/* 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. */}
    {prefs.onboarded ? ( ) : (

    EventClock

    )} {prefs.onboarded && ( 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, }} /> )}
    {saveError && !saveError.ok && (

    Your last change could not be saved ({saveError.reason}). Your previous data remains available. Try again or export it in Backup.

    )} {backupError && (

    {backupError}

    )} {!prefs.onboarded ? ( { 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" && (
    {mainUpdates}

    Running now

    {["standard", "compact"].map((d) => ( ))}
    {(["ending", "doing"] as const).map((sort) => ( ))}
    {/* One line under the title and the buttons: the count on the left, live's legend on the right. */}

    {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" : ""}

    {visible.length ? ( <> {prefs.density === "compact" && }
      {visible .filter((r) => !r.clock.upcoming) .map(renderRow)}
    {visible.some((r) => r.clock.upcoming) && (

    Coming up

    Not started yet

    {upcomingSummary( visible.filter((r) => r.clock.upcoming), )}

    {prefs.density === "compact" && ( )}
      {visible .filter((r) => r.clock.upcoming) .map(renderRow)}
    )} ) : (

    No events to show. Choose another game or change tracking in Settings → Your games.

    )}

    Done or ignored events leave this list and wait in the{" "} .

    )} {page === "timeline" && ( <> {mainUpdates} )} {page === "routines" && ( )} {page === "archive" && ( )} {page === "settings" && ( { setCodesGame(undefined); setSettingsPage(p); }} onGame={(g) => { setCodesGame(g); setGameTab("setup"); setSettingsPage("games"); }} > {settingsPage === "appearance" || (settingsPage === "games" && !codesGame) ? ( { void dispatch({ type: "prefs", patch: { redesignTourSeen: "replay" }, ...actionMeta(), }); }} /> ) : settingsPage === "games" && codesGame ? ( ) : settingsPage === "time" ? ( ) : settingsPage === "sources" ? ( 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" ? ( ) : settingsPage === "changelog" ? ( ) : settingsPage === "about" ? ( ) : settingsPage === "own" ? (

    Your own events and games

    Only what you added yourself. Routines live on the Routines tab.

    ) : null}
    )} showSettings("about")} onRefresh={refreshFeed} /> )} {transition && ( )} {archivedOpen && ( setArchivedOpen(null)} /> )} {open && ( { 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 && ( )}
    {touring && ( <> 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); }} /> )}
    ); } 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 { 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; }) { 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) => 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 (

    Event data may be out of date.

    {stale[0]!.sources.map((s) => (

    {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)}` : ""}

    ))} ) : 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 | 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 } /> ); }