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:
Lucas Winther
2026-10-18 01:55:16 +02:00
co-authored by Claude Opus 5.5
parent ddd9673f86
commit 50c6e91b00
284 changed files with 45100 additions and 1628 deletions
@@ -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;
}