feat: the redesigned Event Clock
The new reader app replaces the old UI: Checklist with a rail (focus pills, Next to expire, today's dailies, pinned routines), Timeline in a new and a classic look, Routines, Archive and a Settings area, in a Structured (default) and a Relaxed interface. It follows the approved Round 6 B and Round 7 mockups, corrected screen by screen with the owner. Reader data moves to a v2 store (gacha-tracker:v2:*). Existing v1 data is migrated on first load, a recovery copy is written first, and the v1 keys are never removed, so a rollback finds them unchanged. Redeem codes for Genshin arrive as their own feed. Also: live's urgency ramp, editable per theme; completion and section-complete fireworks; a returning-user tour; the design mockups, specs, plans, reviews and handoff record. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
ddd9673f86
commit
50c6e91b00
@@ -0,0 +1,99 @@
|
||||
import { Fragment, type ReactNode } from "react";
|
||||
import type { SourceHealth } from "../../../shared/feed.ts";
|
||||
import { useGameMeta } from "../../state/gameMeta.tsx";
|
||||
import {
|
||||
AUTHOR,
|
||||
BUG_URL,
|
||||
FEATURE_URL,
|
||||
IDEA_CREDITS,
|
||||
REPO_URL,
|
||||
RESET_CLOCK_CREDIT,
|
||||
siteFor,
|
||||
} from "../Colophon.tsx";
|
||||
|
||||
/**
|
||||
* The Round 6 B “About Event Clock” pane: credits and the disclaimer only.
|
||||
* Data freshness lives in Data & sources.
|
||||
*/
|
||||
export function AboutPage({
|
||||
sources,
|
||||
}: {
|
||||
/** Every source read, so every editor and game is credited, not just the reader's. */
|
||||
sources: SourceHealth[];
|
||||
}) {
|
||||
const meta = useGameMeta();
|
||||
const sites = [
|
||||
...new Map(
|
||||
sources.map((s) => siteFor(s.url)).map((s) => [s.url, s]),
|
||||
).values(),
|
||||
].sort((a, b) => a.name.localeCompare(b.name));
|
||||
const games = [...new Set(sources.map((s) => s.game))];
|
||||
const studios = [
|
||||
...new Set(sources.map((s) => meta(s.game).studio).filter(Boolean)),
|
||||
];
|
||||
const link = (href: string, label: ReactNode) => (
|
||||
<a href={href} target="_blank" rel="noreferrer">
|
||||
{label}
|
||||
</a>
|
||||
);
|
||||
const dotted = (items: ReactNode[]) =>
|
||||
items.map((item, i) => (
|
||||
<Fragment key={i}>
|
||||
{i > 0 && " · "}
|
||||
{item}
|
||||
</Fragment>
|
||||
));
|
||||
return (
|
||||
<section className="about-page">
|
||||
<h1>About Event Clock</h1>
|
||||
<div className="about-prose">
|
||||
<p>
|
||||
Live and upcoming gacha events, sorted by what expires next. An
|
||||
unofficial, fan-made calendar with progress saved in your browser.
|
||||
</p>
|
||||
<h3>Made by</h3>
|
||||
<p>
|
||||
{link(AUTHOR.site, AUTHOR.name)} ·{" "}
|
||||
{link(AUTHOR.github, "StereotypicalCat")}
|
||||
</p>
|
||||
<p className="about-links">
|
||||
{link(REPO_URL, "Source code")}
|
||||
{link(BUG_URL, "Report a problem")}
|
||||
{link(FEATURE_URL, "Request a feature")}
|
||||
{link(AUTHOR.kofi, "Support the project")}
|
||||
</p>
|
||||
<h3>With thanks to the source editors</h3>
|
||||
<p>
|
||||
The source communities compile and maintain the schedules; Event Clock
|
||||
rearranges their work. {dotted(sites.map((s) => link(s.url, s.name)))}
|
||||
.
|
||||
</p>
|
||||
<p>
|
||||
{link(RESET_CLOCK_CREDIT.url, RESET_CLOCK_CREDIT.name)} provides the
|
||||
reference for per-server reset clocks.
|
||||
</p>
|
||||
<h3>The studios and games</h3>
|
||||
<p>
|
||||
{studios.join(" · ")}, who make the games worth keeping track of:{" "}
|
||||
{dotted(
|
||||
games.map((g) => (
|
||||
<span className="game-name" style={{ color: meta(g).hue }}>
|
||||
{meta(g).name}
|
||||
</span>
|
||||
)),
|
||||
)}
|
||||
. All game names, event names and trademarks belong to their
|
||||
respective owners.
|
||||
</p>
|
||||
<h3>Ideas and acknowledgements</h3>
|
||||
<p>{dotted(IDEA_CREDITS.map((c) => link(c.url, c.handle)))}.</p>
|
||||
<h3>Unofficial and unaffiliated</h3>
|
||||
<p>
|
||||
Not endorsed by or connected to any game publisher or source named
|
||||
here. Dates can be wrong or go out of date: check the original source
|
||||
before the final hours.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { Prefs } from "../../state/usePrefs.ts";
|
||||
export type AppPage = "soon" | "timeline" | "routines" | "archive" | "settings";
|
||||
export const APP_PAGES: readonly [AppPage, string][] = [
|
||||
["soon", "Checklist"],
|
||||
["timeline", "Timeline"],
|
||||
["routines", "Routines"],
|
||||
["archive", "Archive"],
|
||||
["settings", "Settings"],
|
||||
];
|
||||
export function mainViewPatch(page: AppPage): Partial<Prefs> {
|
||||
return page === "soon" || page === "timeline" ? { view: page } : {};
|
||||
}
|
||||
export function AppNavigation({
|
||||
page,
|
||||
onPage,
|
||||
counts = {},
|
||||
}: {
|
||||
counts?: Partial<Record<AppPage, number>>;
|
||||
page: AppPage;
|
||||
onPage: (page: AppPage) => void;
|
||||
}) {
|
||||
return (
|
||||
<nav className="app-navigation" aria-label="Main pages">
|
||||
{APP_PAGES.map(([id, label]) => (
|
||||
<button
|
||||
type="button"
|
||||
key={id}
|
||||
className={id === "settings" ? "gear" : undefined}
|
||||
aria-current={page === id ? "page" : undefined}
|
||||
onClick={() => onPage(id)}
|
||||
>
|
||||
{label}
|
||||
{counts[id] !== undefined && (
|
||||
<span className="n">
|
||||
{counts[id]}
|
||||
{id === "routines" ? " left" : ""}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
import type { CSSProperties } from "react";
|
||||
import { recordOf, actionMeta } from "../../state/reader/actions.ts";
|
||||
import { useGameMeta } from "../../state/gameMeta.tsx";
|
||||
import { orderGames } from "../../state/gameOrder.ts";
|
||||
import type { ReaderState } from "../../state/reader/schema.ts";
|
||||
import type { ActionHandler } from "./GameSettings.tsx";
|
||||
import { ChecklistDisplay } from "./RoutinesPage.tsx";
|
||||
import { timelineLook } from "./TimelinePage.tsx";
|
||||
import { ViewCards } from "./ViewCards.tsx";
|
||||
import { focusBarShown } from "./FocusPicker.tsx";
|
||||
import { URGENCY_STEPS, urgencyChoice } from "../../state/appearance.ts";
|
||||
|
||||
/** The live legend's words for each step. */
|
||||
const URGENCY_LABEL = {
|
||||
calm: "A week or more",
|
||||
near: "Under a week",
|
||||
soon: "Under 3 days",
|
||||
critical: "Under a day",
|
||||
} as const;
|
||||
export function AppearancePage({
|
||||
state,
|
||||
onAction,
|
||||
onReplayTour,
|
||||
}: {
|
||||
state: ReaderState;
|
||||
onAction: ActionHandler;
|
||||
/** Starts the returning tour at its first step. */
|
||||
onReplayTour?: () => void;
|
||||
}) {
|
||||
const meta = useGameMeta();
|
||||
const p = state.prefs,
|
||||
patch = (patch: Record<string, unknown>) =>
|
||||
void onAction({ type: "prefs", patch, ...actionMeta() });
|
||||
const hidden = Array.isArray(p.hiddenGames)
|
||||
? (p.hiddenGames as string[])
|
||||
: [];
|
||||
const saved = Array.isArray(p.gameOrder)
|
||||
? (p.gameOrder as string[])
|
||||
: undefined;
|
||||
const byName = (g: string) => meta(g).name;
|
||||
const accounts = Object.values(state.accounts).filter((a) => !a.retiredAt);
|
||||
// Only the games being tracked: not hidden, with events or a followed routine.
|
||||
const tracked = orderGames(
|
||||
[
|
||||
...new Set(
|
||||
accounts
|
||||
.filter(
|
||||
(a) =>
|
||||
!hidden.includes(a.game) &&
|
||||
(a.intent.events ||
|
||||
Object.values(a.routines).some((r) => r.followed)),
|
||||
)
|
||||
.map((a) => a.game),
|
||||
),
|
||||
],
|
||||
saved,
|
||||
byName,
|
||||
);
|
||||
const untracked = orderGames(
|
||||
[...new Set(accounts.map((a) => a.game))].filter(
|
||||
(g) => !tracked.includes(g),
|
||||
),
|
||||
saved,
|
||||
byName,
|
||||
);
|
||||
const move = (index: number, by: -1 | 1) => {
|
||||
const next = tracked.slice();
|
||||
const [g] = next.splice(index, 1);
|
||||
next.splice(index + by, 0, g!);
|
||||
patch({ gameOrder: [...next, ...untracked] });
|
||||
};
|
||||
return (
|
||||
<section>
|
||||
<h1>Appearance</h1>
|
||||
<div className="interface-choices">
|
||||
{(["structured", "relaxed"] as const).map((id) => (
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={(p.interface ?? "structured") === id}
|
||||
key={id}
|
||||
onClick={() => patch({ interface: id })}
|
||||
>
|
||||
<span className={`interface-preview ${id}`} aria-hidden>
|
||||
<i />
|
||||
<i />
|
||||
<i />
|
||||
</span>
|
||||
<strong>{id === "relaxed" ? "Relaxed" : "Structured"}</strong>
|
||||
<small>
|
||||
{id === "relaxed"
|
||||
? "Separated panels with more breathing room."
|
||||
: "A compact, ruled planner."}
|
||||
</small>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<section className="settings-group">
|
||||
<h2>Theme</h2>
|
||||
<p className="help">
|
||||
Choose a light or dark workspace, or follow your device.
|
||||
</p>
|
||||
<div className="segmented">
|
||||
{["dark", "light", "system"].map((theme) => (
|
||||
<button
|
||||
key={theme}
|
||||
aria-pressed={(p.theme ?? "dark") === theme}
|
||||
onClick={() => patch({ theme })}
|
||||
>
|
||||
{theme[0]!.toUpperCase() + theme.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<section className="settings-group">
|
||||
<h2>Colour scheme</h2>
|
||||
<div className="scheme-choices">
|
||||
{["slate", "warm", "plum", "custom"].map((s) => (
|
||||
<button
|
||||
className={`scheme-option scheme-${s}`}
|
||||
key={s}
|
||||
aria-pressed={(p.scheme ?? "slate") === s}
|
||||
onClick={() => patch({ scheme: s })}
|
||||
>
|
||||
<span className="scheme-swatch" aria-hidden="true">
|
||||
<i />
|
||||
<i />
|
||||
<i />
|
||||
</span>
|
||||
{s[0]!.toUpperCase() + s.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
{p.scheme === "custom" && (
|
||||
<div className="custom-palettes">
|
||||
{["dark", "light"].map((theme) => (
|
||||
<fieldset key={theme}>
|
||||
<legend>
|
||||
{theme === "dark" ? "Dark colours" : "Light colours"}
|
||||
</legend>
|
||||
{["ground", "surface", "text", "accent"].map((k) => (
|
||||
<label key={k}>
|
||||
{k}
|
||||
<input
|
||||
type="color"
|
||||
value={String(
|
||||
recordOf(recordOf(p.customPalette)[theme])[k] ??
|
||||
(theme === "dark"
|
||||
? k === "text"
|
||||
? "#eeeae3"
|
||||
: "#22252f"
|
||||
: k === "text"
|
||||
? "#28272d"
|
||||
: "#fffdf8"),
|
||||
)}
|
||||
onChange={(e) =>
|
||||
patch({
|
||||
customPalette: {
|
||||
...recordOf(p.customPalette),
|
||||
[theme]: {
|
||||
...recordOf(recordOf(p.customPalette)[theme]),
|
||||
[k]: e.target.value,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
))}
|
||||
<p className="help">
|
||||
Your chosen colours are saved. Displayed text and surfaces adjust to
|
||||
keep them readable.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<section className="settings-group urgency-colours">
|
||||
<h2>Urgency colours</h2>
|
||||
<p className="help">
|
||||
Countdowns and time bars change colour as an event's end gets closer.
|
||||
Pick each step for dark and light. They work with any scheme, and are
|
||||
only adjusted if needed to stay readable.
|
||||
</p>
|
||||
<div className="custom-palettes">
|
||||
{(["dark", "light"] as const).map((theme) => {
|
||||
const chosen = urgencyChoice(p, theme),
|
||||
own = recordOf(recordOf(p.urgencyPalette)[theme]);
|
||||
return (
|
||||
<fieldset key={theme}>
|
||||
<legend>
|
||||
{theme === "dark" ? "Dark theme" : "Light theme"}
|
||||
</legend>
|
||||
{URGENCY_STEPS.map((k) => (
|
||||
<label key={k} style={{ "--u": chosen[k] } as CSSProperties}>
|
||||
<input
|
||||
type="color"
|
||||
value={chosen[k]}
|
||||
onChange={(e) =>
|
||||
patch({
|
||||
urgencyPalette: {
|
||||
...recordOf(p.urgencyPalette),
|
||||
[theme]: { ...own, [k]: e.target.value },
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="urgency-sample" aria-hidden="true">
|
||||
<i />
|
||||
<i />
|
||||
<i />
|
||||
</span>
|
||||
{URGENCY_LABEL[k]}
|
||||
</label>
|
||||
))}
|
||||
{Object.keys(own).length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className="quiet"
|
||||
onClick={() =>
|
||||
patch({
|
||||
urgencyPalette: {
|
||||
...recordOf(p.urgencyPalette),
|
||||
[theme]: {},
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
Use the default colours
|
||||
</button>
|
||||
)}
|
||||
</fieldset>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
<section className="settings-group">
|
||||
<h2>Default view</h2>
|
||||
<p className="help">
|
||||
What opens when you come back. What's new shows in both, so you won't
|
||||
miss it either way.
|
||||
</p>
|
||||
<ViewCards
|
||||
value={p.view === "timeline" ? "timeline" : "soon"}
|
||||
onChange={(view) => patch({ view })}
|
||||
/>
|
||||
<div className="acts">
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
onClick={() =>
|
||||
onReplayTour ? onReplayTour() : patch({ redesignTourSeen: null })
|
||||
}
|
||||
>
|
||||
Replay the introduction
|
||||
</button>
|
||||
<span className="help">
|
||||
Your games, events and progress stay in this browser.
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
{tracked.length > 1 && (
|
||||
<section className="settings-group game-order">
|
||||
<h2>Game order</h2>
|
||||
<p className="help">
|
||||
The games you track, in the order focus, routines and the timeline
|
||||
list them. Move the ones you play most to the top.
|
||||
</p>
|
||||
<ol>
|
||||
{tracked.map((g, i) => (
|
||||
<li key={g} style={{ "--g": meta(g).hue } as CSSProperties}>
|
||||
<span className="settings-marker" aria-hidden="true" />
|
||||
<span className="name">{meta(g).name}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="move"
|
||||
aria-label={`Move ${meta(g).name} up`}
|
||||
disabled={i === 0}
|
||||
onClick={() => move(i, -1)}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M6 15l6-6 6 6" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="move"
|
||||
aria-label={`Move ${meta(g).name} down`}
|
||||
disabled={i === tracked.length - 1}
|
||||
onClick={() => move(i, 1)}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M6 9l6 6 6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
{saved && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
onClick={() => patch({ gameOrder: null })}
|
||||
>
|
||||
Sort A–Z
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
<section className="settings-group">
|
||||
<h2>Timeline design</h2>
|
||||
<p className="help">
|
||||
The new look puts a name column beside each bar. The classic look
|
||||
draws filled bars with the title inside, grouped under each game.
|
||||
</p>
|
||||
<div className="segmented">
|
||||
{(["new", "classic"] as const).map((look) => (
|
||||
<button
|
||||
key={look}
|
||||
aria-pressed={timelineLook(p) === look}
|
||||
onClick={() => patch({ timelineLook: look })}
|
||||
>
|
||||
{look === "new" ? "New look" : "Classic look"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<section className="settings-group">
|
||||
<h2>Focus bar</h2>
|
||||
<p className="help">
|
||||
The game pills that narrow a view to one game. A view without them
|
||||
always shows every game.
|
||||
</p>
|
||||
{(["checklist", "timeline"] as const).map((view) => (
|
||||
<label key={view}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={focusBarShown(p, view)}
|
||||
onChange={(e) =>
|
||||
patch({
|
||||
focusBar: {
|
||||
checklist: focusBarShown(p, "checklist"),
|
||||
timeline: focusBarShown(p, "timeline"),
|
||||
[view]: e.target.checked,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
Show on the {view === "checklist" ? "Checklist" : "Timeline"}
|
||||
</label>
|
||||
))}
|
||||
</section>
|
||||
<section className="settings-group checklist-settings">
|
||||
<h2>Checklist</h2>
|
||||
<div className="setting-fields">
|
||||
<label>
|
||||
Event rows
|
||||
<select
|
||||
value={String(p.density ?? "standard")}
|
||||
onChange={(e) => patch({ density: e.target.value })}
|
||||
>
|
||||
<option value="standard">Standard</option>
|
||||
<option value="compact">Compact</option>
|
||||
</select>
|
||||
</label>
|
||||
<ChecklistDisplay state={state} onAction={onAction} plain />
|
||||
</div>
|
||||
<p className="help">
|
||||
Focusing a game opens its routines automatically. Pins on the Routines
|
||||
tab decide which routines appear on your Checklist.
|
||||
</p>
|
||||
</section>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { serverSuffix } from "../../state/reader/servers.ts";
|
||||
import { useGameMeta } from "../../state/gameMeta.tsx";
|
||||
import { formatInZone } from "../../state/displayTime.ts";
|
||||
import { actionMeta, recordOf } from "../../state/reader/actions.ts";
|
||||
import type { ArchiveRecord, ReaderState } from "../../state/reader/schema.ts";
|
||||
import type { ActionHandler } from "./GameSettings.tsx";
|
||||
|
||||
/** An archive is a saved snapshot, including its clock and progress, not a live editor. */
|
||||
export function ArchiveDetail({
|
||||
record,
|
||||
state,
|
||||
onAction,
|
||||
onClose,
|
||||
}: {
|
||||
record: ArchiveRecord;
|
||||
state: ReaderState;
|
||||
onAction: ActionHandler;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const dialog = useRef<HTMLDialogElement>(null);
|
||||
const meta = useGameMeta()(record.event.game);
|
||||
const account = state.accounts[record.accountId];
|
||||
const clock = account?.clocks.find((c) => c.id === record.clockRevision);
|
||||
const progress = recordOf(record.progress);
|
||||
const zone =
|
||||
typeof state.prefs.timezone === "string" ? state.prefs.timezone : null;
|
||||
const outcome = {
|
||||
done: "Completed",
|
||||
ignored: "Ignored",
|
||||
unfinished: "Ended unfinished",
|
||||
}[record.outcome];
|
||||
useEffect(() => {
|
||||
dialog.current?.showModal();
|
||||
}, []);
|
||||
return (
|
||||
<dialog
|
||||
ref={dialog}
|
||||
className="archive-detail event-detail-sheet"
|
||||
aria-label={record.event.title}
|
||||
onCancel={onClose}
|
||||
>
|
||||
<div className="drawer-head">
|
||||
<p className="detail-game" style={{ color: meta.hue }}>
|
||||
{meta.name}
|
||||
{serverSuffix(
|
||||
state,
|
||||
record.event.game,
|
||||
clock?.region ?? account?.region,
|
||||
true,
|
||||
)}
|
||||
</p>
|
||||
<button onClick={onClose} aria-label="Close archived details">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
<h2 className="detail-title">{record.event.title}</h2>
|
||||
{record.event.summary && <p className="help">{record.event.summary}</p>}
|
||||
<dl className="archive-facts">
|
||||
<div>
|
||||
<dt>Outcome</dt>
|
||||
<dd>
|
||||
{outcome} · {formatInZone(record.outcomeAt, true, zone)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>End recorded</dt>
|
||||
<dd>
|
||||
{record.resolvedEnd === null
|
||||
? "Unannounced"
|
||||
: formatInZone(
|
||||
new Date(record.resolvedEnd).toISOString(),
|
||||
record.event.endPrecision === "exact",
|
||||
zone,
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Saved progress</dt>
|
||||
<dd>
|
||||
{record.days.length} check-ins
|
||||
{typeof progress.effort === "string" ? ` · ${progress.effort}` : ""}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{typeof progress.note === "string" && progress.note && (
|
||||
<section>
|
||||
<h3>Your note</h3>
|
||||
<p className="archive-note">{progress.note}</p>
|
||||
</section>
|
||||
)}
|
||||
{record.days.length > 0 && (
|
||||
<details>
|
||||
<summary>Recorded check-ins</summary>
|
||||
<p>{record.days.join(" · ")}</p>
|
||||
</details>
|
||||
)}
|
||||
{Object.keys(record.amounts).length > 0 && (
|
||||
<details>
|
||||
<summary>Recorded amounts</summary>
|
||||
<dl>
|
||||
{Object.entries(record.amounts).map(([period, raw]) => (
|
||||
<div key={period}>
|
||||
<dt>{period}</dt>
|
||||
<dd>{String(recordOf(raw).value ?? raw)}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</details>
|
||||
)}
|
||||
{record.event.sourceUrl && (
|
||||
<p>
|
||||
<a
|
||||
className="source-link"
|
||||
href={record.event.sourceUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Original source ↗
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
<p className="help">
|
||||
Saved when this event entered your archive. Restoring keeps its
|
||||
progress; an event that has already ended remains in history.
|
||||
</p>
|
||||
<button
|
||||
className="btn"
|
||||
onClick={async () => {
|
||||
const result = await onAction({
|
||||
type: "restore",
|
||||
accountId: record.accountId,
|
||||
eventId: record.event.id,
|
||||
...actionMeta(),
|
||||
});
|
||||
if (result.ok) onClose();
|
||||
}}
|
||||
>
|
||||
Restore event
|
||||
</button>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
import { useState, type CSSProperties } from "react";
|
||||
import { serverSuffix } from "../../state/reader/servers.ts";
|
||||
import { useGameMeta } from "../../state/gameMeta.tsx";
|
||||
import { formatInZone } from "../../state/displayTime.ts";
|
||||
import { actionMeta } from "../../state/reader/actions.ts";
|
||||
import type { ReaderState, ArchiveRecord } from "../../state/reader/schema.ts";
|
||||
import { pairKey } from "../../state/reader/schema.ts";
|
||||
import { ArchiveStory } from "./ArchiveStory.tsx";
|
||||
import type { ActionHandler } from "./GameSettings.tsx";
|
||||
import { DAY } from "../../../shared/time.ts";
|
||||
|
||||
export function ArchivePage({
|
||||
state,
|
||||
onAction,
|
||||
now,
|
||||
onOpen,
|
||||
}: {
|
||||
state: ReaderState;
|
||||
onAction: ActionHandler;
|
||||
now: number;
|
||||
onOpen: (record: ArchiveRecord) => void;
|
||||
}) {
|
||||
const [game, setGame] = useState<string | null>(null),
|
||||
[outcome, setOutcome] = useState("all"),
|
||||
[history, setHistory] = useState(false),
|
||||
meta = useGameMeta();
|
||||
const records = Object.values(state.archive).sort((a, b) =>
|
||||
b.outcomeAt.localeCompare(a.outcomeAt),
|
||||
);
|
||||
const games = [...new Set(records.map((r) => r.event.game))],
|
||||
zone =
|
||||
typeof state.prefs.timezone === "string" ? state.prefs.timezone : null;
|
||||
const shown = records.filter(
|
||||
(r) =>
|
||||
(!game || r.event.game === game) &&
|
||||
(outcome === "all" || outcome === r.outcome),
|
||||
);
|
||||
const start =
|
||||
Math.floor(
|
||||
Math.min(now, ...shown.map((r) => Date.parse(r.event.startsAt))) / DAY,
|
||||
) * DAY;
|
||||
const end = Math.max(
|
||||
start + DAY,
|
||||
now,
|
||||
...shown.map((r) => r.resolvedEnd ?? Date.parse(r.outcomeAt)),
|
||||
);
|
||||
const days = Math.ceil((end - start) / DAY),
|
||||
trackWidth = Math.max(800, days * 16);
|
||||
const summary = (items: ArchiveRecord[]) =>
|
||||
`${items.filter((r) => r.outcome === "done").length} of ${items.filter((r) => r.outcome !== "ignored").length} done · ${items.filter((r) => r.outcome === "unfinished").length} unfinished`;
|
||||
const outcomeBar = (items: ArchiveRecord[]) => (
|
||||
<span className="archive-outcome-bar" aria-hidden="true">
|
||||
{items.map((r) => (
|
||||
<i key={pairKey(r.accountId, r.event.id)} className={r.outcome} />
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
const restore = (r: ArchiveRecord) =>
|
||||
void onAction({
|
||||
type: "restore",
|
||||
accountId: r.accountId,
|
||||
eventId: r.event.id,
|
||||
...actionMeta(),
|
||||
});
|
||||
return (
|
||||
<section className="archive-page">
|
||||
<div className="archive-view">
|
||||
<div className="segmented">
|
||||
<button aria-pressed={!history} onClick={() => setHistory(false)}>
|
||||
By game
|
||||
</button>
|
||||
<button aria-pressed={history} onClick={() => setHistory(true)}>
|
||||
History timeline
|
||||
</button>
|
||||
</div>
|
||||
<p className="help">Recorded history · kept in this browser</p>
|
||||
</div>
|
||||
<ArchiveStory records={records} now={now} game={game} onGame={setGame} />
|
||||
<div className="rt-top">
|
||||
<h1>Archive</h1>
|
||||
<p className="sum">
|
||||
Finished, ignored or ended. Kept in your save and off the Checklist
|
||||
and Timeline. Open one to see it as it was.
|
||||
</p>
|
||||
<div className="segmented">
|
||||
{["all", "done", "unfinished", "ignored"].map((o) => (
|
||||
<button
|
||||
key={o}
|
||||
aria-pressed={o === outcome}
|
||||
onClick={() => setOutcome(o)}
|
||||
>
|
||||
{
|
||||
{
|
||||
all: "All",
|
||||
done: "Completed",
|
||||
unfinished: "Ended unfinished",
|
||||
ignored: "Ignored",
|
||||
}[o]
|
||||
}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="archive-body">
|
||||
<nav className="archive-sidebar" aria-label="Archive games">
|
||||
<button aria-pressed={game === null} onClick={() => setGame(null)}>
|
||||
All games<small>{records.length} events</small>
|
||||
</button>
|
||||
{games.map((g) => (
|
||||
<button
|
||||
key={g}
|
||||
aria-pressed={game === g}
|
||||
onClick={() => setGame(g)}
|
||||
style={{ "--g": meta(g).hue } as CSSProperties}
|
||||
>
|
||||
<strong>{meta(g).name}</strong>
|
||||
<small>
|
||||
{summary(records.filter((r) => r.event.game === g))}
|
||||
</small>
|
||||
{outcomeBar(records.filter((r) => r.event.game === g))}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
<div className="archive-main">
|
||||
{!shown.length && (
|
||||
<p className="empty-state">
|
||||
{records.length
|
||||
? "No events match these filters."
|
||||
: "No archived events yet. Completed and ignored events will appear here."}
|
||||
</p>
|
||||
)}
|
||||
{history && shown.length > 0 ? (
|
||||
<div className="timeline-scroller archive-timeline">
|
||||
<div
|
||||
className="timeline-board"
|
||||
style={
|
||||
{
|
||||
"--track-width": `${trackWidth}px`,
|
||||
"--day-width": `${trackWidth / days}px`,
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<div className="time-axis">
|
||||
<strong>Recorded events</strong>
|
||||
<div className="ticks">
|
||||
{Array.from({ length: Math.ceil(days / 7) }, (_, i) => (
|
||||
<span
|
||||
key={i}
|
||||
style={{ left: ((i * 7) / days) * trackWidth }}
|
||||
>
|
||||
{formatInZone(start + i * 7 * DAY, false, zone).replace(
|
||||
/ \d{4}$/,
|
||||
"",
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{shown.map((r) => (
|
||||
<div
|
||||
className="timeline-entry"
|
||||
key={pairKey(r.accountId, r.event.id)}
|
||||
style={{ "--g": meta(r.event.game).hue } as CSSProperties}
|
||||
>
|
||||
<button className="timeline-name" onClick={() => onOpen(r)}>
|
||||
{r.event.title}
|
||||
<small>
|
||||
{meta(r.event.game).short} · {r.outcome}
|
||||
</small>
|
||||
</button>
|
||||
<div className="time-track">
|
||||
<button
|
||||
className={`period-block archive-period ${r.outcome}${r.resolvedEnd === null ? " unknown-end" : ""}`}
|
||||
onClick={() => onOpen(r)}
|
||||
style={{
|
||||
left:
|
||||
((Date.parse(r.event.startsAt) - start) /
|
||||
(end - start)) *
|
||||
trackWidth,
|
||||
width: Math.max(
|
||||
18,
|
||||
(((r.resolvedEnd ?? Date.parse(r.outcomeAt)) -
|
||||
Date.parse(r.event.startsAt)) /
|
||||
(end - start)) *
|
||||
trackWidth,
|
||||
),
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
{r.resolvedEnd === null ? "End unknown" : r.outcome}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
games
|
||||
.filter((g) => !game || game === g)
|
||||
.map((g) => {
|
||||
const own = shown.filter((r) => r.event.game === g);
|
||||
return (
|
||||
own.length > 0 && (
|
||||
<section
|
||||
className="archive-game"
|
||||
key={g}
|
||||
style={{ "--g": meta(g).hue } as CSSProperties}
|
||||
>
|
||||
<h2>{meta(g).name}</h2>
|
||||
<div className="archive-saved">
|
||||
<div className="archive-saved-heading">
|
||||
<h3>Saved events</h3>
|
||||
<small>{summary(own)}</small>
|
||||
{outcomeBar(own)}
|
||||
</div>
|
||||
{own.map((r) => (
|
||||
<article
|
||||
className="archive-record"
|
||||
key={pairKey(r.accountId, r.event.id)}
|
||||
>
|
||||
<button
|
||||
className="archive-open"
|
||||
onClick={() => onOpen(r)}
|
||||
title={r.event.summary ?? undefined}
|
||||
>
|
||||
<span
|
||||
className={`outcome-symbol ${r.outcome}`}
|
||||
aria-label={r.outcome}
|
||||
>
|
||||
{r.outcome === "done"
|
||||
? "✓"
|
||||
: r.outcome === "ignored"
|
||||
? "−"
|
||||
: "!"}
|
||||
</span>
|
||||
<span className="event-link">
|
||||
{r.event.title}
|
||||
<small>
|
||||
{serverSuffix(
|
||||
state,
|
||||
r.event.game,
|
||||
state.accounts[r.accountId]?.region,
|
||||
true,
|
||||
).slice(3)}
|
||||
</small>
|
||||
</span>
|
||||
<span className="archive-when">
|
||||
{formatInZone(r.outcomeAt, false, zone)}
|
||||
</span>
|
||||
<span aria-hidden="true">›</span>
|
||||
</button>
|
||||
<button
|
||||
className="archive-restore"
|
||||
onClick={() => restore(r)}
|
||||
>
|
||||
Restore
|
||||
</button>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
);
|
||||
})
|
||||
)}
|
||||
<p className="help archive-history-note">
|
||||
History from before this redesign may be incomplete. Unknown end
|
||||
dates stay unknown; earlier progress is preserved.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { archiveStats } from "../../state/reader/archiveStats.ts";
|
||||
import type { ArchiveRecord } from "../../state/reader/schema.ts";
|
||||
import { useGameMeta } from "../../state/gameMeta.tsx";
|
||||
import type { CSSProperties } from "react";
|
||||
export function ArchiveStory({
|
||||
records,
|
||||
now,
|
||||
onGame,
|
||||
game,
|
||||
}: {
|
||||
records: ArchiveRecord[];
|
||||
now: number;
|
||||
onGame?: (game: string | null) => void;
|
||||
game?: string | null;
|
||||
}) {
|
||||
const meta = useGameMeta();
|
||||
const s = archiveStats(records, now);
|
||||
return (
|
||||
<section className="archive-story">
|
||||
<h2>Your season, in a few numbers</h2>
|
||||
<p>
|
||||
Your recorded history ·{" "}
|
||||
{records.filter((r) => r.outcome === "done").length} completed,{" "}
|
||||
{records.filter((r) => r.outcome === "ignored").length} set aside. A
|
||||
record of your saved events.
|
||||
</p>
|
||||
<div className="story-stats">
|
||||
<div>
|
||||
<strong>
|
||||
{s.monthly.rate === null ? "—" : s.monthly.rate + "%"}
|
||||
</strong>
|
||||
<span>completed this month</span>
|
||||
<small>
|
||||
{s.overall.rate === null
|
||||
? "No eligible history yet"
|
||||
: s.overall.rate + "% overall"}
|
||||
</small>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{s.currentRun}</strong>
|
||||
<span>completion run</span>
|
||||
<small>Best: {s.bestRun}</small>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{s.checkIns}</strong>
|
||||
<span>event check-ins</span>
|
||||
<small>Summed per event and server</small>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{s.unfinished}</strong>
|
||||
<span>ended unfinished</span>
|
||||
<small>Ignored events excluded from completion rates</small>
|
||||
</div>
|
||||
</div>
|
||||
{Object.entries(s.games).map(([id, g]) => {
|
||||
const total = g.done + g.ignored + g.unfinished;
|
||||
return (
|
||||
<button
|
||||
className="stats-game"
|
||||
key={id}
|
||||
style={{ "--g": meta(id).hue } as CSSProperties}
|
||||
aria-pressed={game === id}
|
||||
onClick={() => onGame?.(game === id ? null : id)}
|
||||
>
|
||||
<span className="name">{meta(id).short}</span>
|
||||
<span className="outcome-stack" aria-hidden="true">
|
||||
<i
|
||||
className="done"
|
||||
style={{ width: `${(g.done / total) * 100}%` }}
|
||||
/>
|
||||
<i
|
||||
className="unfinished"
|
||||
style={{ width: `${(g.unfinished / total) * 100}%` }}
|
||||
/>
|
||||
<i
|
||||
className="ignored"
|
||||
style={{ width: `${(g.ignored / total) * 100}%` }}
|
||||
/>
|
||||
</span>
|
||||
<span className="counts">
|
||||
{g.done} done · {g.unfinished} unfinished · {g.ignored} ignored
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<p className="help">
|
||||
Recorded archive history only. Ignored events are excluded from
|
||||
completion rates. Select a game to filter the records below.
|
||||
</p>
|
||||
{s.shortfalls.map((r) => (
|
||||
<p key={r.id}>
|
||||
{r.title}: {r.needed} check-ins short of your recorded goal.
|
||||
</p>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
import { withReaderLock } from "../../state/reader/lock.ts";
|
||||
import { mergeReaderData } from "../../state/reader/import.ts";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
backupTier,
|
||||
deleteManualBackup,
|
||||
MANUAL_BACKUP_LIMIT,
|
||||
readBackups,
|
||||
readManualBackups,
|
||||
saveManualBackup,
|
||||
type BackupSnapshot,
|
||||
} from "../../state/reader/backups.ts";
|
||||
import { prepareRestore, undoRestore } from "../../state/reader/restore.ts";
|
||||
import {
|
||||
buildReaderExport,
|
||||
parseReaderExport,
|
||||
} from "../../state/reader/export.ts";
|
||||
import { readLegacy } from "../../state/reader/store.ts";
|
||||
import { triggerDownload } from "../../state/export.ts";
|
||||
import { formatInZone } from "../../state/displayTime.ts";
|
||||
import type { ReaderState } from "../../state/reader/schema.ts";
|
||||
export function BackupPage({ state }: { state: ReaderState }) {
|
||||
const [backups] = useState(() => readBackups(localStorage));
|
||||
const [manual, setManual] = useState(() => readManualBackups(localStorage));
|
||||
const [confirming, setConfirming] = useState<string | null>(null);
|
||||
const [selected, setSelected] = useState<BackupSnapshot | null>(null);
|
||||
const [message, setMessage] = useState("");
|
||||
const zone =
|
||||
typeof state.prefs.timezone === "string" ? state.prefs.timezone : null;
|
||||
const restore = async (payload: unknown) => {
|
||||
const result = await withReaderLock(() =>
|
||||
prepareRestore(localStorage, payload, new Date().toISOString()),
|
||||
).catch(() => ({ ok: false as const, reason: "unavailable" }));
|
||||
if (result.ok) location.reload();
|
||||
else
|
||||
setMessage(
|
||||
`Restore could not be completed (${result.reason}). Your current data is still available; export a copy before trying again.`,
|
||||
);
|
||||
};
|
||||
const importProgress = async (file: File) => {
|
||||
try {
|
||||
const parsed = parseReaderExport(JSON.parse(await file.text()));
|
||||
if (!parsed) {
|
||||
setMessage("This is not a supported Event Clock export.");
|
||||
return;
|
||||
}
|
||||
const merged = mergeReaderData(state, parsed);
|
||||
setSelected({
|
||||
id: "progress-import",
|
||||
at: new Date().toISOString(),
|
||||
payload: buildReaderExport(
|
||||
merged,
|
||||
readLegacy(localStorage),
|
||||
new Date().toISOString(),
|
||||
),
|
||||
});
|
||||
setMessage(
|
||||
"Progress will be merged. Your appearance and other local preferences will be kept.",
|
||||
);
|
||||
} catch {
|
||||
setMessage("This file could not be read as an Event Clock export.");
|
||||
}
|
||||
};
|
||||
const preview = selected ? parseReaderExport(selected.payload) : null;
|
||||
const now = Date.now();
|
||||
const summary = (payload: Record<string, unknown>) => {
|
||||
const r = parseReaderExport(payload);
|
||||
if (!r) return "Unreadable backup";
|
||||
// The games that snapshot was tracking: an account exists for every game.
|
||||
const hidden = Array.isArray(r.prefs.hiddenGames)
|
||||
? (r.prefs.hiddenGames as unknown[])
|
||||
: [];
|
||||
const accounts = Object.values(r.accounts).filter(
|
||||
(a) =>
|
||||
!a.retiredAt &&
|
||||
!hidden.includes(a.game) &&
|
||||
(a.intent.events || Object.values(a.routines).some((p) => p.followed)),
|
||||
);
|
||||
const games = new Set(accounts.map((a) => a.game)).size;
|
||||
const routines = accounts.reduce(
|
||||
(n, a) => n + Object.values(a.routines).filter((p) => p.followed).length,
|
||||
0,
|
||||
);
|
||||
const archived = Object.keys(r.archive).length;
|
||||
return `${games} game${games === 1 ? "" : "s"} · ${routines} routine${routines === 1 ? "" : "s"} · ${archived} archived event${archived === 1 ? "" : "s"}`;
|
||||
};
|
||||
const tiers = [
|
||||
{ id: "daily", title: "Daily", span: "Last 7 days" },
|
||||
{
|
||||
id: "weekly",
|
||||
title: "Weekly",
|
||||
span: "Older snapshots, folded into 4 weeks",
|
||||
},
|
||||
{
|
||||
id: "monthly",
|
||||
title: "Monthly",
|
||||
span: "Older snapshots, folded into 6 months",
|
||||
},
|
||||
] as const;
|
||||
const fileButton = (label: string, onFile: (file: File) => void) => (
|
||||
<label className="btn file-button">
|
||||
{label}
|
||||
<input
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = "";
|
||||
if (file) onFile(file);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
const importAll = async (file: File) => {
|
||||
try {
|
||||
const payload: unknown = JSON.parse(await file.text());
|
||||
const reader = parseReaderExport(payload);
|
||||
if (!reader) {
|
||||
setMessage("This file is not a supported Event Clock backup.");
|
||||
return;
|
||||
}
|
||||
if ((payload as { scope?: unknown }).scope === "progress") {
|
||||
await importProgress(file);
|
||||
return;
|
||||
}
|
||||
setSelected({
|
||||
id: "import",
|
||||
at: new Date().toISOString(),
|
||||
payload: payload as Record<string, unknown>,
|
||||
});
|
||||
} catch {
|
||||
setMessage("This file could not be read as JSON.");
|
||||
}
|
||||
};
|
||||
return (
|
||||
<section className="backup-page">
|
||||
<h1>Backup</h1>
|
||||
<p className="muted-copy">
|
||||
Automatic snapshots keep a recent history of your data in this browser.
|
||||
The latest snapshot in each period is kept as older days fold into
|
||||
weeks, then months.
|
||||
</p>
|
||||
<dl className="backup-policy">
|
||||
<div>
|
||||
<dt>7 days</dt>
|
||||
<dd>Daily snapshots</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>4 weeks</dt>
|
||||
<dd>Weekly snapshots</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>6 months</dt>
|
||||
<dd>Monthly snapshots</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p className="source-note">
|
||||
Backups are created while you use the app. Days you don't open it have
|
||||
no new snapshot. These copies live in this browser, and clearing its
|
||||
storage removes them; export a file to keep a copy elsewhere.
|
||||
</p>
|
||||
<div className="backup-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn solid"
|
||||
onClick={() => {
|
||||
// Its own list: automatic history keeps one snapshot a day, and a
|
||||
// copy made on purpose must not replace it or be replaced by it.
|
||||
const result = saveManualBackup(
|
||||
localStorage,
|
||||
state,
|
||||
new Date().toISOString(),
|
||||
);
|
||||
setMessage(
|
||||
result.ok
|
||||
? "Backup saved to Your backups."
|
||||
: result.reason === "full"
|
||||
? "Your backups are full. Delete one to make room; this keeps space free for your progress."
|
||||
: `Backup could not be saved (${result.reason}). Export a copy instead.`,
|
||||
);
|
||||
setManual(readManualBackups(localStorage));
|
||||
}}
|
||||
>
|
||||
Back up now
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
onClick={() =>
|
||||
triggerDownload(
|
||||
buildReaderExport(
|
||||
state,
|
||||
readLegacy(localStorage),
|
||||
new Date().toISOString(),
|
||||
),
|
||||
`event-clock-${new Date().toISOString().slice(0, 10)}.json`,
|
||||
)
|
||||
}
|
||||
>
|
||||
Export all data
|
||||
</button>
|
||||
{fileButton("Import all data", (file) => void importAll(file))}
|
||||
</div>
|
||||
<p className="backup-secondary">
|
||||
<span>Progress only, keeping this browser's settings:</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn link"
|
||||
onClick={() =>
|
||||
triggerDownload(
|
||||
{
|
||||
...buildReaderExport(
|
||||
{ ...state, prefs: {} },
|
||||
readLegacy(localStorage),
|
||||
new Date().toISOString(),
|
||||
),
|
||||
scope: "progress",
|
||||
},
|
||||
"event-clock-progress.json",
|
||||
)
|
||||
}
|
||||
>
|
||||
Export progress
|
||||
</button>
|
||||
<label className="btn link file-button">
|
||||
Import progress
|
||||
<input
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = "";
|
||||
if (file) void importProgress(file);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="btn link"
|
||||
onClick={async () => {
|
||||
const result = await withReaderLock(() =>
|
||||
undoRestore(localStorage),
|
||||
).catch(() => ({ ok: false }));
|
||||
if (result.ok) location.reload();
|
||||
else setMessage("There is no recovery copy that can be restored.");
|
||||
}}
|
||||
>
|
||||
Undo last restore
|
||||
</button>
|
||||
</p>
|
||||
<p role="status" className="backup-message">
|
||||
{message}
|
||||
</p>
|
||||
{selected && preview && (
|
||||
<section className="backup-preview">
|
||||
<h2>Backup from {formatInZone(selected.at, true, zone)}</h2>
|
||||
<p>{summary(selected.payload)}</p>
|
||||
<p className="help">
|
||||
This replaces your current data. A recovery copy is saved first.
|
||||
</p>
|
||||
<div className="backup-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn solid"
|
||||
onClick={() => restore(selected.payload)}
|
||||
>
|
||||
Restore this backup
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
onClick={() => setSelected(null)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
<section className="backup-tier">
|
||||
<div className="backup-tier-h">
|
||||
<h3>Your backups</h3>
|
||||
<span>
|
||||
{manual.length} of {MANUAL_BACKUP_LIMIT} · Kept until you delete
|
||||
them
|
||||
</span>
|
||||
</div>
|
||||
{manual.length === 0 ? (
|
||||
<p className="help">
|
||||
Back up now saves a copy here. Automatic history never replaces it.
|
||||
</p>
|
||||
) : (
|
||||
manual.map((b) => (
|
||||
<div className="backup-row" key={b.id}>
|
||||
<div>
|
||||
<time dateTime={b.at}>{formatInZone(b.at, true, zone)}</time>
|
||||
<small>{summary(b.payload)}</small>
|
||||
</div>
|
||||
{confirming === b.id ? (
|
||||
<div className="backup-confirm">
|
||||
<span>Delete this backup?</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn sm danger"
|
||||
onClick={() => {
|
||||
const result = deleteManualBackup(localStorage, b.id);
|
||||
setConfirming(null);
|
||||
if (selected?.id === b.id) setSelected(null);
|
||||
setManual(readManualBackups(localStorage));
|
||||
setMessage(
|
||||
result.ok
|
||||
? "Backup deleted."
|
||||
: `The backup could not be deleted (${result.reason}).`,
|
||||
);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn sm"
|
||||
onClick={() => setConfirming(null)}
|
||||
>
|
||||
Keep
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="backup-confirm">
|
||||
<button
|
||||
type="button"
|
||||
className="btn sm"
|
||||
onClick={() => setSelected(b)}
|
||||
>
|
||||
View backup
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn sm"
|
||||
aria-label={`Delete backup from ${formatInZone(b.at, true, zone)}`}
|
||||
onClick={() => setConfirming(b.id)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</section>
|
||||
<h2 className="backup-history-h">Automatic history</h2>
|
||||
{backups.length === 0 ? (
|
||||
<p className="help">
|
||||
No saved history yet. A backup is created when you use the app.
|
||||
</p>
|
||||
) : (
|
||||
tiers.map((tier) => {
|
||||
const rows = backups.filter((b) => backupTier(b.at, now) === tier.id);
|
||||
if (!rows.length) return null;
|
||||
return (
|
||||
<section className="backup-tier" key={tier.id}>
|
||||
<div className="backup-tier-h">
|
||||
<h3>{tier.title}</h3>
|
||||
<span>
|
||||
{rows.length} snapshot{rows.length === 1 ? "" : "s"} ·{" "}
|
||||
{tier.span}
|
||||
</span>
|
||||
</div>
|
||||
{rows.map((b) => (
|
||||
<div className="backup-row" key={b.id}>
|
||||
<div>
|
||||
<time dateTime={b.at}>
|
||||
{formatInZone(b.at, true, zone)}
|
||||
</time>
|
||||
<small>{summary(b.payload)}</small>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn sm"
|
||||
onClick={() => setSelected(b)}
|
||||
>
|
||||
View backup
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import {
|
||||
commitUrl,
|
||||
compareUrl,
|
||||
KIND_LABEL,
|
||||
specUrl,
|
||||
type Entry,
|
||||
type Note,
|
||||
} from "../../../shared/changelog.ts";
|
||||
import { asOfLabel } from "../../../shared/routines.ts";
|
||||
import { REPO_URL } from "../Colophon.tsx";
|
||||
|
||||
const KIND_ORDER: readonly Entry["kind"][] = ["feature", "games", "fix"];
|
||||
|
||||
/**
|
||||
* The Round 6 B changelog page (`.changelog-page .rel`): a dated, typed entry
|
||||
* per release note, its summary, and the commits it covers as a list, with the
|
||||
* commit range and design notes beneath. Loose commits follow by month.
|
||||
*/
|
||||
export function ChangelogPage({
|
||||
notes,
|
||||
entries,
|
||||
}: {
|
||||
notes: readonly Note[];
|
||||
entries: readonly Entry[];
|
||||
}) {
|
||||
const loose = entries.filter((e) => e.note === undefined);
|
||||
const months = [...new Set(loose.map((e) => e.date.slice(0, 7)))];
|
||||
return (
|
||||
<section className="changelog-page">
|
||||
<h1>Changelog</h1>
|
||||
<p className="muted-copy">
|
||||
Changes and release notes for Event Clock, summarised from the project's
|
||||
history and design notes. Each links to the commits it covers.
|
||||
</p>
|
||||
{notes.map((note) => {
|
||||
const covered = entries.filter((e) => e.note === note.id);
|
||||
const kinds = KIND_ORDER.filter((k) =>
|
||||
covered.some((e) => e.kind === k),
|
||||
);
|
||||
return (
|
||||
<article className="rel" key={note.id}>
|
||||
<div className="d">
|
||||
{asOfLabel(note.date)}
|
||||
{kinds.map((k) => ` · ${KIND_LABEL[k]}`).join("")}
|
||||
</div>
|
||||
<h3>{note.title}</h3>
|
||||
{note.summary.map((paragraph, i) => (
|
||||
<p key={i}>{paragraph}</p>
|
||||
))}
|
||||
{covered.length > 0 && (
|
||||
<ul>
|
||||
{covered.map((entry) => (
|
||||
<EntryItem key={entry.id} entry={entry} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<p className="rel-links">
|
||||
<a
|
||||
href={compareUrl(REPO_URL, note.from, note.to)}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
Commits {note.from}…{note.to}
|
||||
</a>
|
||||
{(note.specs ?? []).map((path) => (
|
||||
<a
|
||||
key={path}
|
||||
href={specUrl(REPO_URL, path)}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
Spec: {path.split("/").at(-1)}
|
||||
</a>
|
||||
))}
|
||||
</p>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
{months.length > 0 && (
|
||||
<section className="rel other-changes">
|
||||
<h3>Other changes</h3>
|
||||
{months.map((month) => (
|
||||
<div key={month}>
|
||||
<div className="d">
|
||||
{asOfLabel(`${month}-01`).replace(/^1 /, "")}
|
||||
</div>
|
||||
<ul>
|
||||
{loose
|
||||
.filter((e) => e.date.startsWith(month))
|
||||
.map((entry) => (
|
||||
<EntryItem key={entry.id} entry={entry} />
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function EntryItem({ entry }: { entry: Entry }) {
|
||||
return (
|
||||
<li>
|
||||
<span className="t">{entry.title}</span>
|
||||
<span className="meta">
|
||||
<span className={`kind ${entry.kind}`}>{KIND_LABEL[entry.kind]}</span>
|
||||
<a
|
||||
href={commitUrl(REPO_URL, entry.ref)}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="hash"
|
||||
>
|
||||
{entry.ref}
|
||||
</a>
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
import { useContext, type CSSProperties } from "react";
|
||||
import type { ReaderEventRow } from "../../state/reader/eventRows.ts";
|
||||
import { useGameMeta } from "../../state/gameMeta.tsx";
|
||||
import { DisplayTimeContext, formatInZone } from "../../state/displayTime.ts";
|
||||
import { formatRemaining } from "../../../shared/time.ts";
|
||||
import { Meter, URGENCY_COLOR } from "../Meter.tsx";
|
||||
import { STEPS } from "../Legend.tsx";
|
||||
import { isCustomEventId } from "../../../shared/custom.ts";
|
||||
|
||||
/** Compact's column headings: the Round 6 B ledger (`table.led`) as a grid over list rows. */
|
||||
/** The compact ledger's column labels; events not started yet count down to their start. */
|
||||
export function LedgerColumns({ upcoming = false }: { upcoming?: boolean }) {
|
||||
return (
|
||||
<div className="ledger-columns" aria-hidden="true">
|
||||
<span>Game</span>
|
||||
<span>Event</span>
|
||||
<span className="h-meter">{upcoming ? "Duration" : "Time left"}</span>
|
||||
<span>{upcoming ? "Starts in" : "Ends in"}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
/**
|
||||
* Live's legend, said once under "Running now": what the bars and their
|
||||
* colours mean. Colours come from the same `URGENCY_COLOR`, so they follow
|
||||
* the reader's urgency colours.
|
||||
*/
|
||||
export function UrgencyLegend() {
|
||||
return (
|
||||
<div className="urgency-legend">
|
||||
<p>
|
||||
<span className="bars" aria-hidden="true">
|
||||
{Array.from({ length: 8 }, (_, i) => (
|
||||
<i key={i} data-on={i < 5 || undefined} />
|
||||
))}
|
||||
</span>
|
||||
bars = time left
|
||||
</p>
|
||||
<p>
|
||||
<span>colour = ends in</span>
|
||||
{STEPS.map((step) => (
|
||||
<span key={step.urgency} className="step">
|
||||
<i
|
||||
aria-hidden="true"
|
||||
style={{ background: URGENCY_COLOR[step.urgency] }}
|
||||
/>
|
||||
{step.label}
|
||||
</span>
|
||||
))}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export function ChecklistEvent({
|
||||
row,
|
||||
now,
|
||||
doing,
|
||||
onOpen,
|
||||
server = false,
|
||||
compact = false,
|
||||
}: {
|
||||
row: ReaderEventRow;
|
||||
now: number;
|
||||
doing?: boolean;
|
||||
server?: boolean;
|
||||
compact?: boolean;
|
||||
onOpen: () => void;
|
||||
}) {
|
||||
const meta = useGameMeta()(row.event.game),
|
||||
zone = useContext(DisplayTimeContext);
|
||||
const { event, clock } = row;
|
||||
const remaining = clock.upcoming
|
||||
? `In ${formatRemaining(clock.startsMs - now)}`
|
||||
: clock.msRemaining === null
|
||||
? "Unknown"
|
||||
: formatRemaining(clock.msRemaining);
|
||||
const date = clock.upcoming ? clock.startsMs : clock.endsMs;
|
||||
const meter = (
|
||||
<Meter
|
||||
progress={clock.progress}
|
||||
urgency={clock.urgency}
|
||||
animate={false}
|
||||
label={
|
||||
clock.msRemaining === null ? "End date unknown" : `${remaining} left`
|
||||
}
|
||||
/>
|
||||
);
|
||||
const unknown = clock.msRemaining === null && !clock.upcoming;
|
||||
if (compact)
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="ledger-row"
|
||||
style={{ "--g": meta.hue } as CSSProperties}
|
||||
onClick={onOpen}
|
||||
>
|
||||
<span className="gcell">
|
||||
<span>
|
||||
{meta.short}
|
||||
{server && <small>{row.region}</small>}
|
||||
</span>
|
||||
</span>
|
||||
<span className="tcell">
|
||||
<span className="gsm">{meta.short}</span>
|
||||
{event.title}
|
||||
{doing && <span className="tag">Doing it</span>}
|
||||
{isCustomEventId(event.id) && <span className="tag own">Yours</span>}
|
||||
</span>
|
||||
<span className="mcell">{meter}</span>
|
||||
<span
|
||||
className="cd"
|
||||
style={{
|
||||
color: unknown
|
||||
? "var(--color-faint)"
|
||||
: URGENCY_COLOR[clock.urgency],
|
||||
}}
|
||||
>
|
||||
{unknown ? "End unknown" : remaining}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`event-entry${event.summary ? "" : " no-description"}`}
|
||||
style={{ "--g": meta.hue } as CSSProperties}
|
||||
onClick={onOpen}
|
||||
>
|
||||
<span className="event-copy">
|
||||
<span className="identity">
|
||||
{meta.name}
|
||||
{server && ` · ${row.region}`}
|
||||
{doing && " · Doing it"}
|
||||
{isCustomEventId(event.id) && " · Yours"}
|
||||
</span>
|
||||
<span className="event-title">{event.title}</span>
|
||||
{event.summary && (
|
||||
<span className="event-description">{event.summary}</span>
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
className="deadline"
|
||||
style={{ color: URGENCY_COLOR[clock.urgency] }}
|
||||
>
|
||||
{remaining}
|
||||
<small>
|
||||
{date === null
|
||||
? "End not announced"
|
||||
: `${clock.upcoming ? "Starts" : "Ends"} ${formatInZone(new Date(date).toISOString(), false, zone).replace(/ \d{4}$/, "")}`}
|
||||
</small>
|
||||
{/* Every row shows its time left. The mockup hid it on rows without a
|
||||
description; the owner chose consistency over the tighter row. */}
|
||||
{meter}
|
||||
</span>
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="m6 4 4 4-4 4" />
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
export function NextDeadline({
|
||||
row,
|
||||
then = [],
|
||||
servers = () => false,
|
||||
onOpen,
|
||||
}: {
|
||||
row: ReaderEventRow | undefined;
|
||||
/** The next few after `row`, as in the Round 6 rail's `.then` list. */
|
||||
then?: readonly ReaderEventRow[];
|
||||
/** Whether a game is on several servers, so its server is named. */
|
||||
servers?: (game: string) => boolean;
|
||||
onOpen: (row: ReaderEventRow) => void;
|
||||
}) {
|
||||
const meta = useGameMeta(),
|
||||
zone = useContext(DisplayTimeContext);
|
||||
if (!row)
|
||||
return (
|
||||
<section className="head">
|
||||
<p className="legend">Next to expire</p>
|
||||
<h2>All caught up</h2>
|
||||
<p className="help">No outstanding deadlines in this view.</p>
|
||||
</section>
|
||||
);
|
||||
const { event, clock } = row;
|
||||
return (
|
||||
<section className="head">
|
||||
<p className="legend">Next to expire</p>
|
||||
<button className="headline-event" onClick={() => onOpen(row)}>
|
||||
<h2>{event.title}</h2>
|
||||
<span className="game" style={{ color: meta(event.game).hue }}>
|
||||
{meta(event.game).name}
|
||||
{servers(event.game) && ` · ${row.region}`}
|
||||
</span>
|
||||
</button>
|
||||
<div className="big" style={{ color: URGENCY_COLOR[clock.urgency] }}>
|
||||
{clock.msRemaining === null
|
||||
? "Unknown"
|
||||
: formatRemaining(clock.msRemaining)}
|
||||
{clock.msRemaining !== null && <small>left</small>}
|
||||
</div>
|
||||
<p className="exact">
|
||||
{clock.endsMs === null
|
||||
? "End not announced"
|
||||
: `Ends ${formatInZone(new Date(clock.endsMs).toISOString(), true, zone)}`}
|
||||
</p>
|
||||
<Meter
|
||||
progress={clock.progress}
|
||||
urgency={clock.urgency}
|
||||
animate={false}
|
||||
label="Time remaining in this event"
|
||||
/>
|
||||
{then.length > 0 && (
|
||||
<ul className="then" aria-label="Then">
|
||||
{then.map((r) => (
|
||||
<li
|
||||
key={`${r.accountId} ${r.event.id}`}
|
||||
style={{ "--g": meta(r.event.game).hue } as CSSProperties}
|
||||
>
|
||||
<button type="button" onClick={() => onOpen(r)}>
|
||||
<span className="dot" aria-hidden="true" />
|
||||
<span className="t">{r.event.title}</span>
|
||||
<span
|
||||
className="r"
|
||||
style={{
|
||||
color:
|
||||
r.clock.msRemaining === null
|
||||
? "var(--color-faint)"
|
||||
: URGENCY_COLOR[r.clock.urgency],
|
||||
}}
|
||||
>
|
||||
{r.clock.msRemaining === null
|
||||
? "no end"
|
||||
: formatRemaining(r.clock.msRemaining)}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { freshness } from "../../../shared/feed.ts";
|
||||
import type { CodesFeed } from "../../../shared/codes.ts";
|
||||
import type { ReaderState } from "../../state/reader/schema.ts";
|
||||
import { newCodes } from "../../state/reader/codes.ts";
|
||||
import { actionMeta } from "../../state/reader/actions.ts";
|
||||
import { formatInZone } from "../../state/displayTime.ts";
|
||||
import type { ActionHandler } from "./GameSettings.tsx";
|
||||
export function CodesPage({
|
||||
game,
|
||||
feed,
|
||||
state,
|
||||
onAction,
|
||||
}: {
|
||||
game: string;
|
||||
feed: CodesFeed | null;
|
||||
state: ReaderState;
|
||||
onAction: ActionHandler;
|
||||
}) {
|
||||
const [message, setMessage] = useState("");
|
||||
const accounts = Object.values(state.accounts).filter(
|
||||
(a) => a.game === game && !a.retiredAt,
|
||||
);
|
||||
const codes = feed?.codes.filter((c) => c.game === game) ?? [];
|
||||
const unseen = feed ? newCodes(feed, state.codeSeen[game] ?? [], game) : [];
|
||||
useEffect(() => {
|
||||
if (unseen.length)
|
||||
void onAction({
|
||||
type: "seen",
|
||||
kind: "codeSeen",
|
||||
id: game,
|
||||
ids: unseen.map((c) => c.id),
|
||||
...actionMeta(),
|
||||
});
|
||||
}, [game, feed, onAction]);
|
||||
return (
|
||||
<section className="codes-page">
|
||||
<h2>Redeem codes</h2>
|
||||
<p className="help">
|
||||
New-code notices are separate from redemption. Mark codes redeemed for
|
||||
each account you play.
|
||||
</p>
|
||||
<p role="status">{message}</p>
|
||||
{feed &&
|
||||
freshness(
|
||||
feed.sources.filter((s) => s.game === game),
|
||||
Date.now(),
|
||||
).stale.length > 0 && (
|
||||
<p className="help">
|
||||
Code data may be out of date. Check the linked source before relying
|
||||
on a code's availability.
|
||||
</p>
|
||||
)}
|
||||
{!feed ? (
|
||||
<p>The codes feed is unavailable. Event tracking is still available.</p>
|
||||
) : !codes.length ? (
|
||||
<p>No published codes are available for this game.</p>
|
||||
) : (
|
||||
codes.map((c) => (
|
||||
<article className="code-row" key={c.id}>
|
||||
<div>
|
||||
<code>{c.code}</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(c.code);
|
||||
setMessage(`Copied ${c.code}.`);
|
||||
} catch {
|
||||
setMessage(
|
||||
"Could not copy automatically. Select the code to copy it.",
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
{c.reward && <p>{c.reward}</p>}
|
||||
<small>
|
||||
{c.expiresAt
|
||||
? `Expires ${formatInZone(c.expiresAt, true, typeof state.prefs.timezone === "string" ? state.prefs.timezone : null)}`
|
||||
: c.expiresOn
|
||||
? `Expires on ${c.expiresOn} (time not stated)`
|
||||
: "Expiry not announced"}{" "}
|
||||
·{" "}
|
||||
<a href={c.sourceUrl} target="_blank" rel="noreferrer">
|
||||
Source
|
||||
</a>
|
||||
</small>
|
||||
</div>
|
||||
<div>
|
||||
{accounts.map((a) => (
|
||||
<label key={a.id}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(state.codeRedeemed[a.id]?.[c.id])}
|
||||
onChange={(e) =>
|
||||
void onAction({
|
||||
type: "redeem",
|
||||
accountId: a.id,
|
||||
codeId: c.id,
|
||||
redeemed: e.target.checked,
|
||||
...actionMeta(),
|
||||
})
|
||||
}
|
||||
/>
|
||||
Redeemed · {a.region}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
))
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
import { useGameMeta } from "../../state/gameMeta.tsx";
|
||||
import { serverSuffix } from "../../state/reader/servers.ts";
|
||||
import type { CSSProperties } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { readerDayKey } from "../../../shared/readerClock.ts";
|
||||
import { formatRemaining, DAY, resetShiftMs } from "../../../shared/time.ts";
|
||||
import { streakOf } from "../../../shared/daily.ts";
|
||||
import { goalCount } from "../../../shared/goal.ts";
|
||||
import { chipLabel } from "../Dailies.tsx";
|
||||
import { Fireworks } from "../Fireworks.tsx";
|
||||
import { useSectionCelebration } from "./useSectionCelebration.ts";
|
||||
|
||||
import type { ReaderState } from "../../state/reader/schema.ts";
|
||||
import type { RoutineRow } from "../../state/reader/routines.ts";
|
||||
import { actionMeta } from "../../state/reader/actions.ts";
|
||||
import type { ActionHandler } from "./GameSettings.tsx";
|
||||
export function RoutineControl({
|
||||
row,
|
||||
onAction,
|
||||
now,
|
||||
}: {
|
||||
row: RoutineRow;
|
||||
onAction: ActionHandler;
|
||||
now: number;
|
||||
}) {
|
||||
const today = readerDayKey(now, row.clock);
|
||||
const [amount, setAmount] = useState(String(row.status.count));
|
||||
const editing = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!editing.current) setAmount(String(row.status.count));
|
||||
}, [row.status.count, row.status.periodStart]);
|
||||
return (
|
||||
<div className="routine-control">
|
||||
<div>
|
||||
<strong>{row.title}</strong>
|
||||
<small>
|
||||
{row.cadence === "day" ? "Today" : `This ${row.cadence}`} ·{" "}
|
||||
{row.status.count.toLocaleString()} /{" "}
|
||||
{row.status.required.toLocaleString()}
|
||||
</small>
|
||||
</div>
|
||||
{row.status.measure === "amount" ? (
|
||||
<label className="numeric-routine">
|
||||
<span className="sr-only">{row.title} progress</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
max="1000000000"
|
||||
value={amount}
|
||||
onFocus={() => {
|
||||
editing.current = true;
|
||||
}}
|
||||
onBlur={() => {
|
||||
editing.current = false;
|
||||
if (amount === "") setAmount(String(row.status.count));
|
||||
}}
|
||||
onChange={(e) => {
|
||||
setAmount(e.target.value);
|
||||
const value = e.target.valueAsNumber;
|
||||
if (Number.isInteger(value) && value >= 0)
|
||||
void onAction({
|
||||
type: "amount",
|
||||
accountId: row.accountId,
|
||||
revisionId: row.clock.revision,
|
||||
itemId: row.id,
|
||||
period: row.status.periodStart,
|
||||
value,
|
||||
...actionMeta(),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="daily-tick"
|
||||
aria-label={`${row.title}, ${row.days.includes(today) ? "done" : "not done"} today`}
|
||||
aria-pressed={row.days.includes(today)}
|
||||
onClick={() => toggleToday(row, today, onAction)}
|
||||
>
|
||||
<svg aria-hidden viewBox="0 0 16 16">
|
||||
<rect
|
||||
x="2"
|
||||
y="2"
|
||||
width="12"
|
||||
height="12"
|
||||
rx="3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
/>
|
||||
{row.days.includes(today) && (
|
||||
<path
|
||||
d="m4 8 3 3 5-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
/** Today's tick for a daily row: on, or off again. */
|
||||
function toggleToday(row: RoutineRow, today: string, onAction: ActionHandler) {
|
||||
void onAction({
|
||||
type: "day",
|
||||
accountId: row.accountId,
|
||||
revisionId: row.clock.revision,
|
||||
itemId: row.id,
|
||||
day: today,
|
||||
checked: !row.days.includes(today),
|
||||
...actionMeta(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A check-in goal: "5 days a week" and the like, as on Genshin's "To Temper
|
||||
* Thyself and Journey Far". A plain daily is a one-day event-scoped status.
|
||||
*/
|
||||
function hasGoal(row: RoutineRow): boolean {
|
||||
return (
|
||||
row.status.scope !== "day" &&
|
||||
!(row.status.scope === "event" && row.status.required === 1)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Done for now: ticked today, or its goal already met for the week (or the
|
||||
* event), as the live strip counts it. Nothing is ticked on a met goal's behalf.
|
||||
*/
|
||||
function doneToday(row: RoutineRow, today: string): boolean {
|
||||
if (row.status.measure === "amount") return row.status.met;
|
||||
return row.days.includes(today) || (hasGoal(row) && row.status.met);
|
||||
}
|
||||
|
||||
/**
|
||||
* Today's dailies in the Checklist rail. The whole row is the tick target, as
|
||||
* the live strip's pill is. A tick washes the row in the game's colour, pops
|
||||
* the box and draws the check. The live strip's fireworks go off when the last
|
||||
* one is ticked, under the same rule as live (see `Dailies.tsx`): only a tick
|
||||
* made just now that finishes the list counts, not a list that got shorter.
|
||||
*/
|
||||
export function DailyPanel({
|
||||
rows,
|
||||
state,
|
||||
onAction,
|
||||
now,
|
||||
onManage,
|
||||
}: {
|
||||
onManage?: () => void;
|
||||
rows: RoutineRow[];
|
||||
state: ReaderState;
|
||||
onAction: ActionHandler;
|
||||
now: number;
|
||||
}) {
|
||||
const meta = useGameMeta();
|
||||
const total = rows.length;
|
||||
const done = rows.filter((r) =>
|
||||
doneToday(r, readerDayKey(now, r.clock)),
|
||||
).length;
|
||||
const allDone = total > 0 && done === total;
|
||||
const burst = useSectionCelebration(total, done);
|
||||
// Rows ticked by a click in this panel; only those animate, so a reload
|
||||
// with everything already done doesn't replay every pop.
|
||||
const [fresh, setFresh] = useState<ReadonlySet<string>>(new Set());
|
||||
if (!rows.length) return null;
|
||||
return (
|
||||
<section
|
||||
className="daily-panel"
|
||||
data-all-done={allDone || undefined}
|
||||
data-burst={burst > 0 || undefined}
|
||||
>
|
||||
{burst > 0 && <Fireworks key={burst} grand />}
|
||||
<div className="daily-heading">
|
||||
<h2>Today's dailies</h2>
|
||||
{onManage && <button onClick={onManage}>Edit pins</button>}
|
||||
</div>
|
||||
<div className="daily-progress">
|
||||
<span className="daily-pips" aria-hidden="true">
|
||||
{rows.map((row) => (
|
||||
<i
|
||||
key={row.accountId + row.id}
|
||||
data-on={
|
||||
doneToday(row, readerDayKey(now, row.clock)) || undefined
|
||||
}
|
||||
style={
|
||||
{
|
||||
"--g": meta(state.accounts[row.accountId]!.game).hue,
|
||||
} as CSSProperties
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
<span className="daily-count" aria-live="polite">
|
||||
{allDone ? "All done for today" : `${done} of ${total} done`}
|
||||
</span>
|
||||
</div>
|
||||
{rows.map((row) => {
|
||||
const account = state.accounts[row.accountId]!;
|
||||
const s = resetShiftMs(account.region, account.game, row.clock),
|
||||
next = Math.floor((now + s) / DAY) * DAY + DAY - s;
|
||||
const today = readerDayKey(now, row.clock);
|
||||
const key = row.accountId + row.id;
|
||||
const ticked = row.days.includes(today);
|
||||
const isDone = doneToday(row, today);
|
||||
// Met this week without today's tick: done, drawn with a dotted check.
|
||||
const met = isDone && !ticked;
|
||||
const goal = hasGoal(row);
|
||||
const streak = streakOf(row.days, today);
|
||||
const label = (
|
||||
<span className="daily-text">
|
||||
<strong>
|
||||
{meta(account.game).short}
|
||||
{serverSuffix(state, account.game, account.region)}
|
||||
</strong>
|
||||
<small>
|
||||
{row.title} · reset {formatRemaining(next - now)}
|
||||
</small>
|
||||
</span>
|
||||
);
|
||||
const style = { "--g": meta(account.game).hue } as CSSProperties;
|
||||
if (row.status.measure === "amount")
|
||||
return (
|
||||
<div
|
||||
className="daily-row"
|
||||
key={key}
|
||||
style={style}
|
||||
data-done={isDone || undefined}
|
||||
>
|
||||
{label}
|
||||
<RoutineControl row={row} onAction={onAction} now={now} />
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="daily-row daily-hit"
|
||||
key={key}
|
||||
style={style}
|
||||
data-done={isDone || undefined}
|
||||
data-met={met || undefined}
|
||||
data-fresh={(ticked && fresh.has(key)) || undefined}
|
||||
aria-pressed={ticked}
|
||||
aria-label={chipLabel(
|
||||
`${meta(account.game).short} ${row.title}`,
|
||||
ticked,
|
||||
goal ? row.status : undefined,
|
||||
)}
|
||||
onClick={() => {
|
||||
setFresh((f) => {
|
||||
const n = new Set(f);
|
||||
if (ticked) n.delete(key);
|
||||
else n.add(key);
|
||||
return n;
|
||||
});
|
||||
toggleToday(row, today, onAction);
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
{/* One counter, as on live: a goal's count answers "is this week
|
||||
done?", so a streak beside it is beside the point. */}
|
||||
{goal ? (
|
||||
<span className="daily-tally">{goalCount(row.status)}</span>
|
||||
) : (
|
||||
streak > 1 && (
|
||||
<span className="daily-tally">{streak}-day streak</span>
|
||||
)
|
||||
)}
|
||||
<span className="daily-box" aria-hidden="true">
|
||||
<svg viewBox="0 0 16 16">
|
||||
<rect x="1.5" y="1.5" width="13" height="13" rx="3.5" />
|
||||
<path d="m4.4 8.2 2.5 2.5 4.7-5.2" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import { useState, type CSSProperties } from "react";
|
||||
import type { SourceHealth } from "../../../shared/feed.ts";
|
||||
import { freshness, STALE_AFTER_MS } from "../../../shared/feed.ts";
|
||||
import { formatRemaining } from "../../../shared/time.ts";
|
||||
import { formatInZone } from "../../state/displayTime.ts";
|
||||
import { useGameMeta } from "../../state/gameMeta.tsx";
|
||||
import type { RefreshOutcome } from "../../api.ts";
|
||||
import { siteFor } from "../Colophon.tsx";
|
||||
|
||||
/** The Round 6 B “Data & sources” pane: when data was refreshed, and each source's state. */
|
||||
export function DataSourcesPage({
|
||||
sources,
|
||||
now,
|
||||
timezone,
|
||||
online = true,
|
||||
onRefresh,
|
||||
showUpcoming,
|
||||
detectDaily,
|
||||
standardPulls,
|
||||
onPrefs,
|
||||
}: {
|
||||
/** The sources of the games the reader tracks. */
|
||||
sources: SourceHealth[];
|
||||
now: number;
|
||||
timezone: string | null;
|
||||
online?: boolean;
|
||||
onRefresh?: () => Promise<RefreshOutcome>;
|
||||
/** What the event lists include from this data (formerly Settings → Lists). */
|
||||
showUpcoming?: boolean;
|
||||
detectDaily?: boolean;
|
||||
standardPulls?: boolean;
|
||||
onPrefs?: (patch: Record<string, unknown>) => void;
|
||||
}) {
|
||||
const meta = useGameMeta();
|
||||
const [status, setStatus] = useState("");
|
||||
const [checking, setChecking] = useState(false);
|
||||
const when = (at: string | null, never: string) =>
|
||||
at
|
||||
? `${formatInZone(at, true, timezone)} · ${formatRemaining(now - Date.parse(at))} ago`
|
||||
: never;
|
||||
const { refreshedAt, stale } = freshness(sources, now);
|
||||
const games = [...new Set(sources.map((s) => s.game))];
|
||||
const listNames = (names: string[]) =>
|
||||
names.length < 2
|
||||
? (names[0] ?? "")
|
||||
: `${names.slice(0, -1).join(", ")} and ${names.at(-1)}`;
|
||||
const staleNames = stale.map((s) => meta(s.game).name);
|
||||
return (
|
||||
<section className="sources-page">
|
||||
<h1>Data & sources</h1>
|
||||
{onPrefs && (
|
||||
<section className="settings-group list-options">
|
||||
<h2>In your lists</h2>
|
||||
<label className="chk">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showUpcoming === true}
|
||||
onChange={(e) => onPrefs({ showUpcoming: e.target.checked })}
|
||||
/>
|
||||
<span>
|
||||
Show events that haven't started
|
||||
<small>
|
||||
Include upcoming events in the Checklist and on the Timeline.
|
||||
</small>
|
||||
</span>
|
||||
</label>
|
||||
<label className="chk">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={detectDaily === true}
|
||||
onChange={(e) => onPrefs({ detectDaily: e.target.checked })}
|
||||
/>
|
||||
<span>
|
||||
Detect daily events from source descriptions
|
||||
<small>
|
||||
Treat events whose source says they run daily as daily
|
||||
check-ins.
|
||||
</small>
|
||||
</span>
|
||||
</label>
|
||||
<label className="chk">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={standardPulls === true}
|
||||
onChange={(e) => onPrefs({ standardPulls: e.target.checked })}
|
||||
/>
|
||||
<span>
|
||||
Offer standard-banner pulls in the library
|
||||
<small>Premium pulls are always offered.</small>
|
||||
</span>
|
||||
</label>
|
||||
</section>
|
||||
)}
|
||||
<h2 className="sources-heading">Sources</h2>
|
||||
<p className="muted-copy">
|
||||
The source check and its last content update are separate. A recently
|
||||
checked page may still hold an older schedule. Dates are shown in{" "}
|
||||
{timezone ?? "your device's timezone"}; the source page is the
|
||||
authority, so check it before an event's final hours.
|
||||
</p>
|
||||
{onRefresh && (
|
||||
<div className="acts">
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
disabled={!online || checking}
|
||||
onClick={async () => {
|
||||
setChecking(true);
|
||||
const result = await onRefresh();
|
||||
setStatus(
|
||||
result === "partial"
|
||||
? "Event data checked; redeem codes could not be checked. Try again later."
|
||||
: result === "updated"
|
||||
? "Published data updated."
|
||||
: result === "same"
|
||||
? "Nothing newer has been published yet."
|
||||
: "The server could not be reached. Your saved data is still available.",
|
||||
);
|
||||
setChecking(false);
|
||||
}}
|
||||
>
|
||||
{checking ? "Checking…" : "Check for new data"}
|
||||
</button>
|
||||
<span role="status" className="help">
|
||||
{!online
|
||||
? "You're offline. This is the last downloaded event data."
|
||||
: status}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<p className="source-note">
|
||||
Event data last refreshed{" "}
|
||||
{refreshedAt ? when(refreshedAt, "") : "at an unrecorded time"}. These
|
||||
rows cover the games you have switched on.
|
||||
</p>
|
||||
{staleNames.length > 0 && (
|
||||
<p className="source-warning">
|
||||
{listNames(staleNames)}{" "}
|
||||
{staleNames.length === 1 ? "has a source" : "each have a source"} that
|
||||
has not refreshed in over two days, so end dates there may have moved.
|
||||
</p>
|
||||
)}
|
||||
{games.map((game) => (
|
||||
<div
|
||||
className="source-row"
|
||||
key={game}
|
||||
style={{ "--g": meta(game).hue } as CSSProperties}
|
||||
>
|
||||
<strong>{meta(game).name}</strong>
|
||||
<div>
|
||||
{sources
|
||||
.filter((s) => s.game === game)
|
||||
.map((s) => {
|
||||
const checked =
|
||||
[s.lastConfirmedAt, s.lastSuccessAt]
|
||||
.filter((d): d is string => d !== null)
|
||||
.sort()
|
||||
.at(-1) ?? null;
|
||||
const reason = sourceReason(s, now);
|
||||
return (
|
||||
<div className="source-entry" key={s.sourceId}>
|
||||
{sources.filter((x) => x.game === game).length > 1 && (
|
||||
<p className="source-name">{siteFor(s.url).name}</p>
|
||||
)}
|
||||
<p>Last successful check: {when(checked, "Never")}</p>
|
||||
<p>
|
||||
Source content last changed:{" "}
|
||||
{when(
|
||||
s.contentChangedAt ?? s.lastSuccessAt,
|
||||
"Not recorded",
|
||||
)}
|
||||
</p>
|
||||
<p className={`reason${reason.current ? " current" : ""}`}>
|
||||
{reason.text}
|
||||
</p>
|
||||
<a href={s.url} target="_blank" rel="noreferrer">
|
||||
Check source page
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** One sentence on a source's state, in the mockup's four cases. */
|
||||
export function sourceReason(
|
||||
s: SourceHealth,
|
||||
now: number,
|
||||
): { text: string; current: boolean } {
|
||||
const recent = (at: string | null) =>
|
||||
at !== null && now - Date.parse(at) <= STALE_AFTER_MS;
|
||||
if (s.lastSuccessAt === null && s.lastConfirmedAt === null)
|
||||
return {
|
||||
text: "No successful source fetch has been recorded. A bundled sample schedule is being held.",
|
||||
current: false,
|
||||
};
|
||||
if (recent(s.lastSuccessAt) && recent(s.contentChangedAt ?? s.lastSuccessAt))
|
||||
return {
|
||||
text: "Source check succeeded; the held schedule is recent.",
|
||||
current: true,
|
||||
};
|
||||
if (recent(s.lastConfirmedAt) || recent(s.lastSuccessAt))
|
||||
return {
|
||||
text: "Site has no new updates. The latest check succeeded, but the page has not changed.",
|
||||
current: false,
|
||||
};
|
||||
return {
|
||||
text: "Pull overdue. No recent successful source check; the cause was not reported.",
|
||||
current: false,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useState } from "react";
|
||||
import { freshness, type SourceHealth } from "../../../shared/feed.ts";
|
||||
import { formatInZone } from "../../state/displayTime.ts";
|
||||
import type { RefreshOutcome } from "../../api.ts";
|
||||
export function FeedFooter({
|
||||
sources,
|
||||
now,
|
||||
online,
|
||||
timezone,
|
||||
onRefresh,
|
||||
onAbout,
|
||||
}: {
|
||||
sources: SourceHealth[];
|
||||
now: number;
|
||||
online: boolean;
|
||||
timezone: string | null;
|
||||
onRefresh: () => Promise<RefreshOutcome>;
|
||||
onAbout: () => void;
|
||||
}) {
|
||||
const [status, setStatus] = useState("");
|
||||
const [checking, setChecking] = useState(false);
|
||||
const at = freshness(sources, now).refreshedAt;
|
||||
return (
|
||||
<footer className="feed-footer">
|
||||
<p>
|
||||
Event data last refreshed{" "}
|
||||
{at ? formatInZone(at, true, timezone) : "at an unrecorded time"}. Dates
|
||||
are shown in {timezone ?? "your device's timezone"}.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!online || checking}
|
||||
onClick={async () => {
|
||||
setChecking(true);
|
||||
const result = await onRefresh();
|
||||
setStatus(
|
||||
result === "partial"
|
||||
? "Event data checked; redeem codes could not be checked. Try again later."
|
||||
: result === "updated"
|
||||
? "Published data updated."
|
||||
: result === "same"
|
||||
? "Nothing newer has been published yet."
|
||||
: "The server could not be reached. Your saved data is still available.",
|
||||
);
|
||||
setChecking(false);
|
||||
}}
|
||||
>
|
||||
{checking ? "Checking…" : "Check for new data"}
|
||||
</button>
|
||||
<button type="button" onClick={onAbout}>
|
||||
About Event Clock
|
||||
</button>
|
||||
<span role="status">{status}</span>
|
||||
{!online && (
|
||||
<p>
|
||||
You're offline. This is the last downloaded event data; changes since
|
||||
then may be missing.
|
||||
</p>
|
||||
)}
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { CSSProperties } from "react";
|
||||
import { useGameMeta } from "../../state/gameMeta.tsx";
|
||||
import type { ComponentProps } from "react";
|
||||
import type { GameFocus } from "../GameFocus.tsx";
|
||||
export function FocusPicker({
|
||||
games,
|
||||
focus,
|
||||
counts,
|
||||
total,
|
||||
next,
|
||||
onFocus,
|
||||
onAdvance,
|
||||
}: ComponentProps<typeof GameFocus>) {
|
||||
const meta = useGameMeta();
|
||||
if (games.length < 2) return null;
|
||||
return (
|
||||
<section className="game-focus">
|
||||
<div className="focus-heading">
|
||||
<h2>Focus</h2>
|
||||
<button onClick={onAdvance}>
|
||||
{next === null ? "Show all games" : `Next: ${meta(next).short}`}{" "}
|
||||
<span aria-hidden="true">›</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="focus-pills" role="group" aria-label="Focus on one game">
|
||||
{[null, ...games].map((g) => (
|
||||
<button
|
||||
key={g ?? "all"}
|
||||
className="focus-pill"
|
||||
style={
|
||||
{ "--hue": g ? meta(g).hue : "var(--color-ink)" } as CSSProperties
|
||||
}
|
||||
aria-label={g ? meta(g).name : "All games"}
|
||||
aria-pressed={focus === g}
|
||||
data-empty={(g !== null && (counts[g] ?? 0) === 0) || undefined}
|
||||
onClick={() => onFocus(focus === g ? null : g)}
|
||||
>
|
||||
{g ? meta(g).short : "All"}
|
||||
<span className="n">{g ? (counts[g] ?? 0) : total}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** `prefs.focusBar`: where the focus bar appears. Absent means both views. */
|
||||
export function focusBarShown(
|
||||
prefs: Record<string, unknown>,
|
||||
view: string,
|
||||
): boolean {
|
||||
const bar = prefs.focusBar;
|
||||
if (typeof bar !== "object" || bar === null) return true;
|
||||
const key = view === "timeline" ? "timeline" : "checklist";
|
||||
return (bar as Record<string, unknown>)[key] !== false;
|
||||
}
|
||||
@@ -0,0 +1,711 @@
|
||||
import { useGameMeta } from "../../state/gameMeta.tsx";
|
||||
import { mintAccountId, mintLocalId } from "../../state/reader/ids.ts";
|
||||
import { orderGames } from "../../state/gameOrder.ts";
|
||||
import type { CodesFeed } from "../../../shared/codes.ts";
|
||||
import { CodesPage } from "./CodesPage.tsx";
|
||||
import { useState } from "react";
|
||||
import { metaFor } from "../../../shared/games.ts";
|
||||
import { routinesFor } from "../../../shared/routines.ts";
|
||||
import { REGION_LABEL } from "../../../shared/time.ts";
|
||||
import type { SourceHealth } from "../../../shared/feed.ts";
|
||||
import type { Region } from "../../../shared/schema.ts";
|
||||
import type {
|
||||
ReaderState,
|
||||
SaveResult,
|
||||
Account,
|
||||
TrackingIntent,
|
||||
} from "../../state/reader/schema.ts";
|
||||
import type { ReaderAction } from "../../state/reader/reducer.ts";
|
||||
import {
|
||||
createAccount,
|
||||
changeAccountRegion,
|
||||
retireAccount,
|
||||
} from "../../state/reader/accounts.ts";
|
||||
import { actionMeta, recordOf } from "../../state/reader/actions.ts";
|
||||
import { ResetTimingRow } from "./ResetTimingRow.tsx";
|
||||
import { newCodes } from "../../state/reader/codes.ts";
|
||||
import { useContext } from "react";
|
||||
import { DisplayTimeContext, formatInZone } from "../../state/displayTime.ts";
|
||||
import { resolveReaderClock } from "../../../shared/readerClock.ts";
|
||||
import { DAY, formatRemaining, resetShiftMs } from "../../../shared/time.ts";
|
||||
export type ActionHandler = (action: ReaderAction) => Promise<SaveResult>;
|
||||
const hostOf = (url: string) => {
|
||||
try {
|
||||
return new URL(url).hostname.replace(/^www\./, "");
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
};
|
||||
export function GameSettings({
|
||||
state,
|
||||
sources,
|
||||
onAction,
|
||||
codes = null,
|
||||
initialGame,
|
||||
initialTab = "setup",
|
||||
}: {
|
||||
state: ReaderState;
|
||||
sources: SourceHealth[];
|
||||
onAction: ActionHandler;
|
||||
codes?: CodesFeed | null;
|
||||
initialGame?: string | undefined;
|
||||
initialTab?: "setup" | "codes";
|
||||
}) {
|
||||
const renderedMeta = useGameMeta();
|
||||
const games = orderGames(
|
||||
[...new Set(Object.values(state.accounts).map((a) => a.game))],
|
||||
Array.isArray(state.prefs.gameOrder)
|
||||
? (state.prefs.gameOrder as string[])
|
||||
: undefined,
|
||||
(id) =>
|
||||
metaFor(id, state.customGames as Parameters<typeof metaFor>[1]).name,
|
||||
);
|
||||
const [selected] = useState(
|
||||
initialGame ??
|
||||
games.find(
|
||||
(g) =>
|
||||
!(
|
||||
Array.isArray(state.prefs.hiddenGames) &&
|
||||
state.prefs.hiddenGames.includes(g)
|
||||
),
|
||||
) ??
|
||||
games[0] ??
|
||||
"",
|
||||
);
|
||||
const [tab, setTab] = useState<string>(initialTab);
|
||||
const [trackingAccount, setTrackingAccount] = useState<string | null>(null);
|
||||
const [addingServer, setAddingServer] = useState(false);
|
||||
const [server, setServer] = useState<Region>("asia");
|
||||
const [message, setMessage] = useState("");
|
||||
const accounts = Object.values(state.accounts).filter(
|
||||
(a) => a.game === selected && !a.retiredAt,
|
||||
);
|
||||
const activeAccount =
|
||||
accounts.find((a) => a.id === trackingAccount) ?? accounts[0];
|
||||
const game = metaFor(
|
||||
selected,
|
||||
state.customGames as Parameters<typeof metaFor>[1],
|
||||
);
|
||||
const zone = useContext(DisplayTimeContext),
|
||||
now = Date.now(),
|
||||
short = renderedMeta(selected).short;
|
||||
const freshCodes = codes
|
||||
? newCodes(codes, state.codeSeen[selected] ?? [], selected).length
|
||||
: 0;
|
||||
return (
|
||||
<section className="game-settings" style={{ ["--g" as string]: game.hue }}>
|
||||
{initialGame ? (
|
||||
<div className="ptitle">
|
||||
<h1 style={{ color: renderedMeta(selected).hue }}>{game.name}</h1>
|
||||
{game.studio && <span className="studio">{game.studio}</span>}
|
||||
<button
|
||||
type="button"
|
||||
className="btn sm"
|
||||
onClick={() => {
|
||||
const hidden = Array.isArray(state.prefs.hiddenGames)
|
||||
? (state.prefs.hiddenGames as string[])
|
||||
: [];
|
||||
const following = !hidden.includes(selected);
|
||||
// Following a game with nothing tracked starts with its events.
|
||||
if (!following)
|
||||
for (const a of accounts.filter(
|
||||
(a) =>
|
||||
!a.intent.events &&
|
||||
!Object.values(a.routines).some((r) => r.followed),
|
||||
))
|
||||
void onAction({
|
||||
type: "account",
|
||||
account: { ...a, intent: { ...a.intent, events: true } },
|
||||
...actionMeta(),
|
||||
});
|
||||
void onAction({
|
||||
type: "prefs",
|
||||
patch: {
|
||||
hiddenGames: following
|
||||
? [...hidden, selected]
|
||||
: hidden.filter((g) => g !== selected),
|
||||
},
|
||||
...actionMeta(),
|
||||
});
|
||||
}}
|
||||
>
|
||||
{Array.isArray(state.prefs.hiddenGames) &&
|
||||
state.prefs.hiddenGames.includes(selected)
|
||||
? "Follow game"
|
||||
: "Stop following game"}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="subtabs">
|
||||
{["setup", "codes", "about"].map((id) => (
|
||||
<button
|
||||
type="button"
|
||||
key={id}
|
||||
aria-pressed={tab === id}
|
||||
onClick={() => setTab(id)}
|
||||
>
|
||||
{id[0]!.toUpperCase() + id.slice(1)}
|
||||
{id === "codes" && freshCodes > 0 && (
|
||||
<span className="new-code">{freshCodes} new</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{tab === "about" ? (
|
||||
<div className="game-about">
|
||||
<h3 className="about-h">About {game.name}</h3>
|
||||
<div className="grp">
|
||||
{game.studio && (
|
||||
<div className="trow">
|
||||
<span>Studio</span>
|
||||
<b>{game.studio}</b>
|
||||
</div>
|
||||
)}
|
||||
{accounts.map((a) => {
|
||||
const clock = resolveReaderClock(a, Math.max(now, Date.now())),
|
||||
shift = resetShiftMs(clock.region, a.game, clock),
|
||||
next = Math.floor((now + shift) / DAY) * DAY + DAY - shift;
|
||||
return (
|
||||
<div className="trow" key={a.id}>
|
||||
<span>{REGION_LABEL[a.region]} reset</span>
|
||||
<span>
|
||||
{formatInZone(next, true, zone)} ·{" "}
|
||||
{a.clocks.at(-1)!.reset === null
|
||||
? "automatic"
|
||||
: "custom override"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<section className="grp">
|
||||
<h3>Data sources</h3>
|
||||
{sources.filter((s) => s.game === selected).length === 0 ? (
|
||||
<p className="muted-copy">No built-in source for this game.</p>
|
||||
) : (
|
||||
<>
|
||||
{sources
|
||||
.filter((s) => s.game === selected)
|
||||
.map((src) => (
|
||||
<p className="game-source" key={src.sourceId}>
|
||||
<a href={src.url} target="_blank" rel="noreferrer">
|
||||
{hostOf(src.url)}
|
||||
</a>
|
||||
<small>
|
||||
{/banner/i.test(src.sourceId)
|
||||
? "Banner schedule"
|
||||
: /pass|version|nameless|fund/i.test(src.sourceId)
|
||||
? "Battle pass schedule"
|
||||
: "Event schedule"}
|
||||
</small>
|
||||
</p>
|
||||
))}
|
||||
<p className="muted-copy">
|
||||
Thanks to the editors at{" "}
|
||||
{[
|
||||
...new Set(
|
||||
sources
|
||||
.filter((s) => s.game === selected)
|
||||
.map((src) => hostOf(src.url)),
|
||||
),
|
||||
].join(" and ")}{" "}
|
||||
for compiling and maintaining these schedules. Event Clock
|
||||
reads their work; their source pages are the authority.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn sm"
|
||||
onClick={() => {
|
||||
location.hash = "#settings/sources";
|
||||
}}
|
||||
>
|
||||
Source status and freshness
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
) : tab === "codes" ? (
|
||||
<CodesPage
|
||||
game={selected}
|
||||
feed={codes}
|
||||
state={state}
|
||||
onAction={onAction}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{activeAccount && (
|
||||
<section className="grp profile-tracking">
|
||||
<h3>
|
||||
What to track
|
||||
<span>
|
||||
Any combination. Each one follows or unfollows that kind of
|
||||
built-in for {short}
|
||||
{accounts.length > 1
|
||||
? ` on the ${REGION_LABEL[activeAccount.region]} server`
|
||||
: ""}
|
||||
.
|
||||
</span>
|
||||
</h3>
|
||||
{accounts.length > 1 && (
|
||||
<div className="tracking-account-choice">
|
||||
<span>Tracking for</span>
|
||||
<div className="segmented">
|
||||
{accounts.map((a) => (
|
||||
<button
|
||||
key={a.id}
|
||||
aria-pressed={a.id === activeAccount.id}
|
||||
onClick={() => setTrackingAccount(a.id)}
|
||||
>
|
||||
{REGION_LABEL[a.region]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<AccountSetup
|
||||
key={activeAccount.id + "tracking"}
|
||||
account={activeAccount}
|
||||
onAction={onAction}
|
||||
onTracked={() =>
|
||||
void onAction({
|
||||
type: "prefs",
|
||||
patch: {
|
||||
hiddenGames: (Array.isArray(state.prefs.hiddenGames)
|
||||
? state.prefs.hiddenGames
|
||||
: []
|
||||
).filter((g) => g !== selected),
|
||||
},
|
||||
...actionMeta(),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<div className="compnote">
|
||||
<span>
|
||||
Fine-tune single routines, and where they show, on the
|
||||
Routines tab.
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
location.hash = "#routines";
|
||||
}}
|
||||
>
|
||||
Fine-tune in Routines →
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
<section className="grp profile-servers">
|
||||
<h3>
|
||||
Servers
|
||||
<span>
|
||||
Play on more than one server? Add your other server to track
|
||||
both accounts separately.
|
||||
</span>
|
||||
</h3>
|
||||
{accounts.map((a, index) => {
|
||||
const clock = resolveReaderClock(a, Math.max(now, Date.now())),
|
||||
shift = resetShiftMs(clock.region, a.game, clock),
|
||||
next = Math.floor((now + shift) / DAY) * DAY + DAY - shift,
|
||||
reset = a.clocks.at(-1)!.reset;
|
||||
return (
|
||||
<div className="srvrow" key={a.id}>
|
||||
<span className="tagn">{index === 0 ? "Main" : "Also"}</span>
|
||||
<div className="clock">
|
||||
<b>{REGION_LABEL[a.region]}</b> ·{" "}
|
||||
{reset === null ? "automatic" : "custom"}{" "}
|
||||
{String(clock.resetHour).padStart(2, "0")}:
|
||||
{String(clock.resetMinute).padStart(2, "0")} reset ·{" "}
|
||||
{formatInZone(next, true, zone)} · in{" "}
|
||||
{formatRemaining(next - now)}
|
||||
<small>
|
||||
Separate dailies and routines for this server.
|
||||
</small>
|
||||
</div>
|
||||
<div className="r">
|
||||
<select
|
||||
aria-label={`${REGION_LABEL[a.region]} account server`}
|
||||
value={a.region}
|
||||
onChange={(e) =>
|
||||
void onAction({
|
||||
type: "account",
|
||||
account: changeAccountRegion(
|
||||
a,
|
||||
e.target.value as Region,
|
||||
new Date().toISOString(),
|
||||
mintLocalId(),
|
||||
),
|
||||
...actionMeta(),
|
||||
})
|
||||
}
|
||||
>
|
||||
{(["europe", "america", "asia"] as const).map((r) => (
|
||||
<option
|
||||
key={r}
|
||||
value={r}
|
||||
disabled={accounts.some(
|
||||
(other) => other.id !== a.id && other.region === r,
|
||||
)}
|
||||
>
|
||||
{REGION_LABEL[r]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{index > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn sm"
|
||||
onClick={() =>
|
||||
void onAction({
|
||||
type: "account",
|
||||
account: retireAccount(a, new Date().toISOString()),
|
||||
...actionMeta(),
|
||||
})
|
||||
}
|
||||
>
|
||||
Remove
|
||||
<span className="sr-only">
|
||||
{" "}
|
||||
{REGION_LABEL[a.region]} server
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{Object.values(state.accounts)
|
||||
.filter((a) => a.game === selected && a.retiredAt)
|
||||
.map((a) => (
|
||||
<p key={a.id}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={accounts.some(
|
||||
(other) => other.region === a.region,
|
||||
)}
|
||||
title={
|
||||
accounts.some((other) => other.region === a.region)
|
||||
? "This server is already tracked. Use its active setup above."
|
||||
: undefined
|
||||
}
|
||||
onClick={() => {
|
||||
const resumed = { ...a };
|
||||
delete resumed.retiredAt;
|
||||
void onAction({
|
||||
type: "account",
|
||||
account: resumed,
|
||||
...actionMeta(),
|
||||
});
|
||||
}}
|
||||
>
|
||||
Resume {REGION_LABEL[a.region]} server
|
||||
</button>
|
||||
<span className="help">
|
||||
{" "}
|
||||
· your recorded progress is kept
|
||||
</span>
|
||||
</p>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="btn link server-add-toggle"
|
||||
aria-expanded={addingServer}
|
||||
onClick={() => setAddingServer(!addingServer)}
|
||||
>
|
||||
Track another server
|
||||
</button>
|
||||
{addingServer && (
|
||||
<section className="another-server">
|
||||
<h3>Add a server</h3>
|
||||
<p>
|
||||
Play on more than one server? Add your other server to track
|
||||
both accounts separately.
|
||||
</p>
|
||||
<p>
|
||||
You'll choose what to track again. Progress and routine pins
|
||||
start fresh.
|
||||
</p>
|
||||
<select
|
||||
aria-label="Another server"
|
||||
value={server}
|
||||
onChange={(e) => setServer(e.target.value as Region)}
|
||||
>
|
||||
{(["europe", "america", "asia"] as const).map((r) => (
|
||||
<option value={r} key={r}>
|
||||
{REGION_LABEL[r]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
if (accounts.some((a) => a.region === server)) {
|
||||
setMessage(
|
||||
"You already track this server. Use its setup above.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const a = createAccount(
|
||||
selected,
|
||||
server,
|
||||
new Date().toISOString(),
|
||||
mintAccountId(),
|
||||
);
|
||||
const r = await onAction({
|
||||
type: "account",
|
||||
account: a,
|
||||
...actionMeta(),
|
||||
});
|
||||
setMessage(
|
||||
r.ok
|
||||
? "Server added. Choose what to track in its setup above."
|
||||
: "Could not save this server. Your other accounts are unchanged.",
|
||||
);
|
||||
}}
|
||||
>
|
||||
Add server
|
||||
</button>
|
||||
<p role="status">{message}</p>
|
||||
</section>
|
||||
)}
|
||||
</section>
|
||||
<section className="grp game-colour-group">
|
||||
<h3>
|
||||
Game colour
|
||||
<span>
|
||||
Used for {short} in every view and on all its servers.
|
||||
</span>
|
||||
</h3>
|
||||
<div className="game-colour">
|
||||
<input
|
||||
aria-label={`${game.name} colour`}
|
||||
type="color"
|
||||
value={String(
|
||||
recordOf(state.prefs.gameColors)[selected] ?? game.hue,
|
||||
)}
|
||||
onChange={(e) =>
|
||||
void onAction({
|
||||
type: "prefs",
|
||||
patch: {
|
||||
gameColors: {
|
||||
...recordOf(state.prefs.gameColors),
|
||||
[selected]: e.target.value,
|
||||
},
|
||||
},
|
||||
...actionMeta(),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<input
|
||||
aria-label={`${game.name} hex colour`}
|
||||
type="text"
|
||||
key={
|
||||
selected + String(recordOf(state.prefs.gameColors)[selected])
|
||||
}
|
||||
defaultValue={String(
|
||||
recordOf(state.prefs.gameColors)[selected] ?? game.hue,
|
||||
)}
|
||||
pattern="#[0-9a-fA-F]{6}"
|
||||
maxLength={7}
|
||||
onBlur={(e) => {
|
||||
if (/^#[0-9a-fA-F]{6}$/.test(e.target.value))
|
||||
void onAction({
|
||||
type: "prefs",
|
||||
patch: {
|
||||
gameColors: {
|
||||
...recordOf(state.prefs.gameColors),
|
||||
[selected]: e.target.value,
|
||||
},
|
||||
},
|
||||
...actionMeta(),
|
||||
});
|
||||
else
|
||||
e.target.value = String(
|
||||
recordOf(state.prefs.gameColors)[selected] ?? game.hue,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn sm"
|
||||
onClick={() => {
|
||||
const colors = { ...recordOf(state.prefs.gameColors) };
|
||||
delete colors[selected];
|
||||
void onAction({
|
||||
type: "prefs",
|
||||
patch: { gameColors: colors },
|
||||
...actionMeta(),
|
||||
});
|
||||
}}
|
||||
>
|
||||
Reset to default
|
||||
</button>
|
||||
<span
|
||||
className="colour-preview"
|
||||
style={{ color: renderedMeta(selected).hue }}
|
||||
>
|
||||
{short} event
|
||||
</span>
|
||||
</div>
|
||||
<p className="source-note">
|
||||
The preview adjusts for readable contrast. Your selected base
|
||||
colour stays saved.
|
||||
</p>
|
||||
</section>
|
||||
{activeAccount && (
|
||||
<section className="grp profile-reset">
|
||||
<h3>
|
||||
Reset timing
|
||||
<span>
|
||||
Correct the reset time if needed; Automatic restores the
|
||||
listed clock. Earlier progress is kept.
|
||||
</span>
|
||||
</h3>
|
||||
{accounts.map((a) => (
|
||||
<ResetTimingRow
|
||||
key={a.id}
|
||||
account={a}
|
||||
label={`${short}${accounts.length > 1 ? ` · ${REGION_LABEL[a.region]}` : ""}`}
|
||||
now={now}
|
||||
onAction={onAction}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
function AccountSetup({
|
||||
account: a,
|
||||
onAction,
|
||||
onTracked,
|
||||
}: {
|
||||
account: Account;
|
||||
onAction: ActionHandler;
|
||||
onTracked: () => void;
|
||||
}) {
|
||||
const [draft, setDraft] = useState<TrackingIntent>(a.intent);
|
||||
const builtins = routinesFor(a.game);
|
||||
const availability = {
|
||||
dailies: !a.game.startsWith("mygame:"),
|
||||
weeklyMonthly: builtins.some((r) => r.period.unit !== "cycle"),
|
||||
cycles: builtins.some((r) => r.period.unit === "cycle"),
|
||||
};
|
||||
/**
|
||||
* Saved on each click. Only the kind that changed touches its routines, so
|
||||
* turning one kind on or off never resets pins chosen for the others on the
|
||||
* Routines tab.
|
||||
*/
|
||||
const apply = async (
|
||||
next: TrackingIntent,
|
||||
force?: "dailies" | "weeklyMonthly" | "cycles",
|
||||
) => {
|
||||
const changed = (
|
||||
["events", "dailies", "weeklyMonthly", "cycles"] as const
|
||||
).filter((k) => next[k] !== draft[k] || k === force);
|
||||
setDraft(next);
|
||||
const placements = { ...a.routines };
|
||||
const place = (id: string, followed: boolean) => {
|
||||
placements[id] = { followed, checklist: followed, timeline: followed };
|
||||
};
|
||||
if (changed.includes("dailies")) place(`dailies:${a.game}`, next.dailies);
|
||||
for (const r of builtins) {
|
||||
const kind = r.period.unit === "cycle" ? "cycles" : "weeklyMonthly";
|
||||
if (changed.includes(kind)) place(r.id, next[kind]);
|
||||
}
|
||||
const result = await onAction({
|
||||
type: "account",
|
||||
account: { ...a, intent: next, routines: placements },
|
||||
...actionMeta(),
|
||||
});
|
||||
if (result.ok) onTracked();
|
||||
else setDraft(draft);
|
||||
};
|
||||
return (
|
||||
<section className="account-setup">
|
||||
<div className="comp" role="group" aria-label="What to track">
|
||||
{(
|
||||
[
|
||||
{
|
||||
id: "events",
|
||||
label: "Events",
|
||||
about: "This game's events in Checklist and Timeline",
|
||||
ids: [] as string[],
|
||||
},
|
||||
{
|
||||
id: "dailies",
|
||||
label: "Dailies",
|
||||
about:
|
||||
"Chores that reset each game-day, such as daily commissions",
|
||||
ids: availability.dailies ? [`dailies:${a.game}`] : [],
|
||||
},
|
||||
{
|
||||
id: "weeklyMonthly",
|
||||
label: "Weeklies & monthlies",
|
||||
about:
|
||||
"Weekly goals and monthly restocks, such as bosses and shop pulls",
|
||||
ids: builtins
|
||||
.filter((r) => r.period.unit !== "cycle")
|
||||
.map((r) => r.id),
|
||||
},
|
||||
{
|
||||
id: "cycles",
|
||||
label: "Cycles",
|
||||
about:
|
||||
"Routines on a game-specific schedule, such as a 14-day endgame mode",
|
||||
ids: builtins
|
||||
.filter((r) => r.period.unit === "cycle")
|
||||
.map((r) => r.id),
|
||||
},
|
||||
] as const
|
||||
).map((k) => {
|
||||
const total = k.ids.length,
|
||||
followed = k.ids.filter((id) => a.routines[id]?.followed).length;
|
||||
// Partly followed: some built-ins of this kind were fine-tuned
|
||||
// on the Routines tab. A click follows the rest.
|
||||
const some = followed > 0 && followed < total;
|
||||
const on = k.id === "events" ? draft.events : draft[k.id];
|
||||
const line =
|
||||
k.id === "events"
|
||||
? on
|
||||
? "Shown"
|
||||
: "Hidden"
|
||||
: total === 0
|
||||
? "none built in"
|
||||
: some
|
||||
? "Some, fine-tuned"
|
||||
: `${total} built in`;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={k.id}
|
||||
aria-pressed={some ? "mixed" : on}
|
||||
data-st={some ? "some" : on ? "on" : "off"}
|
||||
data-empty={k.id !== "events" && total === 0}
|
||||
onClick={() =>
|
||||
k.id === "events"
|
||||
? void apply({ ...draft, events: !draft.events })
|
||||
: void apply({ ...draft, [k.id]: some ? true : !on }, k.id)
|
||||
}
|
||||
>
|
||||
<span className="cbx" aria-hidden="true">
|
||||
{on && !some && (
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path d="M5 12.5l4.5 4.5L19 7" />
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
<span className="ln">{k.label}</span>
|
||||
<span className="ls">{k.about}</span>
|
||||
<span className="ls">{line}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
import type { CSSProperties } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { readerDayKey } from "../../../shared/readerClock.ts";
|
||||
import { routinesFor } from "../../../shared/routines.ts";
|
||||
import { formatRemaining } from "../../../shared/time.ts";
|
||||
import type { RoutineRow } from "../../state/reader/routines.ts";
|
||||
import { actionMeta } from "../../state/reader/actions.ts";
|
||||
import { Fireworks } from "../Fireworks.tsx";
|
||||
import type { ActionHandler } from "./GameSettings.tsx";
|
||||
import { routinePeriod } from "./TimelinePage.tsx";
|
||||
|
||||
/** The latest logged day inside the routine's current period, if any. */
|
||||
export function lastDayInPeriod(row: RoutineRow, today: string): string | null {
|
||||
const inPeriod = row.days
|
||||
.filter((d) => d >= row.status.periodStart && d <= today)
|
||||
.sort();
|
||||
return inPeriod.at(-1) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A pinned routine in the Checklist rail, drawn like a daily: a row that
|
||||
* washes in the game's colour once its target is met, with the live
|
||||
* fireworks when an action here completes it.
|
||||
*
|
||||
* - Check-ins: the box ticks today. Unticking a met routine removes the
|
||||
* period's latest tick, which may be from an earlier day.
|
||||
* - Amounts: the box completes it in one go (the amount becomes the target),
|
||||
* "+1" steps it, and the count opens an editor. Unticking a completed
|
||||
* amount puts back the value it had before, when this session saw it;
|
||||
* otherwise it opens the editor so the reader says where they are.
|
||||
*/
|
||||
export function PinnedRoutine({
|
||||
row,
|
||||
hue,
|
||||
onAction,
|
||||
now,
|
||||
}: {
|
||||
row: RoutineRow;
|
||||
hue: string;
|
||||
onAction: ActionHandler;
|
||||
now: number;
|
||||
}) {
|
||||
const today = readerDayKey(now, row.clock);
|
||||
const amount = row.status.measure === "amount";
|
||||
const met = row.status.met;
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [burst, setBurst] = useState(0);
|
||||
// The amount before a completing action, for undo.
|
||||
const [before, setBefore] = useState<number | null>(null);
|
||||
// Set by an action in this row, so only a completion made here celebrates,
|
||||
// never one that arrives with a reload or a new period.
|
||||
const acted = useRef(false);
|
||||
const wasMet = useRef(met);
|
||||
useEffect(() => {
|
||||
if (met && !wasMet.current && acted.current) setBurst((n) => n + 1);
|
||||
wasMet.current = met;
|
||||
acted.current = false;
|
||||
}, [met]);
|
||||
useEffect(() => {
|
||||
if (burst === 0) return;
|
||||
// Longer than the live strip's 1400ms: the sparks wait for the box.
|
||||
const id = setTimeout(() => setBurst(0), 1800);
|
||||
return () => clearTimeout(id);
|
||||
}, [burst]);
|
||||
|
||||
const task = row.task ?? routinesFor(row.game).find((r) => r.id === row.id);
|
||||
const unit = task?.measure.kind === "amount" ? (task.measure.unit ?? "") : "";
|
||||
const count = row.status.count,
|
||||
required = row.status.required;
|
||||
const setAmount = (value: number) => {
|
||||
acted.current = true;
|
||||
void onAction({
|
||||
type: "amount",
|
||||
accountId: row.accountId,
|
||||
revisionId: row.clock.revision,
|
||||
itemId: row.id,
|
||||
period: row.status.periodStart,
|
||||
value,
|
||||
...actionMeta(),
|
||||
});
|
||||
};
|
||||
const setDay = (day: string, checked: boolean) => {
|
||||
acted.current = true;
|
||||
void onAction({
|
||||
type: "day",
|
||||
accountId: row.accountId,
|
||||
revisionId: row.clock.revision,
|
||||
itemId: row.id,
|
||||
day,
|
||||
checked,
|
||||
...actionMeta(),
|
||||
});
|
||||
};
|
||||
const toggle = () => {
|
||||
if (amount) {
|
||||
if (!met) {
|
||||
setBefore(count);
|
||||
setAmount(required);
|
||||
} else if (before !== null && before < required) {
|
||||
setAmount(before);
|
||||
setBefore(null);
|
||||
} else setEditing(true);
|
||||
return;
|
||||
}
|
||||
if (row.days.includes(today)) setDay(today, false);
|
||||
else if (met) {
|
||||
const last = lastDayInPeriod(row, today);
|
||||
if (last) setDay(last, false);
|
||||
} else setDay(today, true);
|
||||
};
|
||||
const step = () => {
|
||||
if (count + 1 >= required) setBefore(count);
|
||||
setAmount(count + 1);
|
||||
};
|
||||
|
||||
const period = row.cadence === "day" ? "Today" : `This ${row.cadence}`;
|
||||
const resets = formatRemaining(routinePeriod(row, now).end - now);
|
||||
const progress = amount
|
||||
? `${count.toLocaleString()} / ${required.toLocaleString()}${unit ? ` ${unit}` : ""}`
|
||||
: required > 1
|
||||
? `${Math.min(count, required)} of ${required} days`
|
||||
: null;
|
||||
return (
|
||||
<div
|
||||
className="pinned-routine"
|
||||
data-done={met || undefined}
|
||||
data-burst={burst > 0 || undefined}
|
||||
style={{ "--g": hue } as CSSProperties}
|
||||
>
|
||||
{burst > 0 && <Fireworks key={burst} />}
|
||||
<div className="pr-text">
|
||||
<strong>{row.title}</strong>
|
||||
<small>
|
||||
{met ? `Done ${lower(period)}` : period} · resets in {resets}
|
||||
</small>
|
||||
{amount && editing ? (
|
||||
<AmountEditor
|
||||
row={row}
|
||||
unit={unit}
|
||||
onSave={(value) => {
|
||||
if (value >= required && count < required) setBefore(count);
|
||||
setAmount(value);
|
||||
setEditing(false);
|
||||
}}
|
||||
onCancel={() => setEditing(false)}
|
||||
/>
|
||||
) : amount ? (
|
||||
<span className="pr-amount">
|
||||
<button
|
||||
type="button"
|
||||
className="pr-count"
|
||||
title="Enter an amount"
|
||||
aria-label={`${row.title}: ${progress}. Enter an amount`}
|
||||
onClick={() => setEditing(true)}
|
||||
>
|
||||
<span className="pr-bar" aria-hidden="true">
|
||||
<i
|
||||
style={
|
||||
{
|
||||
width: `${Math.min(1, count / required) * 100}%`,
|
||||
"--from": `${Math.min(1, (before ?? 0) / required) * 100}%`,
|
||||
} as CSSProperties
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
{progress}
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path d="M10.5 2.5l3 3L6 13H3v-3z" />
|
||||
</svg>
|
||||
</button>
|
||||
{!met && (
|
||||
<button
|
||||
type="button"
|
||||
className="pr-step"
|
||||
aria-label={`Add one to ${row.title}`}
|
||||
onClick={step}
|
||||
>
|
||||
+1
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
progress && <span className="pr-days">{progress}</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="pr-box"
|
||||
aria-pressed={met}
|
||||
aria-label={
|
||||
met
|
||||
? `${row.title}, done ${lower(period)}. Undo`
|
||||
: amount
|
||||
? `Mark ${row.title} complete`
|
||||
: `${row.title}, not done ${lower(period)}. Tick today`
|
||||
}
|
||||
title={met ? "Undo" : amount ? "Mark complete" : "Tick today"}
|
||||
onClick={toggle}
|
||||
>
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true">
|
||||
<rect x="1.5" y="1.5" width="13" height="13" rx="3.5" />
|
||||
<path d="m4.4 8.2 2.5 2.5 4.7-5.2" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** How long typing must pause before a value at the target completes it. */
|
||||
export const AUTO_COMPLETE_MS = 450;
|
||||
|
||||
function AmountEditor({
|
||||
row,
|
||||
unit,
|
||||
onSave,
|
||||
onCancel,
|
||||
}: {
|
||||
row: RoutineRow;
|
||||
unit: string;
|
||||
onSave: (value: number) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const [value, setValue] = useState(String(row.status.count));
|
||||
const input = useRef<HTMLInputElement>(null);
|
||||
useEffect(() => {
|
||||
input.current?.select();
|
||||
}, []);
|
||||
// A number at or over the target completes it without Save. The pause lets
|
||||
// a multi-digit number ("10000") finish before it counts.
|
||||
useEffect(() => {
|
||||
const n = Number(value);
|
||||
if (
|
||||
value === "" ||
|
||||
!Number.isInteger(n) ||
|
||||
n < row.status.required ||
|
||||
n === row.status.count
|
||||
)
|
||||
return;
|
||||
const id = setTimeout(() => onSave(n), AUTO_COMPLETE_MS);
|
||||
return () => clearTimeout(id);
|
||||
}, [value]);
|
||||
return (
|
||||
<form
|
||||
className="pr-edit"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const n = Number(value);
|
||||
if (Number.isInteger(n) && n >= 0 && n !== row.status.count) onSave(n);
|
||||
else onCancel();
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget as Node | null))
|
||||
onCancel();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
ref={input}
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
max="1000000000"
|
||||
inputMode="numeric"
|
||||
aria-label={`${row.title} amount`}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") onCancel();
|
||||
}}
|
||||
/>
|
||||
<span>
|
||||
/ {row.status.required.toLocaleString()}
|
||||
{unit && ` ${unit}`}
|
||||
</span>
|
||||
<button type="submit" className="btn sm">
|
||||
Save
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
/** "This week" → "this week", for the middle of a sentence. */
|
||||
function lower(period: string): string {
|
||||
return period[0]!.toLowerCase() + period.slice(1);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { useContext, type CSSProperties } from "react";
|
||||
import { actionMeta } from "../../state/reader/actions.ts";
|
||||
import { mintLocalId } from "../../state/reader/ids.ts";
|
||||
import type { Account } from "../../state/reader/schema.ts";
|
||||
import { useGameMeta } from "../../state/gameMeta.tsx";
|
||||
import { DisplayTimeContext, formatInZone } from "../../state/displayTime.ts";
|
||||
import {
|
||||
appendClockRevision,
|
||||
resolveReaderClock,
|
||||
} from "../../../shared/readerClock.ts";
|
||||
import {
|
||||
DAY,
|
||||
formatRemaining,
|
||||
REGION_LABEL,
|
||||
resetShiftMs,
|
||||
} from "../../../shared/time.ts";
|
||||
import type { ActionHandler } from "./GameSettings.tsx";
|
||||
|
||||
const hh = (h: number, m = 0) =>
|
||||
`${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
|
||||
|
||||
/**
|
||||
* One server's reset (Round 6 B `.reset-editor`): its clock, when that is in
|
||||
* your time and how long until it, and a choice of Automatic or a whole hour.
|
||||
* A change appends a clock revision, so earlier progress is kept.
|
||||
*/
|
||||
export function ResetTimingRow({
|
||||
account: a,
|
||||
label,
|
||||
now,
|
||||
onAction,
|
||||
}: {
|
||||
account: Account;
|
||||
/** "Genshin" or "Genshin · Europe". */
|
||||
label: string;
|
||||
now: number;
|
||||
onAction: ActionHandler;
|
||||
}) {
|
||||
const meta = useGameMeta(),
|
||||
zone = useContext(DisplayTimeContext);
|
||||
// A just-saved revision is stamped with the real time, which can be up to a
|
||||
// tick past `now`: resolve at whichever is later.
|
||||
const clock = resolveReaderClock(a, Math.max(now, Date.now())),
|
||||
current = a.clocks.at(-1)!,
|
||||
shift = resetShiftMs(clock.region, a.game, clock),
|
||||
next = Math.floor((now + shift) / DAY) * DAY + DAY - shift;
|
||||
const value =
|
||||
current.reset === null
|
||||
? "automatic"
|
||||
: `${current.reset.hour}:${current.reset.minute}`;
|
||||
const setReset = (reset: { hour: number; minute: number } | null) =>
|
||||
void onAction({
|
||||
type: "account",
|
||||
account: appendClockRevision(
|
||||
a,
|
||||
reset,
|
||||
a.region,
|
||||
new Date().toISOString(),
|
||||
mintLocalId(),
|
||||
),
|
||||
...actionMeta(),
|
||||
});
|
||||
return (
|
||||
<div
|
||||
className="reset-row"
|
||||
style={{ "--g": meta(a.game).hue } as CSSProperties}
|
||||
>
|
||||
<div>
|
||||
<strong>{label}</strong>
|
||||
<small>
|
||||
{current.reset === null ? "Automatic" : "Custom"}:{" "}
|
||||
{hh(clock.resetHour, clock.resetMinute)} {REGION_LABEL[a.region]}{" "}
|
||||
server time · {formatInZone(next, true, zone)} · in{" "}
|
||||
{formatRemaining(next - now)}
|
||||
</small>
|
||||
</div>
|
||||
<label>
|
||||
<span>Reset timing</span>
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
if (e.target.value === "automatic") setReset(null);
|
||||
else {
|
||||
const [hour, minute] = e.target.value.split(":").map(Number);
|
||||
setReset({ hour: hour!, minute: minute! });
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value="automatic">
|
||||
Automatic (
|
||||
{hh(current.automaticBase.hour, current.automaticBase.minute)})
|
||||
</option>
|
||||
{current.reset !== null && current.reset.minute !== 0 && (
|
||||
<option value={value}>
|
||||
{hh(current.reset.hour, current.reset.minute)} server time
|
||||
</option>
|
||||
)}
|
||||
{Array.from({ length: 24 }, (_, h) => (
|
||||
<option key={h} value={`${h}:0`}>
|
||||
{hh(h)} server time
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
/** Where a step takes the reader; ReaderApp opens the matching page. */
|
||||
export type TourStop =
|
||||
"routines" | "soon" | "timeline" | "codes" | "archive" | "settings";
|
||||
|
||||
export const TOUR_STEPS: readonly {
|
||||
stop: TourStop;
|
||||
title: string;
|
||||
body: string;
|
||||
}[] = [
|
||||
{
|
||||
stop: "routines",
|
||||
title: "Your routines have a home",
|
||||
body: "Follow your dailies, weeklies and monthlies in the Routines tab, and pin the ones you want beside your Checklist or on the Timeline.",
|
||||
},
|
||||
{
|
||||
stop: "soon",
|
||||
title: "Your Checklist",
|
||||
body: "What ends first, at the top. Focus on a game, tick today's dailies and pinned routines beside the list, and switch between Standard and Compact rows.",
|
||||
},
|
||||
{
|
||||
stop: "timeline",
|
||||
title: "The Timeline",
|
||||
body: "Every event as a bar across the calendar, with now marked. Show time left or end dates, and choose its look in Settings → Appearance.",
|
||||
},
|
||||
{
|
||||
stop: "codes",
|
||||
title: "Redeem codes",
|
||||
body: "New codes for the games you track show up at the top of the Checklist. Each game's Codes tab lists them with their rewards and expiry, ready to copy, and remembers which you've redeemed.",
|
||||
},
|
||||
{
|
||||
stop: "archive",
|
||||
title: "Your Archive",
|
||||
body: "Done and ignored events move here with their details and progress. Restore any of them at any time.",
|
||||
},
|
||||
{
|
||||
stop: "settings",
|
||||
title: "Your settings, together",
|
||||
body: "Appearance, region and time, each game's servers and colours, data sources and backups. The changelog has its own page here too.",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* The returning reader's introduction to the new look. Continue always sits
|
||||
* first, in the same place, so clicking through never lands on Back; Back
|
||||
* follows it, and Skip stands apart at the end.
|
||||
*/
|
||||
export function ReturningTour({
|
||||
onStep,
|
||||
onFinish,
|
||||
autoStart = false,
|
||||
}: {
|
||||
onStep: (stop: TourStop) => void;
|
||||
onFinish: () => void;
|
||||
/** Replayed from Settings: skip the welcome and open the first step. */
|
||||
autoStart?: boolean;
|
||||
}) {
|
||||
const [step, setStep] = useState(autoStart ? 0 : -1);
|
||||
const go = (next: number) => {
|
||||
setStep(next);
|
||||
onStep(TOUR_STEPS[next]!.stop);
|
||||
};
|
||||
useEffect(() => {
|
||||
if (autoStart) onStep(TOUR_STEPS[0]!.stop);
|
||||
}, []);
|
||||
const last = step === TOUR_STEPS.length - 1;
|
||||
return (
|
||||
<aside className="returning-tour" aria-label="Introduction to the new look">
|
||||
{step >= 0 && (
|
||||
<p className="tour-step">
|
||||
{step + 1} of {TOUR_STEPS.length}
|
||||
</p>
|
||||
)}
|
||||
<h2>
|
||||
{step < 0
|
||||
? "Welcome back. A little has moved."
|
||||
: TOUR_STEPS[step]!.title}
|
||||
</h2>
|
||||
<p>
|
||||
{step < 0
|
||||
? "Your games, routines and progress are all still here. Take a short tour of the new layout, or keep browsing."
|
||||
: TOUR_STEPS[step]!.body}
|
||||
</p>
|
||||
<div className="tour-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="commit"
|
||||
onClick={() => (last ? onFinish() : go(step + 1))}
|
||||
>
|
||||
{step < 0 ? "Take the quick tour" : last ? "Finish tour" : "Continue"}
|
||||
</button>
|
||||
{step > 0 && (
|
||||
<button type="button" className="btn" onClick={() => go(step - 1)}>
|
||||
Back
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="tour-skip" onClick={onFinish}>
|
||||
{step < 0 ? "Keep browsing" : "Skip tour"}
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,53 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Fireworks } from "../Fireworks.tsx";
|
||||
import { useSectionCelebration } from "./useSectionCelebration.ts";
|
||||
|
||||
export function RoutineGroup({
|
||||
title,
|
||||
policy,
|
||||
focused,
|
||||
children,
|
||||
hue,
|
||||
summary,
|
||||
done,
|
||||
total,
|
||||
}: {
|
||||
title: string;
|
||||
hue?: string;
|
||||
summary?: string;
|
||||
policy: "collapsed" | "expanded" | "always";
|
||||
focused: boolean;
|
||||
children: ReactNode;
|
||||
/** Met routines in the group; with `total`, finishing them all celebrates. */
|
||||
done?: number;
|
||||
total?: number;
|
||||
}) {
|
||||
const burst = useSectionCelebration(total ?? 0, done ?? 0);
|
||||
const complete = total !== undefined && total > 0 && done === total;
|
||||
const shared = {
|
||||
className: "routine-group",
|
||||
style: { color: hue },
|
||||
"data-complete": complete || undefined,
|
||||
"data-celebrate": burst > 0 || undefined,
|
||||
};
|
||||
const celebration = burst > 0 && <Fireworks key={burst} grand hue={hue} />;
|
||||
return policy === "always" ? (
|
||||
<section {...shared}>
|
||||
{celebration}
|
||||
<h3>
|
||||
{title}
|
||||
<small>{summary}</small>
|
||||
</h3>
|
||||
{children}
|
||||
</section>
|
||||
) : (
|
||||
<details {...shared} open={focused || policy === "expanded"}>
|
||||
<summary>
|
||||
{title}
|
||||
<small>{summary}</small>
|
||||
</summary>
|
||||
{celebration}
|
||||
{children}
|
||||
</details>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import type { ReaderState, Account } from "../../state/reader/schema.ts";
|
||||
import { recordOf, actionMeta } from "../../state/reader/actions.ts";
|
||||
import {
|
||||
clockFromRevision,
|
||||
readerDayKey,
|
||||
resolveReaderClock,
|
||||
} from "../../../shared/readerClock.ts";
|
||||
import { catchUpDays } from "../../../shared/daily.ts";
|
||||
import { routinesFor } from "../../../shared/routines.ts";
|
||||
import { TaskSchema } from "../../../shared/task.ts";
|
||||
import { formatInZone } from "../../state/displayTime.ts";
|
||||
import type { ActionHandler } from "./GameSettings.tsx";
|
||||
export function RoutineHistory({
|
||||
account,
|
||||
state,
|
||||
now,
|
||||
onAction,
|
||||
}: {
|
||||
account: Account;
|
||||
state: ReaderState;
|
||||
now: number;
|
||||
onAction: ActionHandler;
|
||||
}) {
|
||||
const data = state.data[account.id]!;
|
||||
const current = resolveReaderClock(account, now);
|
||||
const revisions = account.clocks
|
||||
.slice()
|
||||
.sort((a, b) => a.effectiveAt.localeCompare(b.effectiveAt));
|
||||
return (
|
||||
<details className="routine-history">
|
||||
<summary>Catch up & recorded history</summary>
|
||||
{revisions.map((revision, index) => {
|
||||
const clock = clockFromRevision(revision),
|
||||
last =
|
||||
index === revisions.length - 1
|
||||
? now
|
||||
: Math.min(
|
||||
now,
|
||||
Date.parse(revisions[index + 1]!.effectiveAt) - 1,
|
||||
),
|
||||
today = readerDayKey(last, clock);
|
||||
const items = {
|
||||
...data.daily[revision.id],
|
||||
...data.amounts[revision.id],
|
||||
};
|
||||
for (const [id, p] of Object.entries(account.routines))
|
||||
if (p.followed && revision.id === current.revision) items[id] ??= {};
|
||||
return (
|
||||
<section key={revision.id}>
|
||||
<h3>
|
||||
{revision.id === current.revision
|
||||
? "Current reset timing"
|
||||
: "Before reset timing changed"}
|
||||
</h3>
|
||||
<p className="help">
|
||||
{revision.region} ·{" "}
|
||||
{revision.reset
|
||||
? `${revision.reset.hour}:${String(revision.reset.minute).padStart(2, "0")}`
|
||||
: "Automatic"}{" "}
|
||||
·{" "}
|
||||
{revision.effectiveAt.startsWith("1970")
|
||||
? "Migrated history"
|
||||
: formatInZone(revision.effectiveAt, true, null)}
|
||||
</p>
|
||||
{Object.keys(items).map((id) => {
|
||||
const title = id.startsWith("dailies:")
|
||||
? "Daily check-in"
|
||||
: (routinesFor(account.game).find((r) => r.id === id)?.title ??
|
||||
String(
|
||||
recordOf(state.tasks[id]).title ??
|
||||
Object.values(state.seenEvents).find(
|
||||
(s) => s.event.id === id,
|
||||
)?.event.title ??
|
||||
id,
|
||||
));
|
||||
// A routine with its own reset hour keeps its own day labels,
|
||||
// as its row does (state/reader/routines.ts).
|
||||
const own = TaskSchema.safeParse(state.tasks[id]),
|
||||
hour = own.success ? own.data.resetHour : undefined,
|
||||
itemClock =
|
||||
hour === undefined
|
||||
? clock
|
||||
: { ...clock, resetHour: hour, resetMinute: 0 },
|
||||
itemToday =
|
||||
hour === undefined ? today : readerDayKey(last, itemClock);
|
||||
const raw = recordOf(data.daily[revision.id]?.[id]),
|
||||
days = new Set(
|
||||
Array.isArray(raw.days)
|
||||
? raw.days.filter((d): d is string => typeof d === "string")
|
||||
: [],
|
||||
);
|
||||
const recent = catchUpDays(
|
||||
last,
|
||||
revision.region,
|
||||
account.game,
|
||||
revision.effectiveAt.startsWith("1970")
|
||||
? null
|
||||
: Date.parse(revision.effectiveAt),
|
||||
14,
|
||||
itemClock,
|
||||
);
|
||||
return (
|
||||
<div key={id}>
|
||||
<p>{title}</p>
|
||||
{!data.amounts[revision.id]?.[id] && (
|
||||
<div className="history-days">
|
||||
{[...new Set([...recent, ...days])].sort().map((day) => (
|
||||
<button
|
||||
type="button"
|
||||
key={day}
|
||||
disabled={day > itemToday}
|
||||
aria-pressed={days.has(day)}
|
||||
onClick={() =>
|
||||
void onAction({
|
||||
type: "day",
|
||||
accountId: account.id,
|
||||
revisionId: revision.id,
|
||||
itemId: id,
|
||||
day,
|
||||
checked: !days.has(day),
|
||||
...actionMeta(),
|
||||
})
|
||||
}
|
||||
>
|
||||
{day}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{Object.entries(
|
||||
recordOf(data.amounts[revision.id]?.[id]),
|
||||
).map(([period, entry]) => (
|
||||
<p className="help" key={period}>
|
||||
{period}:{" "}
|
||||
{String(recordOf(entry).value ?? "unreadable amount")}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</details>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { readerDayKey } from "../../../shared/readerClock.ts";
|
||||
import { routinesFor } from "../../../shared/routines.ts";
|
||||
import type { RoutineRow } from "../../state/reader/routines.ts";
|
||||
import { actionMeta } from "../../state/reader/actions.ts";
|
||||
import type { ActionHandler } from "./GameSettings.tsx";
|
||||
|
||||
/**
|
||||
* A routine's progress as the Routines table shows it: the Round 6 `.tick` for
|
||||
* check-ins and the `.amt` bar for amounts. An amount reads as text until it is
|
||||
* clicked, then becomes a number field, so the table is not a wall of inputs.
|
||||
*/
|
||||
export function RoutineProgress({
|
||||
row,
|
||||
onAction,
|
||||
now,
|
||||
}: {
|
||||
row: RoutineRow;
|
||||
onAction: ActionHandler;
|
||||
now: number;
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
if (row.status.measure !== "amount") {
|
||||
const today = readerDayKey(now, row.clock),
|
||||
done = row.days.includes(today),
|
||||
many = row.status.required > 1;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="tick"
|
||||
aria-pressed={many ? row.status.met : done}
|
||||
aria-label={`${row.title}, ${done ? "done" : "not done"} today`}
|
||||
onClick={() =>
|
||||
void onAction({
|
||||
type: "day",
|
||||
accountId: row.accountId,
|
||||
revisionId: row.clock.revision,
|
||||
itemId: row.id,
|
||||
day: today,
|
||||
checked: !done,
|
||||
...actionMeta(),
|
||||
})
|
||||
}
|
||||
>
|
||||
<span className="box" aria-hidden="true">
|
||||
<Check />
|
||||
</span>
|
||||
{many &&
|
||||
`${Math.min(row.status.count, row.status.required)} of ${row.status.required} days`}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
const task = row.task ?? routinesFor(row.game).find((r) => r.id === row.id),
|
||||
unit = task?.measure.kind === "amount" ? (task.measure.unit ?? "") : "";
|
||||
const fill = Math.min(1, row.status.count / row.status.required);
|
||||
if (editing)
|
||||
return (
|
||||
<AmountForm
|
||||
row={row}
|
||||
unit={unit}
|
||||
onAction={onAction}
|
||||
onDone={() => setEditing(false)}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="amt"
|
||||
title="Enter an amount"
|
||||
aria-label={`${row.title}: ${row.status.count.toLocaleString()} of ${row.status.required.toLocaleString()}${unit ? ` ${unit}` : ""}. Enter an amount`}
|
||||
onClick={() => setEditing(true)}
|
||||
>
|
||||
<span className={`bar${fill >= 1 ? " full" : ""}`} aria-hidden="true">
|
||||
<i style={{ width: `${fill * 100}%` }} />
|
||||
</span>
|
||||
<span className="num">
|
||||
<b>{row.status.count.toLocaleString()}</b> /{" "}
|
||||
{row.status.required.toLocaleString()}
|
||||
{unit && ` ${unit}`}
|
||||
</span>
|
||||
<svg className="edit" viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path d="M10.5 2.5l3 3L6 13H3v-3z" />
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function AmountForm({
|
||||
row,
|
||||
unit,
|
||||
onAction,
|
||||
onDone,
|
||||
}: {
|
||||
row: RoutineRow;
|
||||
unit: string;
|
||||
onAction: ActionHandler;
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const [value, setValue] = useState(String(row.status.count));
|
||||
const input = useRef<HTMLInputElement>(null);
|
||||
useEffect(() => {
|
||||
input.current?.select();
|
||||
}, []);
|
||||
const save = () => {
|
||||
const n = Number(value);
|
||||
if (Number.isInteger(n) && n >= 0 && n !== row.status.count)
|
||||
void onAction({
|
||||
type: "amount",
|
||||
accountId: row.accountId,
|
||||
revisionId: row.clock.revision,
|
||||
itemId: row.id,
|
||||
period: row.status.periodStart,
|
||||
value: n,
|
||||
...actionMeta(),
|
||||
});
|
||||
onDone();
|
||||
};
|
||||
return (
|
||||
<form
|
||||
className="amt editing"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
save();
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) onDone();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
ref={input}
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
max="1000000000"
|
||||
inputMode="numeric"
|
||||
aria-label={`${row.title} amount`}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") onDone();
|
||||
}}
|
||||
/>
|
||||
<span className="num">
|
||||
/ {row.status.required.toLocaleString()}
|
||||
{unit && ` ${unit}`}
|
||||
</span>
|
||||
<button type="submit" className="btn sm">
|
||||
Save
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function Check() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path d="M5 12.5l4.5 4.5L19 7" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,586 @@
|
||||
import { routinePeriod } from "./TimelinePage.tsx";
|
||||
import { manyServers, serverSuffix } from "../../state/reader/servers.ts";
|
||||
import { formatRemaining } from "../../../shared/time.ts";
|
||||
import { useGameMeta } from "../../state/gameMeta.tsx";
|
||||
import type { CSSProperties } from "react";
|
||||
import { RoutineHistory } from "./RoutineHistory.tsx";
|
||||
import { useState } from "react";
|
||||
|
||||
import { routinesFor, mineFrom } from "../../../shared/routines.ts";
|
||||
import { mintTaskId, type Task } from "../../../shared/task.ts";
|
||||
import {
|
||||
RoutineDrawer,
|
||||
type CreatePlacement,
|
||||
type DrawerPane,
|
||||
} from "./RoutineDrawer.tsx";
|
||||
import { actionMeta } from "../../state/reader/actions.ts";
|
||||
import type { ReaderState, Account } from "../../state/reader/schema.ts";
|
||||
import { routineRows, type RoutineRow } from "../../state/reader/routines.ts";
|
||||
import type { TaskDraft } from "../../state/useTasks.ts";
|
||||
import { RoutineProgress } from "./RoutineProgress.tsx";
|
||||
import type { ActionHandler } from "./GameSettings.tsx";
|
||||
// One account write per bulk action prevents a later row from restoring an earlier pin.
|
||||
export function placementAccounts(
|
||||
state: ReaderState,
|
||||
rows: readonly RoutineRow[],
|
||||
key: "checklist" | "timeline",
|
||||
value: boolean,
|
||||
): Account[] {
|
||||
const accounts = new Map<string, Account>();
|
||||
for (const row of rows) {
|
||||
const account =
|
||||
accounts.get(row.accountId) ?? state.accounts[row.accountId]!;
|
||||
accounts.set(row.accountId, {
|
||||
...account,
|
||||
routines: {
|
||||
...account.routines,
|
||||
[row.id]: { ...row.placement, [key]: value },
|
||||
},
|
||||
});
|
||||
}
|
||||
return [...accounts.values()];
|
||||
}
|
||||
export function ChecklistDisplay({
|
||||
state,
|
||||
onAction,
|
||||
plain = false,
|
||||
}: {
|
||||
state: ReaderState;
|
||||
onAction: ActionHandler;
|
||||
/** Just the two fields, for a settings group; the Routines page shows them as a band. */
|
||||
plain?: boolean;
|
||||
}) {
|
||||
const fields = (
|
||||
<>
|
||||
<label>
|
||||
Dailies on Checklist
|
||||
<select
|
||||
value={String(state.prefs.dailyLayout ?? "separate")}
|
||||
onChange={(e) =>
|
||||
void onAction({
|
||||
type: "prefs",
|
||||
patch: { dailyLayout: e.target.value },
|
||||
...actionMeta(),
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="separate">Separate panel</option>
|
||||
<option value="grouped">Grouped with routines</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Routine groups
|
||||
<select
|
||||
value={String(state.prefs.routineDisclosure ?? "collapsed")}
|
||||
onChange={(e) =>
|
||||
void onAction({
|
||||
type: "prefs",
|
||||
patch: { routineDisclosure: e.target.value },
|
||||
...actionMeta(),
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="collapsed">Collapsed by default</option>
|
||||
<option value="expanded">Expanded by default</option>
|
||||
<option value="always">Always expanded</option>
|
||||
</select>
|
||||
</label>
|
||||
</>
|
||||
);
|
||||
if (plain) return fields;
|
||||
return (
|
||||
<fieldset className="checklist-display">
|
||||
<legend className="sr-only">Checklist display</legend>
|
||||
{fields}
|
||||
<p className="help">
|
||||
Focusing a game opens its routines automatically. Pins below decide
|
||||
which routines appear on your Checklist.
|
||||
</p>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
export function RoutinesPage({
|
||||
state,
|
||||
onAction,
|
||||
now,
|
||||
}: {
|
||||
state: ReaderState;
|
||||
onAction: ActionHandler;
|
||||
now: number;
|
||||
}) {
|
||||
const meta = useGameMeta();
|
||||
const [drawer, setDrawer] = useState<DrawerPane | null>(null);
|
||||
const [editing, setEditing] = useState<Task | null>(null);
|
||||
const open = (pane: DrawerPane, task: Task | null = null) => {
|
||||
setEditing(task);
|
||||
setDrawer(pane);
|
||||
};
|
||||
const accounts = Object.values(state.accounts).filter(
|
||||
(a) =>
|
||||
!a.retiredAt &&
|
||||
!(
|
||||
Array.isArray(state.prefs.hiddenGames) &&
|
||||
state.prefs.hiddenGames.includes(a.game)
|
||||
),
|
||||
);
|
||||
const save = async (draft: TaskDraft, placement: CreatePlacement) => {
|
||||
const at = new Date().toISOString(),
|
||||
id = editing ? editing.id : mintTaskId();
|
||||
const task = {
|
||||
...(editing ?? {}),
|
||||
...draft,
|
||||
id,
|
||||
at: editing ? editing.at : at,
|
||||
updatedAt: at,
|
||||
};
|
||||
const result = await onAction({
|
||||
type: "record",
|
||||
collection: "tasks",
|
||||
id,
|
||||
value: task,
|
||||
...actionMeta(),
|
||||
});
|
||||
if (!result.ok) return false;
|
||||
// An edit keeps where the routine already shows; a new one follows on the
|
||||
// chosen server with the chosen pins.
|
||||
if (editing) return true;
|
||||
const a = state.accounts[placement.accountId];
|
||||
if (!a) return false;
|
||||
const placed = await onAction({
|
||||
type: "account",
|
||||
account: {
|
||||
...a,
|
||||
routines: {
|
||||
...a.routines,
|
||||
[id]: {
|
||||
followed: true,
|
||||
checklist: placement.checklist,
|
||||
timeline: placement.timeline,
|
||||
},
|
||||
},
|
||||
},
|
||||
...actionMeta(),
|
||||
});
|
||||
return placed.ok;
|
||||
};
|
||||
const copy = async (row: RoutineRow) => {
|
||||
const routine = routinesFor(row.game).find((r) => r.id === row.id);
|
||||
if (!routine) return;
|
||||
const at = new Date().toISOString(),
|
||||
id = mintTaskId();
|
||||
const account = state.accounts[row.accountId]!;
|
||||
const result = await onAction({
|
||||
type: "record",
|
||||
collection: "tasks",
|
||||
id,
|
||||
value: { ...mineFrom(routine), id, at, updatedAt: at },
|
||||
...actionMeta(),
|
||||
});
|
||||
if (!result.ok) return;
|
||||
for (const [revision, items] of Object.entries(
|
||||
state.data[account.id]!.daily,
|
||||
)) {
|
||||
const log = items[row.id] as { days?: string[] } | undefined;
|
||||
for (const day of log?.days ?? [])
|
||||
await onAction({
|
||||
type: "day",
|
||||
accountId: account.id,
|
||||
revisionId: revision,
|
||||
itemId: id,
|
||||
day,
|
||||
checked: true,
|
||||
...actionMeta(),
|
||||
});
|
||||
}
|
||||
for (const [revision, items] of Object.entries(
|
||||
state.data[account.id]!.amounts,
|
||||
)) {
|
||||
const log = items[row.id] as
|
||||
Record<string, { value: number }> | undefined;
|
||||
for (const [period, value] of Object.entries(log ?? {}))
|
||||
await onAction({
|
||||
type: "amount",
|
||||
accountId: account.id,
|
||||
revisionId: revision,
|
||||
itemId: id,
|
||||
period,
|
||||
value: value.value,
|
||||
...actionMeta(),
|
||||
});
|
||||
}
|
||||
await onAction({
|
||||
type: "account",
|
||||
account: {
|
||||
...account,
|
||||
routines: {
|
||||
...account.routines,
|
||||
[id]: row.placement,
|
||||
[row.id]: { ...row.placement, followed: false },
|
||||
},
|
||||
},
|
||||
...actionMeta(),
|
||||
});
|
||||
};
|
||||
const group = state.prefs.routineGroup === "game" ? "game" : "reset";
|
||||
const allRows = accounts
|
||||
.flatMap((a) => routineRows(state, a.id, now))
|
||||
.filter((r) => r.placement.followed);
|
||||
const bands =
|
||||
group === "game"
|
||||
? accounts.map((a) => ({
|
||||
id: a.id,
|
||||
title: meta(a.game).name,
|
||||
rows: allRows.filter((r) => r.accountId === a.id),
|
||||
}))
|
||||
: (["day", "week", "month", "cycle"] as const).map((c) => ({
|
||||
id: c,
|
||||
title: {
|
||||
day: "Dailies",
|
||||
week: "Weeklies",
|
||||
month: "Monthlies",
|
||||
cycle: "Cycles",
|
||||
}[c],
|
||||
rows: allRows.filter((r) => r.cadence === c),
|
||||
}));
|
||||
return (
|
||||
<section className="routines-page">
|
||||
<div className="rt-top">
|
||||
<h1>Routines</h1>
|
||||
<p className="sum">
|
||||
{`${allRows.filter((r) => !r.status.met).length} of ${allRows.length} still to do. ${allRows.filter((r) => r.placement.checklist).length} pinned to the checklist, ${allRows.filter((r) => r.placement.timeline && r.cadence !== "day").length} to the timeline. The rest live only here.`}
|
||||
</p>
|
||||
<div className="segmented">
|
||||
{(["reset", "game"] as const).map((g) => (
|
||||
<button
|
||||
key={g}
|
||||
aria-pressed={g === group}
|
||||
onClick={() =>
|
||||
void onAction({
|
||||
type: "prefs",
|
||||
patch: { routineGroup: g },
|
||||
...actionMeta(),
|
||||
})
|
||||
}
|
||||
>
|
||||
{g === "reset" ? "By reset" : "By game"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button className="btn" onClick={() => open("library")}>
|
||||
Browse the library
|
||||
</button>
|
||||
<button type="button" className="commit" onClick={() => open("create")}>
|
||||
Create your own routine
|
||||
</button>
|
||||
</div>
|
||||
{drawer && (
|
||||
<RoutineDrawer
|
||||
state={state}
|
||||
now={now}
|
||||
pane={drawer}
|
||||
editing={editing}
|
||||
onPane={(pane) => open(pane)}
|
||||
onClose={() => {
|
||||
setDrawer(null);
|
||||
setEditing(null);
|
||||
}}
|
||||
onAction={onAction}
|
||||
onSave={save}
|
||||
/>
|
||||
)}
|
||||
<ChecklistDisplay state={state} onAction={onAction} />
|
||||
{bands.map((band) => {
|
||||
const rows = band.rows;
|
||||
if (!rows.length) return null;
|
||||
const setPlacement = (
|
||||
row: RoutineRow,
|
||||
key: "followed" | "checklist" | "timeline",
|
||||
value: boolean,
|
||||
) => {
|
||||
const a = state.accounts[row.accountId]!;
|
||||
return onAction({
|
||||
type: "account",
|
||||
account: {
|
||||
...a,
|
||||
routines: {
|
||||
...a.routines,
|
||||
[row.id]: { ...row.placement, [key]: value },
|
||||
},
|
||||
},
|
||||
...actionMeta(),
|
||||
});
|
||||
};
|
||||
return (
|
||||
<section className="band" key={band.id}>
|
||||
<div className="band-h">
|
||||
<div>
|
||||
<h2>{band.title}</h2>
|
||||
<p className="meta">
|
||||
{
|
||||
rows.filter((r) => r.placement.followed && !r.status.met)
|
||||
.length
|
||||
}{" "}
|
||||
of {rows.length} still to do
|
||||
</p>
|
||||
<div className="bulk-pins">
|
||||
{(["checklist", "timeline"] as const)
|
||||
.filter(
|
||||
(key) =>
|
||||
key !== "timeline" ||
|
||||
rows.some((r) => r.cadence !== "day"),
|
||||
)
|
||||
.map((key) => (
|
||||
<button
|
||||
className="btn"
|
||||
key={key}
|
||||
onClick={async () => {
|
||||
const value = !rows.every((r) => r.placement[key]);
|
||||
for (const account of placementAccounts(
|
||||
state,
|
||||
rows,
|
||||
key,
|
||||
value,
|
||||
))
|
||||
await onAction({
|
||||
type: "account",
|
||||
account,
|
||||
...actionMeta(),
|
||||
});
|
||||
}}
|
||||
>
|
||||
{rows.every((r) => r.placement[key])
|
||||
? "Unpin all from"
|
||||
: "Pin all to"}{" "}
|
||||
{key}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="band-reset">
|
||||
<small>Next reset in</small>
|
||||
<strong>
|
||||
{formatRemaining(
|
||||
Math.min(...rows.map((r) => routinePeriod(r, now).end)) -
|
||||
now,
|
||||
)}
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
<table className="rt">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="h-g">Game</th>
|
||||
<th className="h-t">Routine</th>
|
||||
<th className="h-prog">Progress</th>
|
||||
{(["checklist", "timeline"] as const).map((key) => (
|
||||
<th className="c-pin" key={key}>
|
||||
<span className="pinlbl">
|
||||
<PinIcon kind={key} on={false} />
|
||||
<span>
|
||||
{key === "checklist" ? "Checklist" : "Timeline"}
|
||||
</span>
|
||||
</span>
|
||||
</th>
|
||||
))}
|
||||
<th className="c-k">
|
||||
<span className="sr-only">Actions</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr
|
||||
key={row.accountId + row.id}
|
||||
className={row.status.met ? "met" : ""}
|
||||
style={{ "--g": meta(row.game).hue } as CSSProperties}
|
||||
>
|
||||
<td>
|
||||
<span className="gcell">
|
||||
<span>
|
||||
{meta(row.game).short}
|
||||
{manyServers(state, row.game) && (
|
||||
<small>
|
||||
{state.accounts[row.accountId]?.region}
|
||||
</small>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</td>
|
||||
<td className="rtitle">
|
||||
<span className="gsm">
|
||||
{meta(row.game).short}
|
||||
{serverSuffix(
|
||||
state,
|
||||
row.game,
|
||||
state.accounts[row.accountId]?.region,
|
||||
)}
|
||||
</span>
|
||||
{row.cadence === "day" && row.id.startsWith("dailies:")
|
||||
? "Dailies: "
|
||||
: ""}
|
||||
{row.title}
|
||||
{row.task?.note && (
|
||||
<small className="routine-note">{row.task.note}</small>
|
||||
)}
|
||||
{group === "game" && (
|
||||
<small className="routine-cadence">
|
||||
{row.cadence === "day"
|
||||
? "Daily"
|
||||
: row.cadence === "week"
|
||||
? "Weekly"
|
||||
: row.cadence === "month"
|
||||
? "Monthly"
|
||||
: "Game cycle"}
|
||||
</small>
|
||||
)}
|
||||
</td>
|
||||
<td className="prog">
|
||||
<RoutineProgress
|
||||
row={row}
|
||||
onAction={onAction}
|
||||
now={now}
|
||||
/>
|
||||
</td>
|
||||
{(["checklist", "timeline"] as const).map((key) => (
|
||||
<td className="c-pin" key={key}>
|
||||
{key === "timeline" && row.cadence === "day" ? (
|
||||
<span
|
||||
className="no-pin"
|
||||
title="Dailies stay on the Checklist"
|
||||
>
|
||||
—
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
className="pin"
|
||||
aria-label={`${row.placement[key] ? "Unpin from" : "Pin to"} ${key === "checklist" ? "Checklist" : "Timeline"}: ${row.title}`}
|
||||
aria-pressed={row.placement[key]}
|
||||
title={`${row.placement[key] ? "Shown in" : "Show in"} ${key === "checklist" ? "Checklist" : "Timeline"}`}
|
||||
onClick={() =>
|
||||
setPlacement(row, key, !row.placement[key])
|
||||
}
|
||||
>
|
||||
<PinIcon kind={key} on={row.placement[key]} />
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
<td className="c-k">
|
||||
<details className="routine-menu">
|
||||
<summary
|
||||
className="kebab"
|
||||
aria-label={`More for ${row.title}`}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle cx="5" cy="12" r="1.7" />
|
||||
<circle cx="12" cy="12" r="1.7" />
|
||||
<circle cx="19" cy="12" r="1.7" />
|
||||
</svg>
|
||||
</summary>
|
||||
<div>
|
||||
{row.source && (
|
||||
<a
|
||||
href={row.source}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Source
|
||||
</a>
|
||||
)}
|
||||
{row.id.startsWith("routine:") ? (
|
||||
<button onClick={() => void copy(row)}>
|
||||
Make it mine
|
||||
</button>
|
||||
) : (
|
||||
row.task && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => open("create", row.task)}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
void onAction({
|
||||
type: "record",
|
||||
collection: "tasks",
|
||||
id: row.id,
|
||||
value: null,
|
||||
...actionMeta(),
|
||||
})
|
||||
}
|
||||
>
|
||||
Delete routine
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
<button
|
||||
onClick={() =>
|
||||
setPlacement(
|
||||
row,
|
||||
"followed",
|
||||
!row.placement.followed,
|
||||
)
|
||||
}
|
||||
>
|
||||
{row.placement.followed
|
||||
? "Stop following"
|
||||
: "Follow"}
|
||||
</button>
|
||||
</div>
|
||||
</details>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
<details className="routine-histories">
|
||||
<summary>Catch up & recorded history</summary>
|
||||
{accounts.map((a) => (
|
||||
<RoutineHistory
|
||||
key={a.id}
|
||||
account={a}
|
||||
state={state}
|
||||
now={now}
|
||||
onAction={onAction}
|
||||
/>
|
||||
))}
|
||||
</details>
|
||||
{!accounts.some((a) =>
|
||||
routineRows(state, a.id, now).some((r) => r.placement.followed),
|
||||
) && (
|
||||
<p className="empty-state">
|
||||
No routines followed yet. Browse the library to choose your dailies,
|
||||
weekly tasks and game cycles.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** The Round 6 pin glyphs: a list for the Checklist, staggered bars for the Timeline. */
|
||||
function PinIcon({ kind }: { kind: "checklist" | "timeline"; on?: boolean }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{kind === "checklist" ? (
|
||||
<>
|
||||
<path d="M9 6h11M9 12h11M9 18h11" />
|
||||
<path d="M4 6h.01M4 12h.01M4 18h.01" strokeWidth="3" />
|
||||
</>
|
||||
) : (
|
||||
<path d="M4 6h9M8 12h12M4 18h7" strokeWidth="2.2" />
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import type { ReaderState } from "../../state/reader/schema.ts";
|
||||
import { useGameMeta } from "../../state/gameMeta.tsx";
|
||||
import { orderGames } from "../../state/gameOrder.ts";
|
||||
import { useState, type ReactNode } from "react";
|
||||
export type SettingsPage =
|
||||
| "own"
|
||||
| "games"
|
||||
| "appearance"
|
||||
| "time"
|
||||
| "sources"
|
||||
| "backup"
|
||||
| "about"
|
||||
| "changelog";
|
||||
export const SETTINGS_PAGES: readonly [SettingsPage, string][] = [
|
||||
["about", "About Event Clock"],
|
||||
["changelog", "Changelog"],
|
||||
["sources", "Data & sources"],
|
||||
["time", "Region & time"],
|
||||
["appearance", "Appearance"],
|
||||
["own", "Your own events and games"],
|
||||
["backup", "Backup"],
|
||||
];
|
||||
export function SettingsLayout({
|
||||
page,
|
||||
onPage,
|
||||
children,
|
||||
state,
|
||||
game,
|
||||
onGame,
|
||||
}: {
|
||||
state?: ReaderState;
|
||||
game?: string | undefined;
|
||||
onGame?: (game: string) => void;
|
||||
page: SettingsPage;
|
||||
onPage: (page: SettingsPage) => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const meta = useGameMeta();
|
||||
const [allGames, setAllGames] = useState(false);
|
||||
const games = state
|
||||
? orderGames(
|
||||
[
|
||||
...new Set(
|
||||
Object.values(state.accounts)
|
||||
.filter((a) => !a.retiredAt)
|
||||
.map((a) => a.game),
|
||||
),
|
||||
],
|
||||
Array.isArray(state.prefs.gameOrder)
|
||||
? (state.prefs.gameOrder as string[])
|
||||
: undefined,
|
||||
(g) => meta(g).name,
|
||||
)
|
||||
: [];
|
||||
const isHidden = (g: string) =>
|
||||
Array.isArray(state?.prefs.hiddenGames) &&
|
||||
state.prefs.hiddenGames.includes(g);
|
||||
games.sort((a, b) => Number(isHidden(a)) - Number(isHidden(b)));
|
||||
const descriptions: Partial<Record<SettingsPage, string>> = {
|
||||
own: "Events and games you added",
|
||||
appearance: "Theme, colours & default view",
|
||||
time: "Server defaults & display timezone",
|
||||
sources: "Freshness, sources & list options",
|
||||
backup: "Save, export & restore",
|
||||
about: "Credits & acknowledgements",
|
||||
changelog: "What changed",
|
||||
};
|
||||
const chevron = (
|
||||
<svg
|
||||
className="settings-chevron"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="m6 4 4 4-4 4" />
|
||||
</svg>
|
||||
);
|
||||
const gameButton = (g: string) => {
|
||||
const accounts = Object.values(state!.accounts).filter(
|
||||
(a) => a.game === g && !a.retiredAt,
|
||||
);
|
||||
const parts = [
|
||||
["events", "events"],
|
||||
["dailies", "dailies"],
|
||||
["weeklyMonthly", "weeklies"],
|
||||
["cycles", "cycles"],
|
||||
] as const;
|
||||
const summary = isHidden(g)
|
||||
? "Not followed"
|
||||
: parts
|
||||
.filter(([key]) => accounts.some((a) => a.intent[key]))
|
||||
.map(([, label]) => label)
|
||||
.join(", ") || "Nothing tracked";
|
||||
return (
|
||||
<button
|
||||
key={g}
|
||||
className="settings-row"
|
||||
data-off={isHidden(g) || undefined}
|
||||
aria-current={page === "games" && game === g ? "page" : undefined}
|
||||
onClick={() => onGame?.(g)}
|
||||
style={{ ["--g" as string]: meta(g).hue }}
|
||||
>
|
||||
<span className="settings-marker" aria-hidden="true" />
|
||||
<span className="settings-copy">
|
||||
<span>{meta(g).name}</span>
|
||||
<small>
|
||||
{summary}
|
||||
{!isHidden(g) && accounts.length > 1
|
||||
? ` · ${accounts.map((a) => a.region).join(" + ")}`
|
||||
: ""}
|
||||
</small>
|
||||
</span>
|
||||
{chevron}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
const others = games.filter(isHidden);
|
||||
return (
|
||||
<div className="settings-layout">
|
||||
<nav aria-label="Settings pages">
|
||||
<h2>General</h2>
|
||||
{SETTINGS_PAGES.map(([id, label]) => (
|
||||
<button
|
||||
key={id}
|
||||
className="settings-row"
|
||||
aria-current={id === page && !game ? "page" : undefined}
|
||||
onClick={() => onPage(id)}
|
||||
>
|
||||
<span className="settings-marker" aria-hidden="true" />
|
||||
<span className="settings-copy">
|
||||
<span>{label}</span>
|
||||
<small>{descriptions[id]}</small>
|
||||
</span>
|
||||
{chevron}
|
||||
</button>
|
||||
))}
|
||||
<p className="settings-games-label">Your games</p>
|
||||
{games.filter((g) => !isHidden(g)).map(gameButton)}
|
||||
{others.length > 0 && (
|
||||
<>
|
||||
<p className="settings-games-label">Other games</p>
|
||||
{(allGames ? others : others.slice(0, 3)).map(gameButton)}
|
||||
{!allGames && others.length > 3 && (
|
||||
<button onClick={() => setAllGames(true)}>
|
||||
Show {others.length - 3} more games
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
<section className="settings-content">{children}</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,517 @@
|
||||
import { useState, type CSSProperties, type ReactNode } from "react";
|
||||
import { TimezoneSelect } from "./TimezoneSelect.tsx";
|
||||
import { ViewCards } from "./ViewCards.tsx";
|
||||
import { GAMES, metaFor, type GameMeta } from "../../../shared/games.ts";
|
||||
import { offered, routinesFor } from "../../../shared/routines.ts";
|
||||
import type { ReaderState, TrackingIntent } from "../../state/reader/schema.ts";
|
||||
import type { SetupDraft } from "../../state/reader/onboarding.ts";
|
||||
|
||||
// A translation of the Round 6 B onboarding (design/mockups/round6/b-rail.html, `onboarding()`):
|
||||
// preferences, a two-column game list, one tracking table and view cards.
|
||||
type Kind = "events" | "dailies" | "weeklyMonthly" | "cycles";
|
||||
const KINDS: readonly { id: Kind; label: string; about: string }[] = [
|
||||
{
|
||||
id: "events",
|
||||
label: "Events",
|
||||
about:
|
||||
"Time-limited activities from source pages. Track a deadline once, then complete or ignore it.",
|
||||
},
|
||||
{
|
||||
id: "dailies",
|
||||
label: "Dailies",
|
||||
about: "Chores that reset each game-day, such as daily commissions.",
|
||||
},
|
||||
{
|
||||
id: "weeklyMonthly",
|
||||
label: "Weeklies & monthlies",
|
||||
about:
|
||||
"Goals that reset each week or month, such as weekly bosses or shop restocks.",
|
||||
},
|
||||
{
|
||||
id: "cycles",
|
||||
label: "Cycles",
|
||||
about:
|
||||
"Routines that follow a game’s own schedule. For example, an endgame mode might reset every 14 days. You can follow individual modes later in Routines.",
|
||||
},
|
||||
];
|
||||
const EVENTS_ONLY: TrackingIntent = {
|
||||
events: true,
|
||||
dailies: false,
|
||||
weeklyMonthly: false,
|
||||
cycles: false,
|
||||
};
|
||||
const ORDER = Object.keys(GAMES);
|
||||
const rank = (g: string) =>
|
||||
ORDER.includes(g) ? ORDER.indexOf(g) : ORDER.length;
|
||||
const Check = () => (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M5 12.5l4.5 4.5L19 7.5" />
|
||||
</svg>
|
||||
);
|
||||
export function SetupWizard({
|
||||
state,
|
||||
meta = (id) => metaFor(id, {}),
|
||||
onFinish,
|
||||
}: {
|
||||
state: ReaderState;
|
||||
meta?: (id: string) => GameMeta;
|
||||
onFinish: (draft: SetupDraft) => Promise<void>;
|
||||
}) {
|
||||
const [step, setStep] = useState(0);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [draft, setDraft] = useState<SetupDraft>({
|
||||
region: "europe",
|
||||
interface: "structured",
|
||||
theme: "dark",
|
||||
scheme: "slate",
|
||||
timezone: null,
|
||||
games: [],
|
||||
tracking: {},
|
||||
showUpcoming: false,
|
||||
view: "soon",
|
||||
});
|
||||
const games = [
|
||||
...new Set(Object.values(state.accounts).map((a) => a.game)),
|
||||
].sort((a, b) => rank(a) - rank(b));
|
||||
const set = (
|
||||
p: Partial<SetupDraft> | ((d: SetupDraft) => Partial<SetupDraft>),
|
||||
) => setDraft((d) => ({ ...d, ...(typeof p === "function" ? p(d) : p) }));
|
||||
const hue = (g: string) => ({ "--g": meta(g).hue }) as CSSProperties;
|
||||
const intentOf = (g: string) => draft.tracking[g] ?? EVENTS_ONLY;
|
||||
const standard = state.prefs.standardPulls === true;
|
||||
const builtins = (g: string, k: Kind) =>
|
||||
k === "events"
|
||||
? 0
|
||||
: k === "dailies"
|
||||
? 1
|
||||
: routinesFor(g).filter(
|
||||
(r) =>
|
||||
offered(r, standard) &&
|
||||
(r.period.unit === "cycle") === (k === "cycles"),
|
||||
).length;
|
||||
const withGames = (choose: (current: string[]) => string[]) =>
|
||||
set((d) => {
|
||||
const chosen = choose(d.games).sort((a, b) => rank(a) - rank(b));
|
||||
return {
|
||||
games: chosen,
|
||||
tracking: Object.fromEntries(
|
||||
chosen.map((g) => [g, d.tracking[g] ?? EVENTS_ONLY]),
|
||||
),
|
||||
};
|
||||
});
|
||||
const next = () => setStep(step + 1);
|
||||
const back = () => setStep(step - 1);
|
||||
const finish = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await onFinish(draft);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
const foot = (count: string, action: ReactNode, extra?: ReactNode) => (
|
||||
<div className="ob-foot">
|
||||
{step > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn link"
|
||||
disabled={busy}
|
||||
onClick={back}
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
)}
|
||||
<span className="cnt">{count}</span>
|
||||
{extra}
|
||||
{action}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<section className="setup-wizard ob" aria-labelledby="setup-title">
|
||||
<div className="step">Step {step + 1} of 4</div>
|
||||
{step === 0 ? (
|
||||
<>
|
||||
<h1 id="setup-title">Make it yours</h1>
|
||||
<p className="lede">
|
||||
Start with a few preferences. You can change every answer in
|
||||
Settings later.
|
||||
</p>
|
||||
<div className="preference-grid">
|
||||
<label>
|
||||
Default game server
|
||||
<select
|
||||
value={draft.region}
|
||||
onChange={(e) =>
|
||||
set({ region: e.target.value as SetupDraft["region"] })
|
||||
}
|
||||
>
|
||||
<option value="europe">Europe</option>
|
||||
<option value="america">America</option>
|
||||
<option value="asia">Asia</option>
|
||||
</select>
|
||||
<small>
|
||||
Each game starts here. You can choose a different server for a
|
||||
game or track two accounts separately.
|
||||
</small>
|
||||
</label>
|
||||
<label>
|
||||
Appearance
|
||||
<select
|
||||
value={draft.theme}
|
||||
onChange={(e) =>
|
||||
set({ theme: e.target.value as SetupDraft["theme"] })
|
||||
}
|
||||
>
|
||||
<option value="dark">Dark</option>
|
||||
<option value="light">Light</option>
|
||||
<option value="system">System</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Colour scheme
|
||||
<select
|
||||
value={draft.scheme}
|
||||
onChange={(e) => set({ scheme: e.target.value })}
|
||||
>
|
||||
<option value="slate">Slate</option>
|
||||
<option value="warm">Warm</option>
|
||||
<option value="plum">Plum</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Your timezone
|
||||
<TimezoneSelect
|
||||
value={draft.timezone}
|
||||
onChange={(timezone) => set({ timezone })}
|
||||
/>
|
||||
<small>
|
||||
Automatic follows your device; server reset clocks stay
|
||||
separate.
|
||||
</small>
|
||||
</label>
|
||||
</div>
|
||||
{foot(
|
||||
"Next: choose your games",
|
||||
<button type="button" className="btn solid" onClick={next}>
|
||||
Continue
|
||||
</button>,
|
||||
)}
|
||||
</>
|
||||
) : step === 1 ? (
|
||||
<>
|
||||
<h1 id="setup-title">Which games do you play?</h1>
|
||||
<p className="lede">
|
||||
You'll only see events for these. You can change it, and set each
|
||||
game's server, in Settings.
|
||||
</p>
|
||||
<div className="gpick">
|
||||
{games.map((g) => {
|
||||
const on = draft.games.includes(g);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={g}
|
||||
aria-pressed={on}
|
||||
style={hue(g)}
|
||||
onClick={() =>
|
||||
withGames((current) =>
|
||||
current.includes(g)
|
||||
? current.filter((x) => x !== g)
|
||||
: [...current, g],
|
||||
)
|
||||
}
|
||||
>
|
||||
<span className="sw2" aria-hidden="true" />
|
||||
{meta(g).name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{foot(
|
||||
draft.games.length
|
||||
? `${draft.games.length} chosen`
|
||||
: "Pick at least one",
|
||||
<button
|
||||
type="button"
|
||||
className="btn solid"
|
||||
disabled={!draft.games.length}
|
||||
onClick={next}
|
||||
>
|
||||
Continue
|
||||
</button>,
|
||||
<button
|
||||
type="button"
|
||||
className="btn link"
|
||||
onClick={() => {
|
||||
withGames(() => [...games]);
|
||||
next();
|
||||
}}
|
||||
>
|
||||
Choose all games instead
|
||||
</button>,
|
||||
)}
|
||||
</>
|
||||
) : step === 2 ? (
|
||||
<TrackingStep
|
||||
draft={draft}
|
||||
set={set}
|
||||
hue={hue}
|
||||
meta={meta}
|
||||
intentOf={intentOf}
|
||||
builtins={builtins}
|
||||
foot={foot(
|
||||
trackingSummary(draft, intentOf, builtins),
|
||||
<button type="button" className="btn solid" onClick={next}>
|
||||
Continue
|
||||
</button>,
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<h1 id="setup-title">Which view should open first?</h1>
|
||||
<p className="lede">
|
||||
Both are always one tab away. This is just what you land on.
|
||||
</p>
|
||||
<ViewCards
|
||||
value={draft.view === "timeline" ? "timeline" : "soon"}
|
||||
onChange={(view) => set({ view })}
|
||||
/>
|
||||
{draft.view === "timeline" && (
|
||||
<div className="look-choice">
|
||||
<h2>Which timeline design?</h2>
|
||||
<p className="lede">
|
||||
Both show the same events. You can switch any time in Settings →
|
||||
Appearance.
|
||||
</p>
|
||||
<div className="viewpick">
|
||||
<button
|
||||
type="button"
|
||||
className="vcard"
|
||||
aria-pressed={(draft.timelineLook ?? "new") === "new"}
|
||||
onClick={() => set({ timelineLook: "new" })}
|
||||
>
|
||||
<div className="vprev tl-new" aria-hidden="true">
|
||||
{[
|
||||
["#4EA8DE", "0%", "62%"],
|
||||
["#F2A03D", "18%", "48%"],
|
||||
["#3DD6A0", "8%", "80%"],
|
||||
].map(([g, left, width]) => (
|
||||
<span key={g}>
|
||||
<i className="name" />
|
||||
<i
|
||||
className="bar"
|
||||
style={{ "--g": g, left, width } as CSSProperties}
|
||||
/>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<b>New look</b>
|
||||
<small>
|
||||
A name column beside each bar, with routines on the same
|
||||
grid.
|
||||
</small>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="vcard"
|
||||
aria-pressed={draft.timelineLook === "classic"}
|
||||
onClick={() => set({ timelineLook: "classic" })}
|
||||
>
|
||||
<div className="vprev tl-classic" aria-hidden="true">
|
||||
<em
|
||||
className="lane"
|
||||
style={{ "--g": "#4EA8DE" } as CSSProperties}
|
||||
/>
|
||||
{[
|
||||
["#4EA8DE", "0%", "70%"],
|
||||
["#4EA8DE", "0%", "42%"],
|
||||
["#F2A03D", "24%", "64%"],
|
||||
].map(([g, left, width], i) => (
|
||||
<i
|
||||
key={i}
|
||||
style={{ "--g": g, left, width } as CSSProperties}
|
||||
>
|
||||
<span />
|
||||
</i>
|
||||
))}
|
||||
</div>
|
||||
<b>Classic look</b>
|
||||
<small>
|
||||
Filled bars with the title inside, grouped under each game.
|
||||
</small>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{foot(
|
||||
"",
|
||||
<button
|
||||
type="button"
|
||||
className="btn solid"
|
||||
disabled={busy}
|
||||
onClick={finish}
|
||||
>
|
||||
Start
|
||||
</button>,
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
function trackingSummary(
|
||||
draft: SetupDraft,
|
||||
intentOf: (g: string) => TrackingIntent,
|
||||
builtins: (g: string, k: Kind) => number,
|
||||
): string {
|
||||
const routineKinds: Kind[] = ["dailies", "weeklyMonthly", "cycles"];
|
||||
const total = draft.games.reduce(
|
||||
(n, g) =>
|
||||
n +
|
||||
routineKinds
|
||||
.filter((k) => intentOf(g)[k])
|
||||
.reduce((t, k) => t + builtins(g, k), 0),
|
||||
0,
|
||||
);
|
||||
if (total)
|
||||
return `Follows ${total} built-in routine${total === 1 ? "" : "s"}. You'll choose where they show once you've seen the views.`;
|
||||
return draft.games.some((g) => routineKinds.some((k) => intentOf(g)[k]))
|
||||
? "Your type choices are saved; no matching built-ins are available yet."
|
||||
: "Events only, for now.";
|
||||
}
|
||||
function TrackingStep({
|
||||
draft,
|
||||
set,
|
||||
hue,
|
||||
meta,
|
||||
intentOf,
|
||||
builtins,
|
||||
foot,
|
||||
}: {
|
||||
draft: SetupDraft;
|
||||
set: (
|
||||
p: Partial<SetupDraft> | ((d: SetupDraft) => Partial<SetupDraft>),
|
||||
) => void;
|
||||
hue: (g: string) => CSSProperties;
|
||||
meta: (id: string) => GameMeta;
|
||||
intentOf: (g: string) => TrackingIntent;
|
||||
builtins: (g: string, k: Kind) => number;
|
||||
foot: ReactNode;
|
||||
}) {
|
||||
const update = (gs: string[], k: Kind, on: boolean) =>
|
||||
set((d) => ({
|
||||
tracking: {
|
||||
...d.tracking,
|
||||
...Object.fromEntries(
|
||||
gs.map((g) => [g, { ...(d.tracking[g] ?? EVENTS_ONLY), [k]: on }]),
|
||||
),
|
||||
},
|
||||
}));
|
||||
const missing = draft.games.flatMap((g) =>
|
||||
KINDS.filter((k) => k.id !== "events" && !builtins(g, k.id)).map(
|
||||
(k) =>
|
||||
`${meta(g).short}: no built-in ${k.label.toLowerCase()} available yet.`,
|
||||
),
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<h1 id="setup-title">What do you want to track?</h1>
|
||||
<p className="lede">
|
||||
Pick any mix, per game or for all of them. Routines are the things that
|
||||
reset; they get their own tab, where you can follow or drop single ones
|
||||
later.
|
||||
</p>
|
||||
<dl className="type-guide">
|
||||
{KINDS.map((k) => (
|
||||
<div key={k.id}>
|
||||
<dt>{k.label}</dt>
|
||||
<dd>{k.about}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
<div className="availability">
|
||||
<p>
|
||||
Every type is selectable. We remember what you want to track even when
|
||||
there is nothing available today.
|
||||
</p>
|
||||
{missing.length > 0 && <p>{missing.join(" ")}</p>}
|
||||
<p>
|
||||
A supported routine between runs is different: its next window will
|
||||
appear when its schedule is available. New built-ins are offered for
|
||||
review in Routines.
|
||||
</p>
|
||||
</div>
|
||||
<div className="matrix-scroll">
|
||||
<table className="mtx">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Game</th>
|
||||
{KINDS.map((k) => (
|
||||
<th scope="col" key={k.id}>
|
||||
{k.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr className="all">
|
||||
<th scope="row">
|
||||
Same for all<small>Sets every game below</small>
|
||||
</th>
|
||||
{KINDS.map((k) => {
|
||||
const n = draft.games.filter((g) => intentOf(g)[k.id]).length;
|
||||
const all = n === draft.games.length;
|
||||
return (
|
||||
<td key={k.id}>
|
||||
<button
|
||||
type="button"
|
||||
className="tg"
|
||||
aria-pressed={all ? true : n ? "mixed" : false}
|
||||
data-mixed={n > 0 && !all}
|
||||
aria-label={`All games: ${k.label}`}
|
||||
onClick={() => update(draft.games, k.id, !all)}
|
||||
>
|
||||
<Check />
|
||||
</button>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
{draft.games.map((g) => (
|
||||
<tr key={g} style={hue(g)}>
|
||||
<th scope="row">{meta(g).short}</th>
|
||||
{KINDS.map((k) => (
|
||||
<td key={k.id}>
|
||||
<button
|
||||
type="button"
|
||||
className="tg"
|
||||
aria-pressed={intentOf(g)[k.id]}
|
||||
aria-label={`${meta(g).short}: ${k.label}`}
|
||||
onClick={() => update([g], k.id, !intentOf(g)[k.id])}
|
||||
>
|
||||
<Check />
|
||||
</button>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="upcoming-choice">
|
||||
<label className="chk">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={draft.showUpcoming}
|
||||
onChange={(e) => set({ showUpcoming: e.target.checked })}
|
||||
/>
|
||||
<span>
|
||||
Show events that haven’t started
|
||||
<small>Include upcoming events in Checklist and Timeline.</small>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
{foot}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { actionMeta } from "../../state/reader/actions.ts";
|
||||
import type { ReaderState } from "../../state/reader/schema.ts";
|
||||
import { useGameMeta } from "../../state/gameMeta.tsx";
|
||||
import { manyServers } from "../../state/reader/servers.ts";
|
||||
import { REGION_LABEL } from "../../../shared/time.ts";
|
||||
import type { ActionHandler } from "./GameSettings.tsx";
|
||||
import { TimezoneSelect, zoneOffset } from "./TimezoneSelect.tsx";
|
||||
import { ResetTimingRow } from "./ResetTimingRow.tsx";
|
||||
|
||||
/** The Round 6 B “Region & time” pane: default server, display timezone, and each server's reset. */
|
||||
export function TimeSettings({
|
||||
state,
|
||||
now,
|
||||
onAction,
|
||||
}: {
|
||||
state: ReaderState;
|
||||
now: number;
|
||||
onAction: ActionHandler;
|
||||
}) {
|
||||
const meta = useGameMeta();
|
||||
const chosen =
|
||||
typeof state.prefs.timezone === "string" ? state.prefs.timezone : null;
|
||||
const shown = chosen ?? Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
const patch = (value: Record<string, unknown>) =>
|
||||
void onAction({ type: "prefs", patch: value, ...actionMeta() });
|
||||
const accounts = Object.values(state.accounts).filter(
|
||||
(a) =>
|
||||
!a.retiredAt &&
|
||||
!(
|
||||
Array.isArray(state.prefs.hiddenGames) &&
|
||||
state.prefs.hiddenGames.includes(a.game)
|
||||
),
|
||||
);
|
||||
return (
|
||||
<section className="time-page">
|
||||
<h1>Region & time</h1>
|
||||
<p className="muted-copy">
|
||||
Your default server starts each game's setup. You can change it per game
|
||||
and track more than one server.
|
||||
</p>
|
||||
<section className="settings-group">
|
||||
<h2>Default game server</h2>
|
||||
<div
|
||||
className="segmented"
|
||||
role="group"
|
||||
aria-label="Default game server"
|
||||
>
|
||||
{(["asia", "america", "europe"] as const).map((region) => (
|
||||
<button
|
||||
key={region}
|
||||
aria-pressed={(state.prefs.region ?? "europe") === region}
|
||||
onClick={() => patch({ region })}
|
||||
>
|
||||
{REGION_LABEL[region]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<section className="settings-group">
|
||||
<h2>Your timezone</h2>
|
||||
<p className="muted-copy">
|
||||
Automatic follows your device. A chosen timezone changes displayed
|
||||
dates and times; game servers still determine resets and deadlines.
|
||||
</p>
|
||||
<div className="zone-pick">
|
||||
<TimezoneSelect
|
||||
value={chosen}
|
||||
onChange={(timezone) => patch({ timezone })}
|
||||
/>
|
||||
</div>
|
||||
<p className="source-note">
|
||||
Currently displaying {shown} · {zoneOffset(shown)}. Named zones follow
|
||||
daylight saving automatically.
|
||||
</p>
|
||||
</section>
|
||||
{accounts.length > 0 && (
|
||||
<section className="settings-group">
|
||||
<h2>Reset timing for your games</h2>
|
||||
<p className="muted-copy">
|
||||
Each server uses its automatic reset clock. Set an override if the
|
||||
listed time is wrong, and return to Automatic any time. Earlier
|
||||
progress is kept.
|
||||
</p>
|
||||
{accounts.map((a) => (
|
||||
<ResetTimingRow
|
||||
key={a.id}
|
||||
account={a}
|
||||
label={`${meta(a.game).short}${manyServers(state, a.game) ? ` · ${REGION_LABEL[a.region]}` : ""}`}
|
||||
now={now}
|
||||
onAction={onAction}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,550 @@
|
||||
import { manyServers, serverSuffix } from "../../state/reader/servers.ts";
|
||||
import {
|
||||
Fragment,
|
||||
useContext,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { occurrencesInFor, validRecords } from "../../state/useCustom.ts";
|
||||
import { CustomEvent } from "../../../shared/custom.ts";
|
||||
import {
|
||||
readerClockFor,
|
||||
readerDayKey,
|
||||
resolveReaderClock,
|
||||
} from "../../../shared/readerClock.ts";
|
||||
import { RoutineControl } from "./DailyPanel.tsx";
|
||||
import { routineRows, type RoutineRow } from "../../state/reader/routines.ts";
|
||||
import { actionMeta } from "../../state/reader/actions.ts";
|
||||
import { useGameMeta } from "../../state/gameMeta.tsx";
|
||||
import { DAY, formatRemaining, resetShiftMs } from "../../../shared/time.ts";
|
||||
import { periodOf } from "../../../shared/period.ts";
|
||||
import { routinesFor } from "../../../shared/routines.ts";
|
||||
import { canStep, snapDayWidth, stepDayWidth } from "../../state/zoom.ts";
|
||||
import { orderGames } from "../../state/gameOrder.ts";
|
||||
import { DisplayTimeContext, formatInZone } from "../../state/displayTime.ts";
|
||||
import type { ReaderState } from "../../state/reader/schema.ts";
|
||||
import type { ReaderEventRow } from "../../state/reader/eventRows.ts";
|
||||
import type { ActionHandler } from "./GameSettings.tsx";
|
||||
import {
|
||||
BoardHeader,
|
||||
Timeline as ClassicTimeline,
|
||||
PillChoice,
|
||||
openingLeft,
|
||||
} from "../Timeline.tsx";
|
||||
|
||||
/** `prefs.timelineLook`: the redesign's grid (default) or the paimon.moe-style board. */
|
||||
export const timelineLook = (prefs: Record<string, unknown>) =>
|
||||
prefs.timelineLook === "classic" ? "classic" : "new";
|
||||
/** Dailies stay on the Checklist; the Timeline draws longer routines only. */
|
||||
export const onTimeline = (r: RoutineRow) =>
|
||||
r.placement.followed && r.placement.timeline && r.cadence !== "day";
|
||||
|
||||
/** Periods use each routine's resolved clock, including built-in clock overrides. */
|
||||
export function routinePeriod(row: RoutineRow, now: number) {
|
||||
const key = readerDayKey(now, row.clock),
|
||||
task = row.task ?? routinesFor(row.game).find((r) => r.id === row.id);
|
||||
const period = task
|
||||
? periodOf(task.period, key)
|
||||
: { start: key, days: [key] };
|
||||
const shift = resetShiftMs(row.clock.region, row.game, row.clock);
|
||||
const start = Date.parse(period.start + "T00:00:00Z") - shift;
|
||||
return { start, end: start + period.days.length * DAY };
|
||||
}
|
||||
export function TimelinePage({
|
||||
state,
|
||||
focus = null,
|
||||
focusBar = null,
|
||||
rows,
|
||||
now,
|
||||
onAction,
|
||||
onOpen,
|
||||
}: {
|
||||
state: ReaderState;
|
||||
/** The game in focus, already null when this view hides its focus bar. */
|
||||
focus?: string | null;
|
||||
/** Drawn under the board's header, in either look. */
|
||||
focusBar?: ReactNode;
|
||||
rows: ReaderEventRow[];
|
||||
now: number;
|
||||
onAction: ActionHandler;
|
||||
onOpen: (row: ReaderEventRow) => void;
|
||||
}) {
|
||||
const p = state.prefs,
|
||||
meta = useGameMeta(),
|
||||
zone = useContext(DisplayTimeContext);
|
||||
const [routine, setRoutine] = useState<RoutineRow | null>(null),
|
||||
scroller = useRef<HTMLDivElement>(null),
|
||||
previous = useRef<{ width: number; min: number } | null>(null);
|
||||
const dayWidth = snapDayWidth(
|
||||
typeof p.timelineDayWidth === "number" ? p.timelineDayWidth : 32,
|
||||
);
|
||||
const active = Object.values(state.accounts).filter(
|
||||
(a) =>
|
||||
!a.retiredAt &&
|
||||
!(Array.isArray(p.hiddenGames) && p.hiddenGames.includes(a.game)),
|
||||
);
|
||||
const accounts = active.filter(
|
||||
(a) =>
|
||||
(!focus || a.game === focus) &&
|
||||
(rows.some((r) => r.accountId === a.id) ||
|
||||
routineRows(state, a.id, now).some(onTimeline)),
|
||||
);
|
||||
const min =
|
||||
Math.floor(
|
||||
Math.max(
|
||||
now - 60 * DAY,
|
||||
Math.min(now - 7 * DAY, ...rows.map((r) => r.clock.startsMs)) - 7 * DAY,
|
||||
) / DAY,
|
||||
) * DAY;
|
||||
const max =
|
||||
Math.ceil(
|
||||
Math.max(
|
||||
now + 35 * DAY,
|
||||
...rows.map((r) => r.clock.endsMs ?? now + 35 * DAY),
|
||||
) / DAY,
|
||||
) * DAY;
|
||||
const days = Math.ceil((max - min) / DAY),
|
||||
width = days * dayWidth;
|
||||
const look = timelineLook(p),
|
||||
showEnd = p.timelineLabel === "end",
|
||||
day = (ms: number) => formatInZone(ms, false, zone).replace(/ \d{4}$/, "");
|
||||
const patch = (value: Record<string, unknown>) =>
|
||||
void onAction({ type: "prefs", patch: value, ...actionMeta() });
|
||||
const ownSection = p.resetStrips === true;
|
||||
const options = (
|
||||
<>
|
||||
<PillChoice
|
||||
label="Bar labels"
|
||||
value={showEnd ? "end" : "left"}
|
||||
options={[
|
||||
{ id: "left", label: "Show time left" },
|
||||
{ id: "end", label: "Show end dates" },
|
||||
]}
|
||||
onChange={(timelineLabel) => patch({ timelineLabel })}
|
||||
/>
|
||||
<PillChoice
|
||||
label="Routines"
|
||||
value={ownSection ? "own" : "together"}
|
||||
options={[
|
||||
{ id: "together", label: "Group routines and events" },
|
||||
{ id: "own", label: "Show routines in own section" },
|
||||
]}
|
||||
onChange={(v) => patch({ resetStrips: v === "own" })}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
const focusRow = focusBar && <div className="timeline-focus">{focusBar}</div>;
|
||||
const liveStarts = rows
|
||||
.filter((r) => r.clock.live)
|
||||
.map((r) => r.clock.startsMs)
|
||||
.sort((a, b) => a - b);
|
||||
const openLeft = (viewport: number) =>
|
||||
openingLeft(
|
||||
((now - min) / DAY) * dayWidth,
|
||||
(((liveStarts[Math.floor(liveStarts.length / 2)] ?? now) - min) / DAY) *
|
||||
dayWidth,
|
||||
viewport,
|
||||
);
|
||||
useLayoutEffect(() => {
|
||||
const el = scroller.current;
|
||||
if (!el) return;
|
||||
const old = previous.current;
|
||||
const label = el.clientWidth < 700 ? 170 : 250;
|
||||
if (old && old.width !== dayWidth) {
|
||||
const centre = (el.scrollLeft + (el.clientWidth - label) / 2) / old.width;
|
||||
el.scrollLeft = centre * dayWidth - (el.clientWidth - label) / 2;
|
||||
} else if (!old) el.scrollLeft = openLeft(el.clientWidth - label);
|
||||
previous.current = { width: dayWidth, min };
|
||||
}, [dayWidth, min, now]);
|
||||
const expanded = accounts
|
||||
.filter((a) => a.intent.events)
|
||||
.flatMap((a) =>
|
||||
occurrencesInFor(validRecords(state.customEvents, CustomEvent), min, max)
|
||||
.filter(
|
||||
(e) =>
|
||||
e.game === a.game && !state.archive[JSON.stringify([a.id, e.id])],
|
||||
)
|
||||
.map((event) => ({
|
||||
event,
|
||||
accountId: a.id,
|
||||
region: a.region,
|
||||
clockRevision: resolveReaderClock(a, now).revision,
|
||||
clock: readerClockFor(event, a, now),
|
||||
})),
|
||||
);
|
||||
const eventRows = [
|
||||
...new Map(
|
||||
[...rows, ...expanded]
|
||||
.filter(
|
||||
(r) =>
|
||||
(!focus || r.event.game === focus) &&
|
||||
!r.clock.ended &&
|
||||
(p.showUpcoming === true || !r.clock.upcoming),
|
||||
)
|
||||
.map((r) => [JSON.stringify([r.accountId, r.event.id]), r]),
|
||||
).values(),
|
||||
];
|
||||
const order = orderGames(
|
||||
[...new Set(accounts.map((a) => a.game))],
|
||||
Array.isArray(p.gameOrder) ? (p.gameOrder as string[]) : undefined,
|
||||
(g) => meta(g).name,
|
||||
);
|
||||
const ordered = accounts
|
||||
.slice()
|
||||
.sort((a, b) =>
|
||||
p.timelineGroup === "ending"
|
||||
? Math.min(
|
||||
...eventRows
|
||||
.filter((r) => r.accountId === a.id)
|
||||
.map((r) => r.clock.endsMs ?? Infinity),
|
||||
) -
|
||||
Math.min(
|
||||
...eventRows
|
||||
.filter((r) => r.accountId === b.id)
|
||||
.map((r) => r.clock.endsMs ?? Infinity),
|
||||
)
|
||||
: order.indexOf(a.game) - order.indexOf(b.game),
|
||||
);
|
||||
const pos = (ms: number) => ((ms - min) / DAY) * dayWidth;
|
||||
const span = (start: number, end: number) => ({
|
||||
left: Math.max(0, pos(start)),
|
||||
width: Math.max(4, Math.min(width, pos(end)) - Math.max(0, pos(start))),
|
||||
});
|
||||
const eventRow = (r: ReaderEventRow) => (
|
||||
<div
|
||||
className="timeline-entry"
|
||||
key={JSON.stringify([r.accountId, r.event.id])}
|
||||
style={{ "--g": meta(r.event.game).hue } as CSSProperties}
|
||||
>
|
||||
<button
|
||||
className="timeline-name"
|
||||
onClick={() => onOpen(r)}
|
||||
aria-label={`View ${r.event.title} details`}
|
||||
>
|
||||
{r.event.title}
|
||||
<small>
|
||||
{meta(r.event.game).short}
|
||||
{serverSuffix(state, r.event.game, r.region)}
|
||||
{r.clock.upcoming ? " · Not started yet" : ""}
|
||||
</small>
|
||||
</button>
|
||||
<div className="time-track">
|
||||
<button
|
||||
className={`period-block${r.clock.endsMs === null ? " unknown-end" : ""}${r.clock.upcoming ? " future" : ""}`}
|
||||
style={span(r.clock.startsMs, r.clock.endsMs ?? max)}
|
||||
onClick={() => onOpen(r)}
|
||||
title={r.event.title}
|
||||
>
|
||||
<span>
|
||||
{r.clock.msRemaining === null
|
||||
? "End date unknown"
|
||||
: showEnd && r.clock.endsMs !== null
|
||||
? `Ends ${day(r.clock.endsMs)}`
|
||||
: formatRemaining(r.clock.msRemaining) + " left"}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
const routineRow = (r: RoutineRow) => {
|
||||
const period = routinePeriod(r, now);
|
||||
return (
|
||||
<div className="routine-timeline-row" key={r.accountId + r.id}>
|
||||
<div
|
||||
className="timeline-entry"
|
||||
style={{ "--g": meta(r.game).hue } as CSSProperties}
|
||||
>
|
||||
<button className="timeline-name" onClick={() => setRoutine(r)}>
|
||||
{r.title}
|
||||
<small>
|
||||
{r.cadence} · {r.status.count}/{r.status.required}
|
||||
</small>
|
||||
</button>
|
||||
<div className="time-track">
|
||||
<button
|
||||
className="period-block routine"
|
||||
style={span(period.start, period.end)}
|
||||
onClick={() => setRoutine(r)}
|
||||
>
|
||||
<i
|
||||
className="fill"
|
||||
style={{
|
||||
width: `${Math.min(100, (r.status.count / r.status.required) * 100)}%`,
|
||||
}}
|
||||
/>
|
||||
<span>
|
||||
{r.status.count}/{r.status.required} ·{" "}
|
||||
{showEnd
|
||||
? `resets ${formatInZone(period.end, true, zone)}`
|
||||
: `${formatRemaining(period.end - now)} to reset`}
|
||||
</span>
|
||||
</button>
|
||||
<i className="reset-mark" style={{ left: pos(period.end) }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
const routines = accounts
|
||||
.flatMap((a) => routineRows(state, a.id, now))
|
||||
.filter(onTimeline);
|
||||
const items = [
|
||||
...eventRows.map((row) => ({
|
||||
kind: "event" as const,
|
||||
row,
|
||||
accountId: row.accountId,
|
||||
end: row.clock.endsMs ?? Infinity,
|
||||
})),
|
||||
...routines.map((row) => ({
|
||||
kind: "routine" as const,
|
||||
row,
|
||||
accountId: row.accountId,
|
||||
end: routinePeriod(row, now).end,
|
||||
})),
|
||||
].sort((a, b) => a.end - b.end);
|
||||
const renderItem = (item: (typeof items)[number]) =>
|
||||
item.kind === "event" ? eventRow(item.row) : routineRow(item.row);
|
||||
if (p.timelineGroup === "ending")
|
||||
ordered.sort(
|
||||
(a, b) =>
|
||||
Math.min(
|
||||
...items.filter((i) => i.accountId === a.id).map((i) => i.end),
|
||||
) -
|
||||
Math.min(
|
||||
...items.filter((i) => i.accountId === b.id).map((i) => i.end),
|
||||
),
|
||||
);
|
||||
const queue = p.timelineGroup === "ending" && p.resetStrips !== true;
|
||||
return (
|
||||
<section className="reader-timeline">
|
||||
<div className="rt-top">
|
||||
<h1>Timeline</h1>
|
||||
<p className="sum">
|
||||
One row per event or routine. Your pins decide which routines appear.
|
||||
</p>
|
||||
</div>
|
||||
{look === "new" && (
|
||||
<BoardHeader
|
||||
group={p.timelineGroup === "ending" ? "ending" : "game"}
|
||||
onGroup={(timelineGroup) => patch({ timelineGroup })}
|
||||
controls={options}
|
||||
canOut={canStep(dayWidth, -1)}
|
||||
canIn={canStep(dayWidth, 1)}
|
||||
onZoom={(by) =>
|
||||
patch({ timelineDayWidth: stepDayWidth(dayWidth, by) })
|
||||
}
|
||||
onToday={() => {
|
||||
const el = scroller.current;
|
||||
if (el)
|
||||
el.scrollLeft = openLeft(
|
||||
el.clientWidth - (el.clientWidth < 700 ? 170 : 250),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{look === "new" && focusRow}
|
||||
{look === "classic" ? (
|
||||
<div className="classic-timeline">
|
||||
<ClassicTimeline
|
||||
rows={eventRows}
|
||||
now={now}
|
||||
dayWidth={dayWidth}
|
||||
onZoom={(timelineDayWidth) => patch({ timelineDayWidth })}
|
||||
group={p.timelineGroup === "ending" ? "ending" : "game"}
|
||||
onGroup={(timelineGroup) => patch({ timelineGroup })}
|
||||
showUpcoming={p.showUpcoming === true}
|
||||
splitUpcoming={p.timelineSplitUpcoming !== false}
|
||||
gameOrder={order}
|
||||
onOpen={() => {}}
|
||||
onOpenRow={(row) => onOpen(row as ReaderEventRow)}
|
||||
servers={(game) => manyServers(state, game)}
|
||||
isDone={() => false}
|
||||
controls={options}
|
||||
afterHeader={focusRow}
|
||||
routinesInline={!ownSection}
|
||||
label={(row) =>
|
||||
row.clock.msRemaining === null
|
||||
? null
|
||||
: showEnd && row.clock.endsMs !== null
|
||||
? `Ends ${day(row.clock.endsMs)}`
|
||||
: `${formatRemaining(row.clock.msRemaining)} left`
|
||||
}
|
||||
routines={routines.map((r) => {
|
||||
const period = routinePeriod(r, now);
|
||||
return {
|
||||
key: r.accountId + r.id,
|
||||
game: r.game,
|
||||
title: r.title,
|
||||
detail: `${r.status.count}/${r.status.required}`,
|
||||
start: period.start,
|
||||
end: period.end,
|
||||
done: r.status.met,
|
||||
label: showEnd
|
||||
? `resets ${day(period.end)}`
|
||||
: `${formatRemaining(period.end - now)} to reset`,
|
||||
onOpen: () => setRoutine(r),
|
||||
};
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
) : accounts.length === 0 ? (
|
||||
<p className="empty-state">
|
||||
No tracked events or routines to show here.
|
||||
</p>
|
||||
) : (
|
||||
<div className="timeline-scroller" ref={scroller}>
|
||||
<div
|
||||
className="timeline-board"
|
||||
style={
|
||||
{
|
||||
"--track-width": `${width}px`,
|
||||
"--day-width": `${dayWidth}px`,
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<div className="time-axis">
|
||||
<strong>Events & routines</strong>
|
||||
<div className="ticks">
|
||||
{Array.from({ length: days }, (_, i) => i)
|
||||
.filter(
|
||||
(i) => i % Math.max(1, Math.ceil(64 / dayWidth)) === 0,
|
||||
)
|
||||
.map((i) => (
|
||||
<span key={i} style={{ left: i * dayWidth }}>
|
||||
{formatInZone(min + i * DAY, false, zone).replace(
|
||||
/ \d{4}$/,
|
||||
"",
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="today-line"
|
||||
style={{ left: `calc(var(--name-width) + ${pos(now)}px)` }}
|
||||
/>
|
||||
{queue
|
||||
? items.map(renderItem)
|
||||
: ordered.map((a) => (
|
||||
<Fragment key={a.id}>
|
||||
<div
|
||||
className="game-separator"
|
||||
style={{ "--g": meta(a.game).hue } as CSSProperties}
|
||||
>
|
||||
<strong>{meta(a.game).name}</strong>
|
||||
<span>
|
||||
{manyServers(state, a.game)
|
||||
? `${a.region} · Events & routines`
|
||||
: "Events & routines"}
|
||||
</span>
|
||||
</div>
|
||||
{p.resetStrips === true ? (
|
||||
<>
|
||||
{eventRows
|
||||
.filter((r) => r.accountId === a.id)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(a.clock.endsMs ?? Infinity) -
|
||||
(b.clock.endsMs ?? Infinity),
|
||||
)
|
||||
.map(eventRow)}
|
||||
{routines.some((r) => r.accountId === a.id) && (
|
||||
<section
|
||||
className="reset-strip"
|
||||
aria-label={`${meta(a.game).short} routines`}
|
||||
>
|
||||
<div className="routine-divider">
|
||||
<span>Routines</span>
|
||||
</div>
|
||||
{routines
|
||||
.filter((r) => r.accountId === a.id)
|
||||
.map(routineRow)}
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
items.filter((i) => i.accountId === a.id).map(renderItem)
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{look === "new" && (
|
||||
<p className="timeline-help">
|
||||
Events and routine periods share one time axis. Dashed bars are
|
||||
routines; the vertical markers show resets. Open a bar to see details
|
||||
or record progress.
|
||||
</p>
|
||||
)}
|
||||
{routine && (
|
||||
<RoutineDetail
|
||||
row={
|
||||
routineRows(state, routine.accountId, now).find(
|
||||
(r) => r.id === routine.id,
|
||||
) ?? routine
|
||||
}
|
||||
server={serverSuffix(state, routine.game, routine.clock.region)}
|
||||
now={now}
|
||||
onAction={onAction}
|
||||
onClose={() => setRoutine(null)}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** A routine opened from either Timeline look: its period and its progress. */
|
||||
function RoutineDetail({
|
||||
row,
|
||||
server,
|
||||
now,
|
||||
onAction,
|
||||
onClose,
|
||||
}: {
|
||||
row: RoutineRow;
|
||||
/** `" · asia"` when the game has several servers, otherwise empty. */
|
||||
server: string;
|
||||
now: number;
|
||||
onAction: ActionHandler;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const dialog = useRef<HTMLDialogElement>(null),
|
||||
meta = useGameMeta()(row.game),
|
||||
zone = useContext(DisplayTimeContext),
|
||||
period = routinePeriod(row, now);
|
||||
useEffect(() => {
|
||||
dialog.current?.showModal();
|
||||
}, []);
|
||||
return (
|
||||
<dialog
|
||||
ref={dialog}
|
||||
className="routine-detail event-detail-sheet"
|
||||
aria-label={row.title}
|
||||
onCancel={onClose}
|
||||
onClick={(e) => {
|
||||
if (e.target === dialog.current) onClose();
|
||||
}}
|
||||
>
|
||||
<div className="drawer-head">
|
||||
<p className="detail-game" style={{ color: meta.hue }}>
|
||||
{meta.name}
|
||||
{server}
|
||||
</p>
|
||||
<button onClick={onClose} aria-label="Close routine">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
<h2 className="detail-title">{row.title}</h2>
|
||||
<p className="help">
|
||||
Resets {formatInZone(period.end, true, zone)} · in{" "}
|
||||
{formatRemaining(period.end - now)}
|
||||
</p>
|
||||
<RoutineControl row={row} onAction={onAction} now={now} />
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
/** "GMT+2" for a zone, now; daylight saving included. */
|
||||
export function zoneOffset(zone: string, at = new Date()): string {
|
||||
try {
|
||||
return (
|
||||
new Intl.DateTimeFormat("en", {
|
||||
timeZone: zone,
|
||||
timeZoneName: "shortOffset",
|
||||
})
|
||||
.formatToParts(at)
|
||||
.find((p) => p.type === "timeZoneName")?.value ?? ""
|
||||
);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/** "America/Argentina/Buenos_Aires" → "Buenos Aires, Argentina". */
|
||||
export function zoneName(zone: string): string {
|
||||
const parts = zone.split("/").map((p) => p.replace(/_/g, " "));
|
||||
if (parts.length === 1) return parts[0]!;
|
||||
const [, ...rest] = parts;
|
||||
return rest.reverse().join(", ");
|
||||
}
|
||||
|
||||
const REGIONS = [
|
||||
"Europe",
|
||||
"America",
|
||||
"Asia",
|
||||
"Australia",
|
||||
"Pacific",
|
||||
"Africa",
|
||||
"Atlantic",
|
||||
"Indian",
|
||||
"Antarctica",
|
||||
"Arctic",
|
||||
];
|
||||
|
||||
/** Every IANA zone, grouped by region and named by city, A–Z within each group. */
|
||||
export function zoneGroups(zones: readonly string[]) {
|
||||
const groups = new Map<string, { zone: string; name: string }[]>();
|
||||
for (const zone of zones) {
|
||||
const head = zone.split("/")[0]!;
|
||||
const group = REGIONS.includes(head) && zone.includes("/") ? head : "Other";
|
||||
const list = groups.get(group) ?? [];
|
||||
list.push({ zone, name: zoneName(zone) });
|
||||
groups.set(group, list);
|
||||
}
|
||||
return [...REGIONS, "Other"]
|
||||
.filter((g) => groups.has(g))
|
||||
.map((g) => ({
|
||||
group: g,
|
||||
zones: groups.get(g)!.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* The display timezone choice: Automatic first, then every zone grouped by
|
||||
* region with its offset. One native select, so the whole list opens at once
|
||||
* and typing a city's first letters jumps to it — no separate search box.
|
||||
*/
|
||||
export function TimezoneSelect({
|
||||
value,
|
||||
onChange,
|
||||
label = "Your timezone",
|
||||
}: {
|
||||
/** null = Automatic (follow the device). */
|
||||
value: string | null;
|
||||
onChange: (zone: string | null) => void;
|
||||
label?: string;
|
||||
}) {
|
||||
const device = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
const groups = useMemo(
|
||||
() => zoneGroups(Intl.supportedValuesOf("timeZone")),
|
||||
[],
|
||||
);
|
||||
return (
|
||||
<select
|
||||
aria-label={label}
|
||||
value={value ?? "automatic"}
|
||||
onChange={(e) =>
|
||||
onChange(e.target.value === "automatic" ? null : e.target.value)
|
||||
}
|
||||
>
|
||||
<option value="automatic">
|
||||
Automatic · {device} ({zoneOffset(device)})
|
||||
</option>
|
||||
{value !== null &&
|
||||
!groups.some((g) => g.zones.some((z) => z.zone === value)) && (
|
||||
// A saved zone this browser does not list still shows as chosen.
|
||||
<option value={value}>
|
||||
{value} · {zoneOffset(value)}
|
||||
</option>
|
||||
)}
|
||||
{groups.map(({ group, zones }) => (
|
||||
<optgroup key={group} label={group}>
|
||||
{zones.map(({ zone, name }) => (
|
||||
<option key={zone} value={zone}>
|
||||
{name} · {zoneOffset(zone)}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import type { CodesFeed } from "../../../shared/codes.ts";
|
||||
import { serverSuffix } from "../../state/reader/servers.ts";
|
||||
import { newCodes } from "../../state/reader/codes.ts";
|
||||
import { trackedGames } from "../../state/reader/updates.ts";
|
||||
import type { ReaderState } from "../../state/reader/schema.ts";
|
||||
import {
|
||||
MAX_SNOOZE_DAYS,
|
||||
staleSnooze,
|
||||
updateItems,
|
||||
} from "../../state/reader/updates.ts";
|
||||
const SNOOZE_DAYS = [1, 3, MAX_SNOOZE_DAYS];
|
||||
import { actionMeta } from "../../state/reader/actions.ts";
|
||||
import type { EventFeed } from "../../../shared/feed.ts";
|
||||
import { metaFor } from "../../../shared/games.ts";
|
||||
import type { ActionHandler } from "./GameSettings.tsx";
|
||||
export function UpdatesPanel({
|
||||
state,
|
||||
feed,
|
||||
now,
|
||||
onSources,
|
||||
onAction,
|
||||
codes = null,
|
||||
onCodes,
|
||||
}: {
|
||||
state: ReaderState;
|
||||
feed: EventFeed;
|
||||
now: number;
|
||||
onSources: () => void;
|
||||
onAction: ActionHandler;
|
||||
codes?: CodesFeed | null;
|
||||
onCodes?: (game: string) => void;
|
||||
}) {
|
||||
const items = updateItems(state, feed, now);
|
||||
if (codes)
|
||||
for (const game of trackedGames(state)) {
|
||||
const found = newCodes(codes, state.codeSeen[game] ?? [], game);
|
||||
if (found.length)
|
||||
items.push({ kind: "codes", game, ids: found.map((c) => c.id) });
|
||||
}
|
||||
if (!items.length) return null;
|
||||
return (
|
||||
<section className="updates-panel" aria-label="Updates">
|
||||
{items.map((item, i) =>
|
||||
item.kind === "sources" ? (
|
||||
<div className="update-row" key={i}>
|
||||
<strong>Event data may be out of date</strong>
|
||||
<span>
|
||||
For games you track:{" "}
|
||||
{item.games.map((g) => metaFor(g, {}).name).join(", ")}
|
||||
</span>
|
||||
<button type="button" onClick={onSources}>
|
||||
Details & reasons
|
||||
</button>
|
||||
<span className="snooze" role="group" aria-label="Hide this notice">
|
||||
Hide for
|
||||
{SNOOZE_DAYS.map((days) => (
|
||||
<button
|
||||
type="button"
|
||||
key={days}
|
||||
onClick={() =>
|
||||
void onAction({
|
||||
type: "prefs",
|
||||
patch: {
|
||||
staleSnooze: staleSnooze(item.games, now, days),
|
||||
},
|
||||
...actionMeta(),
|
||||
})
|
||||
}
|
||||
>
|
||||
{days} day{days === 1 ? "" : "s"}
|
||||
</button>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
) : item.kind === "events" ? (
|
||||
<div className="update-row" key={i}>
|
||||
<strong>New since you last looked</strong>
|
||||
<span>
|
||||
{item.ids.length} event{item.ids.length === 1 ? "" : "s"}
|
||||
{state.accounts[item.accountId] &&
|
||||
` · ${metaFor(state.accounts[item.accountId]!.game, {}).name}`}
|
||||
{state.accounts[item.accountId] &&
|
||||
serverSuffix(
|
||||
state,
|
||||
state.accounts[item.accountId]!.game,
|
||||
state.accounts[item.accountId]!.region,
|
||||
)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void onAction({
|
||||
type: "seen",
|
||||
kind: "eventSeen",
|
||||
id: item.accountId,
|
||||
ids: item.ids,
|
||||
...actionMeta(),
|
||||
})
|
||||
}
|
||||
>
|
||||
Got it
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="update-row" key={i}>
|
||||
<strong>New redeem codes</strong>
|
||||
<span>
|
||||
{item.ids.length} · {metaFor(item.game, {}).name}
|
||||
</span>
|
||||
<button type="button" onClick={() => onCodes?.(item.game)}>
|
||||
View codes
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { CSSProperties } from "react";
|
||||
|
||||
export type MainView = "soon" | "timeline";
|
||||
|
||||
/**
|
||||
* The Round 6 B view cards (`viewCards()`): a drawn Checklist and a drawn
|
||||
* Timeline, used wherever the reader picks the view Event Clock opens on.
|
||||
*/
|
||||
export function ViewCards({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: MainView;
|
||||
onChange: (view: MainView) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="viewpick" role="group" aria-label="Default view">
|
||||
<button
|
||||
type="button"
|
||||
className="vcard"
|
||||
aria-pressed={value === "soon"}
|
||||
onClick={() => onChange("soon")}
|
||||
>
|
||||
<div className="vprev list" aria-hidden="true">
|
||||
<i />
|
||||
<i />
|
||||
<i />
|
||||
</div>
|
||||
<b>Checklist</b>
|
||||
<small>A list, closest deadline first.</small>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="vcard"
|
||||
aria-pressed={value === "timeline"}
|
||||
onClick={() => onChange("timeline")}
|
||||
>
|
||||
<div className="vprev tl" aria-hidden="true">
|
||||
{(
|
||||
[
|
||||
["#4EA8DE", "4%", "52%", 6],
|
||||
["#F2A03D", "30%", "44%", 24],
|
||||
["#3DD6A0", "50%", "46%", 42],
|
||||
] as const
|
||||
).map(([g, left, width, top]) => (
|
||||
<i
|
||||
key={g}
|
||||
style={{ "--g": g, left, width, top } as CSSProperties}
|
||||
/>
|
||||
))}
|
||||
<em />
|
||||
</div>
|
||||
<b>Timeline</b>
|
||||
<small>Bars per game, today pinned.</small>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
/** How long the grand celebration stays mounted: the last wave's sparks included. */
|
||||
export const CELEBRATION_MS = 2600;
|
||||
|
||||
/**
|
||||
* Counts up when a section has just been finished: everything in it done,
|
||||
* more done than a moment ago, and the same number of things as before. That
|
||||
* is the live strip's rule (Dailies.tsx): a list that got shorter (focusing a
|
||||
* game, unpinning) or a page that opens already complete is not the reader
|
||||
* finishing anything, so neither celebrates.
|
||||
*/
|
||||
export function useSectionCelebration(total: number, done: number): number {
|
||||
const [burst, setBurst] = useState(0);
|
||||
const previous = useRef<{ total: number; done: number } | null>(null);
|
||||
const complete = total > 0 && done === total;
|
||||
useEffect(() => {
|
||||
const was = previous.current;
|
||||
previous.current = { total, done };
|
||||
if (was === null || !complete) return;
|
||||
if (was.total === total && done > was.done) setBurst((n) => n + 1);
|
||||
}, [total, done, complete]);
|
||||
useEffect(() => {
|
||||
if (burst === 0) return;
|
||||
const id = setTimeout(() => setBurst(0), CELEBRATION_MS);
|
||||
return () => clearTimeout(id);
|
||||
}, [burst]);
|
||||
return burst;
|
||||
}
|
||||
Reference in New Issue
Block a user