From 45dc93932a0f3e05d1535f7879fe24e8b92ced78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 13 Aug 2026 12:24:46 +0200 Subject: [PATCH] Settle a timed-out assessment refresh instead of calling it failed The force regenerate aborts its request at 45 s while the server's inline generation keeps running and usually writes the fresh briefing moments later. The old handling fired an error toast at the abort and re-read the old cache once; with a one hour staleTime and focus refetch off, nothing ever converged, so the page claimed failure while holding a finished assessment at arm's length. A timeout now enters a settling state: the hook remembers the cachedAt baseline it was showing and polls the read path on the existing bounded cadence (25 s, up to 10 attempts) until it can prove an outcome. Cache advanced past the baseline: the fresh payload lands and the success toast fires. A failure marker newer than the baseline, or the attempt cap: an honest error. While settling, the strip's button stays busy and says the assessment is still being prepared; no toast fires until the outcome is proven. Both poll reasons live in the nextAdvisorPollInterval seam, the verdict in resolveAdvisorSettle, the toast mapping in regenerateToastKind; all three are pure and pinned by unit tests. The contract is documented at both ends (hook and generate route). New copy in all six locales. Refs #786 --- messages/de.json | 2 + messages/en.json | 2 + messages/es.json | 2 + messages/fr.json | 2 + messages/it.json | 2 + messages/pl.json | 2 + .../inbound/reindex/__tests__/route.test.ts | 6 +- src/app/api/insights/generate/route.ts | 13 ++ .../insights-tab-strip-settling.test.tsx | 89 +++++++++ .../use-insights-advisor-settling.test.ts | 143 +++++++++++++++ .../insights/insights-layout-shell.tsx | 1 + .../insights/insights-tab-strip.tsx | 86 +++++++-- .../insights/use-insights-advisor.ts | 172 +++++++++++++++++- 13 files changed, 498 insertions(+), 24 deletions(-) create mode 100644 src/components/insights/__tests__/insights-tab-strip-settling.test.tsx create mode 100644 src/components/insights/__tests__/use-insights-advisor-settling.test.ts diff --git a/messages/de.json b/messages/de.json index 2d115aed0..779d21e65 100644 --- a/messages/de.json +++ b/messages/de.json @@ -4172,6 +4172,8 @@ "regenerateRateLimited": "Aktualisierungslimit erreicht — bitte später erneut versuchen.", "regenerateUnavailable": "Keine neue Analyse erhalten — bitte später erneut versuchen.", "regenerateError": "Aktualisierung läuft noch — bitte gleich erneut versuchen.", + "regenerateSettling": "Die Einschätzung wird noch erstellt", + "regenerateSettleFailed": "Die Aktualisierung konnte nicht abgeschlossen werden. Bitte später erneut versuchen.", "deviceScore": { "learning": "Deine Basislinie wird noch gelernt – ein paar weitere Nächte vervollständigen den Verlauf.", "average": "30-Tage-Durchschnitt {value}", diff --git a/messages/en.json b/messages/en.json index 15f1df75d..f417c2673 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4172,6 +4172,8 @@ "regenerateRateLimited": "Refresh limit reached — please try again later.", "regenerateUnavailable": "No fresh analysis arrived — please try again later.", "regenerateError": "Still refreshing — please try again in a moment.", + "regenerateSettling": "Your assessment is still being prepared", + "regenerateSettleFailed": "The refresh could not be completed. Please try again later.", "deviceScore": { "learning": "Still learning your baseline — a few more nights will fill in the trend.", "average": "30-day average {value}", diff --git a/messages/es.json b/messages/es.json index 13c66febd..09bea375a 100644 --- a/messages/es.json +++ b/messages/es.json @@ -4172,6 +4172,8 @@ "regenerateRateLimited": "Límite de actualizaciones alcanzado: inténtalo de nuevo más tarde.", "regenerateUnavailable": "No llegó un análisis nuevo: inténtalo de nuevo más tarde.", "regenerateError": "Aún se está actualizando: inténtalo de nuevo en un momento.", + "regenerateSettling": "La valoración todavía se está preparando", + "regenerateSettleFailed": "No se pudo completar la actualización. Inténtalo de nuevo más tarde.", "deviceScore": { "learning": "Aún estamos aprendiendo tu línea base: unas noches más completarán la tendencia.", "average": "Media de 30 días {value}", diff --git a/messages/fr.json b/messages/fr.json index dd414bccc..f55859fba 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -4172,6 +4172,8 @@ "regenerateRateLimited": "Limite d'actualisation atteinte — réessayez plus tard.", "regenerateUnavailable": "Aucune nouvelle analyse reçue — réessayez plus tard.", "regenerateError": "Actualisation en cours — réessayez dans un instant.", + "regenerateSettling": "L’évaluation est encore en cours de préparation", + "regenerateSettleFailed": "L’actualisation n’a pas pu aboutir. Réessayez plus tard.", "deviceScore": { "learning": "Nous apprenons encore votre référence : quelques nuits de plus compléteront la tendance.", "average": "Moyenne sur 30 jours {value}", diff --git a/messages/it.json b/messages/it.json index 7ee83cfc5..844cdb904 100644 --- a/messages/it.json +++ b/messages/it.json @@ -4172,6 +4172,8 @@ "regenerateRateLimited": "Limite di aggiornamento raggiunto — riprova più tardi.", "regenerateUnavailable": "Nessuna nuova analisi ricevuta — riprova più tardi.", "regenerateError": "Aggiornamento ancora in corso — riprova tra un momento.", + "regenerateSettling": "La valutazione è ancora in preparazione", + "regenerateSettleFailed": "Non è stato possibile completare l’aggiornamento. Riprova più tardi.", "deviceScore": { "learning": "Stiamo ancora imparando la tua linea di base: qualche notte in più completerà l'andamento.", "average": "Media a 30 giorni {value}", diff --git a/messages/pl.json b/messages/pl.json index 91caae79a..082ee3bac 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -4172,6 +4172,8 @@ "regenerateRateLimited": "Osiągnięto limit odświeżeń — spróbuj ponownie później.", "regenerateUnavailable": "Nie otrzymano nowej analizy — spróbuj ponownie później.", "regenerateError": "Trwa odświeżanie — spróbuj ponownie za chwilę.", + "regenerateSettling": "Ocena jest wciąż przygotowywana", + "regenerateSettleFailed": "Nie udało się ukończyć odświeżania. Spróbuj ponownie później.", "deviceScore": { "learning": "Wciąż poznajemy Twoją linię bazową – kilka kolejnych nocy uzupełni trend.", "average": "Średnia z 30 dni {value}", diff --git a/src/app/api/documents/inbound/reindex/__tests__/route.test.ts b/src/app/api/documents/inbound/reindex/__tests__/route.test.ts index d26443e4b..ddcf1900b 100644 --- a/src/app/api/documents/inbound/reindex/__tests__/route.test.ts +++ b/src/app/api/documents/inbound/reindex/__tests__/route.test.ts @@ -87,7 +87,7 @@ beforeEach(() => { describe("POST /api/documents/inbound/reindex", () => { it("returns the remaining un-indexed count as a NUMBER, never a boolean", async () => { - const res = await POST(req() as never, {} as never); + const res = await POST(req() as never); expect(res.status).toBe(200); const body = await res.json(); expect(typeof body.data.enqueued).toBe("number"); @@ -98,14 +98,14 @@ describe("POST /api/documents/inbound/reindex", () => { vi.mocked(enqueueContentIndexBackfill).mockResolvedValue({ enqueued: false, }); - const res = await POST(req() as never, {} as never); + const res = await POST(req() as never); expect(res.status).toBe(200); const body = await res.json(); expect(body.data.enqueued).toBe(0); }); it("scopes both counts to the caller's LIVE documents (the gauge's own figures)", async () => { - await POST(req() as never, {} as never); + await POST(req() as never); expect(prisma.inboundDocument.count).toHaveBeenCalledWith({ where: { userId: "user-1", deletedAt: null }, }); diff --git a/src/app/api/insights/generate/route.ts b/src/app/api/insights/generate/route.ts index 1cd21297c..8c13b2c62 100644 --- a/src/app/api/insights/generate/route.ts +++ b/src/app/api/insights/generate/route.ts @@ -1,3 +1,16 @@ +/** + * Refs #786 — async-truth contract, server end (the client end lives in + * `src/components/insights/use-insights-advisor.ts`, `AdvisorSettleState`): + * the force POST generates INLINE and, on success, writes the fresh briefing + * to the user cache (`insightsCachedAt` advances) — even when the client has + * long since aborted its 45 s request. A failed inline generation writes a + * failure marker the GET reports as `generationFailed`. The client relies on + * exactly those two signals to settle a timed-out regenerate: it polls the + * GET until `cachedAt` advances past its baseline (success) or a NEWER + * failure marker appears (honest error). Renaming or short-circuiting either + * write breaks that convergence — the settling seam tests pin the client + * half, this comment is the server half of the contract. + */ import { prisma } from "@/lib/db"; import { auditLog } from "@/lib/auth/audit"; import { diff --git a/src/components/insights/__tests__/insights-tab-strip-settling.test.tsx b/src/components/insights/__tests__/insights-tab-strip-settling.test.tsx new file mode 100644 index 000000000..c296cab43 --- /dev/null +++ b/src/components/insights/__tests__/insights-tab-strip-settling.test.tsx @@ -0,0 +1,89 @@ +import { describe, it, expect, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +/** + * Refs #786 — the regenerate button's settling state. + * + * While a timed-out force regenerate is settling (the server is still + * generating; the hook polls for the outcome) the button may NOT offer a + * retry or read as failed: it stays disabled, keeps the spinner, and its + * label says the assessment is still being prepared. Static renders pin the + * three visible facts; the toast edges live in the hook/strip effect and are + * pinned by the outcome mapping (`regenerateToastKind`). + */ + +vi.mock("next/navigation", () => ({ + usePathname: () => "/insights", +})); +vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } })); + +import { InsightsTabStrip, regenerateToastKind } from "../insights-tab-strip"; +import { I18nProvider } from "@/lib/i18n/context"; + +function render(node: React.ReactNode) { + return renderToStaticMarkup( + {node}, + ); +} + +describe(" regenerate settling state (#786)", () => { + it("settling: button disabled, spinner on, label says still being prepared", () => { + const html = render( + {}} + regenerating={false} + regenerateSettling + />, + ); + expect(html).toContain('data-slot="insights-tab-strip-regenerate"'); + expect(html).toContain("Your assessment is still being prepared"); + expect(html).toContain("disabled"); + expect(html).toContain("animate-spin"); + }); + + it("idle: button enabled with the regenerate label", () => { + const html = render( + {}} regenerating={false} />, + ); + expect(html).toContain("Re-run assessment"); + expect(html).not.toContain("Your assessment is still being prepared"); + }); +}); + +describe("regenerateToastKind — falling-edge outcome mapping (#786)", () => { + it("maps settle-failed to the honest settle error, never success", () => { + expect(regenerateToastKind("settle-failed")).toEqual({ + kind: "error", + messageKey: "insights.regenerateSettleFailed", + }); + }); + + it("keeps the existing mappings", () => { + expect(regenerateToastKind("fresh")).toEqual({ + kind: "success", + messageKey: "insights.regenerateSuccess", + }); + expect(regenerateToastKind("timeout")).toEqual({ + kind: "error", + messageKey: "insights.regenerateError", + }); + expect(regenerateToastKind("rate-limited")).toEqual({ + kind: "error", + messageKey: "insights.regenerateRateLimited", + }); + expect(regenerateToastKind("empty")).toEqual({ + kind: "error", + messageKey: "insights.regenerateUnavailable", + }); + expect(regenerateToastKind("no-provider")).toBe(null); + // Legacy mounts without an outcome keep the unconditional success. + expect(regenerateToastKind(null)).toEqual({ + kind: "success", + messageKey: "insights.regenerateSuccess", + }); + expect(regenerateToastKind(undefined)).toEqual({ + kind: "success", + messageKey: "insights.regenerateSuccess", + }); + }); +}); diff --git a/src/components/insights/__tests__/use-insights-advisor-settling.test.ts b/src/components/insights/__tests__/use-insights-advisor-settling.test.ts new file mode 100644 index 000000000..855c1c6cf --- /dev/null +++ b/src/components/insights/__tests__/use-insights-advisor-settling.test.ts @@ -0,0 +1,143 @@ +import { describe, it, expect } from "vitest"; + +/** + * Refs #786 — the async-truth contract for the force regenerate. + * + * A regenerate the client gives up on at 45 s is NOT a failure: the server's + * inline generation keeps running and usually writes the cache moments later. + * The old behaviour fired an error toast and then never converged (staleTime + * 1 h + focus refetch off), so the page kept showing the old assessment while + * claiming the refresh failed. The fix enters a "settling" state: remember + * the baseline `cachedAt`, keep polling on the existing bounded cadence, and + * resolve to a REAL outcome — fresh (cache advanced), settle-failed (a newer + * failure marker, or the attempt cap). + * + * Two pure seams pin the state machine: + * - `nextAdvisorPollInterval` gains settling as the second poll reason, + * with attempts counted from the settle start. + * - `resolveAdvisorSettle` decides fresh / settle-failed / keep-waiting. + */ + +import { + ADVISOR_REVALIDATE_POLL_MAX_ATTEMPTS, + ADVISOR_REVALIDATE_POLL_MS, + nextAdvisorPollInterval, + resolveAdvisorSettle, + type AdvisorSettleState, +} from "../use-insights-advisor"; + +function settle( + overrides: Partial = {}, +): AdvisorSettleState { + return { + baselineCachedAt: "2026-08-13T06:00:00.000Z", + baselineFailed: false, + startedAtUpdateCount: 4, + ...overrides, + }; +} + +describe("nextAdvisorPollInterval — settling is the second poll reason (#786)", () => { + it("polls while settling even when nothing reports revalidating", () => { + expect(nextAdvisorPollInterval(false, 5, settle())).toBe( + ADVISOR_REVALIDATE_POLL_MS, + ); + expect(nextAdvisorPollInterval(undefined, 5, settle())).toBe( + ADVISOR_REVALIDATE_POLL_MS, + ); + }); + + it("counts settling attempts from the settle start, not from mount", () => { + // 12 data updates total but only 8 since settling began → keep polling. + expect( + nextAdvisorPollInterval(false, 12, settle({ startedAtUpdateCount: 4 })), + ).toBe(ADVISOR_REVALIDATE_POLL_MS); + }); + + it("stops at the settling attempt ceiling", () => { + expect( + nextAdvisorPollInterval( + false, + 4 + ADVISOR_REVALIDATE_POLL_MAX_ATTEMPTS, + settle({ startedAtUpdateCount: 4 }), + ), + ).toBe(false); + }); + + it("keeps the pre-existing revalidating behaviour when not settling", () => { + expect(nextAdvisorPollInterval(true, 1, null)).toBe( + ADVISOR_REVALIDATE_POLL_MS, + ); + expect(nextAdvisorPollInterval(false, 1, null)).toBe(false); + expect(nextAdvisorPollInterval(true, 1)).toBe(ADVISOR_REVALIDATE_POLL_MS); + }); +}); + +describe("resolveAdvisorSettle — the settle state machine (#786)", () => { + it("resolves fresh once cachedAt advances past the baseline", () => { + expect( + resolveAdvisorSettle( + settle(), + { cachedAt: "2026-08-13T06:02:31.000Z", generationFailed: false }, + 6, + ), + ).toBe("fresh"); + }); + + it("resolves fresh on ANY cachedAt when there was no baseline (first generation)", () => { + expect( + resolveAdvisorSettle( + settle({ baselineCachedAt: null }), + { cachedAt: "2026-08-13T06:02:31.000Z", generationFailed: false }, + 6, + ), + ).toBe("fresh"); + }); + + it("keeps waiting while the cache is unchanged and attempts remain", () => { + expect( + resolveAdvisorSettle( + settle(), + { cachedAt: "2026-08-13T06:00:00.000Z", generationFailed: false }, + 6, + ), + ).toBe(null); + }); + + it("resolves settle-failed on a NEWER failure marker (failed flipped during settling)", () => { + expect( + resolveAdvisorSettle( + settle({ baselineFailed: false }), + { cachedAt: "2026-08-13T06:00:00.000Z", generationFailed: true }, + 6, + ), + ).toBe("settle-failed"); + }); + + it("does NOT treat a failure marker that predates the settle as fresh evidence", () => { + // The baseline already carried the failed flag — an unchanged flag says + // nothing about THIS generation; keep waiting for cachedAt or the cap. + expect( + resolveAdvisorSettle( + settle({ baselineFailed: true }), + { cachedAt: "2026-08-13T06:00:00.000Z", generationFailed: true }, + 6, + ), + ).toBe(null); + }); + + it("resolves settle-failed at the attempt cap — never waits forever", () => { + expect( + resolveAdvisorSettle( + settle({ startedAtUpdateCount: 4 }), + { cachedAt: "2026-08-13T06:00:00.000Z", generationFailed: false }, + 4 + ADVISOR_REVALIDATE_POLL_MAX_ATTEMPTS, + ), + ).toBe("settle-failed"); + }); + + it("a missing payload keeps waiting (no evidence either way)", () => { + expect(resolveAdvisorSettle(settle(), null, 6)).toBe(null); + expect(resolveAdvisorSettle(settle(), undefined, 6)).toBe(null); + }); +}); diff --git a/src/components/insights/insights-layout-shell.tsx b/src/components/insights/insights-layout-shell.tsx index bc861d344..dbf4d0ea6 100644 --- a/src/components/insights/insights-layout-shell.tsx +++ b/src/components/insights/insights-layout-shell.tsx @@ -200,6 +200,7 @@ export function InsightsLayoutShell({ children }: { children: ReactNode }) { onRegenerate={advisorEnabled ? advisor.regenerate : undefined} regenerating={advisor.isRegenerating} regenerateOutcome={advisor.regenerateOutcome} + regenerateSettling={advisor.regenerateSettling} availability={availability} visibleTileIds={visibleTileIds} tileOrder={tileOrder} diff --git a/src/components/insights/insights-tab-strip.tsx b/src/components/insights/insights-tab-strip.tsx index 33e41e837..60f6613d0 100644 --- a/src/components/insights/insights-tab-strip.tsx +++ b/src/components/insights/insights-tab-strip.tsx @@ -88,7 +88,20 @@ export interface InsightsTabStripProps { * mounts / no regenerate) falls back to the prior unconditional success. */ regenerateOutcome?: - "fresh" | "empty" | "rate-limited" | "timeout" | "no-provider" | null; + | "fresh" + | "empty" + | "rate-limited" + | "timeout" + | "no-provider" + | "settle-failed" + | null; + /** + * Refs #786 — true while a timed-out regenerate is settling (the server is + * still generating; the hook polls for the proven outcome). The button + * stays busy with a "still being prepared" label, and the falling-edge + * toast waits for the COMBINED busy state so no error fires mid-settle. + */ + regenerateSettling?: boolean; /** * v1.4.27 F19 — analytics + event-driven availability inputs the * gating helper reads. When omitted the strip falls back to its @@ -637,10 +650,38 @@ function buildTabs( return entries; } +/** + * Refs #786 — the falling-edge toast decision as a pure seam. Maps the last + * settled outcome to the toast that fires when the busy state falls: + * `settle-failed` (a settling poll proved the generation failed, or the + * attempt cap ran out) gets its own honest error copy; `no-provider` stays + * silent (the surface already shows the connect-AI empty state); a missing + * outcome (legacy mounts) keeps the historical unconditional success. + */ +export function regenerateToastKind( + outcome: InsightsTabStripProps["regenerateOutcome"] | undefined, +): { kind: "success" | "error"; messageKey: string } | null { + switch (outcome) { + case "timeout": + return { kind: "error", messageKey: "insights.regenerateError" }; + case "rate-limited": + return { kind: "error", messageKey: "insights.regenerateRateLimited" }; + case "empty": + return { kind: "error", messageKey: "insights.regenerateUnavailable" }; + case "settle-failed": + return { kind: "error", messageKey: "insights.regenerateSettleFailed" }; + case "no-provider": + return null; + default: + return { kind: "success", messageKey: "insights.regenerateSuccess" }; + } +} + function InsightsTabStripImpl({ onRegenerate, regenerating = false, regenerateOutcome, + regenerateSettling = false, availability, visibleTileIds, tileOrder, @@ -686,24 +727,33 @@ function InsightsTabStripImpl({ // regenerate claimed "refreshed" while serving the old text. The latest // outcome is read through a ref-free dep so the effect fires only on the // spinner edge. - const lastRegeneratingRef = useRef(regenerating); + // + // Refs #786 — the edge watches the COMBINED busy state (`regenerating || + // regenerateSettling`): a timed-out regenerate flows straight into + // settling, so the spinner never falls at 45 s and NO error toast fires + // while the server may still deliver. The edge fires once settling + // resolves, with the proven outcome — `"fresh"` (success) or + // `"settle-failed"` (its own honest error copy). The mapping is the pure + // `regenerateToastKind` seam above. + const busy = regenerating || regenerateSettling; + const lastBusyRef = useRef(busy); useEffect(() => { - if (lastRegeneratingRef.current && !regenerating) { - if (regenerateOutcome === "timeout") { - toast.error(t("insights.regenerateError")); - } else if (regenerateOutcome === "rate-limited") { - toast.error(t("insights.regenerateRateLimited")); - } else if (regenerateOutcome === "empty") { - toast.error(t("insights.regenerateUnavailable")); - } else if (regenerateOutcome !== "no-provider") { - // `"fresh"` or legacy `undefined`/null → success. - toast.success(t("insights.regenerateSuccess")); + if (lastBusyRef.current && !busy) { + const decision = regenerateToastKind(regenerateOutcome); + if (decision) { + if (decision.kind === "error") { + toast.error(t(decision.messageKey)); + } else { + toast.success(t(decision.messageKey)); + } } } - lastRegeneratingRef.current = regenerating; - }, [regenerating, regenerateOutcome, t]); + lastBusyRef.current = busy; + }, [busy, regenerateOutcome, t]); - const regenerateLabel = t("insights.regenerateAnalysis"); + const regenerateLabel = regenerateSettling + ? t("insights.regenerateSettling") + : t("insights.regenerateAnalysis"); const customizeLabel = t("insights.customize"); // Overflow affordance for the pill row. The row hides its scrollbar @@ -1000,7 +1050,9 @@ function InsightsTabStripImpl({