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({