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 <noreply@anthropic.com>
This commit is contained in:
1 parent
99af24e0d3
commit
3296784ca1
7 files changed
+493
-197
No files matched your search
@@ -273,13 +273,16 @@ function ReaderContent({
|
|||||||
// A new tab starts at its top. Without this the window kept the last page's
|
// 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
|
// 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.
|
// 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 firstPage = useRef(true);
|
||||||
|
const [catchUpRequested, setCatchUpRequested] = useState(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (firstPage.current) {
|
if (firstPage.current) {
|
||||||
firstPage.current = false;
|
firstPage.current = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
window.scrollTo(0, 0);
|
if (!catchUpRequested) window.scrollTo(0, 0);
|
||||||
}, [page]);
|
}, [page]);
|
||||||
// Everyone takes the tour once before using the app; a replay from Settings
|
// 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.
|
// is optional. A returning reader who has not taken it starts on Relaxed.
|
||||||
@@ -508,6 +511,10 @@ function ReaderContent({
|
|||||||
if (Object.keys(patch).length)
|
if (Object.keys(patch).length)
|
||||||
void dispatch({ type: "prefs", patch, ...actionMeta() });
|
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. */
|
/** Ask for the newest published feed and codes; shared by the footer and Data & sources. */
|
||||||
const refreshFeed = async (): Promise<RefreshOutcome> => {
|
const refreshFeed = async (): Promise<RefreshOutcome> => {
|
||||||
try {
|
try {
|
||||||
@@ -807,6 +814,7 @@ function ReaderContent({
|
|||||||
{separate && (
|
{separate && (
|
||||||
<DailyPanel
|
<DailyPanel
|
||||||
onManage={() => changePage("routines")}
|
onManage={() => changePage("routines")}
|
||||||
|
onCatchUp={goToCatchUp}
|
||||||
rows={pins.filter((r) => r.cadence === "day")}
|
rows={pins.filter((r) => r.cadence === "day")}
|
||||||
state={state}
|
state={state}
|
||||||
onAction={dispatch}
|
onAction={dispatch}
|
||||||
@@ -819,9 +827,12 @@ function ReaderContent({
|
|||||||
<h2>Routines</h2>
|
<h2>Routines</h2>
|
||||||
<p className="sub">Showing only pinned</p>
|
<p className="sub">Showing only pinned</p>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => changePage("routines")}>
|
<div className="heading-actions">
|
||||||
Edit pins
|
<button onClick={goToCatchUp}>Catch up</button>
|
||||||
</button>
|
<button onClick={() => changePage("routines")}>
|
||||||
|
Edit pins
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{accounts
|
{accounts
|
||||||
.filter((a) => focus === null || a.game === focus)
|
.filter((a) => focus === null || a.game === focus)
|
||||||
@@ -1009,7 +1020,13 @@ function ReaderContent({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{page === "routines" && (
|
{page === "routines" && (
|
||||||
<RoutinesPage state={state} onAction={dispatch} now={now} />
|
<RoutinesPage
|
||||||
|
state={state}
|
||||||
|
onAction={dispatch}
|
||||||
|
now={now}
|
||||||
|
catchUpRequested={catchUpRequested}
|
||||||
|
onCatchUpShown={() => setCatchUpRequested(false)}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{page === "archive" && (
|
{page === "archive" && (
|
||||||
<ArchivePage
|
<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,
|
onAction,
|
||||||
now,
|
now,
|
||||||
onManage,
|
onManage,
|
||||||
|
onCatchUp,
|
||||||
}: {
|
}: {
|
||||||
onManage?: () => void;
|
onManage?: () => void;
|
||||||
|
onCatchUp?: () => void;
|
||||||
rows: RoutineRow[];
|
rows: RoutineRow[];
|
||||||
state: ReaderState;
|
state: ReaderState;
|
||||||
onAction: ActionHandler;
|
onAction: ActionHandler;
|
||||||
@@ -228,7 +230,10 @@ export function DailyPanel({
|
|||||||
{burst > 0 && <Fireworks key={burst} grand />}
|
{burst > 0 && <Fireworks key={burst} grand />}
|
||||||
<div className="daily-heading">
|
<div className="daily-heading">
|
||||||
<h2>Today's dailies</h2>
|
<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>
|
||||||
<div className="daily-progress">
|
<div className="daily-progress">
|
||||||
<span className="daily-pips" aria-hidden="true">
|
<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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -4,8 +4,8 @@ import { formatRemaining } from "../../../shared/time.ts";
|
|||||||
import { useGameMeta } from "../../state/gameMeta.tsx";
|
import { useGameMeta } from "../../state/gameMeta.tsx";
|
||||||
import { orderGameItems } from "../../state/gameOrder.ts";
|
import { orderGameItems } from "../../state/gameOrder.ts";
|
||||||
import type { CSSProperties } from "react";
|
import type { CSSProperties } from "react";
|
||||||
import { RoutineHistory } from "./RoutineHistory.tsx";
|
import { CatchUpSection } from "./CatchUpSection.tsx";
|
||||||
import { useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
import { routinesFor, mineFrom, ownable } from "../../../shared/routines.ts";
|
import { routinesFor, mineFrom, ownable } from "../../../shared/routines.ts";
|
||||||
|
|
||||||
@@ -109,10 +109,15 @@ export function RoutinesPage({
|
|||||||
state,
|
state,
|
||||||
onAction,
|
onAction,
|
||||||
now,
|
now,
|
||||||
|
catchUpRequested = false,
|
||||||
|
onCatchUpShown,
|
||||||
}: {
|
}: {
|
||||||
state: ReaderState;
|
state: ReaderState;
|
||||||
onAction: ActionHandler;
|
onAction: ActionHandler;
|
||||||
now: number;
|
now: number;
|
||||||
|
/** Set when a Checklist button sent the reader here for Catch up. */
|
||||||
|
catchUpRequested?: boolean;
|
||||||
|
onCatchUpShown?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const meta = useGameMeta();
|
const meta = useGameMeta();
|
||||||
const [drawer, setDrawer] = useState<DrawerPane | null>(null);
|
const [drawer, setDrawer] = useState<DrawerPane | null>(null);
|
||||||
@@ -228,6 +233,20 @@ export function RoutinesPage({
|
|||||||
...actionMeta(),
|
...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 group = state.prefs.routineGroup === "game" ? "game" : "reset";
|
||||||
const allRows = accounts
|
const allRows = accounts
|
||||||
.flatMap((a) => routineRows(state, a.id, now))
|
.flatMap((a) => routineRows(state, a.id, now))
|
||||||
@@ -273,6 +292,9 @@ export function RoutinesPage({
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
<button className="btn" onClick={showCatchUp}>
|
||||||
|
Catch up
|
||||||
|
</button>
|
||||||
<button className="btn" onClick={() => open("library")}>
|
<button className="btn" onClick={() => open("library")}>
|
||||||
Browse the library
|
Browse the library
|
||||||
</button>
|
</button>
|
||||||
@@ -553,18 +575,13 @@ export function RoutinesPage({
|
|||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
<details className="routine-histories">
|
<CatchUpSection
|
||||||
<summary>Catch up & recorded history</summary>
|
accounts={accounts}
|
||||||
{accounts.map((a) => (
|
state={state}
|
||||||
<RoutineHistory
|
now={now}
|
||||||
key={a.id}
|
onAction={onAction}
|
||||||
account={a}
|
headingRef={catchUpHeading}
|
||||||
state={state}
|
/>
|
||||||
now={now}
|
|
||||||
onAction={onAction}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</details>
|
|
||||||
{!accounts.some((a) =>
|
{!accounts.some((a) =>
|
||||||
routineRows(state, a.id, now).some((r) => r.placement.followed),
|
routineRows(state, a.id, now).some((r) => r.placement.followed),
|
||||||
) && (
|
) && (
|
||||||
|
|||||||
+139
-31
@@ -813,9 +813,6 @@ textarea {
|
|||||||
.routine-library-heading h2 {
|
.routine-library-heading h2 {
|
||||||
font: 600 26px var(--font-display);
|
font: 600 26px var(--font-display);
|
||||||
}
|
}
|
||||||
.routines-page .routine-history {
|
|
||||||
margin: 16px 24px;
|
|
||||||
}
|
|
||||||
@media (hover: hover) {
|
@media (hover: hover) {
|
||||||
.event-entry:hover {
|
.event-entry:hover {
|
||||||
background: var(--color-surface);
|
background: var(--color-surface);
|
||||||
@@ -1206,32 +1203,6 @@ textarea {
|
|||||||
flex-shrink: 0;
|
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 {
|
.code-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -2082,11 +2053,148 @@ textarea {
|
|||||||
display: block;
|
display: block;
|
||||||
font: 700 34px/1 var(--font-display);
|
font: 700 34px/1 var(--font-display);
|
||||||
}
|
}
|
||||||
.routine-histories {
|
.catch-up {
|
||||||
padding: 20px 24px;
|
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;
|
font-size: 13px;
|
||||||
color: var(--color-muted);
|
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 {
|
.archive-view {
|
||||||
padding: 24px 32px 0;
|
padding: 24px 32px 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -5393,7 +5501,7 @@ button.mark {
|
|||||||
.timeline-help,
|
.timeline-help,
|
||||||
.checklist-display,
|
.checklist-display,
|
||||||
.band-h,
|
.band-h,
|
||||||
.routine-histories,
|
.catch-up,
|
||||||
.archive-view {
|
.archive-view {
|
||||||
padding-inline: var(--edge);
|
padding-inline: var(--edge);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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("gone", list)).toBe("b");
|
||||||
expect(pickAccount(null, 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<");
|
||||||
|
});
|
||||||
Reference in new issue
Block a user