diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index f4aecdf..70ac8ab 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -96,7 +96,7 @@ src/ sw.js offline: shell cache, feed fallback manifest.webmanifest, icon.svg components/ - NextUp.tsx the hero countdown (PRD F1) + NextUp.tsx the next three deadlines (PRD F2) EventRow.tsx row + meter + caption (F2, F3) Meter.tsx the depletion meter Legend.tsx what the bars and colours mean diff --git a/docs/FEEDBACK.md b/docs/FEEDBACK.md index 4c9b71c..d3d9bae 100644 --- a/docs/FEEDBACK.md +++ b/docs/FEEDBACK.md @@ -301,7 +301,7 @@ diagnosis in each item still holds — what changed is whether it has been acted |---|---| | P0 refresh pipeline | **Diagnosed, half acted on.** game8.co answers a GitHub Actions runner with `202` and a bot-management body, so those eight sources have only ever built from fixtures in CI — see `AGENTS.md` § Scraping conduct, including why it is not to be worked around. The `broken` tier now makes a source failing three cycles fail the run. Step 5 (a build assertion on snapshot age) is **not built** | | P1a Arknights | **Done.** `arknights-akwiki-events`, via the new `akwiki` parser | -| P1b `NextUp` → three | **Not done.** Still one row | +| P1b `NextUp` → three | **Done** (2026-08-18). One headline and two behind it, off `nextToExpire` | | P1b cap the long list | **Not done.** No "show all N" expander | | P1b persist `view` | **Not done.** Still `useState` in `App.tsx` | | P1b Calendar → Timeline | **Done.** The tab reads "Timeline" | diff --git a/src/client/App.tsx b/src/client/App.tsx index 88e7631..37cfd60 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -23,7 +23,7 @@ import { compareRows, SORT_MODES, type Activity, type SortMode } from "./state/s import { advanceFocus, countByGame, - firstToExpire, + nextToExpire, outstanding, resolveFocus, } from "./state/lens.ts"; @@ -40,6 +40,15 @@ import { metaFor } from "../shared/games.ts"; type View = "soon" | "timeline"; +/** + * How many deadlines the headline carries. + * + * One was the whole panel, and one is what a reader who has just finished it + * needs replacing. Three is what they asked for: enough to plan an evening + * around, few enough that the closest one still owns the page. + */ +const HEADLINE_DEADLINES = 3; + /** * Connection state. Offline is not an error here — the service worker serves * the last feed it saw and countdowns run off the local clock — but it does @@ -245,7 +254,7 @@ export function App() { * them on screen, not keep nagging me about them. */ const todo = outstanding(live, isDone, isIgnored); - const next = firstToExpire(todo); + const headline = nextToExpire(todo, HEADLINE_DEADLINES); // Counted across every game the reader plays, not just the focused one — a // chip has to say what is waiting behind it to be worth tapping. @@ -356,7 +365,7 @@ export function App() { {view === "soon" ? ( <> diff --git a/src/client/components/NextUp.tsx b/src/client/components/NextUp.tsx index b1ed5fe..64915ba 100644 --- a/src/client/components/NextUp.tsx +++ b/src/client/components/NextUp.tsx @@ -5,29 +5,34 @@ import { Meter, URGENCY_COLOR } from "./Meter.tsx"; /** * The thesis of the page: this app is a clock, so the first thing you see is - * the single event closest to expiring, at a size nothing else competes with. + * the event closest to expiring, at a size nothing else competes with. * - * Deliberately not a stat grid. One number, because the reader has exactly one - * question on arrival. + * The two behind it are listed under it, small. A reader asked for the three + * next deadlines and he was right that one is too few — finishing the headline + * event used to leave the panel pointing at something with no context — but + * three equal panels is a stat grid, and the reader arrives with one question. + * So the shape is one answer and two follow-ups, not three answers. */ export function NextUp({ - row, + rows, focused, onOpen, }: { /** - * The soonest-expiring event the reader has neither finished nor ignored. - * A panel headed "next to expire" is a deadline they still have to meet, so - * an event they already ticked off does not belong in it however visible - * they have chosen to keep it elsewhere. + * The soonest-expiring events the reader has neither finished nor ignored, + * closest first. A panel headed "next to expire" is a list of deadlines they + * still have to meet, so events they already ticked off do not belong in it + * however visible they have chosen to keep them elsewhere. */ - row: RowEvent | null; + rows: RowEvent[]; /** Name of the game being focused on, when the page is narrowed to one. */ focused: string | null; onOpen: (id: string) => void; }) { const gameMeta = useGameMeta(); - if (row === null) { + const [lead, ...rest] = rows; + + if (lead === undefined) { return (

Nothing running

@@ -40,7 +45,7 @@ export function NextUp({ ); } - const { event, clock } = row; + const { event, clock } = lead; const game = gameMeta(event.game); const heat = URGENCY_COLOR[clock.urgency]; const known = clock.msRemaining !== null; @@ -92,7 +97,66 @@ export function NextUp({ label={`${event.title} time remaining`} /> + + {rest.length > 0 && ( +
+

Then

+
    + {rest.map((row) => ( + + ))} +
+
+ )}
); } + +/** + * A deadline waiting behind the headline. + * + * Deliberately a different object from an `EventRow`: no meter, no summary, no + * badges. Its whole job is "what is after this one, and how long have I got" — + * anything more turns the panel into a second copy of the list it sits above. + */ +function QueuedRow({ + row, + onOpen, +}: { + row: RowEvent; + onOpen: (id: string) => void; +}) { + const gameMeta = useGameMeta(); + const { event, clock } = row; + const game = gameMeta(event.game); + const known = clock.msRemaining !== null; + + return ( +
  • + +
  • + ); +} diff --git a/test/views.test.tsx b/test/views.test.tsx new file mode 100644 index 0000000..933f36f --- /dev/null +++ b/test/views.test.tsx @@ -0,0 +1,97 @@ +import { describe, expect, test } from "bun:test"; +import { renderToStaticMarkup } from "react-dom/server"; +import { NextUp } from "../src/client/components/NextUp.tsx"; +import { GameMetaProvider } from "../src/client/state/gameMeta.tsx"; +import { metaFor } from "../src/shared/games.ts"; +import { clockFor } from "../src/shared/time.ts"; +import { GachaEvent, type GameId } from "../src/shared/schema.ts"; + +/** + * Static-render checks on the headline panel. + * + * Not a substitute for using the thing, but they pin the claims it makes: it + * leads with the closest deadline, it carries the ones behind it, and it never + * dresses an unannounced end up as a countdown. + */ + +const NOW = Date.parse("2026-08-17T12:00:00.000Z"); +const HOUR = 60 * 60 * 1000; + +function render(node: React.ReactElement): string { + return renderToStaticMarkup( + metaFor(id, {})}>{node}, + ); +} + +function row(title: string, game: GameId, endsInHours: number | null) { + // Through the schema rather than cast into shape: it is the single source of + // truth for this type, and it is what would catch a fixture that no longer + // resembles a real event. + const event = GachaEvent.parse({ + id: `${game}:${title.toLowerCase().replace(/\W+/g, "-")}:2026-08-10`, + game, + title, + type: "story", + summary: null, + startsAt: "2026-08-10T00:00:00.000Z", + startPrecision: "day", + endsAt: + endsInHours === null + ? null + : new Date(NOW + endsInHours * HOUR).toISOString(), + endPrecision: endsInHours === null ? "unknown" : "exact", + regionScoped: false, + regionEnds: null, + sourceUrl: "https://example.invalid/events", + sourceId: "example-events", + status: "published", + confidence: 1, + extractionMethod: "parser", + version: 1, + firstSeenAt: "2026-08-17T00:00:00.000Z", + updatedAt: "2026-08-17T00:00:00.000Z", + }); + return { event, clock: clockFor(event, "europe", NOW) }; +} + +describe("NextUp", () => { + const rows = [ + row("Closing Ceremony", "genshin", 6), + row("Second Wind", "hsr", 30), + row("Third Rail", "zzz", 100), + ]; + + test("leads with the closest deadline and lists the ones behind it", () => { + // A reader asked for the next three, and was right that one is too few: + // finishing the headline event left the panel pointing at nothing. + const html = render( {}} />); + expect(html).toContain("Closing Ceremony"); + expect(html).toContain("Second Wind"); + expect(html).toContain("Third Rail"); + // The lead keeps the big countdown; the rest are a queue under it. + expect(html.indexOf("Closing Ceremony")).toBeLessThan(html.indexOf("Then")); + expect(html.indexOf("Then")).toBeLessThan(html.indexOf("Second Wind")); + }); + + test("one deadline is a headline with nothing behind it", () => { + const html = render( + {}} />, + ); + expect(html).toContain("Closing Ceremony"); + expect(html).not.toContain(">Then<"); + }); + + test("no deadlines says so rather than rendering an empty panel", () => { + const html = render( {}} />); + expect(html).toContain("Nothing running"); + }); + + test("an unannounced end is never dressed up as a countdown", () => { + // The rule the whole product rests on, at the largest type size it has. + const html = render( + {}} />, + ); + expect(html).toContain("unknown"); + expect(html).toContain("no end date"); + }); +});