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:
co-authored by
Claude Opus 5.5
parent
99af24e0d3
commit
3296784ca1
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user