feat(routines): one game at a time in Catch up, with a way to reach it

Opening the panel gave one identical dropdown per game, naming none of them, at
the bottom of a page the Checklist did not link to. Named chips show one game's
titled rows and dated strips, and the Checklist's buttons land on the section.
The page-change scroll reset now skips these arrivals: a child's effect runs
first, so it was undoing the jump.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-11-06 11:16:46 +01:00
co-authored by Claude Opus 5.5
parent 99af24e0d3
commit 3296784ca1
7 changed files with 493 additions and 197 deletions
+22 -5
View File
@@ -273,13 +273,16 @@ function ReaderContent({
// A new tab starts at its top. Without this the window kept the last page's
// scroll, so leaving a scrolled Settings page opened the Checklist part-way
// down. The first render is skipped so a reload's restored scroll stands.
// Catch up is the exception: its button sends the reader to a place on the new
// page, and a child's effect runs first, so this would undo that scroll.
const firstPage = useRef(true);
const [catchUpRequested, setCatchUpRequested] = useState(false);
useEffect(() => {
if (firstPage.current) {
firstPage.current = false;
return;
}
window.scrollTo(0, 0);
if (!catchUpRequested) window.scrollTo(0, 0);
}, [page]);
// Everyone takes the tour once before using the app; a replay from Settings
// is optional. A returning reader who has not taken it starts on Relaxed.
@@ -508,6 +511,10 @@ function ReaderContent({
if (Object.keys(patch).length)
void dispatch({ type: "prefs", patch, ...actionMeta() });
};
const goToCatchUp = () => {
setCatchUpRequested(true);
changePage("routines");
};
/** Ask for the newest published feed and codes; shared by the footer and Data & sources. */
const refreshFeed = async (): Promise<RefreshOutcome> => {
try {
@@ -807,6 +814,7 @@ function ReaderContent({
{separate && (
<DailyPanel
onManage={() => changePage("routines")}
onCatchUp={goToCatchUp}
rows={pins.filter((r) => r.cadence === "day")}
state={state}
onAction={dispatch}
@@ -819,9 +827,12 @@ function ReaderContent({
<h2>Routines</h2>
<p className="sub">Showing only pinned</p>
</div>
<button onClick={() => changePage("routines")}>
Edit pins
</button>
<div className="heading-actions">
<button onClick={goToCatchUp}>Catch up</button>
<button onClick={() => changePage("routines")}>
Edit pins
</button>
</div>
</div>
{accounts
.filter((a) => focus === null || a.game === focus)
@@ -1009,7 +1020,13 @@ function ReaderContent({
</>
)}
{page === "routines" && (
<RoutinesPage state={state} onAction={dispatch} now={now} />
<RoutinesPage
state={state}
onAction={dispatch}
now={now}
catchUpRequested={catchUpRequested}
onCatchUpShown={() => setCatchUpRequested(false)}
/>
)}
{page === "archive" && (
<ArchivePage
@@ -0,0 +1,211 @@
import { useState, type CSSProperties, type RefObject } from "react";
import { actionMeta } from "../../state/reader/actions.ts";
import {
catchUpFor,
defaultCatchUpAccount,
keepKey,
pickAccount,
type CatchUpDay,
type CatchUpItem,
} from "../../state/reader/catchUp.ts";
import { useGameMeta } from "../../state/gameMeta.tsx";
import type { Account, ReaderState } from "../../state/reader/schema.ts";
import { serverSuffix } from "../../state/reader/servers.ts";
import type { ActionHandler } from "./GameSettings.tsx";
const VALUES_SHOWN = 4;
const dayName = (key: string, options: Intl.DateTimeFormatOptions) =>
new Date(`${key}T00:00:00Z`).toLocaleDateString("en-GB", {
timeZone: "UTC",
...options,
});
const plural = (n: number, one: string) => `${n} ${one}${n === 1 ? "" : "s"}`;
function DayStrip({
days,
onToggle,
}: {
days: readonly CatchUpDay[];
onToggle: (day: CatchUpDay) => void;
}) {
return (
<div className="cu-strip">
{days.map((day) => {
const label = dayName(day.key, {
weekday: "short",
day: "numeric",
month: "short",
});
return (
<button
type="button"
className="cu-day"
key={day.key}
aria-label={label}
aria-pressed={day.ticked}
title={label}
disabled={day.disabled}
data-today={day.today || undefined}
onClick={() => onToggle(day)}
>
<i>{dayName(day.key, { weekday: "short" }).slice(0, 2)}</i>
<b>{Number(day.key.slice(8))}</b>
</button>
);
})}
</div>
);
}
function ValueList({ values }: { values: NonNullable<CatchUpItem["values"]> }) {
if (!values.length) return <p className="cu-none">Nothing recorded yet</p>;
const line = (v: (typeof values)[number]) => (
<li key={v.period}>
<span>{v.label}</span>
<b>{v.text}</b>
</li>
);
const rest = values.slice(VALUES_SHOWN);
return (
<div className="cu-values">
<ul>{values.slice(0, VALUES_SHOWN).map(line)}</ul>
{rest.length > 0 && (
<details className="cu-earlier">
<summary>{plural(rest.length, "earlier value")}</summary>
<ul>{rest.map(line)}</ul>
</details>
)}
</div>
);
}
function Row({
item,
onToggle,
}: {
item: CatchUpItem;
onToggle: (item: CatchUpItem, day: CatchUpDay) => void;
}) {
const sub = [
item.kind,
item.behind > 0 ? `${item.behind} not ticked` : "",
].filter(Boolean);
const toggle = (day: CatchUpDay) => onToggle(item, day);
return (
<div className="cu-row">
<div className="cu-name">
<strong>{item.title}</strong>
{sub.length > 0 && <small>{sub.join(" · ")}</small>}
</div>
<div className="cu-log">
{item.days && <DayStrip days={item.days} onToggle={toggle} />}
{item.values && <ValueList values={item.values} />}
{item.earlier.length > 0 && (
<details className="cu-earlier">
<summary>{plural(item.earlier.length, "earlier day")}</summary>
<DayStrip days={item.earlier} onToggle={toggle} />
</details>
)}
</div>
</div>
);
}
/**
* Catch up on the Routines page (spec 2026-11-06): one account at a time, named
* on a chip, so finding Endfield's dailies is one tap rather than seven
* identical dropdowns. The choice is per-visit and holds through a tick, so the
* list never moves under the reader as a count reaches zero.
*/
export function CatchUpSection({
accounts,
state,
now,
onAction,
headingRef,
}: {
accounts: readonly Account[];
state: ReaderState;
now: number;
onAction: ActionHandler;
headingRef?: RefObject<HTMLHeadingElement | null>;
}) {
const meta = useGameMeta();
const [kept, setKept] = useState<ReadonlySet<string>>(new Set());
const list = accounts.map((a) => catchUpFor(state, a, now, kept));
const [selected, setSelected] = useState(() => defaultCatchUpAccount(list));
const chosenId = pickAccount(selected, list);
const account = accounts.find((a) => a.id === chosenId);
const chosen = list.find((c) => c.accountId === chosenId);
if (!account || !chosen) return null;
const toggle = (item: CatchUpItem, day: CatchUpDay) => {
if (day.ticked)
setKept((old) =>
new Set(old).add(keepKey(account.id, item.revisionId, item.id, day.key)),
);
void onAction({
type: "day",
accountId: account.id,
revisionId: item.revisionId,
itemId: item.id,
day: day.key,
checked: !day.ticked,
...actionMeta(),
});
};
const olderCount = chosen.older.reduce((n, g) => n + g.items.length, 0);
return (
<section
className="catch-up"
style={{ "--hue": meta(account.game).hue } as CSSProperties}
>
<h2 ref={headingRef} tabIndex={-1}>
Catch up
</h2>
<p className="meta">
Last 14 days. Tick anything you did but didn't record.
</p>
<div className="focus-pills" role="group" aria-label="Game to catch up on">
{accounts.map((a) => {
const behind = list.find((c) => c.accountId === a.id)?.behind ?? 0;
return (
<button
type="button"
className="focus-pill"
key={a.id}
style={{ "--hue": meta(a.game).hue } as CSSProperties}
aria-pressed={a.id === chosenId}
onClick={() => setSelected(a.id)}
>
{`${meta(a.game).short}${serverSuffix(state, a.game, a.region)}`}
{behind > 0 && <span className="n">{behind}</span>}
</button>
);
})}
</div>
{chosen.items.length === 0 && (
<p className="cu-none">
Nothing followed for {meta(account.game).name} yet.
</p>
)}
{chosen.items.map((item) => (
<Row key={item.id} item={item} onToggle={toggle} />
))}
{olderCount > 0 && (
<details className="cu-older">
<summary>Older records ({olderCount})</summary>
{chosen.older.map((group) => (
<section key={group.id}>
<h3>{group.title}</h3>
{group.note && <p className="meta">{group.note}</p>}
{group.items.map((item) => (
<Row key={item.id} item={item} onToggle={toggle} />
))}
</section>
))}
</details>
)}
</section>
);
}
@@ -186,8 +186,10 @@ export function DailyPanel({
onAction,
now,
onManage,
onCatchUp,
}: {
onManage?: () => void;
onCatchUp?: () => void;
rows: RoutineRow[];
state: ReaderState;
onAction: ActionHandler;
@@ -228,7 +230,10 @@ export function DailyPanel({
{burst > 0 && <Fireworks key={burst} grand />}
<div className="daily-heading">
<h2>Today's dailies</h2>
{onManage && <button onClick={onManage}>Edit pins</button>}
<div className="heading-actions">
{onCatchUp && <button onClick={onCatchUp}>Catch up</button>}
{onManage && <button onClick={onManage}>Edit pins</button>}
</div>
</div>
<div className="daily-progress">
<span className="daily-pips" aria-hidden="true">
@@ -1,146 +0,0 @@
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>
);
}
+31 -14
View File
@@ -4,8 +4,8 @@ import { formatRemaining } from "../../../shared/time.ts";
import { useGameMeta } from "../../state/gameMeta.tsx";
import { orderGameItems } from "../../state/gameOrder.ts";
import type { CSSProperties } from "react";
import { RoutineHistory } from "./RoutineHistory.tsx";
import { useState } from "react";
import { CatchUpSection } from "./CatchUpSection.tsx";
import { useEffect, useRef, useState } from "react";
import { routinesFor, mineFrom, ownable } from "../../../shared/routines.ts";
@@ -109,10 +109,15 @@ export function RoutinesPage({
state,
onAction,
now,
catchUpRequested = false,
onCatchUpShown,
}: {
state: ReaderState;
onAction: ActionHandler;
now: number;
/** Set when a Checklist button sent the reader here for Catch up. */
catchUpRequested?: boolean;
onCatchUpShown?: () => void;
}) {
const meta = useGameMeta();
const [drawer, setDrawer] = useState<DrawerPane | null>(null);
@@ -228,6 +233,20 @@ export function RoutinesPage({
...actionMeta(),
});
};
const catchUpHeading = useRef<HTMLHeadingElement>(null);
const showCatchUp = () => {
const heading = catchUpHeading.current;
if (!heading) return;
const still = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
heading.scrollIntoView({ block: "start", behavior: still ? "auto" : "smooth" });
heading.focus({ preventScroll: true });
};
// One shot: a later visit to this page must open at the top as usual.
useEffect(() => {
if (!catchUpRequested) return;
showCatchUp();
onCatchUpShown?.();
}, [catchUpRequested]);
const group = state.prefs.routineGroup === "game" ? "game" : "reset";
const allRows = accounts
.flatMap((a) => routineRows(state, a.id, now))
@@ -273,6 +292,9 @@ export function RoutinesPage({
</button>
))}
</div>
<button className="btn" onClick={showCatchUp}>
Catch up
</button>
<button className="btn" onClick={() => open("library")}>
Browse the library
</button>
@@ -553,18 +575,13 @@ export function RoutinesPage({
</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>
<CatchUpSection
accounts={accounts}
state={state}
now={now}
onAction={onAction}
headingRef={catchUpHeading}
/>
{!accounts.some((a) =>
routineRows(state, a.id, now).some((r) => r.placement.followed),
) && (
+139 -31
View File
@@ -813,9 +813,6 @@ textarea {
.routine-library-heading h2 {
font: 600 26px var(--font-display);
}
.routines-page .routine-history {
margin: 16px 24px;
}
@media (hover: hover) {
.event-entry:hover {
background: var(--color-surface);
@@ -1206,32 +1203,6 @@ textarea {
flex-shrink: 0;
}
}
.routine-history {
margin: 20px 0;
border-top: 1px solid var(--color-hairline);
padding-top: 16px;
}
.routine-history summary {
cursor: pointer;
color: var(--color-muted);
font-size: 13px;
}
.history-days {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin: 12px 0;
}
.history-days button {
padding: 6px 9px;
font-size: 11px;
border: 1px solid var(--color-hairline);
border-radius: 6px;
}
.history-days button[aria-pressed="true"] {
color: var(--color-near);
border-color: var(--color-near);
}
.code-row {
display: flex;
flex-wrap: wrap;
@@ -2082,11 +2053,148 @@ textarea {
display: block;
font: 700 34px/1 var(--font-display);
}
.routine-histories {
.catch-up {
padding: 20px 24px;
border-top: 1px solid var(--color-hairline);
}
.catch-up h2 {
font: 600 26px var(--font-display);
margin: 0;
scroll-margin-top: 72px;
}
.catch-up h2:focus {
outline: none;
}
.catch-up > .meta {
margin: 2px 0 12px;
color: var(--color-muted);
font-size: 13px;
}
.catch-up > .focus-pills {
margin-bottom: 8px;
}
.cu-row {
display: grid;
grid-template-columns: minmax(180px, 1fr) minmax(0, 2fr);
gap: 8px 20px;
align-items: start;
padding: 12px 0;
border-top: 1px solid var(--color-hairline);
}
.cu-name strong {
display: block;
font: 500 15px/1.35 var(--font-body);
color: var(--color-ink);
}
.cu-name small {
color: var(--color-muted);
font-size: 12px;
}
.cu-strip {
display: grid;
grid-template-columns: repeat(14, minmax(0, 36px));
gap: 4px;
}
.cu-day {
display: block;
padding: 4px 0 5px;
border: 1px solid var(--color-hairline);
border-radius: 6px;
color: var(--color-muted);
text-align: center;
}
.cu-day i {
display: block;
font: 500 10px/1.3 var(--font-body);
}
.cu-day b {
display: block;
font: 600 12px/1.3 var(--font-body);
font-variant-numeric: tabular-nums;
}
.cu-day[aria-pressed="true"] {
color: var(--color-ink);
border-color: var(--hue);
background: color-mix(in srgb, var(--hue) 28%, transparent);
}
.cu-day[data-today] {
outline: 2px solid var(--color-ink);
outline-offset: 1px;
}
.cu-day:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.cu-values ul {
list-style: none;
margin: 0;
padding: 0;
}
.cu-values li {
display: flex;
justify-content: space-between;
gap: 16px;
max-width: 340px;
padding: 2px 0;
font-size: 13px;
color: var(--color-muted);
}
.cu-values b {
color: var(--color-ink);
font-weight: 500;
font-variant-numeric: tabular-nums;
}
.cu-none {
margin: 0;
padding: 2px 0;
font-size: 13px;
color: var(--color-muted);
}
.catch-up > .cu-none {
padding: 12px 0;
}
.cu-earlier {
margin-top: 8px;
font-size: 12px;
color: var(--color-muted);
}
.cu-earlier summary,
.cu-older > summary {
cursor: pointer;
}
.cu-earlier .cu-strip {
margin-top: 6px;
}
.cu-older {
padding-top: 12px;
border-top: 1px solid var(--color-hairline);
font-size: 13px;
color: var(--color-muted);
}
.cu-older h3 {
margin: 14px 0 0;
font: 600 14px var(--font-display);
color: var(--color-ink);
}
.cu-older .meta {
margin: 0 0 8px;
font-size: 12px;
}
.heading-actions {
display: flex;
gap: 12px;
flex: none;
}
@media (max-width: 1050px) {
.cu-row {
grid-template-columns: minmax(0, 1fr);
}
}
@media (max-width: 700px) {
.cu-strip {
grid-template-columns: repeat(7, minmax(0, 1fr));
}
}
.archive-view {
padding: 24px 32px 0;
display: flex;
@@ -5393,7 +5501,7 @@ button.mark {
.timeline-help,
.checklist-display,
.band-h,
.routine-histories,
.catch-up,
.archive-view {
padding-inline: var(--edge);
}
+84
View File
@@ -280,3 +280,87 @@ test("pickAccount keeps a live selection, and falls back when it is gone", () =>
expect(pickAccount("gone", list)).toBe("b");
expect(pickAccount(null, list)).toBe("b");
});
// The section, as the Routines page draws it.
import { renderToStaticMarkup as render } from "react-dom/server";
import { RoutinesPage } from "../src/client/components/redesign/RoutinesPage.tsx";
import { DailyPanel } from "../src/client/components/redesign/DailyPanel.tsx";
const page = (state: ReaderState) =>
render(<RoutinesPage state={state} onAction={async () => ({ ok: true })} now={now} />);
function twoGames() {
let n = 0;
const state = migrateLegacy({}, ["genshin", "endfield"], at, () => `account:${++n}`);
state.prefs.gameOrder = ["genshin", "endfield"];
const byGame = (game: string) => Object.values(state.accounts).find((a) => a.game === game)!;
byGame("genshin").routines["dailies:genshin"] = pin;
byGame("endfield").routines[DAILY] = pin;
return { state, byGame };
}
const chips = (html: string) =>
[...html.matchAll(/class="focus-pill"[^>]*>([^<]+)/g)].map((m) => m[1]);
test("Routines has one Catch up heading and no nested dropdowns", () => {
const html = page(twoGames().state);
expect(html).not.toContain("recorded history");
expect(html.match(/<h2[^>]*>Catch up<\/h2>/g)).toHaveLength(1);
expect(html).not.toContain("routine-histor");
});
test("chips name the game and follow Game order", () => {
expect(chips(page(twoGames().state))).toEqual(["Genshin", "Endfield"]);
});
test("two servers of one game have distinct chips", () => {
const { state, byGame } = twoGames();
const first = byGame("endfield");
state.accounts["account:second"] = {
...first,
id: "account:second",
region: first.region === "asia" ? "europe" : "asia",
};
state.data["account:second"] = structuredClone(state.data[first.id]!);
const names = chips(page(state));
expect(new Set(names).size).toBe(names.length);
expect(names.filter((n) => n!.startsWith("Endfield"))).toHaveLength(2);
});
test("a day cell says weekday and date, and aria-pressed tracks the tick", () => {
const { state, byGame } = twoGames();
const a = byGame("genshin");
(state.data[a.id]!.daily[a.clocks[0]!.id] ??= {})["dailies:genshin"] = { days: ["2026-11-03"] };
const html = page(state);
expect(html).toMatch(/aria-label="Tue, 3 Nov"[^>]*aria-pressed="true"/);
expect(html).toMatch(/aria-label="Wed, 4 Nov"[^>]*aria-pressed="false"/);
});
test("the first game with something behind is chosen", () => {
const { state, byGame } = twoGames();
const a = byGame("endfield");
(state.data[a.id]!.daily[a.clocks[0]!.id] ??= {})[DAILY] = { days: ["2026-11-03"] };
const html = page(state);
expect(html).toContain("Daily missions");
expect(html).toMatch(/aria-pressed="true"[^>]*>Endfield/);
});
test("the Routines top bar offers Catch up", () => {
expect(page(twoGames().state)).toMatch(/class="btn"[^>]*>Catch up</);
});
test("Today's dailies offers Catch up beside Edit pins", () => {
const { state, byGame } = twoGames();
const a = byGame("endfield");
const rows = routineRows(state, a.id, now).filter((r) => r.id === DAILY);
const html = render(
<DailyPanel
rows={rows}
state={state}
now={now}
onAction={async () => ({ ok: true })}
onManage={() => {}}
onCatchUp={() => {}}
/>,
);
expect(html).toContain(">Catch up<");
expect(html).toContain(">Edit pins<");
});