diff --git a/src/client/App.tsx b/src/client/App.tsx index 26d53dc..f8c811e 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -30,10 +30,10 @@ import { } from "./state/lens.ts"; import { clockFor, formatRemaining } from "../shared/time.ts"; import { dailySummary, isDaily, resolveDaily } from "../shared/daily.ts"; +import { orderGames } from "./state/gameOrder.ts"; import { GameMetaProvider, type MetaResolver } from "./state/gameMeta.tsx"; import { metaOnTheme, useTheme } from "./state/theme.ts"; import { - isCustomGameId, type CustomEvents, type CustomGames, type LaneId, @@ -238,10 +238,24 @@ export function App() { if (patch !== null) update(patch); }, [state.status, games, prefs.knownGames, prefs.hiddenGames, update]); - /** Games the reader plays, in feed order. The focus bar rotates through these. */ + /** + * Every lane in the order the reader reads them in. + * + * `games` above stays the lane-*identity* list: `adoptNewLanes` diffs it to + * decide which games arrive switched off and `knownGames` is seeded from it, + * so reordering it at source would let a display preference reach the logic + * that hides a reader's games. Ordering is applied here instead, once, and + * handed to every surface that shows a game. + */ + const ordered = useMemo( + () => orderGames(games, prefs.gameOrder, (id) => gameMeta(id).name), + [games, prefs.gameOrder, gameMeta], + ); + + /** Games the reader plays, in their order. The focus bar rotates through these. */ const enabled = useMemo( - () => games.filter((g) => !prefs.hiddenGames.includes(g)), - [games, prefs.hiddenGames], + () => ordered.filter((g) => !prefs.hiddenGames.includes(g)), + [ordered, prefs.hiddenGames], ); // A focus on a game they have since switched off is ignored, not obeyed — @@ -359,7 +373,7 @@ export function App() { update({ onboarded: true, @@ -477,11 +491,12 @@ export function App() { that expires tonight rather than next patch. */} {/* Standing chores are a tracked-game notion: there is no routine we could name on behalf of a game the reader invented, so their lanes - contribute repeating events here but no chore of their own. */} + contribute repeating events here but no chore of their own. That + exclusion is `dailyGroups`' to make, not this call site's — filtering + the lanes out here would also cost a reader's own game its place in + the order their repeating events are grouped under. */} !isCustomGameId(id), - )} + games={focus === null ? enabled : [focus]} events={todo.filter(repeatsDaily).map((r) => r.event)} region={prefs.region} now={now} @@ -571,6 +586,9 @@ export function App() { // nothing else left to draw. The switch is in settings. showUpcoming={prefs.showUpcoming} splitUpcoming={prefs.timelineSplitUpcoming} + // Lanes stack in the reader's game order, so their main game is the + // top lane rather than whichever one held the first row. + gameOrder={ordered} onOpen={setOpenId} isDone={isDone} /> @@ -578,7 +596,7 @@ export function App() { )} ` for a chore, the event id otherwise. */ + key: string; + game: LaneId; + label: string; + title: string; + ariaLabel: string; + today: string; + resetsIn: number; + /** + * Where a catch-up strip starts: the event's start, or null for a chore, + * which has no start because it is a routine rather than an event. + */ + notBefore: number | null; +} + +/** A game and everything of its that wants ticking today. */ +export interface DailyGroup { + game: LaneId; + meta: GameMeta; + items: DailyItem[]; +} + +/** + * A game's dailies, together. + * + * This used to be `[...chores, ...repeating]` — every game's standing chore, + * then every repeating event — which put Genshin's commissions and Genshin's + * login event at opposite ends of the strip with a dozen other games between + * them. The reader thinks in games: if they marked an event as repeating, it + * belongs beside the chores of the game it came from. + * + * The chore comes first inside a group because it is the one that exists every + * day; the events follow **in the order they arrived**, because grouping is not + * a licence to re-sort within a group — the same rule `lanes.ts` states for the + * timeline's lanes. + * + * A lane the reader invented contributes events but no chore: there is no + * routine we could name on their behalf (`docs/DATA-MODEL.md` § Reader-authored + * key spaces). A group with nothing in it is dropped rather than rendered as an + * empty heading. + * + * Pure, and exported so it is tested directly — the pattern `Timeline.tsx` uses + * for `boardWindow` and `splitAt`. + */ +export function dailyGroups( + games: readonly LaneId[], + events: readonly DisplayEvent[], + now: number, + region: Region, + meta: (id: LaneId) => GameMeta, + startOf: (event: DisplayEvent) => number, +): DailyGroup[] { + // A lane can arrive through an event without being in `games` — an event on a + // game the reader has since switched off, say — and dropping it here would + // quietly remove a tickable line. Ordered lanes first, then any straggler in + // the order its events came. + const lanes: LaneId[] = [...games]; + for (const event of events) { + if (!lanes.includes(event.game)) lanes.push(event.game); + } + + const groups: DailyGroup[] = []; + for (const lane of lanes) { + const game = meta(lane); + const items: DailyItem[] = []; + const today = dayKey(now, region, lane); + const resetsIn = msUntilReset(now, region, lane); + + if (!isCustomGameId(lane)) { + items.push({ + key: dailiesId(lane as GameId), + game: lane, + label: game.short, + title: game.dailyTasks, + ariaLabel: `${game.name} dailies — ${game.dailyTasks}`, + today, + resetsIn, + notBefore: null, + }); + } + + for (const event of events) { + if (event.game !== lane) continue; + items.push({ + key: event.id, + game: lane, + label: event.title, + title: `${game.name} — ${event.title}`, + ariaLabel: `${event.title} (${game.name})`, + today, + resetsIn, + notBefore: startOf(event), + }); + } + + if (items.length > 0) groups.push({ game: lane, meta: game, items }); + } + return groups; +} + export function Dailies({ games, events, @@ -30,6 +142,7 @@ export function Dailies({ daysFor, onToggleDay, }: { + /** Every lane the reader is looking at, in their own order. */ games: LaneId[]; /** * Live events that repeat daily — detected, or marked by the reader — and @@ -43,27 +156,28 @@ export function Dailies({ onToggleDay: (id: string, day: string) => void; }) { const gameMeta = useGameMeta(); + /** + * Whether the strip is showing the last fortnight. + * + * Per-visit state and deliberately not a stored preference, for the reason + * expanding a truncated list is not one: it is something a reader does while + * reading — "I did Tuesday, let me say so" — rather than a statement about how + * the app should work. + */ + const [catchUp, setCatchUp] = useState(false); + // Each game rolls on its own server clock, so "today" is asked per game // rather than once for the section — Endfield's European day can still be // yesterday's while every HoYo game has already turned over. - // Only tracked games have a standing chore — a lane the reader invented has - // no routine we could name for them (docs/DATA-MODEL.md § Reader-authored key - // spaces), so App passes tracked lanes here and this stays a total mapping. - const chores = games.map((id) => ({ - key: dailiesId(id as GameId), - game: gameMeta(id), - today: dayKey(now, region, id), - resetsIn: msUntilReset(now, region, id), - })); - const repeating = events.map((event) => ({ - key: event.id, - event, - game: gameMeta(event.game), - today: dayKey(now, region, event.game), - resetsIn: msUntilReset(now, region, event.game), - })); - - const items = [...chores, ...repeating]; + const groups = dailyGroups( + games, + events, + now, + region, + gameMeta, + (event) => Date.parse(event.startsAt), + ); + const items = groups.flatMap((group) => group.items); const total = items.length; const complete = items.filter((i) => daysFor(i.key).includes(i.today)).length; const allDone = total > 0 && complete === total; @@ -81,6 +195,7 @@ export function Dailies({ // shorter when the reader focuses a single game or marks a repeating event // done, which can land on "all complete" without them having ticked // anything — a burst there is the app congratulating them for filtering. + // Backfilling a past day moves no count here, so it never bursts. if (was.total === total && complete > was.complete) setBurst((n) => n + 1); }, [total, complete, allDone]); @@ -111,45 +226,122 @@ export function Dailies({

-
    - {chores.map((chore) => ( -
  • - onToggleDay(chore.key, chore.today)} - /> -
  • - ))} + {catchUp ? ( + + ) : ( + /* One wrapping row, with each game's chore and its events adjacent. No + per-game headings here: this is the part of the page answerable in ten + seconds, and a heading per game would make it the tallest block on it, + pushing "next to expire" — the answer the reader came for — down the + page. The hue already says which game a chip belongs to. */ +
      + {items.map((item) => ( +
    • + onToggleDay(item.key, item.today)} + /> +
    • + ))} +
    + )} - {repeating.map((row) => ( -
  • - onToggleDay(row.key, row.today)} - /> -
  • - ))} -
- -

- {allDone - ? "All done. Nothing else expires tonight." - : `${waiting(total - complete)} still waiting on you today.`} -

+
+

+ {catchUp + ? `Tick a day you did but didn't record. The last ${CATCH_UP_DAYS} days, and nothing later than today.` + : allDone + ? "All done. Nothing else expires tonight." + : `${waiting(total - complete)} still waiting on you today.`} +

+ {/* The way back to a day you already did. It lives on the section rather + than on each chip because a chip is a single tick target, and a + second control inside one is a mis-tap that costs a streak. */} + +
); } +/** + * The last fortnight, for saying you did a day you never ticked. + * + * Its own component rather than a branch inside the section, so it can be + * rendered and asserted on directly — the interesting parts are which days + * appear and whose clock they were cut on, and neither is reachable through a + * `useState` from a test. + * + * Expanded there is vertical room, so the grouping becomes a heading per game. + * Collapsed it is adjacency alone: the strip is the part of the page answerable + * in ten seconds, and a heading per game would make it the tallest block on it. + * + * Every strip is cut with its own item's game, never the section's — Endfield + * serves Europe off the Americas machine, so a tick written under one clock and + * read under another is a day the reader loses. + */ +export function CatchUpPanel({ + groups, + now, + region, + daysFor, + onToggleDay, +}: { + groups: DailyGroup[]; + now: number; + region: Region; + daysFor: (id: string) => string[]; + onToggleDay: (id: string, day: string) => void; +}) { + return ( +
+ {groups.map((group) => ( +
+

+ {group.meta.name} +

+ {group.items.map((item) => ( +
+

{item.label}

+
+ {catchUpDays(now, region, item.game, item.notBefore).map((day) => ( + onToggleDay(item.key, day)} + /> + ))} +
+
+ ))} +
+ ))} +
+ ); +} + /** * One thing to tick off today. * diff --git a/src/client/components/Timeline.tsx b/src/client/components/Timeline.tsx index dd84800..7c18010 100644 --- a/src/client/components/Timeline.tsx +++ b/src/client/components/Timeline.tsx @@ -1,5 +1,6 @@ import { Fragment, useLayoutEffect, useRef } from "react"; import { useGameMeta } from "../state/gameMeta.tsx"; +import type { LaneId } from "../../shared/custom.ts"; import { DAY } from "../../shared/time.ts"; import type { RowEvent } from "./EventRow.tsx"; import { URGENCY_COLOR } from "./Meter.tsx"; @@ -99,6 +100,7 @@ export function Timeline({ onGroup, showUpcoming, splitUpcoming, + gameOrder, onOpen, isDone, }: { @@ -138,6 +140,11 @@ export function Timeline({ * re-sorts instead, and this only decides whether the heading is drawn. */ splitUpcoming: boolean; + /** + * The reader's game order, which stacks the lanes. Orders lanes only — the + * rows inside one keep the order they arrived in. + */ + gameOrder?: readonly LaneId[]; onOpen: (id: string) => void; /** * Asked rather than derived from the progress store: an entry exists there @@ -215,7 +222,7 @@ export function Timeline({ ); } - const lanes = timelineLanes(plotted, group, splitUpcoming); + const lanes = timelineLanes(plotted, group, splitUpcoming, gameOrder); const marks = startMarkers(plotted, x); const months = monthBoundaries(min, max); diff --git a/src/client/components/Welcome.tsx b/src/client/components/Welcome.tsx index f78be83..7358326 100644 --- a/src/client/components/Welcome.tsx +++ b/src/client/components/Welcome.tsx @@ -1,5 +1,6 @@ import { useMemo, useState } from "react"; import { useGameMeta } from "../state/gameMeta.tsx"; +import { orderGames } from "../state/gameOrder.ts"; import type { LaneId } from "../../shared/custom.ts"; import type { View } from "../state/usePrefs.ts"; @@ -40,19 +41,16 @@ export function Welcome({ /** * Alphabetical, by the name on the button. * - * `available` arrives in feed order — whichever game happened to hold the - * first row — which is meaningful everywhere else in the app and meaningless - * here, where the reader is not reading the list but looking for the two or - * three names they already know. Sorted by `name` and not by `LaneId`, - * because the id is not what is printed: `hsr` is Honkai: Star Rail. And - * through `localeCompare`, because `<` orders by code point and would file - * hololive Dreams after every capitalised name on the screen. + * `available` arrives in lane order, which everywhere else in the app means + * the reader's own game order — but this screen runs before they have one, so + * it asks `orderGames` for the no-stored-order case and gets the alphabetical + * rule. Through the shared function rather than a comparator of its own, so + * the picker and the four surfaces behind it cannot drift apart: the reader is + * not reading this list, they are looking for the two or three names they + * already know. */ const ordered = useMemo( - () => - [...available].sort((a, b) => - gameMeta(a).name.localeCompare(gameMeta(b).name), - ), + () => orderGames(available, undefined, (id) => gameMeta(id).name), [available, gameMeta], ); diff --git a/src/client/state/lanes.ts b/src/client/state/lanes.ts index 9feb3d7..ec789a6 100644 --- a/src/client/state/lanes.ts +++ b/src/client/state/lanes.ts @@ -74,11 +74,21 @@ export interface Lane { * modes, lane mode included: leaving a lane's given order alone there would * produce the block it was told not to draw, minus the heading that explained * it, which is the worst of both answers. + * + * `gameOrder` stacks the lanes themselves — the reader's own game order, so + * their main game is the top lane instead of whichever one held the first row. + * It orders **lanes and never the rows inside one**, which is the same rule as + * above read one level up. A game the order does not name sorts after the ones + * it does, in the order its rows arrived, so this stays total for a lane the + * reader never placed. Omitted leaves the stacking exactly as it was. + * + * The merged mode ignores it, having one lane and no game to order by. */ export function timelineLanes( rows: readonly T[], mode: TimelineGroup, split = true, + gameOrder?: readonly LaneId[], ): Array> { const order = split ? endingSoonestFirst : byDeadline; @@ -91,7 +101,20 @@ export function timelineLanes( for (const row of rows) { byGame.set(row.event.game, [...(byGame.get(row.event.game) ?? []), row]); } - return [...byGame].map(([game, laneRows]) => ({ + + let lanes = [...byGame]; + if (gameOrder !== undefined) { + // Unplaced lanes take a rank past every placed one, and ties keep their + // arrival order — `sort` is stable, so a game the reader never placed does + // not jump the ones they did. + const rank = (game: LaneId) => { + const at = gameOrder.indexOf(game); + return at === -1 ? gameOrder.length : at; + }; + lanes = lanes.sort(([a], [b]) => rank(a) - rank(b)); + } + + return lanes.map(([game, laneRows]) => ({ id: game, game, rows: split ? laneRows : [...laneRows].sort(byDeadline), diff --git a/test/views.test.tsx b/test/views.test.tsx index 5eeb54d..185192b 100644 --- a/test/views.test.tsx +++ b/test/views.test.tsx @@ -8,10 +8,12 @@ import { startMarkers, Timeline, } from "../src/client/components/Timeline.tsx"; +import { CatchUpPanel, dailyGroups } from "../src/client/components/Dailies.tsx"; import { Welcome } from "../src/client/components/Welcome.tsx"; import { timelineLanes } from "../src/client/state/lanes.ts"; import { GameMetaProvider } from "../src/client/state/gameMeta.tsx"; import { metaFor } from "../src/shared/games.ts"; +import { dayKey } from "../src/shared/daily.ts"; import { clockFor } from "../src/shared/time.ts"; import { GachaEvent, type GameId } from "../src/shared/schema.ts"; @@ -262,6 +264,39 @@ describe("timelineLanes", () => { ]); }); + test("the reader's game order stacks the lanes", () => { + const lanes = timelineLanes(rows, "game", true, ["zzz", "hsr", "genshin"]); + expect(lanes.map((l) => l.game)).toEqual(["zzz", "hsr", "genshin"]); + }); + + test("ordering the lanes does not re-sort the rows inside one", () => { + // The same rule the mode itself follows, read one level up. + const lanes = timelineLanes(rows, "game", true, ["genshin"]); + expect(lanes[0]?.rows.map((r) => r.event.title)).toEqual([ + "Closing Ceremony", + "Third Rail", + ]); + }); + + test("a game the order does not name keeps its place behind the ones it does", () => { + // Total for a lane the reader never placed — and stable, so the unplaced + // games stay in the order their rows arrived rather than shuffling. + const lanes = timelineLanes(rows, "game", true, ["zzz"]); + expect(lanes.map((l) => l.game)).toEqual(["zzz", "genshin", "hsr"]); + }); + + test("no order given stacks exactly as it did before", () => { + expect(timelineLanes(rows, "game", true).map((l) => l.game)).toEqual( + timelineLanes(rows, "game", true, undefined).map((l) => l.game), + ); + }); + + test("the merged stack ignores it, having one lane and no game", () => { + const lanes = timelineLanes(rows, "ending", true, ["zzz", "hsr", "genshin"]); + expect(lanes).toHaveLength(1); + expect(lanes[0]?.game).toBeNull(); + }); + test("ending soonest: every game in one stack, deadline order", () => { const lanes = timelineLanes(rows, "ending"); expect(lanes).toHaveLength(1); @@ -569,3 +604,220 @@ describe("markerLabel", () => { expect(label).toContain("–"); }); }); + +describe("dailyGroups", () => { + const NOW = Date.parse("2026-08-17T12:00:00.000Z"); + const meta = (id: string) => metaFor(id, {}); + const startOf = (e: { startsAt: string }) => Date.parse(e.startsAt); + + const repeating = (id: string, game: string, title: string) => + ({ + id, + game, + title, + type: "login", + summary: null, + startsAt: "2026-08-10T00:00:00.000Z", + startPrecision: "day", + endsAt: null, + endPrecision: "unknown", + regionScoped: false, + regionEnds: null, + sourceUrl: "https://example.test", + }) as never; + + test("a game's chore and its events are adjacent, chore first", () => { + // This is the whole point: the strip used to list every chore and then + // every event, so Genshin's commissions and Genshin's login event sat at + // opposite ends with a dozen games between them. + const groups = dailyGroups( + ["genshin", "hsr"], + [repeating("e1", "genshin", "Login Bonus"), repeating("e2", "hsr", "Sign In")], + NOW, + "europe", + meta, + startOf, + ); + expect(groups.map((g) => g.items.map((i) => i.key))).toEqual([ + ["dailies:genshin", "e1"], + ["dailies:hsr", "e2"], + ]); + }); + + test("groups follow the order the games arrive in", () => { + // The reader's order, resolved upstream by `orderGames`. + const groups = dailyGroups(["hsr", "genshin"], [], NOW, "europe", meta, startOf); + expect(groups.map((g) => g.game)).toEqual(["hsr", "genshin"]); + }); + + test("events keep their given order inside a game", () => { + // Grouping is not a licence to re-sort within a group. + const groups = dailyGroups( + ["genshin"], + [ + repeating("late", "genshin", "Second"), + repeating("early", "genshin", "First"), + ], + NOW, + "europe", + meta, + startOf, + ); + expect(groups[0]?.items.map((i) => i.key)).toEqual([ + "dailies:genshin", + "late", + "early", + ]); + }); + + test("a lane the reader invented gets no standing chore", () => { + // There is no routine we could name on their behalf — but their repeating + // events still group under it. + const groups = dailyGroups( + ["mygame:mine"], + [repeating("m1", "mygame:mine", "My Daily")], + NOW, + "europe", + meta, + startOf, + ); + expect(groups[0]?.items.map((i) => i.key)).toEqual(["m1"]); + }); + + test("a lane with nothing to tick is dropped, not rendered as an empty heading", () => { + const groups = dailyGroups(["mygame:empty"], [], NOW, "europe", meta, startOf); + expect(groups).toEqual([]); + }); + + test("an event whose lane is not listed still gets a line", () => { + // Dropping it would quietly remove something tickable. It trails the lanes + // that were listed. + const groups = dailyGroups( + ["genshin"], + [repeating("stray", "zzz", "Stray Daily")], + NOW, + "europe", + meta, + startOf, + ); + expect(groups.map((g) => g.game)).toEqual(["genshin", "zzz"]); + }); + + test("a done event contributes nothing, because it never arrives here", () => { + // The strip is an instruction, not a record: App filters completed and + // ignored events out before this sees them (`outstanding` in lens.ts), so + // catch-up cannot resurrect a chip for something the reader finished. + const groups = dailyGroups(["genshin"], [], NOW, "europe", meta, startOf); + expect(groups[0]?.items.map((i) => i.key)).toEqual(["dailies:genshin"]); + }); + + test("a chore has no start to clip a catch-up strip at; an event does", () => { + const groups = dailyGroups( + ["genshin"], + [repeating("e1", "genshin", "Login Bonus")], + NOW, + "europe", + meta, + startOf, + ); + const [chore, event] = groups[0]?.items ?? []; + expect(chore?.notBefore).toBeNull(); + expect(event?.notBefore).toBe(Date.parse("2026-08-10T00:00:00.000Z")); + }); + + test("each item carries its own game's reset clock", () => { + // Endfield serves Europe off the Americas machine, so its day rolls at + // 09:00 UTC. A section-wide "today" would tick the wrong box for hours. + const dawn = Date.parse("2026-08-17T05:00:00.000Z"); + const groups = dailyGroups( + ["genshin", "endfield"], + [], + dawn, + "europe", + meta, + startOf, + ); + const today = groups.map((g) => g.items[0]?.today); + expect(today[0]).not.toBe(today[1]); + }); +}); + +describe("CatchUpPanel", () => { + const NOW = Date.parse("2026-08-17T12:00:00.000Z"); + const meta = (id: string) => metaFor(id, {}); + const startOf = (e: { startsAt: string }) => Date.parse(e.startsAt); + + const groups = () => + dailyGroups(["genshin"], [], NOW, "europe", meta, startOf); + + const panel = (logged: string[] = []) => + render( + logged} + onToggleDay={() => {}} + />, + ); + + test("a fortnight of days, each one tickable", () => { + const markup = panel(); + const pips = [...markup.matchAll(/aria-label="[^"]*(?:not )?done"/g)]; + expect(pips).toHaveLength(14); + }); + + test("no day later than today, because nobody can have done tomorrow", () => { + // The pip for a future day would be a control for a claim that cannot be + // true. It is absent rather than present-and-disabled. + expect(panel()).not.toContain('disabled=""'); + }); + + test("a logged day reads as done, an unlogged one does not", () => { + const markup = panel([dayKey(NOW, "europe", "genshin")]); + expect(markup).toContain(", done\""); + expect(markup).toContain(", not done\""); + }); + + test("names the game, so an expanded strip says whose days these are", () => { + expect(panel()).toContain("Genshin Impact"); + }); + + test("an undated event's strip starts at the event, not a fortnight ago", () => { + const started = NOW - 2 * 86_400_000; + const withEvent = dailyGroups( + ["genshin"], + [ + { + id: "e1", + game: "genshin", + title: "Login Bonus", + type: "login", + summary: null, + startsAt: new Date(started).toISOString(), + startPrecision: "day", + endsAt: null, + endPrecision: "unknown", + regionScoped: false, + regionEnds: null, + sourceUrl: "https://example.test", + } as never, + ], + NOW, + "europe", + meta, + startOf, + ); + const markup = render( + []} + onToggleDay={() => {}} + />, + ); + // The chore's fourteen, plus three for an event that opened two days ago. + expect([...markup.matchAll(/aria-label="[^"]*(?:not )?done"/g)]).toHaveLength(17); + }); +});