feat(list): cap each section and offer the rest
The reader who reported this had two games switched on, twenty-one live events, and said the list stopped being usable — and every game added doubles down on that. Each section now shows six rows with an explicit "show all N". It truncates the view and nothing else. The slice comes off the front of a list already in the order the reader chose, so the hidden rows keep their place, stay counted in the header, and stay on the timeline. Expanding is per-visit state: it is something you do while reading one list, not a statement about how the app should work. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
7804a08863
commit
6403dec944
@@ -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
|
- 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
|
parser still handles the previous format. Fixtures are pinned and permanent; `snapshots/` is the
|
||||||
current page and gets overwritten. Do not conflate them.
|
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)
|
- **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
|
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
|
the timeline was put back on the list by every reload, with nothing to blame but the app
|
||||||
|
|||||||
+1
-1
@@ -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** |
|
| 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 | **Done** (2026-08-18). One headline and two behind it, off `nextToExpire` |
|
| 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 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" |
|
| 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 |
|
| 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 |
|
||||||
|
|||||||
@@ -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
|
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.
|
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
|
**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
|
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
|
on every reload. So the first run asks (F8) and the answer is stored in `prefs.view`. The list is
|
||||||
|
|||||||
+71
-27
@@ -47,6 +47,17 @@ import { metaFor } from "../shared/games.ts";
|
|||||||
*/
|
*/
|
||||||
const HEADLINE_DEADLINES = 3;
|
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
|
* 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
|
||||||
@@ -267,6 +278,21 @@ export function App() {
|
|||||||
|
|
||||||
const openRow = allRows.find((r) => r.event.id === openId) ?? null;
|
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) => (
|
||||||
|
<EventRow
|
||||||
|
key={row.event.id}
|
||||||
|
row={row}
|
||||||
|
completed={isDone(row.event.id)}
|
||||||
|
status={prog.progress[row.event.id]?.status}
|
||||||
|
effort={prog.progress[row.event.id]?.effort}
|
||||||
|
daily={dailyBadge(row)}
|
||||||
|
ignored={isIgnored(row.event.id)}
|
||||||
|
onRestore={(id) => ignored.toggle(id)}
|
||||||
|
onOpen={setOpenId}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
if (state.status === "loading") {
|
if (state.status === "loading") {
|
||||||
return <Shell><p className="px-4 py-16 text-sm text-muted">Loading events…</p></Shell>;
|
return <Shell><p className="px-4 py-16 text-sm text-muted">Loading events…</p></Shell>;
|
||||||
}
|
}
|
||||||
@@ -408,19 +434,7 @@ export function App() {
|
|||||||
) : undefined
|
) : undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{live.map((row) => (
|
<EventList rows={live} render={renderRow} />
|
||||||
<EventRow
|
|
||||||
key={row.event.id}
|
|
||||||
row={row}
|
|
||||||
completed={isDone(row.event.id)}
|
|
||||||
status={prog.progress[row.event.id]?.status}
|
|
||||||
effort={prog.progress[row.event.id]?.effort}
|
|
||||||
daily={dailyBadge(row)}
|
|
||||||
ignored={isIgnored(row.event.id)}
|
|
||||||
onRestore={(id) => ignored.toggle(id)}
|
|
||||||
onOpen={setOpenId}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Section>
|
</Section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -438,19 +452,7 @@ export function App() {
|
|||||||
) : undefined
|
) : undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{upcoming.map((row) => (
|
<EventList rows={upcoming} render={renderRow} />
|
||||||
<EventRow
|
|
||||||
key={row.event.id}
|
|
||||||
row={row}
|
|
||||||
completed={isDone(row.event.id)}
|
|
||||||
status={prog.progress[row.event.id]?.status}
|
|
||||||
effort={prog.progress[row.event.id]?.effort}
|
|
||||||
daily={dailyBadge(row)}
|
|
||||||
ignored={isIgnored(row.event.id)}
|
|
||||||
onRestore={(id) => ignored.toggle(id)}
|
|
||||||
onOpen={setOpenId}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Section>
|
</Section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -607,11 +609,53 @@ function Section({
|
|||||||
{action ?? (hint !== undefined && <p className="text-xs text-faint">{hint}</p>)}
|
{action ?? (hint !== undefined && <p className="text-xs text-faint">{hint}</p>)}
|
||||||
</div>
|
</div>
|
||||||
{legend === true && <Legend />}
|
{legend === true && <Legend />}
|
||||||
<ul className="border-t border-hairline">{children}</ul>
|
{children}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 (
|
||||||
|
<>
|
||||||
|
<ul className="border-t border-hairline">{shown.map(render)}</ul>
|
||||||
|
{rows.length > LIST_CAP && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowAll((v) => !v)}
|
||||||
|
className="w-full border-b border-hairline px-4 py-3 text-left text-xs font-medium text-muted transition-colors duration-150 hover:text-ink"
|
||||||
|
>
|
||||||
|
{showAll ? "Show fewer" : `Show all ${rows.length}`}
|
||||||
|
{!showAll && (
|
||||||
|
<span className="text-faint">{` · ${hidden} more below the cut`}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Order the list by deadline, or by what the reader is partway through.
|
* Order the list by deadline, or by what the reader is partway through.
|
||||||
*
|
*
|
||||||
|
|||||||
Reference in New Issue
Block a user