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.
This commit is contained in:
Lucas Winther
2026-11-02 03:35:32 +01:00
parent 2056630f67
commit dd5d374422
8 changed files with 74 additions and 8 deletions
+5 -2
View File
@@ -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
+3 -3
View File
@@ -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,
@@ -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)),
@@ -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();
+10
View File
@@ -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<T extends { game: LaneId }>(
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.
*
+15 -1
View File
@@ -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.
*
+20
View File
@@ -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(<DailyPanel rows={rows} state={state} now={now} onAction={async () => ({ ok: true })} />);
expect([...html.matchAll(/<strong>(.*?)<\/strong>/g)].map((m) => m[1])).toEqual([...expected]);
const pips = [...html.matchAll(/<i[^>]*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"] = {
+11
View File
@@ -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(<RoutinesPage state={state} now={Date.parse(at)} onAction={async () => ({ ok: true })} />);
const cells = [...html.matchAll(/class="gcell"><span>([^<]*)/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");