diff --git a/AGENTS.md b/AGENTS.md index 4adbc39..51621b0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -433,6 +433,11 @@ to an open page). Four things hold it up: - Keep old fixtures when a source changes shape — the old one is the regression test proving the parser still handles the previous format. Fixtures are pinned and permanent; `snapshots/` is the current page and gets overwritten. Do not conflate them. +- **Truncating a list is not re-sorting it.** Each section shows `LIST_CAP` rows and offers "show + all N". The rows below the cut keep their place in the order, stay counted in the header, and stay + on the timeline — so the deadline guarantee two bullets down holds for what is hidden exactly as it + does for what is shown. Expanding is per-visit state, not a stored preference: it is something a + reader does while reading one list, not a statement about how the app should work. - **Which view opens is the reader's answer.** `prefs.view` is asked once on the first run (PRD F8) and written by the tabs from then on. It was component state, which meant a reader who preferred the timeline was put back on the list by every reload, with nothing to blame but the app diff --git a/docs/FEEDBACK.md b/docs/FEEDBACK.md index 0e5e4e3..b5f5e38 100644 --- a/docs/FEEDBACK.md +++ b/docs/FEEDBACK.md @@ -302,7 +302,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 | **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 | **Done** (2026-08-18). Both sections cap at six with "show all N" — truncation of the view, not a re-sort | | P1b persist `view` | **Done** (2026-08-18). `prefs.view`, and the first run now asks which one to open on (PRD F8) | | P1b Calendar → Timeline | **Done.** The tab reads "Timeline" | | P1c more games | **Done, four of them.** Infinity Nikki, Persona 5: The Phantom X, Reverse: 1999, Blue Archive — one commit each. Of the games named in the thread, Azur Lane and Umamusume are **declined on conduct** rather than pending, and the declined and cleared-but-unbuilt candidates are recorded in `AGENTS.md` § Scraping conduct so they are not re-litigated | diff --git a/docs/PRD.md b/docs/PRD.md index d456b7b..3a9b19a 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -72,6 +72,11 @@ A flat list of all *currently running* events sorted ascending by end date, with countdown ("ends in 2 days", "ends in 4 hours"). Under 24 hours, the row is emphasized. This is the view that justifies the app, and it is one tap from the timeline. +**The list is capped and offers the rest.** Two games already run to twenty-one live events, and a +reader who tried exactly that said the list stopped being usable. Each section shows a handful with +an explicit "show all N". This truncates the *view* only: the order is untouched, and the rows below +the cut are still counted in the header, still on the timeline, and one tap away. + **Which view opens is the reader's answer, not ours.** This spec said "calendar (default)" and the app shipped opening on the list; both were a decision made on the reader's behalf and then forgotten on every reload. So the first run asks (F8) and the answer is stored in `prefs.view`. The list is diff --git a/src/client/App.tsx b/src/client/App.tsx index 82062eb..b174128 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -47,6 +47,17 @@ import { metaFor } from "../shared/games.ts"; */ const HEADLINE_DEADLINES = 3; +/** + * How many rows a section shows before it offers the rest. + * + * Two games already run to twenty-one live events and every game added doubles + * down on that, which is the point at which a list stops being read at all. + * This truncates the *view* and nothing else: the order is untouched, the + * hidden rows are still counted in the header, still on the timeline, and one + * tap away here. + */ +const LIST_CAP = 6; + /** * 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 @@ -267,6 +278,21 @@ export function App() { const openRow = allRows.find((r) => r.event.id === openId) ?? null; + /** One row, wired up. Both lists render the same thing from the same props. */ + const renderRow = (row: RowEvent) => ( + ignored.toggle(id)} + onOpen={setOpenId} + /> + ); + if (state.status === "loading") { return

Loading events…

; } @@ -408,19 +434,7 @@ export function App() { ) : undefined } > - {live.map((row) => ( - ignored.toggle(id)} - onOpen={setOpenId} - /> - ))} + )} @@ -438,19 +452,7 @@ export function App() { ) : undefined } > - {upcoming.map((row) => ( - ignored.toggle(id)} - onOpen={setOpenId} - /> - ))} + )} @@ -607,11 +609,53 @@ function Section({ {action ?? (hint !== undefined &&

{hint}

)} {legend === true && } -
    {children}
+ {children} ); } +/** + * A list of events, capped at a length someone will actually read. + * + * The reader who asked for this had two games switched on and twenty-one live + * events, and said the list stopped being usable — so the default view shows a + * handful and offers the rest. What it must never do is *reorder*: this slices + * the front off a list that is already in the order the reader chose, so the + * deadline guarantee holds for what is shown and what is hidden alike. + * + * Expanding is per-visit rather than a stored preference: it is an action taken + * while reading one list, not a statement about how they want the app to work. + */ +function EventList({ + rows, + render, +}: { + rows: RowEvent[]; + render: (row: RowEvent) => React.ReactNode; +}) { + const [showAll, setShowAll] = useState(false); + const shown = showAll ? rows : rows.slice(0, LIST_CAP); + const hidden = rows.length - shown.length; + + return ( + <> +
    {shown.map(render)}
+ {rows.length > LIST_CAP && ( + + )} + + ); +} + /** * Order the list by deadline, or by what the reader is partway through. *