feat(insights): serve assessments stale-while-revalidate on a cache miss

A category open whose today-cache had been evicted dropped to the
preparing skeleton even when a perfectly good assessment from an earlier
day was still on record. Read the most recent non-stub assessment for the
scope and serve it instantly while the worker re-warms the cache; only
fall back to the preparing state when no assessment was ever produced.
The user no longer waits on an open if an assessment ever existed.
This commit is contained in:
Marc-André Bombeck committed 2026-06-02 10:00:48 +02:00
1 parent f4d5114dcd
commit 60f347bf8d
9 files changed
+154 -46

No files matched your search

@@ -2,7 +2,7 @@ import { describe, expect, it, vi, beforeEach } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
auditLog: { findFirst: vi.fn() },
auditLog: { findFirst: vi.fn(), findMany: vi.fn() },
},
}));
@@ -137,6 +137,11 @@ describe("readFreshStatusText", () => {
});
describe("resolveReadOnlyStatusMiss", () => {
beforeEach(() => {
// Default: no prior assessment to serve stale (readLastGoodStatusText).
vi.mocked(prisma.auditLog.findMany).mockResolvedValue([] as never);
});
it("returns no-provider without enqueuing when the user has no provider", async () => {
hasUsableStatusProvider.mockResolvedValue(false);
const outcome = await resolveReadOnlyStatusMiss({
@@ -144,7 +149,7 @@ describe("resolveReadOnlyStatusMiss", () => {
metric: "weight",
locale: "en",
});
expect(outcome).toBe("no-provider");
expect(outcome.kind).toBe("no-provider");
expect(enqueueStatusGeneration).not.toHaveBeenCalled();
});
@@ -157,7 +162,8 @@ describe("resolveReadOnlyStatusMiss", () => {
metric: "pulse",
locale: "de",
});
expect(outcome).toBe("preparing");
expect(outcome.kind).toBe("preparing");
expect(outcome).toEqual({ kind: "preparing", lastGood: null });
expect(enqueueStatusGeneration).toHaveBeenCalledWith({
userId: "u1",
metric: "pulse",
@@ -165,6 +171,28 @@ describe("resolveReadOnlyStatusMiss", () => {
});
});
it("serves the last good assessment stale-while-revalidate on a clean miss", async () => {
hasUsableStatusProvider.mockResolvedValue(true);
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null as never);
// A prior (e.g. yesterday's) real assessment is on record.
vi.mocked(prisma.auditLog.findMany).mockResolvedValue([
cacheRow(
{ dateKey: "2026-05-30", text: "Steady upward trend.", model: "gpt-4o-mini" },
new Date("2026-05-30T04:30:00.000Z"),
),
] as never);
const outcome = await resolveReadOnlyStatusMiss({
userId: "u1",
metric: "weight",
locale: "en",
});
expect(outcome.kind).toBe("preparing");
if (outcome.kind !== "preparing") throw new Error("expected preparing");
expect(outcome.lastGood?.text).toBe("Steady upward trend.");
// A refresh is still enqueued behind the stale serve.
expect(enqueueStatusGeneration).toHaveBeenCalledTimes(1);
});
it("suppresses re-enqueue while a fresh timeout stub exists", async () => {
hasUsableStatusProvider.mockResolvedValue(true);
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(
@@ -180,7 +208,7 @@ describe("resolveReadOnlyStatusMiss", () => {
metric: "mood",
locale: "en",
});
expect(outcome).toBe("preparing");
expect(outcome.kind).toBe("preparing");
expect(enqueueStatusGeneration).not.toHaveBeenCalled();
});
@@ -199,7 +227,7 @@ describe("resolveReadOnlyStatusMiss", () => {
metric: "bmi",
locale: "de",
});
expect(outcome).toBe("preparing");
expect(outcome.kind).toBe("preparing");
expect(enqueueStatusGeneration).toHaveBeenCalledTimes(1);
});
});
+8 -5
View File
@@ -134,7 +134,7 @@ export async function generateBloodPressureStatusForUser(
metric: "blood-pressure",
locale,
});
if (outcome === "no-provider") {
if (outcome.kind === "no-provider") {
return {
hasProvider: false,
text: getNoKeyBloodPressureStatusText(locale),
@@ -142,12 +142,15 @@ export async function generateBloodPressureStatusForUser(
updatedAt: null,
};
}
// v1.8.7 — stale-while-revalidate: serve the last good assessment
// (if any) instantly while the worker re-warms; only fall to the empty
// preparing skeleton when none was ever produced.
return {
hasProvider: true,
text: null,
cached: false,
updatedAt: null,
preparing: true,
text: outcome.lastGood?.text ?? null,
cached: outcome.lastGood !== null,
updatedAt: outcome.lastGood?.updatedAt ?? null,
preparing: outcome.lastGood === null,
};
}
+8 -5
View File
@@ -72,7 +72,7 @@ export async function generateBmiStatusForUser(
metric: "bmi",
locale,
});
if (outcome === "no-provider") {
if (outcome.kind === "no-provider") {
return {
hasProvider: false,
text: getNoKeyBmiStatusText(locale),
@@ -80,12 +80,15 @@ export async function generateBmiStatusForUser(
updatedAt: null,
};
}
// v1.8.7 — stale-while-revalidate: serve the last good assessment
// (if any) instantly while the worker re-warms; only fall to the empty
// preparing skeleton when none was ever produced.
return {
hasProvider: true,
text: null,
cached: false,
updatedAt: null,
preparing: true,
text: outcome.lastGood?.text ?? null,
cached: outcome.lastGood !== null,
updatedAt: outcome.lastGood?.updatedAt ?? null,
preparing: outcome.lastGood === null,
};
}
+8 -5
View File
@@ -87,7 +87,7 @@ export async function generateGeneralStatusForUser(
metric: "general",
locale,
});
if (outcome === "no-provider") {
if (outcome.kind === "no-provider") {
return {
hasProvider: false,
text: getNoKeyGeneralStatusText(locale),
@@ -95,12 +95,15 @@ export async function generateGeneralStatusForUser(
updatedAt: null,
};
}
// v1.8.7 — stale-while-revalidate: serve the last good assessment
// (if any) instantly while the worker re-warms; only fall to the empty
// preparing skeleton when none was ever produced.
return {
hasProvider: true,
text: null,
cached: false,
updatedAt: null,
preparing: true,
text: outcome.lastGood?.text ?? null,
cached: outcome.lastGood !== null,
updatedAt: outcome.lastGood?.updatedAt ?? null,
preparing: outcome.lastGood === null,
};
}
@@ -121,7 +121,7 @@ export async function generateMedicationComplianceStatusForUser(
metric: "medication-compliance",
locale,
});
if (outcome === "no-provider") {
if (outcome.kind === "no-provider") {
return {
hasProvider: false,
summary: getNoKeyMedicationComplianceStatusText(locale),
@@ -130,13 +130,16 @@ export async function generateMedicationComplianceStatusForUser(
updatedAt: null,
};
}
// v1.8.7 — stale-while-revalidate: serve the last good narrative (if
// any) while the worker re-warms; only show the empty preparing
// skeleton when none was ever produced.
return {
hasProvider: true,
summary: null,
summary: outcome.lastGood?.text ?? null,
medications: [],
cached: false,
updatedAt: null,
preparing: true,
cached: outcome.lastGood !== null,
updatedAt: outcome.lastGood?.updatedAt ?? null,
preparing: outcome.lastGood === null,
};
}
+8 -5
View File
@@ -83,7 +83,7 @@ export async function generateMoodStatusForUser(
metric: "mood",
locale,
});
if (outcome === "no-provider") {
if (outcome.kind === "no-provider") {
return {
hasProvider: false,
text: getNoKeyMoodStatusText(locale),
@@ -91,12 +91,15 @@ export async function generateMoodStatusForUser(
updatedAt: null,
};
}
// v1.8.7 — stale-while-revalidate: serve the last good assessment
// (if any) instantly while the worker re-warms; only fall to the empty
// preparing skeleton when none was ever produced.
return {
hasProvider: true,
text: null,
cached: false,
updatedAt: null,
preparing: true,
text: outcome.lastGood?.text ?? null,
cached: outcome.lastGood !== null,
updatedAt: outcome.lastGood?.updatedAt ?? null,
preparing: outcome.lastGood === null,
};
}
+8 -5
View File
@@ -78,7 +78,7 @@ export async function generatePulseStatusForUser(
metric: "pulse",
locale,
});
if (outcome === "no-provider") {
if (outcome.kind === "no-provider") {
return {
hasProvider: false,
text: getNoKeyPulseStatusText(locale),
@@ -86,12 +86,15 @@ export async function generatePulseStatusForUser(
updatedAt: null,
};
}
// v1.8.7 — stale-while-revalidate: serve the last good assessment (if
// any) instantly while the worker re-warms the cache; only fall to the
// empty preparing skeleton when none was ever produced.
return {
hasProvider: true,
text: null,
cached: false,
updatedAt: null,
preparing: true,
text: outcome.lastGood?.text ?? null,
cached: outcome.lastGood !== null,
updatedAt: outcome.lastGood?.updatedAt ?? null,
preparing: outcome.lastGood === null,
};
}
+65 -6
View File
@@ -101,13 +101,62 @@ export async function readFreshStatusText(args: {
}
}
export interface LastGoodStatusHit {
text: string;
updatedAt: string;
}
/**
* v1.8.7 — read the most recent NON-stub assessment for `(userId,
* cacheAction)` regardless of which day it was generated. This is the
* stale-while-revalidate source: when today's cache is a miss the
* read-only path can still serve yesterday's (or older) good text
* instantly while a fresh generation is warmed out of band, so opening a
* category never drops to the "preparing" skeleton if an assessment was
* ever produced. Returns `null` only when there is genuinely no prior
* assessment (or every prior row is a timeout stub / malformed).
*/
export async function readLastGoodStatusText(args: {
userId: string;
cacheAction: string;
}): Promise<LastGoodStatusHit | null> {
const { userId, cacheAction } = args;
const rows = await prisma.auditLog.findMany({
where: { userId, action: cacheAction },
orderBy: { createdAt: "desc" },
take: 5,
select: { createdAt: true, details: true },
});
for (const row of rows) {
if (!row.details) continue;
try {
const parsed = JSON.parse(row.details) as ParsedStatusCache;
if (isTimeoutStub(parsed)) continue;
if (typeof parsed.text !== "string" || parsed.text.trim().length === 0) {
continue;
}
return { text: parsed.text, updatedAt: row.createdAt.toISOString() };
} catch {
// Malformed payload — skip and look further back.
continue;
}
}
return null;
}
/**
* Outcome of the read-only cache-miss resolution. The generators map this
* onto their public return shape:
* - `no-provider` → `{ hasProvider: false, text: <no-key fallback> }`
* - `preparing` → `{ hasProvider: true, text: null, preparing: true }`
* - `preparing` → `{ hasProvider: true, text: <last-good|null>, preparing: true }`
*
* v1.8.7 — `preparing` now carries the last good assessment (if any) so
* the card renders the previous text immediately (stale-while-revalidate)
* instead of a skeleton while the worker re-warms the cache.
*/
export type ReadOnlyMissOutcome = "no-provider" | "preparing";
export type ReadOnlyMissOutcome =
| { kind: "no-provider" }
| { kind: "preparing"; lastGood: LastGoodStatusHit | null };
/**
* v1.8.3 — resolve what a read-only status generation should return on a
@@ -131,10 +180,20 @@ export async function resolveReadOnlyStatusMiss(args: {
locale: "de" | "en";
}): Promise<ReadOnlyMissOutcome> {
const hasProvider = await hasUsableStatusProvider(args.userId);
if (!hasProvider) return "no-provider";
if (!hasProvider) return { kind: "no-provider" };
const cacheAction = `insights.${args.metric}-status.${args.locale}`;
// v1.8.7 — stale-while-revalidate. Surface the last good (non-stub)
// assessment for this scope so the card renders the previous text
// immediately instead of a skeleton while a refresh is warmed. Null when
// no assessment was ever produced — only then does the card show
// "preparing"/"no analysis yet".
const lastGood = await readLastGoodStatusText({
userId: args.userId,
cacheAction,
});
// v1.8.3 — honour the short-TTL negative cache. If the worker recently
// hit a provider stall it wrote a `retryAt` stub; re-enqueuing on every
// navigation while the provider is still degraded would be a storm. While
@@ -145,7 +204,7 @@ export async function resolveReadOnlyStatusMiss(args: {
action: { name: "insights.status.preparing" },
meta: { metric: args.metric, suppressed_enqueue: true },
});
return "preparing";
return { kind: "preparing", lastGood };
}
// Enqueue out of band — do NOT await an LLM here. The enqueue itself is
@@ -157,9 +216,9 @@ export async function resolveReadOnlyStatusMiss(args: {
});
annotate({
action: { name: "insights.status.preparing" },
meta: { metric: args.metric },
meta: { metric: args.metric, stale_served: lastGood !== null },
});
return "preparing";
return { kind: "preparing", lastGood };
}
/**
+8 -5
View File
@@ -132,7 +132,7 @@ export async function generateWeightStatusForUser(
metric: "weight",
locale,
});
if (outcome === "no-provider") {
if (outcome.kind === "no-provider") {
return {
hasProvider: false,
text: getNoKeyWeightStatusText(locale),
@@ -140,12 +140,15 @@ export async function generateWeightStatusForUser(
updatedAt: null,
};
}
// v1.8.7 — stale-while-revalidate: serve the last good assessment
// (if any) instantly while the worker re-warms; only fall to the empty
// preparing skeleton when none was ever produced.
return {
hasProvider: true,
text: null,
cached: false,
updatedAt: null,
preparing: true,
text: outcome.lastGood?.text ?? null,
cached: outcome.lastGood !== null,
updatedAt: outcome.lastGood?.updatedAt ?? null,
preparing: outcome.lastGood === null,
};
}