feat(next-up): show the next three deadlines, not one
A reader with two games switched on said the list lost its point at twenty-one rows and asked for the three closest deadlines up front. One row was also fragile on its own terms: ticking off the headline event left the panel pointing at something the reader had no context for. Three equal panels would be a stat grid, and a reader arrives with one question — so the shape is one answer at full size and two follow-ups under it, with no meter, summary or badges to turn the panel into a second copy of the list below it. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
6e95e54ec6
commit
41046899f9
@@ -96,7 +96,7 @@ src/
|
|||||||
sw.js offline: shell cache, feed fallback
|
sw.js offline: shell cache, feed fallback
|
||||||
manifest.webmanifest, icon.svg
|
manifest.webmanifest, icon.svg
|
||||||
components/
|
components/
|
||||||
NextUp.tsx the hero countdown (PRD F1)
|
NextUp.tsx the next three deadlines (PRD F2)
|
||||||
EventRow.tsx row + meter + caption (F2, F3)
|
EventRow.tsx row + meter + caption (F2, F3)
|
||||||
Meter.tsx the depletion meter
|
Meter.tsx the depletion meter
|
||||||
Legend.tsx what the bars and colours mean
|
Legend.tsx what the bars and colours mean
|
||||||
|
|||||||
+1
-1
@@ -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** |
|
| 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 |
|
| 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 cap the long list | **Not done.** No "show all N" expander |
|
||||||
| P1b persist `view` | **Not done.** Still `useState` in `App.tsx` |
|
| P1b persist `view` | **Not done.** Still `useState` in `App.tsx` |
|
||||||
| P1b Calendar → Timeline | **Done.** The tab reads "Timeline" |
|
| P1b Calendar → Timeline | **Done.** The tab reads "Timeline" |
|
||||||
|
|||||||
+12
-3
@@ -23,7 +23,7 @@ import { compareRows, SORT_MODES, type Activity, type SortMode } from "./state/s
|
|||||||
import {
|
import {
|
||||||
advanceFocus,
|
advanceFocus,
|
||||||
countByGame,
|
countByGame,
|
||||||
firstToExpire,
|
nextToExpire,
|
||||||
outstanding,
|
outstanding,
|
||||||
resolveFocus,
|
resolveFocus,
|
||||||
} from "./state/lens.ts";
|
} from "./state/lens.ts";
|
||||||
@@ -40,6 +40,15 @@ import { metaFor } from "../shared/games.ts";
|
|||||||
|
|
||||||
type View = "soon" | "timeline";
|
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
|
* 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
|
* 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.
|
* them on screen, not keep nagging me about them.
|
||||||
*/
|
*/
|
||||||
const todo = outstanding(live, isDone, isIgnored);
|
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
|
// 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.
|
// chip has to say what is waiting behind it to be worth tapping.
|
||||||
@@ -356,7 +365,7 @@ export function App() {
|
|||||||
{view === "soon" ? (
|
{view === "soon" ? (
|
||||||
<>
|
<>
|
||||||
<NextUp
|
<NextUp
|
||||||
row={next}
|
rows={headline}
|
||||||
focused={focus === null ? null : gameMeta(focus).name}
|
focused={focus === null ? null : gameMeta(focus).name}
|
||||||
onOpen={setOpenId}
|
onOpen={setOpenId}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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 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
|
* The two behind it are listed under it, small. A reader asked for the three
|
||||||
* question on arrival.
|
* 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({
|
export function NextUp({
|
||||||
row,
|
rows,
|
||||||
focused,
|
focused,
|
||||||
onOpen,
|
onOpen,
|
||||||
}: {
|
}: {
|
||||||
/**
|
/**
|
||||||
* The soonest-expiring event the reader has neither finished nor ignored.
|
* The soonest-expiring events the reader has neither finished nor ignored,
|
||||||
* A panel headed "next to expire" is a deadline they still have to meet, so
|
* closest first. A panel headed "next to expire" is a list of deadlines they
|
||||||
* an event they already ticked off does not belong in it however visible
|
* still have to meet, so events they already ticked off do not belong in it
|
||||||
* they have chosen to keep it elsewhere.
|
* 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. */
|
/** Name of the game being focused on, when the page is narrowed to one. */
|
||||||
focused: string | null;
|
focused: string | null;
|
||||||
onOpen: (id: string) => void;
|
onOpen: (id: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const gameMeta = useGameMeta();
|
const gameMeta = useGameMeta();
|
||||||
if (row === null) {
|
const [lead, ...rest] = rows;
|
||||||
|
|
||||||
|
if (lead === undefined) {
|
||||||
return (
|
return (
|
||||||
<section className="border-b border-hairline px-4 py-8">
|
<section className="border-b border-hairline px-4 py-8">
|
||||||
<p className="eyebrow">Nothing running</p>
|
<p className="eyebrow">Nothing running</p>
|
||||||
@@ -40,7 +45,7 @@ export function NextUp({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { event, clock } = row;
|
const { event, clock } = lead;
|
||||||
const game = gameMeta(event.game);
|
const game = gameMeta(event.game);
|
||||||
const heat = URGENCY_COLOR[clock.urgency];
|
const heat = URGENCY_COLOR[clock.urgency];
|
||||||
const known = clock.msRemaining !== null;
|
const known = clock.msRemaining !== null;
|
||||||
@@ -92,7 +97,66 @@ export function NextUp({
|
|||||||
label={`${event.title} time remaining`}
|
label={`${event.title} time remaining`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{rest.length > 0 && (
|
||||||
|
<div className="mt-5 border-t border-hairline pt-3">
|
||||||
|
<p className="eyebrow">Then</p>
|
||||||
|
<ul className="mt-1.5">
|
||||||
|
{rest.map((row) => (
|
||||||
|
<QueuedRow key={row.event.id} row={row} onOpen={onOpen} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 (
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onOpen(event.id)}
|
||||||
|
className="group flex w-full items-baseline gap-2.5 py-1.5 text-left"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="size-1.5 shrink-0 translate-y-[-1px] rounded-full"
|
||||||
|
style={{ background: game.hue }}
|
||||||
|
/>
|
||||||
|
<span className="min-w-0 flex-1 truncate text-[0.8125rem] leading-snug text-muted transition-colors duration-150 group-hover:text-ink">
|
||||||
|
<span className="sr-only">{game.name}: </span>
|
||||||
|
{event.title}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="tnum shrink-0 font-display text-xs font-semibold"
|
||||||
|
style={{
|
||||||
|
color: known ? URGENCY_COLOR[clock.urgency] : "var(--color-faint)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{known ? formatRemaining(clock.msRemaining ?? 0) : "no end date"}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
<GameMetaProvider value={(id) => metaFor(id, {})}>{node}</GameMetaProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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(<NextUp rows={rows} focused={null} onOpen={() => {}} />);
|
||||||
|
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(
|
||||||
|
<NextUp rows={rows.slice(0, 1)} focused={null} onOpen={() => {}} />,
|
||||||
|
);
|
||||||
|
expect(html).toContain("Closing Ceremony");
|
||||||
|
expect(html).not.toContain(">Then<");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("no deadlines says so rather than rendering an empty panel", () => {
|
||||||
|
const html = render(<NextUp rows={[]} focused={null} onOpen={() => {}} />);
|
||||||
|
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(
|
||||||
|
<NextUp rows={[row("Unknown End", "wuwa", null)]} focused={null} onOpen={() => {}} />,
|
||||||
|
);
|
||||||
|
expect(html).toContain("unknown");
|
||||||
|
expect(html).toContain("no end date");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user