From 3296784ca1e9fb2b6b272fe018f2509d941ca2db Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 6 Nov 2026 11:16:46 +0100 Subject: [PATCH] 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 --- src/client/ReaderApp.tsx | 27 ++- .../components/redesign/CatchUpSection.tsx | 211 ++++++++++++++++++ src/client/components/redesign/DailyPanel.tsx | 7 +- .../components/redesign/RoutineHistory.tsx | 146 ------------ .../components/redesign/RoutinesPage.tsx | 45 ++-- src/client/redesign.css | 170 +++++++++++--- test/catch-up.test.tsx | 84 +++++++ 7 files changed, 493 insertions(+), 197 deletions(-) create mode 100644 src/client/components/redesign/CatchUpSection.tsx delete mode 100644 src/client/components/redesign/RoutineHistory.tsx diff --git a/src/client/ReaderApp.tsx b/src/client/ReaderApp.tsx index 802893c..038b654 100644 --- a/src/client/ReaderApp.tsx +++ b/src/client/ReaderApp.tsx @@ -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 => { try { @@ -807,6 +814,7 @@ function ReaderContent({ {separate && ( changePage("routines")} + onCatchUp={goToCatchUp} rows={pins.filter((r) => r.cadence === "day")} state={state} onAction={dispatch} @@ -819,9 +827,12 @@ function ReaderContent({

Routines

Showing only pinned

- +
+ + +
{accounts .filter((a) => focus === null || a.game === focus) @@ -1009,7 +1020,13 @@ function ReaderContent({ )} {page === "routines" && ( - + setCatchUpRequested(false)} + /> )} {page === "archive" && ( + 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 ( +
+ {days.map((day) => { + const label = dayName(day.key, { + weekday: "short", + day: "numeric", + month: "short", + }); + return ( + + ); + })} +
+ ); +} + +function ValueList({ values }: { values: NonNullable }) { + if (!values.length) return

Nothing recorded yet

; + const line = (v: (typeof values)[number]) => ( +
  • + {v.label} + {v.text} +
  • + ); + const rest = values.slice(VALUES_SHOWN); + return ( +
    +
      {values.slice(0, VALUES_SHOWN).map(line)}
    + {rest.length > 0 && ( +
    + {plural(rest.length, "earlier value")} +
      {rest.map(line)}
    +
    + )} +
    + ); +} + +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 ( +
    +
    + {item.title} + {sub.length > 0 && {sub.join(" · ")}} +
    +
    + {item.days && } + {item.values && } + {item.earlier.length > 0 && ( +
    + {plural(item.earlier.length, "earlier day")} + +
    + )} +
    +
    + ); +} + +/** + * 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; +}) { + const meta = useGameMeta(); + const [kept, setKept] = useState>(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 ( +
    +

    + Catch up +

    +

    + Last 14 days. Tick anything you did but didn't record. +

    +
    + {accounts.map((a) => { + const behind = list.find((c) => c.accountId === a.id)?.behind ?? 0; + return ( + + ); + })} +
    + {chosen.items.length === 0 && ( +

    + Nothing followed for {meta(account.game).name} yet. +

    + )} + {chosen.items.map((item) => ( + + ))} + {olderCount > 0 && ( +
    + Older records ({olderCount}) + {chosen.older.map((group) => ( +
    +

    {group.title}

    + {group.note &&

    {group.note}

    } + {group.items.map((item) => ( + + ))} +
    + ))} +
    + )} +
    + ); +} diff --git a/src/client/components/redesign/DailyPanel.tsx b/src/client/components/redesign/DailyPanel.tsx index a938fce..9791b82 100644 --- a/src/client/components/redesign/DailyPanel.tsx +++ b/src/client/components/redesign/DailyPanel.tsx @@ -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 && }

    Today's dailies

    - {onManage && } +
    + {onCatchUp && } + {onManage && } +
    + @@ -553,18 +575,13 @@ export function RoutinesPage({ ); })} -
    - Catch up & recorded history - {accounts.map((a) => ( - - ))} -
    + {!accounts.some((a) => routineRows(state, a.id, now).some((r) => r.placement.followed), ) && ( diff --git a/src/client/redesign.css b/src/client/redesign.css index 34df6b6..b164c85 100644 --- a/src/client/redesign.css +++ b/src/client/redesign.css @@ -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); } diff --git a/test/catch-up.test.tsx b/test/catch-up.test.tsx index 994f376..2edf770 100644 --- a/test/catch-up.test.tsx +++ b/test/catch-up.test.tsx @@ -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( ({ 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(/]*>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 { + const { state, byGame } = twoGames(); + const a = byGame("endfield"); + const rows = routineRows(state, a.id, now).filter((r) => r.id === DAILY); + const html = render( + ({ ok: true })} + onManage={() => {}} + onCatchUp={() => {}} + />, + ); + expect(html).toContain(">Catch up<"); + expect(html).toContain(">Edit pins<"); +});