fix(insights): handle legacy cached payload without rationale (regenerate CTA)

Cached insight blobs from before the v1.4.16 strict schema landed
crashed /insights with `Cannot read properties of undefined (reading
'replace')` on `stripChartTokens(insight.summary)`. The pre-strict
shape (`{changed, stable, drivers, nextSteps, confidence,
limitations}`) has no `summary`, no `recommendations[]`, and no
`findings[]`, so the rich card had nothing to render and the
chart-token helper crashed on undefined input.

The advisor card now short-circuits to a self-contained legacy-payload
card with a one-click regenerate CTA when the shape is unrenderable.
`isLegacyInsightPayload()` also flags the v1.4.14 pre-strict shape so
the API surfaces the right `legacyPayload: true` flag from a cache
hit. Defense-in-depth: `stripChartTokens()` and `parseChartTokens()`
treat null/undefined as the empty string instead of crashing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-10 09:49:17 +02:00
1 parent 57de2177a9
commit 79bfa27d65
6 files changed
+210 -12

No files matched your search

@@ -267,6 +267,45 @@ describe("<InsightAdvisorCard> — polished loading / empty / error states (B1b)
expect(html).toMatch(/data-slot="insight-retry-button"/);
});
it("renders the legacy v1.4.14 payload without crashing (regenerate CTA)", () => {
// Reproduces Marc's production /insights crash on 2026-05-10:
//
// TypeError: Cannot read properties of undefined (reading 'replace')
// at stripChartTokens(insight.summary)
//
// The cached blob in the DB predates v1.4.16's strict insight schema
// (no `summary`, no `recommendations[]`, no `findings[]`, no
// `dataQuality`, no `disclaimer` — it carries the old `changed`,
// `stable`, `drivers`, `nextSteps`, `confidence`, `limitations`
// shape). The route's `safeParse(parsed)` fails and falls through
// to `insights = parsed`, so the legacy blob reaches the card with
// `summary === undefined`. The card must surface a regenerate CTA
// and skip rendering the prose, NOT crash.
const legacy = {
changed: "Long-term improvement on weight and BP.",
stable: "Pulse remains stable.",
drivers: "Weight reduction may have contributed.",
nextSteps: "Keep going.",
confidence: "hoch",
limitations: "Correlations don't imply causation.",
} as unknown as InsightResult;
const html = renderToStaticMarkup(
<I18nProvider initialLocale="en">
<InsightAdvisorCard
title="Test card"
insight={legacy}
legacyPayload
onRegenerate={() => {}}
/>
</I18nProvider>,
);
// No crash means the test got here. Surface the regenerate CTA so
// the user has a one-click escape from the legacy state.
expect(html).toContain('data-slot="insight-legacy-payload-cta"');
});
it("error state without onRegenerate hides the retry button", () => {
const html = renderToStaticMarkup(
<I18nProvider initialLocale="en">
@@ -432,6 +432,80 @@ export function InsightAdvisorCard({
);
}
// ── Legacy-Payload State ──────────────────────────────
// v1.4.17 hotfix: when the cached blob predates the v1.4.16 strict
// schema (no `summary`, no `recommendations[]`, no `findings[]` —
// the v1.4.14 `{changed, stable, drivers, ...}` shape), the rich-card
// render path below would crash on `stripChartTokens(undefined)`.
// Surface the regenerate CTA in a self-contained card and skip the
// rich body entirely. The `legacyPayload` prop is the explicit
// signal from the API; we also defensively short-circuit when the
// shape doesn't carry the modern `summary` string at all.
const isUnrenderable =
typeof insight.summary !== "string" ||
!Array.isArray(insight.findings) ||
!Array.isArray(insight.recommendations);
if (legacyPayload || isUnrenderable) {
return (
<Card data-slot="insight-legacy-card">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{icon ?? <Sparkles className="text-dracula-purple h-5 w-5" />}
<CardTitle className="text-lg">
{t("insights.aiAnalysisTitle")}
</CardTitle>
</div>
{onRegenerate && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={onRegenerate}
disabled={regenerating}
title={t("insights.refreshAnalysis")}
>
{regenerating ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<RefreshCw className="h-3.5 w-3.5" />
)}
</Button>
)}
</div>
<p className="text-muted-foreground text-sm">{title}</p>
</CardHeader>
<CardContent>
<div
className="border-dracula-yellow/30 bg-dracula-yellow/10 flex items-start gap-2 rounded-md border px-3 py-2 text-sm"
data-slot="insight-legacy-payload-cta"
>
<Info className="text-dracula-yellow mt-0.5 h-4 w-4 shrink-0" />
<div className="space-y-2">
<p>{t("insights.recommendation.legacyPayloadCta")}</p>
{onRegenerate && (
<Button
variant="outline"
size="sm"
onClick={onRegenerate}
disabled={regenerating}
className="h-7 text-xs"
>
{regenerating ? (
<Loader2 className="mr-1.5 h-3 w-3 animate-spin" />
) : (
<Sparkles className="mr-1.5 h-3 w-3" />
)}
{t("insights.startAnalysis")}
</Button>
)}
</div>
</div>
</CardContent>
</Card>
);
}
// ── Full Insight Card ─────────────────────────────────
return (
<Card className="animate-insight-in">