feat: every list of games follows the reader's order, dailies included

The focus bar, the settings list, the timeline's lanes and the dailies strip all
go through `orderGames` now, resolved once in App. `games` itself stays in feed
order on purpose: `adoptNewLanes` diffs it and `knownGames` is seeded from it,
so ordering it at source would let a display preference reach the code that
decides which of a reader's games get hidden — a reordering bug would become a
game-silently-switched-off bug.

The dailies strip also groups. It was `[...chores, ...repeating]`, which put
Genshin's commissions and Genshin's own login event at opposite ends with a
dozen games between them; a game's chore and its repeating events are now
adjacent, chore first, events in the order they arrived. Collapsed that is
adjacency alone — no per-game headings, because this is the part of the page
answerable in ten seconds and a heading each would make it the tallest block on
it, pushing "next to expire" down the page.

Two things moved to where they belong. Skipping a standing chore for a lane the
reader invented is `dailyGroups`' rule, not the call site's: filtering those
lanes out in App also cost a reader's own game its place in the order its events
group under. And the expanded catch-up panel is its own exported component, so
which days it offers and whose clock they were cut on are testable rather than
trapped behind a `useState`.

`Welcome` drops its own comparator for the shared rule — the picker runs before
any stored order, so it asks for the A–Z case and cannot drift from the four
surfaces behind it.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Lucas Winther
2026-08-20 06:01:37 +02:00
co-authored by Claude Opus 5
parent bf7959c916
commit 721c4c90fb
6 changed files with 567 additions and 77 deletions
+246 -54
View File
@@ -1,9 +1,18 @@
import { useEffect, useRef, useState } from "react";
import { dailiesId, dayKey, msUntilReset, streakOf } from "../../shared/daily.ts";
import {
catchUpDays,
CATCH_UP_DAYS,
dailiesId,
dayKey,
msUntilReset,
streakOf,
} from "../../shared/daily.ts";
import { useGameMeta } from "../state/gameMeta.tsx";
import type { DisplayEvent, LaneId } from "../../shared/custom.ts";
import { isCustomGameId, type DisplayEvent, type LaneId } from "../../shared/custom.ts";
import type { GameMeta } from "../../shared/games.ts";
import type { GameId, Region } from "../../shared/schema.ts";
import { formatRemaining } from "../../shared/time.ts";
import { DayPip } from "./DayPip.tsx";
import { Fireworks } from "./Fireworks.tsx";
/**
@@ -22,6 +31,109 @@ import { Fireworks } from "./Fireworks.tsx";
* Sits above the event list because it is the one part of the page that is
* answerable in ten seconds and expires tonight.
*/
/** One thing to tick: a game's standing chore, or one of its repeating events. */
export interface DailyItem {
/** The day-log key — `dailies:<game>` 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({
</p>
</div>
<ul className="relative mt-2.5 flex flex-wrap gap-1.5">
{chores.map((chore) => (
<li key={chore.key}>
<TickChip
label={chore.game.short}
hue={chore.game.hue}
title={chore.game.dailyTasks}
ariaLabel={`${chore.game.name} dailies — ${chore.game.dailyTasks}`}
days={daysFor(chore.key)}
today={chore.today}
onToggle={() => onToggleDay(chore.key, chore.today)}
/>
</li>
))}
{catchUp ? (
<CatchUpPanel
groups={groups}
now={now}
region={region}
daysFor={daysFor}
onToggleDay={onToggleDay}
/>
) : (
/* 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. */
<ul className="relative mt-2.5 flex flex-wrap gap-1.5">
{items.map((item) => (
<li key={item.key}>
<TickChip
label={item.label}
hue={gameMeta(item.game).hue}
title={item.title}
ariaLabel={item.ariaLabel}
days={daysFor(item.key)}
today={item.today}
onToggle={() => onToggleDay(item.key, item.today)}
/>
</li>
))}
</ul>
)}
{repeating.map((row) => (
<li key={row.key}>
<TickChip
label={row.event.title}
hue={row.game.hue}
title={`${row.game.name} — ${row.event.title}`}
ariaLabel={`${row.event.title} (${row.game.name})`}
days={daysFor(row.key)}
today={row.today}
onToggle={() => onToggleDay(row.key, row.today)}
/>
</li>
))}
</ul>
<p className="relative mt-2 text-[0.6875rem] leading-relaxed text-faint">
{allDone
? "All done. Nothing else expires tonight."
: `${waiting(total - complete)} still waiting on you today.`}
</p>
<div className="relative mt-2 flex items-baseline justify-between gap-3">
<p className="text-[0.6875rem] leading-relaxed text-faint">
{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.`}
</p>
{/* 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. */}
<button
type="button"
onClick={() => setCatchUp((on) => !on)}
aria-expanded={catchUp}
className="shrink-0 text-[0.6875rem] text-faint transition-colors hover:text-muted"
>
{catchUp ? "Done" : "Catch up"}
</button>
</div>
</section>
);
}
/**
* 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 (
<div className="relative mt-3 flex flex-col gap-3">
{groups.map((group) => (
<div key={group.game}>
<p
className="text-[0.6875rem] font-semibold"
style={{ color: group.meta.hue }}
>
{group.meta.name}
</p>
{group.items.map((item) => (
<div key={item.key} className="mt-1.5">
<p className="truncate text-xs text-muted">{item.label}</p>
<div className="mt-1 flex flex-wrap gap-1">
{catchUpDays(now, region, item.game, item.notBefore).map((day) => (
<DayPip
key={day}
day={day}
today={item.today}
done={daysFor(item.key).includes(day)}
onToggle={() => onToggleDay(item.key, day)}
/>
))}
</div>
</div>
))}
</div>
))}
</div>
);
}
/**
* One thing to tick off today.
*