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 (
+
+ );
+}
+
/**
* 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);
+ });
+});