diff --git a/src/lib/insights/__tests__/blood-pressure-status.test.ts b/src/lib/insights/__tests__/blood-pressure-status.test.ts index 7b089aaec..fb76ceb66 100644 --- a/src/lib/insights/__tests__/blood-pressure-status.test.ts +++ b/src/lib/insights/__tests__/blood-pressure-status.test.ts @@ -109,6 +109,106 @@ describe("generateBloodPressureStatusForUser — v1.4.6 bucketed payload", () => }); }); +describe("generateBloodPressureStatusForUser — v1.4.41 timeout-stub persistence", () => { + it("persists a sentinel row keyed to today when the provider times out", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + dateOfBirth: null, + } as never); + vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); + vi.mocked(prisma.measurement.findMany).mockResolvedValue([ + { type: "BLOOD_PRESSURE_SYS", value: 132, measuredAt: new Date() }, + { type: "BLOOD_PRESSURE_DIA", value: 84, measuredAt: new Date() }, + ] as never); + vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( + [] as never, + ); + vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.auditLog.create).mockResolvedValue({ + createdAt: new Date(), + } as never); + + vi.mocked(resolveProvider).mockResolvedValue({ + type: "anthropic", + generateCompletion: vi.fn( + () => + new Promise(() => { + /* never resolves */ + }), + ), + } as never); + + vi.useFakeTimers(); + const promise = generateBloodPressureStatusForUser("user-1", { + locale: "en", + }); + await vi.advanceTimersByTimeAsync(25_000); + const result = await promise; + vi.useRealTimers(); + + 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"); + expect(parsed.dateKey).toMatch(/^\d{4}-\d{2}-\d{2}$/); + }); + + it("subsequent mounts short-circuit at the cache lookup and skip the race", async () => { + 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}`; + const stubRow = { + createdAt: new Date(), + details: JSON.stringify({ + dateKey: todayKey, + locale: "en", + text: "Blood pressure assessment fallback…", + timeout: true, + }), + }; + + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + dateOfBirth: null, + } as never); + 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 generateBloodPressureStatusForUser("user-1", { + locale: "en", + }); + + expect(providerCall).not.toHaveBeenCalled(); + expect(result.text).toBe("Blood pressure assessment fallback…"); + expect(result.cached).toBe(true); + expect(result.updatedAt).toBe(stubRow.createdAt.toISOString()); + expect(prisma.auditLog.create).not.toHaveBeenCalled(); + }); +}); + describe("generateBloodPressureStatusForUser — 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({ diff --git a/src/lib/insights/__tests__/general-status.test.ts b/src/lib/insights/__tests__/general-status.test.ts index c7e758b38..1f7cbc6dc 100644 --- a/src/lib/insights/__tests__/general-status.test.ts +++ b/src/lib/insights/__tests__/general-status.test.ts @@ -100,6 +100,102 @@ describe("generateGeneralStatusForUser — v1.4.6 bucketed payload", () => { }); }); +describe("generateGeneralStatusForUser — v1.4.41 timeout-stub persistence", () => { + it("persists a sentinel row keyed to today when the provider times out", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + dateOfBirth: null, + } as never); + vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); + vi.mocked(prisma.measurement.findMany).mockResolvedValue([ + { type: "WEIGHT", value: 82, measuredAt: new Date() }, + ] as never); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( + [] as never, + ); + vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.auditLog.create).mockResolvedValue({ + createdAt: new Date(), + } as never); + + vi.mocked(resolveProvider).mockResolvedValue({ + type: "anthropic", + generateCompletion: vi.fn( + () => + new Promise(() => { + /* never resolves */ + }), + ), + } as never); + + vi.useFakeTimers(); + const promise = generateGeneralStatusForUser("user-1", { locale: "en" }); + await vi.advanceTimersByTimeAsync(25_000); + const result = await promise; + vi.useRealTimers(); + + 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"); + expect(parsed.dateKey).toMatch(/^\d{4}-\d{2}-\d{2}$/); + }); + + it("subsequent mounts short-circuit at the cache lookup and skip the race", async () => { + 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}`; + const stubRow = { + createdAt: new Date(), + details: JSON.stringify({ + dateKey: todayKey, + locale: "en", + text: "General status fallback…", + timeout: true, + }), + }; + + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + dateOfBirth: null, + } as never); + 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 generateGeneralStatusForUser("user-1", { + locale: "en", + }); + + expect(providerCall).not.toHaveBeenCalled(); + expect(result.text).toBe("General status fallback…"); + expect(result.cached).toBe(true); + expect(result.updatedAt).toBe(stubRow.createdAt.toISOString()); + expect(prisma.auditLog.create).not.toHaveBeenCalled(); + }); +}); + describe("generateGeneralStatusForUser — 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({ diff --git a/src/lib/insights/__tests__/medication-compliance-status.test.ts b/src/lib/insights/__tests__/medication-compliance-status.test.ts index cedf9e67c..234873ed5 100644 --- a/src/lib/insights/__tests__/medication-compliance-status.test.ts +++ b/src/lib/insights/__tests__/medication-compliance-status.test.ts @@ -108,6 +108,114 @@ describe("generateMedicationComplianceStatusForUser — v1.4.6 bucketed payload" }); }); +describe("generateMedicationComplianceStatusForUser — v1.4.41 timeout-stub persistence", () => { + it("persists a sentinel row keyed to today when the provider times out", async () => { + const now = new Date(); + const medication = { + id: "med-1", + name: "Ramipril", + dose: "5mg", + active: true, + createdAt: new Date(now.getTime() - 60 * dayMs), + schedules: [{ id: "s1", time: "08:00" }], + }; + + vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); + vi.mocked(prisma.medication.findMany).mockResolvedValue([ + medication, + ] as never); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( + [] as never, + ); + vi.mocked(prisma.auditLog.create).mockResolvedValue({ + createdAt: new Date(), + } as never); + + vi.mocked(resolveProvider).mockResolvedValue({ + type: "anthropic", + generateCompletion: vi.fn( + () => + new Promise(() => { + /* never resolves */ + }), + ), + } as never); + + vi.useFakeTimers(); + const promise = generateMedicationComplianceStatusForUser("user-1", { + locale: "en", + }); + await vi.advanceTimersByTimeAsync(25_000); + const result = await promise; + vi.useRealTimers(); + + // Route returns the richer `{summary, medications}` shape — the + // helper writes the persist row and the route maps `summary` ← + // `text` on the way out. + expect(result.summary).toBeTruthy(); + expect(result.medications).toEqual([]); + 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"); + expect(parsed.dateKey).toMatch(/^\d{4}-\d{2}-\d{2}$/); + }); +}); + +describe("generateMedicationComplianceStatusForUser — v1.4.41 timeout-stub short-circuit", () => { + it("subsequent mounts short-circuit when a stub row with text+timeout:true exists", async () => { + 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}`; + const stubRow = { + createdAt: new Date(), + details: JSON.stringify({ + dateKey: todayKey, + locale: "en", + text: "Medication compliance fallback…", + 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 generateMedicationComplianceStatusForUser("user-1", { + locale: "en", + }); + + expect(providerCall).not.toHaveBeenCalled(); + expect(result.summary).toBe("Medication compliance fallback…"); + expect(result.medications).toEqual([]); + expect(result.cached).toBe(true); + expect(result.updatedAt).toBe(stubRow.createdAt.toISOString()); + expect(prisma.auditLog.create).not.toHaveBeenCalled(); + }); +}); + describe("generateMedicationComplianceStatusForUser — token-leak hardening (v1.4.27 F16)", () => { it("strips metric: tokens out of the cached summary + per-medication text", async () => { const now = new Date(); diff --git a/src/lib/insights/__tests__/mood-status.test.ts b/src/lib/insights/__tests__/mood-status.test.ts index b9d1e6cb3..bac7611d0 100644 --- a/src/lib/insights/__tests__/mood-status.test.ts +++ b/src/lib/insights/__tests__/mood-status.test.ts @@ -88,6 +88,97 @@ describe("generateMoodStatusForUser — v1.4.6 bucketed payload", () => { }); }); +describe("generateMoodStatusForUser — v1.4.41 timeout-stub persistence", () => { + it("persists a sentinel row keyed to today when the provider times out", async () => { + const t = new Date(); + vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); + vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([ + { + date: t.toISOString().slice(0, 10), + score: 4, + tags: [], + moodLoggedAt: t, + }, + ] as never); + vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.auditLog.create).mockResolvedValue({ + createdAt: new Date(), + } as never); + + vi.mocked(resolveProvider).mockResolvedValue({ + type: "anthropic", + generateCompletion: vi.fn( + () => + new Promise(() => { + /* never resolves */ + }), + ), + } as never); + + vi.useFakeTimers(); + const promise = generateMoodStatusForUser("user-1", { locale: "en" }); + await vi.advanceTimersByTimeAsync(25_000); + const result = await promise; + vi.useRealTimers(); + + 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"); + expect(parsed.dateKey).toMatch(/^\d{4}-\d{2}-\d{2}$/); + }); + + it("subsequent mounts short-circuit at the cache lookup and skip the race", async () => { + 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}`; + const stubRow = { + createdAt: new Date(), + details: JSON.stringify({ + dateKey: todayKey, + locale: "en", + text: "Mood fallback text…", + 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 generateMoodStatusForUser("user-1", { locale: "en" }); + + expect(providerCall).not.toHaveBeenCalled(); + expect(result.text).toBe("Mood fallback text…"); + expect(result.cached).toBe(true); + expect(result.updatedAt).toBe(stubRow.createdAt.toISOString()); + expect(prisma.auditLog.create).not.toHaveBeenCalled(); + }); +}); + describe("generateMoodStatusForUser — token-leak hardening (v1.4.27 F16)", () => { it("strips metric: tokens out of the cached text before persisting", async () => { const t = new Date(); diff --git a/src/lib/insights/__tests__/pulse-status-timeout.test.ts b/src/lib/insights/__tests__/pulse-status-timeout.test.ts index 8936b8c6d..088ea2d8b 100644 --- a/src/lib/insights/__tests__/pulse-status-timeout.test.ts +++ b/src/lib/insights/__tests__/pulse-status-timeout.test.ts @@ -7,6 +7,13 @@ import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; * cached-style envelope so the InsightStatusCard renders the * fallback text instead of spinning behind React-Query's default * retries. + * + * v1.4.41 — the timeout branch now persists a sentinel keyed to + * today (see `persistTimeoutStubAndReturn`) so subsequent mounts + * short-circuit at the cache lookup. The persisted row carries + * `timeout: true` + `model: "timeout-stub"` so the daily pre-warm + * job can recognise and overwrite the stub instead of treating it + * as a real assessment. */ vi.mock("@/lib/db", () => ({ @@ -51,6 +58,9 @@ describe("generatePulseStatusForUser — provider timeout fallback", () => { { value: 72, measuredAt: new Date() }, ] as never); vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.auditLog.create).mockResolvedValue({ + createdAt: new Date(), + } as never); // Provider stalls indefinitely. vi.mocked(resolveProvider).mockResolvedValue({ @@ -69,9 +79,19 @@ describe("generatePulseStatusForUser — provider timeout fallback", () => { expect(result.cached).toBe(true); expect(typeof result.text).toBe("string"); expect(result.text?.length ?? 0).toBeGreaterThan(0); - // The route must NOT persist a fallback into the audit cache — - // a transient provider stall should not poison tomorrow's cache - // hit. - expect(prisma.auditLog.create).not.toHaveBeenCalled(); + // v1.4.41 — the timeout branch now persists a sentinel row so + // subsequent mounts short-circuit at the cache lookup. The + // daily pre-warm worker overwrites the stub by detecting + // `timeout: true` + `model: "timeout-stub"`. + expect(prisma.auditLog.create).toHaveBeenCalledTimes(1); + const createArg = vi.mocked(prisma.auditLog.create).mock.calls[0][0] as { + data: { details: string }; + }; + const parsed = JSON.parse(createArg.data.details) as { + timeout?: boolean; + model?: string; + }; + expect(parsed.timeout).toBe(true); + expect(parsed.model).toBe("timeout-stub"); }); }); diff --git a/src/lib/insights/__tests__/pulse-status.test.ts b/src/lib/insights/__tests__/pulse-status.test.ts index d6562101c..7288196b2 100644 --- a/src/lib/insights/__tests__/pulse-status.test.ts +++ b/src/lib/insights/__tests__/pulse-status.test.ts @@ -82,6 +82,53 @@ describe("generatePulseStatusForUser — v1.4.6 bucketed payload", () => { }); }); +describe("generatePulseStatusForUser — v1.4.41 timeout-stub short-circuit", () => { + // The persist-on-timeout path is covered by + // `pulse-status-timeout.test.ts`. This block pins the second-mount + // behaviour: when a sentinel row already exists for today the cache + // lookup short-circuits and the provider is never invoked. + it("subsequent mounts short-circuit at the cache lookup and skip the race", async () => { + 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}`; + const stubRow = { + createdAt: new Date(), + details: JSON.stringify({ + dateKey: todayKey, + locale: "en", + text: "Pulse fallback text…", + timeout: true, + }), + }; + + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + dateOfBirth: null, + gender: null, + } as never); + 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 generatePulseStatusForUser("user-1", { locale: "en" }); + + expect(providerCall).not.toHaveBeenCalled(); + expect(result.text).toBe("Pulse fallback text…"); + expect(result.cached).toBe(true); + expect(result.updatedAt).toBe(stubRow.createdAt.toISOString()); + expect(prisma.auditLog.create).not.toHaveBeenCalled(); + }); +}); + describe("generatePulseStatusForUser — 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({ diff --git a/src/lib/insights/__tests__/weight-status.test.ts b/src/lib/insights/__tests__/weight-status.test.ts index 82409970f..909dd392f 100644 --- a/src/lib/insights/__tests__/weight-status.test.ts +++ b/src/lib/insights/__tests__/weight-status.test.ts @@ -82,6 +82,94 @@ describe("generateWeightStatusForUser — v1.4.6 bucketed payload", () => { }); }); +describe("generateWeightStatusForUser — v1.4.41 timeout-stub persistence", () => { + it("persists a sentinel row keyed to today when the provider times out", async () => { + vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); + vi.mocked(prisma.measurement.findMany).mockResolvedValue([ + { type: "WEIGHT", value: 82, measuredAt: new Date() }, + ] as never); + vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.auditLog.create).mockResolvedValue({ + createdAt: new Date(), + } as never); + + // Provider stalls indefinitely so the timeout race wins. + vi.mocked(resolveProvider).mockResolvedValue({ + type: "anthropic", + generateCompletion: vi.fn( + () => + new Promise(() => { + /* never resolves */ + }), + ), + } as never); + + vi.useFakeTimers(); + const promise = generateWeightStatusForUser("user-1", { locale: "en" }); + await vi.advanceTimersByTimeAsync(25_000); + const result = await promise; + vi.useRealTimers(); + + 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"); + expect(parsed.dateKey).toMatch(/^\d{4}-\d{2}-\d{2}$/); + }); + + it("subsequent mounts short-circuit at the cache lookup and skip the race", async () => { + 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}`; + const stubRow = { + createdAt: new Date(), + details: JSON.stringify({ + dateKey: todayKey, + locale: "en", + text: "Weight 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 generateWeightStatusForUser("user-1", { + locale: "en", + }); + + expect(providerCall).not.toHaveBeenCalled(); + expect(result.text).toBe("Weight is a directional metric…"); + expect(result.cached).toBe(true); + expect(result.updatedAt).toBe(stubRow.createdAt.toISOString()); + expect(prisma.auditLog.create).not.toHaveBeenCalled(); + }); +}); + describe("generateWeightStatusForUser — token-leak hardening (v1.4.27 F16)", () => { it("strips metric: tokens out of the cached text before persisting", async () => { vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); diff --git a/src/lib/insights/blood-pressure-status.ts b/src/lib/insights/blood-pressure-status.ts index c7f03e84c..a8dca717e 100644 --- a/src/lib/insights/blood-pressure-status.ts +++ b/src/lib/insights/blood-pressure-status.ts @@ -28,6 +28,7 @@ import { withTimeout, STATUS_PROVIDER_TIMEOUT_MS, } from "@/lib/insights/with-timeout"; +import { persistTimeoutStubAndReturn } from "@/lib/insights/persist-timeout-stub"; import { annotate } from "@/lib/logging/context"; const BERLIN_DAY_FORMATTER = new Intl.DateTimeFormat("en-US", { @@ -542,12 +543,18 @@ export async function generateBloodPressureStatusForUser( ); if (raced.timedOut || raced.value === null) { - return { - hasProvider: true, - text: getNoKeyBloodPressureStatusText(locale), - cached: true, - updatedAt: 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. + // See `persistTimeoutStubAndReturn` for the full rationale. + return persistTimeoutStubAndReturn({ + userId, + cacheAction, + todayKey, + locale, + providerType: provider.type, + stubText: getNoKeyBloodPressureStatusText(locale), + }); } const result = raced.value; diff --git a/src/lib/insights/general-status.ts b/src/lib/insights/general-status.ts index e48101dc7..0d4145520 100644 --- a/src/lib/insights/general-status.ts +++ b/src/lib/insights/general-status.ts @@ -18,6 +18,7 @@ import { withTimeout, STATUS_PROVIDER_TIMEOUT_MS, } from "@/lib/insights/with-timeout"; +import { persistTimeoutStubAndReturn } from "@/lib/insights/persist-timeout-stub"; import { annotate } from "@/lib/logging/context"; const BERLIN_DAY_FORMATTER = new Intl.DateTimeFormat("en-US", { @@ -390,12 +391,18 @@ export async function generateGeneralStatusForUser( ); if (raced.timedOut || raced.value === null) { - return { - hasProvider: true, - text: getNoKeyGeneralStatusText(locale), - cached: true, - updatedAt: 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. + // See `persistTimeoutStubAndReturn` for the full rationale. + return persistTimeoutStubAndReturn({ + userId, + cacheAction, + todayKey, + locale, + providerType: provider.type, + stubText: getNoKeyGeneralStatusText(locale), + }); } const result = raced.value; diff --git a/src/lib/insights/medication-compliance-status.ts b/src/lib/insights/medication-compliance-status.ts index cc91e4fa6..bbf6e5096 100644 --- a/src/lib/insights/medication-compliance-status.ts +++ b/src/lib/insights/medication-compliance-status.ts @@ -18,6 +18,7 @@ import { withTimeout, STATUS_PROVIDER_TIMEOUT_MS, } from "@/lib/insights/with-timeout"; +import { persistTimeoutStubAndReturn } from "@/lib/insights/persist-timeout-stub"; import { annotate } from "@/lib/logging/context"; // 360 daily days + 24 monthly windows ≈ 1080 days of intake history. @@ -104,6 +105,8 @@ export async function generateMedicationComplianceStatusForUser( const parsed = JSON.parse(latestCache.details) as { dateKey?: string; summary?: string; + text?: string; + timeout?: boolean; medications?: MedicationSummaryItem[]; }; @@ -126,6 +129,25 @@ export async function generateMedicationComplianceStatusForUser( updatedAt: latestCache.createdAt.toISOString(), }; } + + // v1.4.41 — timeout-stub rows persist `text` + `timeout: true` + // instead of the standard `summary` + `medications` envelope. + // Recognise the stub so the second mount short-circuits at the + // cache lookup instead of re-racing the provider call. + if ( + parsed.dateKey === todayKey && + parsed.timeout === true && + typeof parsed.text === "string" && + parsed.text.trim().length > 0 + ) { + return { + hasProvider: true, + summary: parsed.text, + medications: [], + cached: true, + updatedAt: latestCache.createdAt.toISOString(), + }; + } } catch { // ignore invalid cache payload } @@ -332,12 +354,28 @@ export async function generateMedicationComplianceStatusForUser( ); 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. + // See `persistTimeoutStubAndReturn` for the full rationale. + // This route returns a richer shape (`summary`, `medications`) + // than the standard `text` envelope, so the helper is called + // for its persist side-effect and the route maps `summary` ← + // `text` on the way out. + const stubReturn = await persistTimeoutStubAndReturn({ + userId, + cacheAction, + todayKey, + locale, + providerType: provider.type, + stubText: getNoKeyMedicationComplianceStatusText(locale), + }); return { hasProvider: true, - summary: getNoKeyMedicationComplianceStatusText(locale), + summary: stubReturn.text, medications: [], cached: true, - updatedAt: null, + updatedAt: stubReturn.updatedAt, }; } diff --git a/src/lib/insights/mood-status.ts b/src/lib/insights/mood-status.ts index f8acfb72f..5c2cecdc4 100644 --- a/src/lib/insights/mood-status.ts +++ b/src/lib/insights/mood-status.ts @@ -20,6 +20,7 @@ import { withTimeout, STATUS_PROVIDER_TIMEOUT_MS, } from "@/lib/insights/with-timeout"; +import { persistTimeoutStubAndReturn } from "@/lib/insights/persist-timeout-stub"; import { annotate } from "@/lib/logging/context"; const BERLIN_DAY_FORMATTER = new Intl.DateTimeFormat("en-US", { @@ -447,12 +448,18 @@ export async function generateMoodStatusForUser( ); if (raced.timedOut || raced.value === null) { - return { - hasProvider: true, - text: getNoKeyMoodStatusText(locale), - cached: true, - updatedAt: 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. + // See `persistTimeoutStubAndReturn` for the full rationale. + return persistTimeoutStubAndReturn({ + userId, + cacheAction, + todayKey, + locale, + providerType: provider.type, + stubText: getNoKeyMoodStatusText(locale), + }); } const result = raced.value; diff --git a/src/lib/insights/pulse-status.ts b/src/lib/insights/pulse-status.ts index 5dcb595a9..a84d190cc 100644 --- a/src/lib/insights/pulse-status.ts +++ b/src/lib/insights/pulse-status.ts @@ -19,6 +19,7 @@ import { withTimeout, STATUS_PROVIDER_TIMEOUT_MS, } from "@/lib/insights/with-timeout"; +import { persistTimeoutStubAndReturn } from "@/lib/insights/persist-timeout-stub"; import { annotate } from "@/lib/logging/context"; const BERLIN_DAY_FORMATTER = new Intl.DateTimeFormat("en-US", { @@ -315,8 +316,10 @@ export async function generatePulseStatusForUser( // (or upstream failure) return the no-key fallback text in a // cached-style envelope so the status card renders deterministically // instead of spinning behind React-Query's default retry ladder. - // The fallback is NOT persisted to the audit cache — a transient - // upstream stall would otherwise poison tomorrow's hit. + // v1.4.41 — the timeout branch now persists a sentinel keyed to + // today (see `persistTimeoutStubAndReturn`) so subsequent mounts + // short-circuit at the cache lookup instead of re-racing the + // upstream call. The daily pre-warm job overwrites the stub. const raced = await withTimeout( () => provider.generateCompletion({ @@ -335,12 +338,18 @@ export async function generatePulseStatusForUser( ); if (raced.timedOut || raced.value === null) { - return { - hasProvider: true, - text: getNoKeyPulseStatusText(locale), - cached: true, - updatedAt: 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. + // See `persistTimeoutStubAndReturn` for the full rationale. + return persistTimeoutStubAndReturn({ + userId, + cacheAction, + todayKey, + locale, + providerType: provider.type, + stubText: getNoKeyPulseStatusText(locale), + }); } const result = raced.value; diff --git a/src/lib/insights/weight-status.ts b/src/lib/insights/weight-status.ts index 4f0a72adf..d1e474c63 100644 --- a/src/lib/insights/weight-status.ts +++ b/src/lib/insights/weight-status.ts @@ -23,6 +23,7 @@ import { withTimeout, STATUS_PROVIDER_TIMEOUT_MS, } from "@/lib/insights/with-timeout"; +import { persistTimeoutStubAndReturn } from "@/lib/insights/persist-timeout-stub"; import { annotate } from "@/lib/logging/context"; const BERLIN_DAY_FORMATTER = new Intl.DateTimeFormat("en-US", { @@ -440,12 +441,18 @@ export async function generateWeightStatusForUser( ); if (raced.timedOut || raced.value === null) { - return { - hasProvider: true, - text: getNoKeyWeightStatusText(locale), - cached: true, - updatedAt: 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. + // See `persistTimeoutStubAndReturn` for the full rationale. + return persistTimeoutStubAndReturn({ + userId, + cacheAction, + todayKey, + locale, + providerType: provider.type, + stubText: getNoKeyWeightStatusText(locale), + }); } const result = raced.value;