fix(insights): honest assistant pipeline — gates, tombstones, retries, truthful states

The two-o'clock status crons generated for every account, ignoring both
the per-user coach opt-out and the operator kill switch; they now share
one gated discovery that also skips users the pregenerate pass will
rewarm anyway, and the mood card finally gets the nightly run the other
six always had. Deleted measurements no longer feed any prompt. Locales
without an assistant language fall back to English consistently instead
of German texts with English fallbacks. A rate-limited regenerate stops
claiming success, the dashboard briefing state distinguishes a missing
provider from a pending generation and serves a stale briefing with its
timestamp instead of hiding it, narrative warming actually processes
oldest first, and worker failures reach the error tracker through one
shared reporter while the insight queues gain real retries.
This commit is contained in:
Marc-André Bombeck committed 2026-06-10 13:34:37 +02:00
1 parent 0a4bf34a2b
commit 3cf48fa0f1
32 files changed
+1194 -325

No files matched your search

@@ -202,4 +202,33 @@ describe("<DailyBriefing>", () => {
const html = render(<DailyBriefing briefing={baseBriefing} />);
expect(html).not.toContain('data-slot="daily-briefing-meta-slot"');
});
// v1.15.20 — no-provider state: the regenerate CTA would 422 forever,
// so the card points at Settings → AI instead.
it("renders the connect-provider hint with a settings link when noProvider", () => {
const html = render(
<DailyBriefing briefing={null} onRegenerate={() => {}} noProvider />,
);
expect(html).toContain('data-slot="daily-briefing-no-provider"');
expect(html).toContain("No AI provider connected");
expect(html).toMatch(/href="\/settings\/ai"/);
// The futile regenerate CTA must NOT render in this state.
expect(html).not.toContain('data-slot="daily-briefing-empty-cta"');
});
it("renders the German connect-provider hint under the de locale", () => {
const html = render(
<DailyBriefing briefing={null} noProvider />,
"de",
);
expect(html).toContain("Kein KI-Anbieter verbunden");
});
it("prefers the briefing content over the no-provider hint when both exist", () => {
const html = render(
<DailyBriefing briefing={baseBriefing} noProvider />,
);
expect(html).toContain('data-slot="daily-briefing-findings"');
expect(html).not.toContain('data-slot="daily-briefing-no-provider"');
});
});
@@ -99,6 +99,12 @@ interface DailyBriefingProps {
onRegenerate?: () => void;
/** Disables the regenerate CTA while a generation is in flight. */
regenerating?: boolean;
/**
* v1.15.20 — no AI provider is configured anywhere, so generating is
* futile. The empty state swaps the regenerate CTA for a quiet hint
* linking to Settings → AI instead of an eternal "preparing" loop.
*/
noProvider?: boolean;
/**
* Optional slot for a meta control mounted in the card header — the
* comparison toggle migrates here from the hero in commit 5.
@@ -269,6 +275,7 @@ export function DailyBriefing({
loading = false,
onRegenerate,
regenerating = false,
noProvider = false,
metaSlot,
}: DailyBriefingProps) {
const { t } = useTranslations();
@@ -343,6 +350,29 @@ export function DailyBriefing({
</p>
)}
</div>
) : noProvider ? (
// v1.15.20 — no provider configured anywhere: a regenerate CTA
// would 422 forever, so point at Settings → AI instead.
<EmptyState
data-slot="daily-briefing-no-provider"
variant="plain"
icon={<Sparkles className="size-5" />}
title={t("insights.dailyBriefing.noProviderTitle")}
description={t("insights.dailyBriefing.noProviderDescription")}
action={
<Button
type="button"
size="sm"
variant="outline"
asChild
data-slot="daily-briefing-no-provider-cta"
>
<Link href="/settings/ai">
{t("insights.dailyBriefing.noProviderAction")}
</Link>
</Button>
}
/>
) : (
<EmptyState
data-slot="daily-briefing-empty"
+25 -10
View File
@@ -79,10 +79,18 @@ export interface InsightsTabStripProps {
* toast fires "refreshed" ONLY when this is `"fresh"`; a slow generation the
* client gave up on (`"timeout"`) shows a "still working" hint instead of a
* misleading success, and a missing provider (`"no-provider"`) stays silent
* (the surface already shows the connect-AI empty state). Absent (legacy
* (the surface already shows the connect-AI empty state). v1.15.20 —
* `"rate-limited"` (quota exhausted) and `"empty"` (transient 503) show
* their own honest hints instead of the success toast. Absent (legacy
* mounts / no regenerate) falls back to the prior unconditional success.
*/
regenerateOutcome?: "fresh" | "empty" | "timeout" | "no-provider" | null;
regenerateOutcome?:
| "fresh"
| "empty"
| "rate-limited"
| "timeout"
| "no-provider"
| null;
/**
* v1.4.27 F19 — analytics + event-driven availability inputs the
* gating helper reads. When omitted the strip falls back to its
@@ -486,20 +494,27 @@ function InsightsTabStripImpl({
// Fire a toast on the falling edge of `regenerating`. Same rising-edge ref
// guard as the W3 implementation so the toast fires exactly once per
// regenerate cycle. v1.15.18 — the toast is now HONEST: only a `"fresh"`
// outcome reads "refreshed". A `"timeout"` (a slow generation the client
// gave up on while the server may still be writing) shows a "still working,
// try again" hint rather than claiming success; a `"no-provider"` stays
// silent (the surface already shows the connect-AI empty state). The latest
// outcome is read through a ref so the effect doesn't re-fire when only the
// outcome (not the spinner edge) changed.
// regenerate cycle. v1.15.18 — the toast is HONEST: only a `"fresh"`
// outcome reads "refreshed". v1.15.20 — the honesty extends to the
// non-fresh outcomes: `"rate-limited"` (429) says the quota is exhausted,
// `"empty"` (transient 503) says nothing fresh arrived, `"timeout"` keeps
// its "still working, try again" hint, and `"no-provider"` stays silent
// (the surface already shows the connect-AI empty state). Previously
// `"empty"` lumped 429 + 503 together AND toasted success — a rate-limited
// 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);
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"`, `"empty"`, or legacy `undefined`/null → success.
// `"fresh"` or legacy `undefined`/null → success.
toast.success(t("insights.regenerateSuccess"));
}
}
+34 -14
View File
@@ -78,11 +78,17 @@ const FORCE_ADVISOR_TIMEOUT_MS = 45_000;
* v1.15.18 — the outcome of a force regenerate, so the UI can be HONEST:
* only a `fresh` outcome should toast "refreshed". A `timeout` (slow gen
* the client gave up on) and a `no-provider` (422) are distinct failure
* modes the audit's Fix 2 + Fix 4 call out.
* modes.
*
* v1.15.20 — `rate-limited` (429) splits off from `empty`: the user's
* regenerate quota is exhausted, which deserves a "try again later"
* hint rather than the success toast the old lump produced. `empty` now
* means only the transient 503 surface (provider chain unavailable).
*/
export type AdvisorFetchOutcome =
| "fresh"
| "empty"
| "rate-limited"
| "timeout"
| "no-provider";
@@ -128,15 +134,19 @@ async function fetchAdvisor(
clearTimeout(timeoutHandle);
}
if (!res.ok) {
// 422 (no provider configured) and 429 (rate-limited) are expected
// surfaces — return null so the consuming UI shows the empty / error
// state without the query slipping into an `isError` retry loop. 422
// surfaces as `no-provider` so the regenerate path can distinguish a
// missing provider from a slow generation (audit Fix 4).
// 422 (no provider configured), 429 (rate-limited), and 503
// (provider chain unavailable) are expected surfaces — return null
// so the consuming UI shows the empty / error state without the
// query slipping into an `isError` retry loop. Each gets its own
// outcome tag so the regenerate toast can be honest about WHY no
// fresh payload arrived.
if (res.status === 422) {
return { payload: null, outcome: "no-provider" };
}
if (res.status === 429 || res.status === 503) {
if (res.status === 429) {
return { payload: null, outcome: "rate-limited" };
}
if (res.status === 503) {
return { payload: null, outcome: "empty" };
}
throw new Error(`HTTP ${res.status}`);
@@ -191,6 +201,14 @@ export interface UseInsightsAdvisorResult {
* (`"no-provider"`) never reads as "done".
*/
regenerateOutcome: AdvisorFetchOutcome | null;
/**
* v1.15.20 — the outcome of the last settled READ. Lets surfaces
* distinguish "no briefing yet, a generate could help" (`empty` /
* `timeout`) from "no provider configured, generating is futile"
* (`no-provider`) and render a connect-AI hint instead of a dead
* regenerate CTA.
*/
readOutcome: AdvisorFetchOutcome | null;
}
/**
@@ -203,9 +221,10 @@ export function useInsightsAdvisorQuery(
const queryClient = useQueryClient();
const query = useQuery({
queryKey: queryKeys.insightsAdvisor(),
// Map the tagged fetch result back onto the payload the cache stores —
// the read path only cares about the payload, not the outcome tag.
queryFn: async () => (await fetchAdvisor()).payload,
// v1.15.20 — the cache stores the full tagged result (payload +
// outcome) so the read path can surface WHY a payload is missing
// (`no-provider` → connect-AI hint instead of a dead regenerate CTA).
queryFn: () => fetchAdvisor(),
enabled,
// 24h cache window matches the server-side `insightsCachedAt` TTL.
staleTime: 60 * 60 * 1000,
@@ -214,11 +233,11 @@ export function useInsightsAdvisorQuery(
const mutation = useMutation({
mutationFn: () => fetchAdvisor({ force: true }),
onSuccess: ({ payload }) => {
if (payload) {
onSuccess: (result) => {
if (result.payload) {
// A genuinely fresh generation landed — write it into the shared
// cache so the hero subtitle + briefing card repaint immediately.
queryClient.setQueryData(queryKeys.insightsAdvisor(), payload);
queryClient.setQueryData(queryKeys.insightsAdvisor(), result);
} else {
// No fresh payload (timeout / no-provider / transient). The server's
// inline POST may STILL have written a fresh briefing after the
@@ -245,7 +264,7 @@ export function useInsightsAdvisorQuery(
const regenerate = useCallback(() => mutate(), [mutate]);
return {
payload: query.data ?? null,
payload: query.data?.payload ?? null,
isLoading: query.isLoading,
isError: query.isError,
error: (query.error as Error | null) ?? null,
@@ -253,5 +272,6 @@ export function useInsightsAdvisorQuery(
isRegenerating: mutation.isPending,
regenerateError: (mutation.error as Error | null) ?? null,
regenerateOutcome: mutation.data?.outcome ?? null,
readOutcome: query.data?.outcome ?? null,
};
}
@@ -56,6 +56,7 @@ function buildUser(disableCoach: boolean): AuthUser {
avatarUrl: null,
glucoseUnit: null,
unitPreference: "metric",
timeFormat: "AUTO",
disableCoach,
fullName: null,
insurerName: null,