docs(planning): W2 wave report for the v1.4.37 analytics rollup lift

This commit is contained in:
Marc-André Bombeck committed 2026-05-17 14:35:18 +02:00
1 parent 7032cb1b76
commit bc95e8fcba
5 files changed
+397 -9

No files matched your search

@@ -89,4 +89,22 @@ describe("<InsightStatusCard>", () => {
const html = render(<InsightStatusCard {...baseProps} text={null} />);
expect(html).toContain("No analysis yet.");
});
it("renders the structured skeleton (v1.4.37) when loading instead of a centred spinner", () => {
const html = render(
<InsightStatusCard {...baseProps} text={null} loading />,
);
// The skeleton mounts the same icon dot, title bar, prose lines,
// and footer that the loaded card paints so the loading state
// previews the final geometry instead of pinning the card to a
// single centred spinner row. The aria-busy + sr-only label
// preserve the accessible "loading" semantics for assistive
// tech.
expect(html).toContain('aria-busy="true"');
expect(html).toContain('data-testid="insight-status-card-loading"');
expect(html).toContain("Loading");
// The classic Loader2 + visible "common.loading" copy retired so
// we do not double-paint the announcement.
expect(html).not.toContain("animate-spin");
});
});
@@ -3,7 +3,6 @@
import { useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Loader2 } from "lucide-react";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { stripChartTokens } from "@/lib/insights/chart-tokens";
import { cn } from "@/lib/utils";
@@ -40,13 +39,30 @@ export function InsightStatusCard({
if (!flags.insightStatus) return null;
if (loading) {
// v1.4.37 — structured skeleton over the centred spinner. When the
// per-metric status route had to fall back to the 20 s provider
// race the user used to stare at a single centred spinner with
// "Lade…" copy, which gave no sense of progress and pinned the
// card to a flat dot. The skeleton paints the actual rendered
// geometry (icon dot, title bar, three text lines, footer) so the
// loading state previews where the assessment will land. Heights
// mirror `<CardTitle>` (`text-base` → 1 rem) and the prose
// (`text-sm` → 0.875 rem) so the post-load swap reflows by less
// than a row.
return (
<Card>
<CardContent className="flex items-center justify-center py-8">
<Loader2 className="text-dracula-purple h-5 w-5 animate-spin motion-reduce:animate-none" />
<span className="text-muted-foreground ml-2 text-sm">
{t("common.loading")}
</span>
<Card aria-busy="true" aria-live="polite" data-testid="insight-status-card-loading">
<CardHeader className="pb-2">
<div className="flex items-center gap-2">
<div className="bg-muted h-5 w-5 animate-pulse rounded motion-reduce:animate-none" />
<div className="bg-muted h-4 w-32 animate-pulse rounded motion-reduce:animate-none" />
</div>
</CardHeader>
<CardContent className="space-y-2">
<div className="bg-muted h-3.5 w-full animate-pulse rounded motion-reduce:animate-none" />
<div className="bg-muted h-3.5 w-11/12 animate-pulse rounded motion-reduce:animate-none" />
<div className="bg-muted h-3.5 w-9/12 animate-pulse rounded motion-reduce:animate-none" />
<div className="bg-muted/70 h-3 w-1/3 animate-pulse rounded motion-reduce:animate-none" />
<span className="sr-only">{t("common.loading")}</span>
</CardContent>
</Card>
);
@@ -79,6 +79,126 @@ describe("generateBmiStatusForUser — v1.4.6 bucketed payload", () => {
});
});
describe("generateBmiStatusForUser — v1.4.37 timeout-stub persistence", () => {
it("persists a sentinel row keyed to today when the provider times out", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
heightCm: 175,
} as never);
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{ value: 80, measuredAt: new Date() },
] as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: new Date(),
} as never);
// Simulate a provider that never resolves before the timeout. The
// helper races the call against `STATUS_PROVIDER_TIMEOUT_MS`; a
// promise that hangs forever cuts the race short.
vi.mocked(resolveProvider).mockResolvedValue({
type: "anthropic",
generateCompletion: vi.fn(
() => new Promise(() => {
/* never resolves */
}),
),
} as never);
// Shrink the timeout window so this test does not hang the suite
// for the full 20 s. The withTimeout helper polls in ~1 s
// increments; this still exercises the same race shape.
vi.useFakeTimers();
const promise = generateBmiStatusForUser("user-1", { locale: "en" });
await vi.advanceTimersByTimeAsync(25_000);
const result = await promise;
vi.useRealTimers();
// The user-facing payload still reads the deterministic no-key
// fallback, but the cached/updatedAt fields signal "this is a
// stub" so the next mount short-circuits at the cache lookup.
expect(result.text).toBeTruthy();
expect(result.cached).toBe(true);
expect(result.updatedAt).toBeTruthy();
const createCalls = vi.mocked(prisma.auditLog.create).mock.calls;
expect(createCalls.length).toBe(1);
const details = (createCalls[0][0] as { data: { details: string } }).data
.details;
const parsed = JSON.parse(details) as {
dateKey?: string;
text?: string;
timeout?: boolean;
model?: string;
};
expect(parsed.text).toBeTruthy();
expect(parsed.timeout).toBe(true);
expect(parsed.model).toBe("timeout-stub");
// The dateKey must read as a valid YYYY-MM-DD so the cache hit
// logic on the next mount matches against today's berlin key.
expect(parsed.dateKey).toMatch(/^\d{4}-\d{2}-\d{2}$/);
});
it("subsequent mounts short-circuit at the cache lookup and skip the race", async () => {
// First mount: cache miss, provider times out, stub persisted.
// Simulate that prior state by handing back the stub row from
// findFirst on the second invocation.
const stubRow = {
createdAt: new Date(),
details: JSON.stringify({
dateKey: new Intl.DateTimeFormat("en-US", {
timeZone: "Europe/Berlin",
year: "numeric",
month: "2-digit",
day: "2-digit",
})
.formatToParts(new Date())
.reduce<Record<string, string>>((acc, p) => {
if (p.type !== "literal") acc[p.type] = p.value;
return acc;
}, {}),
locale: "en",
text: "BMI is a directional metric…",
timeout: true,
}),
};
// Build a real YYYY-MM-DD string the same way the source helper
// does so the cache-key match is exact.
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: "Europe/Berlin",
year: "numeric",
month: "2-digit",
day: "2-digit",
}).formatToParts(new Date());
const y = parts.find((p) => p.type === "year")!.value;
const m = parts.find((p) => p.type === "month")!.value;
const d = parts.find((p) => p.type === "day")!.value;
const todayKey = `${y}-${m}-${d}`;
stubRow.details = JSON.stringify({
dateKey: todayKey,
locale: "en",
text: "BMI is a directional metric…",
timeout: true,
});
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(stubRow as never);
const providerCall = vi.fn();
vi.mocked(resolveProvider).mockResolvedValue({
type: "anthropic",
generateCompletion: providerCall,
} as never);
const result = await generateBmiStatusForUser("user-1", { locale: "en" });
// Cache short-circuit fires before the provider call — no race,
// no second persist.
expect(providerCall).not.toHaveBeenCalled();
expect(result.text).toBe("BMI is a directional metric…");
expect(result.cached).toBe(true);
expect(result.updatedAt).toBe(stubRow.createdAt.toISOString());
expect(prisma.auditLog.create).not.toHaveBeenCalled();
});
});
describe("generateBmiStatusForUser — token-leak hardening (v1.4.27 F16)", () => {
it("strips metric: tokens out of the cached text before persisting", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
+42 -2
View File
@@ -291,11 +291,51 @@ export async function generateBmiStatusForUser(
);
if (raced.timedOut || raced.value === null) {
// v1.4.37 — persist a sentinel row keyed to today so the next
// mount short-circuits at the cache lookup above instead of
// re-racing the same 20 s provider call on every cold visit.
// Without this, a single stall (provider hiccup, network blip,
// model warm-up) leaves the user staring at the loading state
// on every reload until the daily 02:20 pre-warm job runs.
// The body is the same no-key fallback the caller returned
// before, so the user-facing UI is identical; only the
// re-fire frequency drops to zero for the rest of the day.
// `meta.timeout` is set so the daily pg-boss pre-warm worker
// (the same job that originally seeded the cache) can
// recognise and overwrite the stub rather than respect it as
// a real assessment.
const stubText = getNoKeyBmiStatusText(locale);
let stubUpdatedAt: string | null = null;
try {
const stub = await prisma.auditLog.create({
data: {
userId,
action: cacheAction,
details: JSON.stringify({
dateKey: todayKey,
locale,
text: stubText,
providerType: provider.type,
model: "timeout-stub",
tokensUsed: null,
timeout: true,
}),
},
select: { createdAt: true },
});
stubUpdatedAt = stub.createdAt.toISOString();
} catch {
// The persist is best-effort — if the row write fails the
// caller still sees the deterministic fallback text and the
// next mount falls back to the (still expensive) race. Do
// not surface the error: the user does not care that the
// cache write missed, only that the page rendered.
}
return {
hasProvider: true,
text: getNoKeyBmiStatusText(locale),
text: stubText,
cached: true,
updatedAt: null,
updatedAt: stubUpdatedAt,
};
}