mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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
This commit is contained in:
1 parent
86a3d74187
commit
45dc93932a
13 files changed
+498
-24
No files matched your search
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<InsightsTabStrip> regenerate settling state (#786)", () => {
|
||||
it("settling: button disabled, spinner on, label says still being prepared", () => {
|
||||
const html = render(
|
||||
<InsightsTabStrip
|
||||
onRegenerate={() => {}}
|
||||
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(
|
||||
<InsightsTabStrip onRegenerate={() => {}} 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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> = {},
|
||||
): 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);
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
@@ -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<boolean>(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<boolean>(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({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRegenerate}
|
||||
disabled={regenerating}
|
||||
// Refs #786 — settling keeps the button busy: the work is still
|
||||
// running server-side, so a retry now would only pile on.
|
||||
disabled={busy}
|
||||
aria-label={regenerateLabel}
|
||||
title={regenerateLabel}
|
||||
data-slot="insights-tab-strip-regenerate"
|
||||
@@ -1016,7 +1068,7 @@ function InsightsTabStripImpl({
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
)}
|
||||
>
|
||||
{regenerating ? (
|
||||
{busy ? (
|
||||
<Loader2
|
||||
className="h-4 w-4 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden="true"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { InsightResult } from "@/lib/ai/types";
|
||||
// Type-only — the runtime schemas load lazily inside `fetchAdvisor` so
|
||||
@@ -139,16 +139,97 @@ const FORCE_ADVISOR_TIMEOUT_MS = 45_000;
|
||||
export const ADVISOR_REVALIDATE_POLL_MS = 25_000;
|
||||
export const ADVISOR_REVALIDATE_POLL_MAX_ATTEMPTS = 10;
|
||||
|
||||
/**
|
||||
* Refs #786 — the settling state a timed-out force regenerate enters.
|
||||
*
|
||||
* Async-truth contract, client end (the server end is documented at the head
|
||||
* of `src/app/api/insights/generate/route.ts`): the force POST generates
|
||||
* INLINE and keeps running after the client aborts at 45 s — the server
|
||||
* usually writes the fresh cache moments later. A page that knows work is
|
||||
* running must keep saying so until it can prove an outcome, so the timeout
|
||||
* is NOT surfaced as an error. Instead the hook remembers the baseline it was
|
||||
* showing and polls (bounded, same cadence as the revalidating poll) until it
|
||||
* can prove one of three outcomes: the cache advanced past the baseline
|
||||
* (fresh), a failure marker newer than the baseline appeared (settle-failed),
|
||||
* or the attempt cap ran out (settle-failed — never wait forever).
|
||||
*/
|
||||
export interface AdvisorSettleState {
|
||||
/** `cachedAt` of the payload shown when the client gave up (null = none). */
|
||||
baselineCachedAt: string | null;
|
||||
/** Whether a failure marker was ALREADY reported at that time — an
|
||||
* unchanged flag proves nothing about THIS generation. */
|
||||
baselineFailed: boolean;
|
||||
/** The query's `dataUpdateCount` when settling began; poll attempts count
|
||||
* from here, not from mount. */
|
||||
startedAtUpdateCount: number;
|
||||
}
|
||||
|
||||
/** The outcome the falling-edge toast reports for a settled regenerate. */
|
||||
export type AdvisorRegenerateOutcome = AdvisorFetchOutcome | "settle-failed";
|
||||
|
||||
/**
|
||||
* Decide what a settling poll result proves. Pure test seam — the effect in
|
||||
* `useInsightsAdvisorQuery` applies the returned resolution verbatim.
|
||||
* `null` = no evidence yet, keep polling.
|
||||
*/
|
||||
export function resolveAdvisorSettle(
|
||||
settling: AdvisorSettleState,
|
||||
payload:
|
||||
| Pick<InsightAdvisorPayload, "cachedAt" | "generationFailed">
|
||||
| null
|
||||
| undefined,
|
||||
dataUpdateCount: number,
|
||||
): "fresh" | "settle-failed" | null {
|
||||
const cachedAt = payload?.cachedAt ?? null;
|
||||
if (
|
||||
cachedAt != null &&
|
||||
(settling.baselineCachedAt == null ||
|
||||
new Date(cachedAt).getTime() >
|
||||
new Date(settling.baselineCachedAt).getTime())
|
||||
) {
|
||||
return "fresh";
|
||||
}
|
||||
// A failure marker that FLIPPED during settling is the server saying the
|
||||
// inline generation died after we stopped watching — an honest error. One
|
||||
// that was already set at the baseline predates this attempt and proves
|
||||
// nothing; keep waiting for the cache or the cap.
|
||||
if ((payload?.generationFailed ?? false) && !settling.baselineFailed) {
|
||||
return "settle-failed";
|
||||
}
|
||||
if (
|
||||
dataUpdateCount - settling.startedAtUpdateCount >=
|
||||
ADVISOR_REVALIDATE_POLL_MAX_ATTEMPTS
|
||||
) {
|
||||
return "settle-failed";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether the advisor query schedules its next poll. Pure so the
|
||||
* ceiling + stop conditions are unit-testable: returns the interval
|
||||
* while the last payload carries `revalidating: true`, `false` once a
|
||||
* response comes back with the flag falsy OR the attempt cap is hit.
|
||||
*
|
||||
* Refs #786 — settling is the SECOND poll reason: while a timed-out force
|
||||
* regenerate awaits its real outcome the query polls regardless of the
|
||||
* `revalidating` flag, with attempts counted from the settle start so a
|
||||
* long-mounted page gets the full budget.
|
||||
*/
|
||||
export function nextAdvisorPollInterval(
|
||||
revalidating: boolean | undefined,
|
||||
dataUpdateCount: number,
|
||||
settling?: Pick<AdvisorSettleState, "startedAtUpdateCount"> | null,
|
||||
): number | false {
|
||||
if (settling) {
|
||||
if (
|
||||
dataUpdateCount - settling.startedAtUpdateCount >=
|
||||
ADVISOR_REVALIDATE_POLL_MAX_ATTEMPTS
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return ADVISOR_REVALIDATE_POLL_MS;
|
||||
}
|
||||
if (!revalidating) return false;
|
||||
if (dataUpdateCount >= ADVISOR_REVALIDATE_POLL_MAX_ATTEMPTS) return false;
|
||||
return ADVISOR_REVALIDATE_POLL_MS;
|
||||
@@ -290,8 +371,19 @@ export interface UseInsightsAdvisorResult {
|
||||
* fires the "refreshed" toast only when this is `"fresh"`, so a slow gen
|
||||
* the client gave up on (`"timeout"`) or a missing provider
|
||||
* (`"no-provider"`) never reads as "done".
|
||||
*
|
||||
* Refs #786 — a timed-out regenerate no longer surfaces `"timeout"` at the
|
||||
* spinner edge: the hook keeps `regenerateSettling` true and polls until it
|
||||
* can PROVE an outcome, then reports `"fresh"` (cache advanced) or
|
||||
* `"settle-failed"` (newer failure marker / attempt cap) here.
|
||||
*/
|
||||
regenerateOutcome: AdvisorFetchOutcome | null;
|
||||
regenerateOutcome: AdvisorRegenerateOutcome | null;
|
||||
/**
|
||||
* Refs #786 — true while a timed-out force regenerate awaits its real
|
||||
* outcome (bounded poll). The strip keeps the button busy and says the
|
||||
* assessment is still being prepared; NO toast fires until this falls.
|
||||
*/
|
||||
regenerateSettling: boolean;
|
||||
/**
|
||||
* v1.15.20 — the outcome of the last settled READ. Lets surfaces
|
||||
* distinguish "no briefing yet, a generate could help" (`empty` /
|
||||
@@ -340,6 +432,13 @@ export function useInsightsAdvisorQuery(
|
||||
enabled: boolean,
|
||||
): UseInsightsAdvisorResult {
|
||||
const queryClient = useQueryClient();
|
||||
// Refs #786 — settling state for a timed-out force regenerate (see the
|
||||
// contract comment above `AdvisorSettleState`). `settleOutcome` carries the
|
||||
// PROVEN outcome once settling resolves; both reset on the next regenerate.
|
||||
const [settling, setSettling] = useState<AdvisorSettleState | null>(null);
|
||||
const [settleOutcome, setSettleOutcome] = useState<
|
||||
"fresh" | "settle-failed" | null
|
||||
>(null);
|
||||
const query = useQuery({
|
||||
queryKey: queryKeys.insightsAdvisor(),
|
||||
// v1.15.20 — the cache stores the full tagged result (payload +
|
||||
@@ -353,13 +452,50 @@ export function useInsightsAdvisorQuery(
|
||||
// v1.16.7 — converge a stale-served briefing in-session: while the
|
||||
// last GET reported `revalidating: true` (warm enqueued), poll on a
|
||||
// bounded interval until a response comes back with the flag falsy.
|
||||
// Refs #786 — settling (timed-out regenerate awaiting its outcome) is
|
||||
// the second poll reason, attempts counted from the settle start.
|
||||
refetchInterval: (query) =>
|
||||
nextAdvisorPollInterval(
|
||||
query.state.data?.payload?.revalidating,
|
||||
query.state.dataUpdateCount,
|
||||
settling,
|
||||
),
|
||||
});
|
||||
|
||||
// `dataUpdateCount` lives on the query STATE (the refetchInterval callback
|
||||
// reads it there); the observer result only exposes `dataUpdatedAt`. Read
|
||||
// it imperatively from the cache where the settle machinery needs it.
|
||||
const readDataUpdateCount = useCallback(
|
||||
() =>
|
||||
queryClient
|
||||
.getQueryCache()
|
||||
.find({ queryKey: queryKeys.insightsAdvisor() })?.state
|
||||
.dataUpdateCount ?? 0,
|
||||
[queryClient],
|
||||
);
|
||||
|
||||
// Apply the settle resolution the pure seam proves. Each poll result lands
|
||||
// in `query.data` (the poll's own cache write IS the fresh payload — no
|
||||
// second setQueryData needed); the seam decides fresh / settle-failed /
|
||||
// keep-waiting, and the strip toasts on the falling edge of the combined
|
||||
// busy state. The effect keys on `dataUpdatedAt`, not the payload object:
|
||||
// structural sharing keeps an unchanged payload reference-identical across
|
||||
// polls, and the attempt cap must still advance on every poll.
|
||||
const settlePayload = query.data?.payload;
|
||||
const settleDataUpdatedAt = query.dataUpdatedAt;
|
||||
useEffect(() => {
|
||||
if (!settling) return;
|
||||
const resolution = resolveAdvisorSettle(
|
||||
settling,
|
||||
settlePayload,
|
||||
readDataUpdateCount(),
|
||||
);
|
||||
if (resolution) {
|
||||
setSettling(null);
|
||||
setSettleOutcome(resolution);
|
||||
}
|
||||
}, [settling, settlePayload, settleDataUpdatedAt, readDataUpdateCount]);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => fetchAdvisor({ force: true }),
|
||||
onSuccess: (result) => {
|
||||
@@ -373,6 +509,25 @@ export function useInsightsAdvisorQuery(
|
||||
// client gave up at 45 s, so re-read the GET to converge — but the
|
||||
// honest toast is gated on `regenerateOutcome` below, not on this
|
||||
// invalidate.
|
||||
//
|
||||
// Refs #786 — on a TIMEOUT specifically, one early invalidate is not
|
||||
// convergence: the generation routinely outlives it, the re-read
|
||||
// serves the OLD cache, and staleTime 1 h + focus-refetch-off remove
|
||||
// every later trigger. Enter the settling state instead: remember
|
||||
// the baseline this page is showing and poll (bounded) until the
|
||||
// cache provably advances or fails — the strip stays busy and no
|
||||
// error toast fires until then.
|
||||
if (result.outcome === "timeout") {
|
||||
const current = queryClient.getQueryData<AdvisorFetchResult>(
|
||||
queryKeys.insightsAdvisor(),
|
||||
);
|
||||
setSettleOutcome(null);
|
||||
setSettling({
|
||||
baselineCachedAt: current?.payload?.cachedAt ?? null,
|
||||
baselineFailed: current?.payload?.generationFailed ?? false,
|
||||
startedAtUpdateCount: readDataUpdateCount(),
|
||||
});
|
||||
}
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.insightsAdvisor(),
|
||||
});
|
||||
@@ -403,7 +558,13 @@ export function useInsightsAdvisorQuery(
|
||||
// status-query flip on a sub-page that re-renders the shell would
|
||||
// otherwise re-reconcile the whole strip mid-gesture and eat taps.
|
||||
const { mutate } = mutation;
|
||||
const regenerate = useCallback(() => mutate(), [mutate]);
|
||||
const regenerate = useCallback(() => {
|
||||
// Refs #786 — a new attempt clears any previous settle verdict so a
|
||||
// stale "fresh"/"settle-failed" cannot leak into this cycle's toast.
|
||||
setSettling(null);
|
||||
setSettleOutcome(null);
|
||||
mutate();
|
||||
}, [mutate]);
|
||||
|
||||
return {
|
||||
payload: query.data?.payload ?? null,
|
||||
@@ -413,7 +574,10 @@ export function useInsightsAdvisorQuery(
|
||||
regenerate,
|
||||
isRegenerating: mutation.isPending,
|
||||
regenerateError: (mutation.error as Error | null) ?? null,
|
||||
regenerateOutcome: mutation.data?.outcome ?? null,
|
||||
// Refs #786 — a proven settle verdict wins over the mutation's raw
|
||||
// outcome ("timeout" while settling is an interim state, not a result).
|
||||
regenerateOutcome: settleOutcome ?? mutation.data?.outcome ?? null,
|
||||
regenerateSettling: settling !== null,
|
||||
readOutcome: query.data?.outcome ?? null,
|
||||
// Absent field (pre-v1.18.9 cached payload, or query still settling) →
|
||||
// assume a provider is present so a transient unknown never flashes a
|
||||
|
||||
Reference in new issue
Block a user