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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user