mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
docs(planning): W2 wave report for the v1.4.37 analytics rollup lift
This commit is contained in:
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({
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user