fix(e24): review round 2 — a buried history entry, a stale hero, and a spinner that was not ours

Nine of the round's ten surviving findings. The tenth (a failed shelf reorder leaving an
order the server rejected on screen) is on ROADMAP.md: its obvious remedy reintroduces the
overlapping-move defect the code already documents, so it wants a design, not a review edit.

- `navigateOverOverlays` buried the live overlay entries before knowing whether the
  navigation pushed one. `setPage` no-ops when the page is already current and Home is the
  default landing page, so Shift+W over a player ON Home marked its entry buried under a
  push that never happened: the unmount consumed the mark instead of reclaiming the entry,
  and the next browser Back was a press that did nothing. `setPage` now reports whether it
  navigated, and one `buryUnder` rule serves both entry points — `closeThenNavigate` takes
  the same no-op navigation from a palette command for the page you are already on.
- The hero could answer a new time budget with the previous one's pick. The chip row resets
  seed/seen/press but cannot reset an answer that already resolved, and `keepPreviousData`
  chained through the disabled seed-0 query — so a reroll under "10 perc" showed the film
  picked for "Bármennyi" while it loaded, and kept it if the request failed. `lastPick` now
  carries the server's own budget echo, and the placeholder is scoped to the budget.
- The reroll button spun and swallowed clicks after mark-watched, bookmark, pin and shelf
  moves: the pick key sits under the invalidated `discover` prefix on purpose, so raw
  `isFetching` was true for background refetches. It now means "a press of mine is
  outstanding" — which still covers the first press, where there is no placeholder.
- Failed writes on Home toasted the page's LOAD-failure string over a fully loaded page;
  they get their own key (HU+EN). The empty-state reroll had no `loading`, so the press was
  unacknowledged and a second click skipped a tier. `danger` moved into the shared unpin row
  so the same command is not red on the shelf and neutral in the rail below it.
- Two comments claimed things the code does not do: the `exclude` cap guards no "unbounded
  scan" (it never reaches SQL), and the route test that named the cap sends three keys.

Tests: the burial rule gets its own file (it is the one assertion that lets a reconcile
issue a real programmatic back()); App's keymap wiring gets a source contract, because
dropping it left all 818 tests green; `add_pin`'s IntegrityError arm was reached by nothing.
Every new test mutation-checked red, both edges where the predicate has two.

Gate: ruff, ruff format, 3x tsc, eslint (0 errors), prettier, knip, vitest 822, pytest 1005.
This commit is contained in:
2026-09-09 00:12:53 +02:00
parent 3709b71a94
commit e4ffd59ca7
13 changed files with 317 additions and 32 deletions
+60 -13
View File
@@ -145,7 +145,9 @@ function Rail({
icon: <PinOff className="w-4 h-4" />,
separatorBefore: true,
// The only row here that removes a curated entry, and it sat in the same colour as
// the four harmless reorders. Every other destructive row in the app is `danger`.
// the four harmless reorders. `danger` matches the shared unpin row in
// `comfortPin.item()`, which is what every OTHER surface (including the hero and the
// rail below) composes for the same command — the two must not disagree.
danger: true,
// The pin id travels on the shelf card, so this needs no lookup — and unlike every
// other surface it can never be in the "not known yet" state.
@@ -378,15 +380,41 @@ export default function Home() {
// has no data for a beat, `hero` falls to null, and the page renders "nothing to suggest yet" —
// an EMPTY state standing in for a loading one, which is the exact dishonesty the house rules
// forbid. Measured in the browser (2026-09-06); the button's own spinner is the loading signal.
placeholderData: keepPreviousData,
//
// But only WITHIN a budget. Plain `keepPreviousData` chains through the disabled seed-0 query a
// chip click leaves behind, so the first reroll under a new budget held up the card the PREVIOUS
// question was answered with — under a chip promising something else (round 2). Returning
// undefined instead falls the hero back to this budget's own loaded answer, which is already in
// hand; the spinner still covers the wait.
placeholderData: (prev) => (prev?.budget === budget ? prev : undefined),
});
// The last pick that actually RESOLVED. Every press is a new query key, so a failed one has no
// data of its own and `keepPreviousData` (pending-only) cannot cover it — without this the hero
// silently rewound to the card the page loaded with, several presses back, while `core.req`'s own
// dialog was explaining the failure. A reroll that fails leaves the answer on screen alone.
const [lastPick, setLastPick] = useState<DiscoverPayload["hero"]>(null);
// Is a PRESS outstanding? NOT `pickQ.isFetching`. The pick key deliberately sits under the
// `["discover"]` prefix so a mutation's invalidation reaches the rerolled hero — which also means
// every mark-watched, bookmark, pin/unpin and shelf move on this page refetches it in the
// background, and `ui/Button` turns `loading` into `disabled`. So the reroll spun and swallowed
// clicks after actions the user never associated with it (round 2). A press is outstanding exactly
// while THIS key has no answer of its own: the placeholder is still standing in for the previous
// press, or — the FIRST press, which has no previous data to stand in — there is nothing at all.
// `isPlaceholderData` alone would drop the spinner from exactly that first press.
const rerolling = pickQ.isFetching && (pickQ.isPlaceholderData || pickQ.data === undefined);
// The last pick that actually RESOLVED, tagged with the budget it answered. Every press is a new
// query key, so a failed one has no data of its own and `keepPreviousData` (pending-only) cannot
// cover it — without this the hero silently rewound to the card the page loaded with, several
// presses back, while `core.req`'s own dialog was explaining the failure. A reroll that fails
// leaves the answer on screen alone.
//
// The TAG is load-bearing, not bookkeeping: the chip row resets `seed`/`seen`/`press` but cannot
// reset an answer that already resolved, so an untagged slot outlived the question it answered —
// switch to "10 perc", press reroll, let that request fail (`retry: false`, so it is terminal),
// and the two-hour film picked under "Bármennyi" was still the hero, under a chip promising ten
// minutes (round 2). Taken from the SERVER's `budget` echo rather than this render's state, so the
// tag cannot be minted from a budget the pick never answered.
const [lastPick, setLastPick] = useState<{
budget: DiscoverBudget;
hero: DiscoverPayload["hero"];
} | null>(null);
useEffect(() => {
if (pickQ.data !== undefined) setLastPick(pickQ.data.hero);
if (pickQ.data !== undefined) setLastPick({ budget: pickQ.data.budget, hero: pickQ.data.hero });
}, [pickQ.data]);
const data = q.data;
@@ -401,7 +429,7 @@ export default function Home() {
? (data?.hero ?? null)
: pickQ.data !== undefined
? pickQ.data.hero
: (lastPick ?? data?.hero ?? null);
: ((lastPick?.budget === budget ? lastPick.hero : null) ?? data?.hero ?? null);
// The hero is the top of the resume rail by construction, so leaving it in the rail below prints
// the same card twice, a hand's width apart. Matched on source+id rather than on the tier alone:
// a rerolled hero can be a surprise pick that happens to be nothing to do with this rail.
@@ -429,7 +457,12 @@ export default function Home() {
.then(() => qc.invalidateQueries({ queryKey: qk.discover() }))
// Without this the rejection escapes unhandled and the click just does nothing — the
// feed's equivalents both catch, and the reorder on this same page already does.
.catch((e: unknown) => notifyActionError(e, t("home.error")));
// A WRITE key, not `home.error`: that string is the page's LOAD failure ("could not load
// your suggestions"), and it is what a detail-less rejection actually shows — an offline
// click raises a bare fetch TypeError, so there is no server reason to prefer over it. It
// would describe a state the user can see is untrue, over a fully populated page whose
// invalidations never even ran (they sit inside `.then`).
.catch((e: unknown) => notifyActionError(e, t("home.actionFailed")));
},
[qc, t],
);
@@ -446,7 +479,8 @@ export default function Home() {
// re-sent the same call — the control looked dead.
qc.invalidateQueries({ queryKey: qk.discover() });
})
.catch((e: unknown) => notifyActionError(e, t("home.error")));
// The write key, for the same reason as `onState` above.
.catch((e: unknown) => notifyActionError(e, t("home.actionFailed")));
},
[qc, t],
);
@@ -607,7 +641,7 @@ export default function Home() {
card={hero.card}
onPlay={onPlay}
onReroll={reroll}
rerolling={pickQ.isFetching}
rerolling={rerolling}
/>
) : (
// Honest: nothing in the library fits what was asked. Offering something that does not fit
@@ -621,7 +655,20 @@ export default function Home() {
</StateMessage>
{budget !== "any" && (
<div className="flex justify-center pb-4">
<Button variant="outline" size="xs" type="button" onClick={reroll}>
{/* The same action as the Hero's own reroll, so it carries the same in-flight signal.
Without it the press was unacknowledged — `hero` stays null for the whole round
trip in this branch, so nothing on the page moved — and a second click spent
another pick AND skipped a tier in the server's `start=` rotation. The icon comes
along because `loading` swaps it for the spinner: with no icon the button would
grow by a spinner's width mid-press. */}
<Button
variant="outline"
size="xs"
type="button"
icon={<RotateCcw className="h-4 w-4" />}
loading={rerolling}
onClick={reroll}
>
{t("home.hero.reroll")}
</Button>
</div>