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");