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