From dd5d374422ec7fee20c14cdad03375e198c5586b Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Mon, 2 Nov 2026 03:34:47 +0100 Subject: [PATCH] fix: apply game order to dailies and routines Account creation order leaked into daily rows and routine groups even when the reader had arranged their games. Apply the shared display rule while preserving each game's server and task order. --- docs/DESIGN.md | 7 +++++-- src/client/ReaderApp.tsx | 6 +++--- src/client/components/redesign/DailyPanel.tsx | 6 ++++++ .../components/redesign/RoutinesPage.tsx | 6 ++++-- src/client/state/gameOrder.ts | 10 ++++++++++ test/game-order.test.ts | 16 ++++++++++++++- test/redesign-daily-panel.test.tsx | 20 +++++++++++++++++++ test/redesign-routines.test.tsx | 11 ++++++++++ 8 files changed, 74 insertions(+), 8 deletions(-) diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 2436126..50efb89 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -609,7 +609,8 @@ legend on the same baseline as "Next: …". ### Ticking things off -- **Today's dailies.** The whole row is the target. A tick washes the row in its game's hue, pops the +- **Today's dailies.** Rows and progress pips follow Game order, falling back to A–Z by game name; + each game's servers and tasks keep their relative order. The whole row is the target. A tick washes the row in its game's hue, pops the box and draws the check. A pip bar and "N of M done" sit under the heading. A check-in goal (for example "5/5 wk") counts as done once met, with a dotted check, and shows its goal count instead of a streak. @@ -655,7 +656,9 @@ legend on the same baseline as "Next: …". ### Other pages -- **Routines.** Bands by reset (Dailies, Weeklies, Monthlies, Cycles) or by game. The table has tick +- **Routines.** Bands by reset (Dailies, Weeklies, Monthlies, Cycles) or by game. Game groups, + rows within each reset band, history and Checklist pins follow Game order with the same A–Z fallback. + The table has tick and click-to-edit amount cells. The library and create/edit drawers are shelves. - **Timeline.** Two looks (new and classic) chosen in Appearance. Dailies are not on the timeline. - **Settings.** About, Changelog, Data & sources (list options first), Region & time, Appearance diff --git a/src/client/ReaderApp.tsx b/src/client/ReaderApp.tsx index a3f18fc..802893c 100644 --- a/src/client/ReaderApp.tsx +++ b/src/client/ReaderApp.tsx @@ -52,7 +52,7 @@ import { import { metaFor } from "../shared/games.ts"; import { readGoal } from "../shared/goal.ts"; import { isDaily, resolveDaily } from "../shared/daily.ts"; -import { orderGames } from "./state/gameOrder.ts"; +import { orderGameItems, orderGames } from "./state/gameOrder.ts"; import { advanceFocus } from "./state/lens.ts"; import { restorePrefsValue } from "./state/usePrefs.ts"; import { readableAmounts } from "./state/useAmounts.ts"; @@ -532,12 +532,12 @@ function ReaderContent({ setSettingsPage(next); setPage("settings"); }; - const accounts = Object.values(state.accounts).filter( + const accounts = orderGameItems(Object.values(state.accounts).filter( (a) => !a.retiredAt && !prefs.hiddenGames.includes(a.game) && (a.intent.events || Object.values(a.routines).some((r) => r.followed)), - ); + ), prefs.gameOrder, (game) => meta(game).name); const ordered = orderGames( [...new Set(accounts.map((a) => a.game))], prefs.gameOrder, diff --git a/src/client/components/redesign/DailyPanel.tsx b/src/client/components/redesign/DailyPanel.tsx index 61676ba..a938fce 100644 --- a/src/client/components/redesign/DailyPanel.tsx +++ b/src/client/components/redesign/DailyPanel.tsx @@ -1,4 +1,5 @@ import { useGameMeta } from "../../state/gameMeta.tsx"; +import { orderGameItems } from "../../state/gameOrder.ts"; import { serverSuffix } from "../../state/reader/servers.ts"; import type { CSSProperties } from "react"; import { useEffect, useRef, useState } from "react"; @@ -193,6 +194,11 @@ export function DailyPanel({ now: number; }) { const meta = useGameMeta(); + rows = orderGameItems( + rows, + Array.isArray(state.prefs.gameOrder) ? state.prefs.gameOrder as string[] : undefined, + (game) => meta(game).name, + ); const total = rows.length; const done = rows.filter((r) => doneToday(r, readerDayKey(now, r.clock)), diff --git a/src/client/components/redesign/RoutinesPage.tsx b/src/client/components/redesign/RoutinesPage.tsx index 9859aa1..b079295 100644 --- a/src/client/components/redesign/RoutinesPage.tsx +++ b/src/client/components/redesign/RoutinesPage.tsx @@ -2,6 +2,7 @@ import { routinePeriod } from "./TimelinePage.tsx"; import { manyServers, serverSuffix } from "../../state/reader/servers.ts"; import { formatRemaining } from "../../../shared/time.ts"; import { useGameMeta } from "../../state/gameMeta.tsx"; +import { orderGameItems } from "../../state/gameOrder.ts"; import type { CSSProperties } from "react"; import { RoutineHistory } from "./RoutineHistory.tsx"; import { useState } from "react"; @@ -120,14 +121,15 @@ export function RoutinesPage({ setEditing(task); setDrawer(pane); }; - const accounts = Object.values(state.accounts).filter( + const accounts = orderGameItems(Object.values(state.accounts).filter( (a) => !a.retiredAt && !( Array.isArray(state.prefs.hiddenGames) && state.prefs.hiddenGames.includes(a.game) ), - ); + ), Array.isArray(state.prefs.gameOrder) ? state.prefs.gameOrder as string[] : undefined, + (game) => meta(game).name); const save = async (draft: TaskDraft, placement: CreatePlacement) => { const at = new Date().toISOString(), id = editing ? editing.id : mintTaskId(); diff --git a/src/client/state/gameOrder.ts b/src/client/state/gameOrder.ts index 32ba688..9bcb2bc 100644 --- a/src/client/state/gameOrder.ts +++ b/src/client/state/gameOrder.ts @@ -64,6 +64,16 @@ export function orderGames( return [...placed, ...alphabetical(lanes.filter((id) => !seen.has(id)))]; } +/** Apply the same display order to accounts or tasks, keeping ties stable. */ +export function orderGameItems( + items: readonly T[], + stored: readonly LaneId[] | undefined, + nameOf: (id: LaneId) => string, +): T[] { + const games = orderGames([...new Set(items.map((item) => item.game))], stored, nameOf); + return [...items].sort((a, b) => games.indexOf(a.game) - games.indexOf(b.game)); +} + /** * Move one entry, shifting the rest. * diff --git a/test/game-order.test.ts b/test/game-order.test.ts index bbed940..cfbe760 100644 --- a/test/game-order.test.ts +++ b/test/game-order.test.ts @@ -1,8 +1,22 @@ import { describe, expect, test } from "bun:test"; -import { moveGame, orderGames } from "../src/client/state/gameOrder.ts"; +import { moveGame, orderGames, orderGameItems } from "../src/client/state/gameOrder.ts"; import { metaFor } from "../src/shared/games.ts"; import type { LaneId } from "../src/shared/custom.ts"; +test("ordering tasks keeps a game's servers and tasks stable without changing its input", () => { + const items = [ + { game: "hsr", id: "first" }, + { game: "genshin", id: "asia-daily" }, + { game: "hsr", id: "second" }, + { game: "genshin", id: "asia-weekly" }, + { game: "genshin", id: "europe-daily" }, + ]; + const original = items.slice(); + expect(orderGameItems(items, ["genshin", "hsr"], (g) => metaFor(g, {}).name).map((r) => r.id)) + .toEqual(["asia-daily", "asia-weekly", "europe-daily", "first", "second"]); + expect(items).toEqual(original); +}); + /** * The reader's game order. * diff --git a/test/redesign-daily-panel.test.tsx b/test/redesign-daily-panel.test.tsx index 62e24b3..a5a5050 100644 --- a/test/redesign-daily-panel.test.tsx +++ b/test/redesign-daily-panel.test.tsx @@ -7,6 +7,26 @@ import { readerDayKey } from "../src/shared/readerClock.ts"; const at = "2026-10-16T12:00:00.000Z"; const now = Date.parse(at); + +test.each([ + { order: ["zzz", "genshin", "hsr"], expected: ["ZZZ", "Genshin", "Star Rail"] }, + { order: undefined, expected: ["Genshin", "Star Rail", "ZZZ"] }, + { order: ["zzz"], expected: ["ZZZ", "Genshin", "Star Rail"] }, +])("dailies and progress pips follow game order $order", ({ order, expected }) => { + let id = 0; + const state = migrateLegacy({}, ["hsr", "genshin", "zzz"], at, () => `account:${++id}`); + state.prefs.gameOrder = order; + const rows = Object.values(state.accounts).flatMap((account) => + routineRows(state, account.id, now).filter((r) => r.id === `dailies:${account.game}`), + ); + const html = render( ({ ok: true })} />); + expect([...html.matchAll(/(.*?)<\/strong>/g)].map((m) => m[1])).toEqual([...expected]); + const pips = [...html.matchAll(/]*style="([^"]*)"/g)].map((m) => m[1]); + const rowHues = [...html.matchAll(/class="daily-row daily-hit"[^>]*style="([^"]*)"/g)].map((m) => m[1]); + expect(pips).toHaveLength(3); + expect(pips).toEqual(rowHues); +}); + function setup() { const state = migrateLegacy({}, ["genshin"], at, () => "account:daily"); state.accounts["account:daily"]!.routines["dailies:genshin"] = { diff --git a/test/redesign-routines.test.tsx b/test/redesign-routines.test.tsx index e246188..79daa38 100644 --- a/test/redesign-routines.test.tsx +++ b/test/redesign-routines.test.tsx @@ -45,6 +45,17 @@ test("bulk pins combine changes per account and preserve the other placement", ( }); import { RoutinesPage } from "../src/client/components/redesign/RoutinesPage.tsx"; +test.each(["game", "reset"])("routines follow Game order when grouped by %s", (group) => { + const at = "2026-10-16T12:00:00.000Z"; + let id = 0; + const state = migrateLegacy({}, ["hsr", "genshin", "zzz"], at, () => `account:${++id}`); + state.prefs.gameOrder = ["zzz", "genshin", "hsr"]; + state.prefs.routineGroup = group; + const html = render( ({ ok: true })} />); + const cells = [...html.matchAll(/class="gcell">([^<]*)/g)].map((m) => m[1]); + expect(cells).toEqual(["ZZZ", "Genshin", "Star Rail"]); +}); + test("the routines table shows progress as text with an edit affordance, not inputs", () => { const at = "2026-10-16T12:00:00.000Z"; const state = migrateLegacy({}, ["genshin"], at, () => "account:table");