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:
co-authored by
Claude Opus 5
parent
bf7959c916
commit
721c4c90fb
+28
-10
@@ -30,10 +30,10 @@ import {
|
|||||||
} from "./state/lens.ts";
|
} from "./state/lens.ts";
|
||||||
import { clockFor, formatRemaining } from "../shared/time.ts";
|
import { clockFor, formatRemaining } from "../shared/time.ts";
|
||||||
import { dailySummary, isDaily, resolveDaily } from "../shared/daily.ts";
|
import { dailySummary, isDaily, resolveDaily } from "../shared/daily.ts";
|
||||||
|
import { orderGames } from "./state/gameOrder.ts";
|
||||||
import { GameMetaProvider, type MetaResolver } from "./state/gameMeta.tsx";
|
import { GameMetaProvider, type MetaResolver } from "./state/gameMeta.tsx";
|
||||||
import { metaOnTheme, useTheme } from "./state/theme.ts";
|
import { metaOnTheme, useTheme } from "./state/theme.ts";
|
||||||
import {
|
import {
|
||||||
isCustomGameId,
|
|
||||||
type CustomEvents,
|
type CustomEvents,
|
||||||
type CustomGames,
|
type CustomGames,
|
||||||
type LaneId,
|
type LaneId,
|
||||||
@@ -238,10 +238,24 @@ export function App() {
|
|||||||
if (patch !== null) update(patch);
|
if (patch !== null) update(patch);
|
||||||
}, [state.status, games, prefs.knownGames, prefs.hiddenGames, update]);
|
}, [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(
|
const enabled = useMemo(
|
||||||
() => games.filter((g) => !prefs.hiddenGames.includes(g)),
|
() => ordered.filter((g) => !prefs.hiddenGames.includes(g)),
|
||||||
[games, prefs.hiddenGames],
|
[ordered, prefs.hiddenGames],
|
||||||
);
|
);
|
||||||
|
|
||||||
// A focus on a game they have since switched off is ignored, not obeyed —
|
// A focus on a game they have since switched off is ignored, not obeyed —
|
||||||
@@ -359,7 +373,7 @@ export function App() {
|
|||||||
<GameMetaProvider value={gameMeta}>
|
<GameMetaProvider value={gameMeta}>
|
||||||
<Shell>
|
<Shell>
|
||||||
<Welcome
|
<Welcome
|
||||||
available={games}
|
available={ordered}
|
||||||
onConfirm={(chosen, chosenView) =>
|
onConfirm={(chosen, chosenView) =>
|
||||||
update({
|
update({
|
||||||
onboarded: true,
|
onboarded: true,
|
||||||
@@ -477,11 +491,12 @@ export function App() {
|
|||||||
that expires tonight rather than next patch. */}
|
that expires tonight rather than next patch. */}
|
||||||
{/* Standing chores are a tracked-game notion: there is no routine we
|
{/* 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
|
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. */}
|
||||||
<Dailies
|
<Dailies
|
||||||
games={(focus === null ? enabled : [focus]).filter(
|
games={focus === null ? enabled : [focus]}
|
||||||
(id) => !isCustomGameId(id),
|
|
||||||
)}
|
|
||||||
events={todo.filter(repeatsDaily).map((r) => r.event)}
|
events={todo.filter(repeatsDaily).map((r) => r.event)}
|
||||||
region={prefs.region}
|
region={prefs.region}
|
||||||
now={now}
|
now={now}
|
||||||
@@ -571,6 +586,9 @@ export function App() {
|
|||||||
// nothing else left to draw. The switch is in settings.
|
// nothing else left to draw. The switch is in settings.
|
||||||
showUpcoming={prefs.showUpcoming}
|
showUpcoming={prefs.showUpcoming}
|
||||||
splitUpcoming={prefs.timelineSplitUpcoming}
|
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}
|
onOpen={setOpenId}
|
||||||
isDone={isDone}
|
isDone={isDone}
|
||||||
/>
|
/>
|
||||||
@@ -578,7 +596,7 @@ export function App() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<Controls
|
<Controls
|
||||||
games={games}
|
games={ordered}
|
||||||
prefs={prefs}
|
prefs={prefs}
|
||||||
onToggleGame={toggleGame}
|
onToggleGame={toggleGame}
|
||||||
onUpdate={update}
|
onUpdate={update}
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
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 { 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 type { GameId, Region } from "../../shared/schema.ts";
|
||||||
import { formatRemaining } from "../../shared/time.ts";
|
import { formatRemaining } from "../../shared/time.ts";
|
||||||
|
import { DayPip } from "./DayPip.tsx";
|
||||||
import { Fireworks } from "./Fireworks.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
|
* Sits above the event list because it is the one part of the page that is
|
||||||
* answerable in ten seconds and expires tonight.
|
* 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({
|
export function Dailies({
|
||||||
games,
|
games,
|
||||||
events,
|
events,
|
||||||
@@ -30,6 +142,7 @@ export function Dailies({
|
|||||||
daysFor,
|
daysFor,
|
||||||
onToggleDay,
|
onToggleDay,
|
||||||
}: {
|
}: {
|
||||||
|
/** Every lane the reader is looking at, in their own order. */
|
||||||
games: LaneId[];
|
games: LaneId[];
|
||||||
/**
|
/**
|
||||||
* Live events that repeat daily — detected, or marked by the reader — and
|
* 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;
|
onToggleDay: (id: string, day: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const gameMeta = useGameMeta();
|
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
|
// 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
|
// rather than once for the section — Endfield's European day can still be
|
||||||
// yesterday's while every HoYo game has already turned over.
|
// yesterday's while every HoYo game has already turned over.
|
||||||
// Only tracked games have a standing chore — a lane the reader invented has
|
const groups = dailyGroups(
|
||||||
// no routine we could name for them (docs/DATA-MODEL.md § Reader-authored key
|
games,
|
||||||
// spaces), so App passes tracked lanes here and this stays a total mapping.
|
events,
|
||||||
const chores = games.map((id) => ({
|
now,
|
||||||
key: dailiesId(id as GameId),
|
region,
|
||||||
game: gameMeta(id),
|
gameMeta,
|
||||||
today: dayKey(now, region, id),
|
(event) => Date.parse(event.startsAt),
|
||||||
resetsIn: msUntilReset(now, region, id),
|
);
|
||||||
}));
|
const items = groups.flatMap((group) => group.items);
|
||||||
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 total = items.length;
|
const total = items.length;
|
||||||
const complete = items.filter((i) => daysFor(i.key).includes(i.today)).length;
|
const complete = items.filter((i) => daysFor(i.key).includes(i.today)).length;
|
||||||
const allDone = total > 0 && complete === total;
|
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
|
// shorter when the reader focuses a single game or marks a repeating event
|
||||||
// done, which can land on "all complete" without them having ticked
|
// done, which can land on "all complete" without them having ticked
|
||||||
// anything — a burst there is the app congratulating them for filtering.
|
// 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);
|
if (was.total === total && complete > was.complete) setBurst((n) => n + 1);
|
||||||
}, [total, complete, allDone]);
|
}, [total, complete, allDone]);
|
||||||
|
|
||||||
@@ -111,45 +226,122 @@ export function Dailies({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul className="relative mt-2.5 flex flex-wrap gap-1.5">
|
{catchUp ? (
|
||||||
{chores.map((chore) => (
|
<CatchUpPanel
|
||||||
<li key={chore.key}>
|
groups={groups}
|
||||||
<TickChip
|
now={now}
|
||||||
label={chore.game.short}
|
region={region}
|
||||||
hue={chore.game.hue}
|
daysFor={daysFor}
|
||||||
title={chore.game.dailyTasks}
|
onToggleDay={onToggleDay}
|
||||||
ariaLabel={`${chore.game.name} dailies — ${chore.game.dailyTasks}`}
|
|
||||||
days={daysFor(chore.key)}
|
|
||||||
today={chore.today}
|
|
||||||
onToggle={() => onToggleDay(chore.key, chore.today)}
|
|
||||||
/>
|
/>
|
||||||
</li>
|
) : (
|
||||||
))}
|
/* 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
|
||||||
{repeating.map((row) => (
|
seconds, and a heading per game would make it the tallest block on it,
|
||||||
<li key={row.key}>
|
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
|
<TickChip
|
||||||
label={row.event.title}
|
label={item.label}
|
||||||
hue={row.game.hue}
|
hue={gameMeta(item.game).hue}
|
||||||
title={`${row.game.name} — ${row.event.title}`}
|
title={item.title}
|
||||||
ariaLabel={`${row.event.title} (${row.game.name})`}
|
ariaLabel={item.ariaLabel}
|
||||||
days={daysFor(row.key)}
|
days={daysFor(item.key)}
|
||||||
today={row.today}
|
today={item.today}
|
||||||
onToggle={() => onToggleDay(row.key, row.today)}
|
onToggle={() => onToggleDay(item.key, item.today)}
|
||||||
/>
|
/>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
<p className="relative mt-2 text-[0.6875rem] leading-relaxed text-faint">
|
<div className="relative mt-2 flex items-baseline justify-between gap-3">
|
||||||
{allDone
|
<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."
|
? "All done. Nothing else expires tonight."
|
||||||
: `${waiting(total - complete)} still waiting on you today.`}
|
: `${waiting(total - complete)} still waiting on you today.`}
|
||||||
</p>
|
</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>
|
</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.
|
* One thing to tick off today.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Fragment, useLayoutEffect, useRef } from "react";
|
import { Fragment, useLayoutEffect, useRef } from "react";
|
||||||
import { useGameMeta } from "../state/gameMeta.tsx";
|
import { useGameMeta } from "../state/gameMeta.tsx";
|
||||||
|
import type { LaneId } from "../../shared/custom.ts";
|
||||||
import { DAY } from "../../shared/time.ts";
|
import { DAY } from "../../shared/time.ts";
|
||||||
import type { RowEvent } from "./EventRow.tsx";
|
import type { RowEvent } from "./EventRow.tsx";
|
||||||
import { URGENCY_COLOR } from "./Meter.tsx";
|
import { URGENCY_COLOR } from "./Meter.tsx";
|
||||||
@@ -99,6 +100,7 @@ export function Timeline({
|
|||||||
onGroup,
|
onGroup,
|
||||||
showUpcoming,
|
showUpcoming,
|
||||||
splitUpcoming,
|
splitUpcoming,
|
||||||
|
gameOrder,
|
||||||
onOpen,
|
onOpen,
|
||||||
isDone,
|
isDone,
|
||||||
}: {
|
}: {
|
||||||
@@ -138,6 +140,11 @@ export function Timeline({
|
|||||||
* re-sorts instead, and this only decides whether the heading is drawn.
|
* re-sorts instead, and this only decides whether the heading is drawn.
|
||||||
*/
|
*/
|
||||||
splitUpcoming: boolean;
|
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;
|
onOpen: (id: string) => void;
|
||||||
/**
|
/**
|
||||||
* Asked rather than derived from the progress store: an entry exists there
|
* 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 marks = startMarkers(plotted, x);
|
||||||
|
|
||||||
const months = monthBoundaries(min, max);
|
const months = monthBoundaries(min, max);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useGameMeta } from "../state/gameMeta.tsx";
|
import { useGameMeta } from "../state/gameMeta.tsx";
|
||||||
|
import { orderGames } from "../state/gameOrder.ts";
|
||||||
import type { LaneId } from "../../shared/custom.ts";
|
import type { LaneId } from "../../shared/custom.ts";
|
||||||
import type { View } from "../state/usePrefs.ts";
|
import type { View } from "../state/usePrefs.ts";
|
||||||
|
|
||||||
@@ -40,19 +41,16 @@ export function Welcome({
|
|||||||
/**
|
/**
|
||||||
* Alphabetical, by the name on the button.
|
* Alphabetical, by the name on the button.
|
||||||
*
|
*
|
||||||
* `available` arrives in feed order — whichever game happened to hold the
|
* `available` arrives in lane order, which everywhere else in the app means
|
||||||
* first row — which is meaningful everywhere else in the app and meaningless
|
* the reader's own game order — but this screen runs before they have one, so
|
||||||
* here, where the reader is not reading the list but looking for the two or
|
* it asks `orderGames` for the no-stored-order case and gets the alphabetical
|
||||||
* three names they already know. Sorted by `name` and not by `LaneId`,
|
* rule. Through the shared function rather than a comparator of its own, so
|
||||||
* because the id is not what is printed: `hsr` is Honkai: Star Rail. And
|
* the picker and the four surfaces behind it cannot drift apart: the reader is
|
||||||
* through `localeCompare`, because `<` orders by code point and would file
|
* not reading this list, they are looking for the two or three names they
|
||||||
* hololive Dreams after every capitalised name on the screen.
|
* already know.
|
||||||
*/
|
*/
|
||||||
const ordered = useMemo(
|
const ordered = useMemo(
|
||||||
() =>
|
() => orderGames(available, undefined, (id) => gameMeta(id).name),
|
||||||
[...available].sort((a, b) =>
|
|
||||||
gameMeta(a).name.localeCompare(gameMeta(b).name),
|
|
||||||
),
|
|
||||||
[available, gameMeta],
|
[available, gameMeta],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -74,11 +74,21 @@ export interface Lane<T> {
|
|||||||
* modes, lane mode included: leaving a lane's given order alone there would
|
* 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
|
* produce the block it was told not to draw, minus the heading that explained
|
||||||
* it, which is the worst of both answers.
|
* 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<T extends Row>(
|
export function timelineLanes<T extends Row>(
|
||||||
rows: readonly T[],
|
rows: readonly T[],
|
||||||
mode: TimelineGroup,
|
mode: TimelineGroup,
|
||||||
split = true,
|
split = true,
|
||||||
|
gameOrder?: readonly LaneId[],
|
||||||
): Array<Lane<T>> {
|
): Array<Lane<T>> {
|
||||||
const order = split ? endingSoonestFirst : byDeadline;
|
const order = split ? endingSoonestFirst : byDeadline;
|
||||||
|
|
||||||
@@ -91,7 +101,20 @@ export function timelineLanes<T extends Row>(
|
|||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
byGame.set(row.event.game, [...(byGame.get(row.event.game) ?? []), row]);
|
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,
|
id: game,
|
||||||
game,
|
game,
|
||||||
rows: split ? laneRows : [...laneRows].sort(byDeadline),
|
rows: split ? laneRows : [...laneRows].sort(byDeadline),
|
||||||
|
|||||||
@@ -8,10 +8,12 @@ import {
|
|||||||
startMarkers,
|
startMarkers,
|
||||||
Timeline,
|
Timeline,
|
||||||
} from "../src/client/components/Timeline.tsx";
|
} from "../src/client/components/Timeline.tsx";
|
||||||
|
import { CatchUpPanel, dailyGroups } from "../src/client/components/Dailies.tsx";
|
||||||
import { Welcome } from "../src/client/components/Welcome.tsx";
|
import { Welcome } from "../src/client/components/Welcome.tsx";
|
||||||
import { timelineLanes } from "../src/client/state/lanes.ts";
|
import { timelineLanes } from "../src/client/state/lanes.ts";
|
||||||
import { GameMetaProvider } from "../src/client/state/gameMeta.tsx";
|
import { GameMetaProvider } from "../src/client/state/gameMeta.tsx";
|
||||||
import { metaFor } from "../src/shared/games.ts";
|
import { metaFor } from "../src/shared/games.ts";
|
||||||
|
import { dayKey } from "../src/shared/daily.ts";
|
||||||
import { clockFor } from "../src/shared/time.ts";
|
import { clockFor } from "../src/shared/time.ts";
|
||||||
import { GachaEvent, type GameId } from "../src/shared/schema.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", () => {
|
test("ending soonest: every game in one stack, deadline order", () => {
|
||||||
const lanes = timelineLanes(rows, "ending");
|
const lanes = timelineLanes(rows, "ending");
|
||||||
expect(lanes).toHaveLength(1);
|
expect(lanes).toHaveLength(1);
|
||||||
@@ -569,3 +604,220 @@ describe("markerLabel", () => {
|
|||||||
expect(label).toContain("–");
|
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(
|
||||||
|
<CatchUpPanel
|
||||||
|
groups={groups()}
|
||||||
|
now={NOW}
|
||||||
|
region="europe"
|
||||||
|
daysFor={() => 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(
|
||||||
|
<CatchUpPanel
|
||||||
|
groups={withEvent}
|
||||||
|
now={NOW}
|
||||||
|
region="europe"
|
||||||
|
daysFor={() => []}
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user