From a2a995e429c56050dd1a52f057599713d5a3ff86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 11 Jun 2026 22:49:42 +0200 Subject: [PATCH] fix(insights,medications): close the staleness escape hatches and harden the review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Staleness can re-enter a generated text: measurement age is hashed as a bucketed tier, so the fingerprint stays stable day to day but flips exactly when a reading crosses the boundary the prompt caveat cares about. The ingest debounce becomes a one-hour minimum gap with the hash gate carrying the budget, so a notable morning reading is narrated the same morning instead of after the next nightly run. A manual regenerate now refills the status cards through the same hash-gated pipeline — changed cards regenerate, unchanged cards refresh free, no eviction touches the hash baselines. The user's self-context joins the comprehensive fingerprint so a newly remembered fact reaches the briefing, the sleep writer collapse covers every caller that reads nights, the batched compliance build fans out bounded instead of sequentially, and a failed compliance read renders a quiet retry state in the exact skeleton footprint instead of swallowing the error. --- messages/de.json | 1 + messages/en.json | 1 + messages/es.json | 1 + messages/fr.json | 1 + messages/it.json | 1 + messages/pl.json | 1 + src/app/api/dashboard/summary/route.ts | 10 +- .../insights/generate/__tests__/route.test.ts | 53 ++++++++ src/app/api/insights/generate/route.ts | 38 +++++- src/app/api/insights/targets/route.ts | 10 +- src/app/api/medications/compliance/route.ts | 47 +++++--- .../medications/__tests__/card-parts.test.tsx | 89 +++++++++++++- .../card-parts/medication-card-body.tsx | 20 ++- .../card-parts/medication-compliance-bars.tsx | 44 ++++++- .../medications/glp1-medication-card.tsx | 10 +- .../medications/medication-card.tsx | 10 +- src/lib/ai/coach/snapshot.ts | 10 +- .../comprehensive-generate-force.test.ts | 10 +- .../invalidate-status-insights.test.ts | 114 ++++++++++++++++-- .../insights/__tests__/snapshot-hash.test.ts | 83 +++++++++++-- src/lib/insights/comprehensive-generate.ts | 108 ++++++++++++++--- src/lib/insights/metric-status.ts | 10 +- src/lib/insights/snapshot-hash.ts | 71 +++++++++-- .../use-medication-compliance-summary.test.ts | 95 +++++++++++++++ .../use-medication-compliance-summary.ts | 29 +++-- 25 files changed, 770 insertions(+), 97 deletions(-) create mode 100644 src/lib/queries/__tests__/use-medication-compliance-summary.test.ts diff --git a/messages/de.json b/messages/de.json index 9850095b8..b6f30b7a7 100644 --- a/messages/de.json +++ b/messages/de.json @@ -735,6 +735,7 @@ "compliance7d": "Therapietreue (7 Tage)", "compliance30d": "Therapietreue (30 Tage)", "complianceWindow": "Therapietreue ({days} Tage)", + "complianceUnavailable": "Therapietreue konnte nicht geladen werden", "complianceInfo": "Anteil der erfassten an den erwarteten Dosen. Wöchentliche Pläne zählen wochenweise.", "complianceInfoLabel": "Was bedeutet Therapietreue?", "complianceDoses": "{count} Dosen", diff --git a/messages/en.json b/messages/en.json index 27f63a816..6c71f63ce 100644 --- a/messages/en.json +++ b/messages/en.json @@ -735,6 +735,7 @@ "compliance7d": "Adherence (7 days)", "compliance30d": "Adherence (30 days)", "complianceWindow": "Adherence ({days} days)", + "complianceUnavailable": "Adherence could not be loaded", "complianceInfo": "Share of expected doses you logged. Weekly schedules count week by week.", "complianceInfoLabel": "What does adherence mean?", "complianceDoses": "{count} doses", diff --git a/messages/es.json b/messages/es.json index f34d8725c..9aea8196d 100644 --- a/messages/es.json +++ b/messages/es.json @@ -735,6 +735,7 @@ "compliance7d": "Cumplimiento 7 días", "compliance30d": "Cumplimiento 30 días", "complianceWindow": "Cumplimiento {days} días", + "complianceUnavailable": "No se pudo cargar el cumplimiento", "complianceInfo": "Proporción de dosis registradas respecto a las esperadas. Las pautas semanales se cuentan semana a semana.", "complianceInfoLabel": "¿Qué significa el cumplimiento?", "complianceDoses": "{count} dosis", diff --git a/messages/fr.json b/messages/fr.json index 2bd929dc7..ca205fd30 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -735,6 +735,7 @@ "compliance7d": "Observance 7 jours", "compliance30d": "Observance 30 jours", "complianceWindow": "Observance {days} jours", + "complianceUnavailable": "Impossible de charger l'observance", "complianceInfo": "Part des doses attendues que vous avez enregistrées. Les plans hebdomadaires comptent semaine par semaine.", "complianceInfoLabel": "Que signifie l'observance ?", "complianceDoses": "{count} doses", diff --git a/messages/it.json b/messages/it.json index 0425b7449..fb771a4db 100644 --- a/messages/it.json +++ b/messages/it.json @@ -735,6 +735,7 @@ "compliance7d": "Aderenza 7 giorni", "compliance30d": "Aderenza 30 giorni", "complianceWindow": "Aderenza {days} giorni", + "complianceUnavailable": "Impossibile caricare l'aderenza", "complianceInfo": "Quota delle dosi attese che hai registrato. I piani settimanali contano settimana per settimana.", "complianceInfoLabel": "Cosa significa aderenza?", "complianceDoses": "{count} dosi", diff --git a/messages/pl.json b/messages/pl.json index 60672612c..451747634 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -735,6 +735,7 @@ "compliance7d": "Przestrzeganie 7 dni", "compliance30d": "Przestrzeganie 30 dni", "complianceWindow": "Przestrzeganie {days} dni", + "complianceUnavailable": "Nie udało się wczytać danych o przestrzeganiu", "complianceInfo": "Udział zarejestrowanych dawek względem oczekiwanych. Plany tygodniowe liczone są tydzień po tygodniu.", "complianceInfoLabel": "Co oznacza przestrzeganie zaleceń?", "complianceDoses": "{count} dawek", diff --git a/src/app/api/dashboard/summary/route.ts b/src/app/api/dashboard/summary/route.ts index 4c0b8bc47..a2bd8b53f 100644 --- a/src/app/api/dashboard/summary/route.ts +++ b/src/app/api/dashboard/summary/route.ts @@ -570,7 +570,15 @@ async function buildDashboardSummary( measuredAt: { gte: streakWindowStart }, }, orderBy: { measuredAt: "asc" }, - select: { value: true, measuredAt: true, sleepStage: true, source: true }, + // Writer-level collapse: two HealthKit apps behind one source + // (watch stages vs phone in-bed) must not blend into one night. + select: { + value: true, + measuredAt: true, + sleepStage: true, + source: true, + deviceType: true, + }, }), ), ]); diff --git a/src/app/api/insights/generate/__tests__/route.test.ts b/src/app/api/insights/generate/__tests__/route.test.ts index 531ad9acf..da86a255e 100644 --- a/src/app/api/insights/generate/__tests__/route.test.ts +++ b/src/app/api/insights/generate/__tests__/route.test.ts @@ -94,6 +94,13 @@ vi.mock("@/lib/jobs/insight-pregenerate-shared", () => ({ enqueueForceWarm: vi.fn(async () => undefined), })); +// The POST follows a successful generation with a hash-gated refill of the +// per-status / generic-metric cards. Mocked at the module boundary — the +// enqueue pipeline itself is covered by the comprehensive-generate tests. +vi.mock("@/lib/insights/comprehensive-generate", () => ({ + enqueueStatusRefillForUser: vi.fn(async () => 7), +})); + vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn(), })); @@ -117,6 +124,7 @@ vi.mock("@/lib/i18n/server-locale", () => ({ })); import { GET, POST, resolveInsightsRateLimit } from "../route"; +import { enqueueStatusRefillForUser } from "@/lib/insights/comprehensive-generate"; import { resolveProvider } from "@/lib/ai/provider"; import { checkRateLimit } from "@/lib/rate-limit"; import { prisma } from "@/lib/db"; @@ -389,6 +397,51 @@ describe("POST /api/insights/generate — cache write (v1.16.8)", () => { expect(prisma.user.update).not.toHaveBeenCalled(); expect(prisma.auditLog.deleteMany).not.toHaveBeenCalled(); }); + + it("enqueues the hash-gated card refill after a successful generation", async () => { + makeWorkingProvider(); + + const res = await POST(jsonRequest({ force: true }) as never); + expect(res.status).toBe(200); + + // The manual regenerate takes the cards along: one refill enqueue for + // the caller's resolved locale, annotated for the wide-event pipeline. + expect(enqueueStatusRefillForUser).toHaveBeenCalledTimes(1); + expect(enqueueStatusRefillForUser).toHaveBeenCalledWith("u-1", "en"); + const refillAnnotate = vi + .mocked(annotate) + .mock.calls.find( + (call) => + (call[0] as { action?: { name?: string } })?.action?.name === + "insights.generate.cards_refill", + ); + expect(refillAnnotate, "cards_refill annotate event").toBeTruthy(); + }); + + it("does NOT enqueue the card refill when serving from the 24h DB cache", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({ + insightsPrivacyMode: "aggregated", + insightsCachedAt: new Date(), + insightsCachedText: JSON.stringify({ changed: "still fresh" }), + locale: "en", + } as never); + + const res = await POST(jsonRequest({}) as never); + expect(res.status).toBe(200); + expect(enqueueStatusRefillForUser).not.toHaveBeenCalled(); + }); + + it("does NOT enqueue the card refill when the provider fails", async () => { + makeProviderThatThrows( + Object.assign(new Error("OpenAI request failed (500)"), { + httpStatus: 500, + }), + ); + + const res = await POST(jsonRequest({ force: true }) as never); + expect(res.status).toBe(503); + expect(enqueueStatusRefillForUser).not.toHaveBeenCalled(); + }); }); // v1.4.36 QA H1 — when both the raw-mode extraction AND the aggregated diff --git a/src/app/api/insights/generate/route.ts b/src/app/api/insights/generate/route.ts index e1e1c4ee4..22dfd4645 100644 --- a/src/app/api/insights/generate/route.ts +++ b/src/app/api/insights/generate/route.ts @@ -61,6 +61,7 @@ import { resolveServerLocale } from "@/lib/i18n/server-locale"; import { hasUsableStatusProvider } from "@/lib/insights/status-provider"; import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash"; import { enqueueForceWarm } from "@/lib/jobs/insight-pregenerate-shared"; +import { enqueueStatusRefillForUser } from "@/lib/insights/comprehensive-generate"; export const dynamic = "force-dynamic"; @@ -159,6 +160,9 @@ async function buildComparisonSnapshotForUser( value: true, sleepStage: true, source: true, + // Writer-level collapse: two HealthKit apps behind one + // source (watch stages vs phone in-bed) must not blend. + deviceType: true, }, }), resolveUserTimezone(userId), @@ -718,15 +722,37 @@ export const POST = apiHandler(async (request: NextRequest) => { // regeneration paths can detect "nothing changed" and skip the // provider call. The user-initiated POST itself stays un-gated: an // explicit regenerate request is honoured even on unchanged data - // (it is already bounded by the hourly rate limit above). - insightsSnapshotHash: hashInsightSnapshot(compactFeatures), + // (it is already bounded by the hourly rate limit above). The + // about-me text joins the fingerprint — it feeds the prompt and can + // change with no data change (Coach remember, Settings → AI edit) — + // and the shape MUST match the gate in `comprehensive-generate.ts`, + // or every off-request warm after a manual regenerate re-pays a + // full generation on unchanged data. + insightsSnapshotHash: hashInsightSnapshot({ + features: compactFeatures, + aboutMe: aboutMe ?? null, + }), }, }); - // v1.16.8 — no blanket per-status eviction here any more. The cards - // track their own data through the ingest invalidator and their own - // content-hash gates; nuking ~45 cache rows per manual regenerate was - // the source of the post-regenerate card-regeneration storm. + // v1.16.8 — no blanket per-status eviction here any more (nuking ~45 + // cache rows per manual regenerate deleted every hash baseline and was + // the source of the post-regenerate card-regeneration storm). Instead, + // the manual regenerate takes the cards along through the hash-gated + // queue: every scope the user has data for is enqueued, the worker + // forces each generator past its same-day cache read, and the content- + // hash gate regenerates exactly the cards whose data changed while + // refreshing the unchanged ones for free. This is the user's escape + // hatch for a card they can SEE is stale — without it the cards had no + // refresh path until the next nightly warm. + const refillScopes = await enqueueStatusRefillForUser( + userId, + locale === "de" ? "de" : "en", + ); + annotate({ + action: { name: "insights.generate.cards_refill" }, + meta: { scopes: refillScopes }, + }); // v1.7.0 W6 — the dashboard snapshot embeds the pre-generated daily // briefing read-only; drop it so the next snapshot carries the fresh diff --git a/src/app/api/insights/targets/route.ts b/src/app/api/insights/targets/route.ts index 1d4504bfa..d54899ecf 100644 --- a/src/app/api/insights/targets/route.ts +++ b/src/app/api/insights/targets/route.ts @@ -277,7 +277,15 @@ async function buildTargetsResponse(user: AuthedUser) { deletedAt: null, }, orderBy: { measuredAt: "asc" }, - select: { value: true, measuredAt: true, sleepStage: true, source: true }, + // Writer-level collapse: two HealthKit apps behind one source (watch + // stages vs phone in-bed) must not blend into one night. + select: { + value: true, + measuredAt: true, + sleepStage: true, + source: true, + deviceType: true, + }, }), // Active medications + their 30-day intake events (section 8). The // intake read depends on the medication ids, so the pair chains diff --git a/src/app/api/medications/compliance/route.ts b/src/app/api/medications/compliance/route.ts index 2bf720e01..11ad38d1e 100644 --- a/src/app/api/medications/compliance/route.ts +++ b/src/app/api/medications/compliance/route.ts @@ -1,3 +1,5 @@ +import pLimit from "p-limit"; + import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; @@ -53,25 +55,32 @@ export const GET = apiHandler(async () => { orderBy: { createdAt: "desc" }, }); - // Sequential on purpose: each cold cell costs one bounded intake read + - // one band-expansion pass, and a sequential walk keeps a many-meds - // account from stampeding the pool. Warm / stale cells return without - // touching the database at all. - const items = []; - for (const medication of medications) { - const payload = await cachedSwr( - caches.medicationCompliance as ServerCache, - complianceCacheKey(user.id, medication.id, userTz), - () => buildCompliancePayload(medication, user.id, userTz), - annotate, - ); - items.push({ - medicationId: medication.id, - compliance7: payload.compliance7, - compliance30: payload.compliance30, - complianceDisplay: payload.complianceDisplay, - }); - } + // Bounded fan-out: each cold cell costs one bounded intake read + one + // band-expansion pass. Three at a time keeps a many-meds account from + // stampeding the pool while bounding the cold wall-clock — the client + // reads this through the shared fetch wrapper's 15 s default timeout, + // and a strictly sequential walk over a large cabinet could outlive it + // on a cold cache. Warm / stale cells return without touching the + // database at all; `Promise.all` keeps the response in list order. + const limit = pLimit(3); + const items = await Promise.all( + medications.map((medication) => + limit(async () => { + const payload = await cachedSwr( + caches.medicationCompliance as ServerCache, + complianceCacheKey(user.id, medication.id, userTz), + () => buildCompliancePayload(medication, user.id, userTz), + annotate, + ); + return { + medicationId: medication.id, + compliance7: payload.compliance7, + compliance30: payload.compliance30, + complianceDisplay: payload.complianceDisplay, + }; + }), + ), + ); annotate({ action: { name: "medication.compliance_summary.read" }, diff --git a/src/components/medications/__tests__/card-parts.test.tsx b/src/components/medications/__tests__/card-parts.test.tsx index 0c8c712d0..1fe9fd8d1 100644 --- a/src/components/medications/__tests__/card-parts.test.tsx +++ b/src/components/medications/__tests__/card-parts.test.tsx @@ -8,7 +8,10 @@ import { Glp1MedicationCard, type Glp1Medication, } from "@/components/medications/glp1-medication-card"; -import { MedicationComplianceBars } from "@/components/medications/card-parts/medication-compliance-bars"; +import { + MedicationComplianceBars, + MedicationComplianceError, +} from "@/components/medications/card-parts/medication-compliance-bars"; import { MedicationStatusPill } from "@/components/medications/card-parts/medication-status-pill"; import { MedicationIntakeActions } from "@/components/medications/card-parts/medication-intake-actions"; import { MedicationStateBadges } from "@/components/medications/card-parts/medication-state-badges"; @@ -550,3 +553,87 @@ describe("compliance bars — fixed geometry (v1.16.6)", () => { expect(skeleton(a)).toBe(skeleton(b)); }); }); + +/** + * v1.16.8 — when the batched compliance query FAILS (post-retry), the + * card swaps the loading skeleton for a quiet error fallback: same + * two-row footprint (no layout jump), no bars (a failed read must not + * paint a 0 % adherence), one muted notice line + a small retry that + * refetches the shared query. Without it the swallowed-error path left + * every card on a permanent skeleton with no recovery affordance. + */ +describe("compliance error fallback (v1.16.8)", () => { + function renderBodyCompliance(args: { + compliance: boolean; + complianceError?: boolean; + }) { + return render( + {}} + currentCycle={null} + intakeLoading={null} + onRecordIntake={() => {}} + />, + ); + } + + it("renders the notice line and the retry affordance, no progress bars", () => { + const html = render( {}} />); + expect(html).toContain("Adherence could not be loaded"); + expect(html).toContain("Retry"); + expect(html).not.toContain('data-slot="progress"'); + }); + + it("mirrors the skeleton's two-row footprint so the card height holds", () => { + const error = render( {}} />); + // Two h-5 label lines + two h-2 bar-height slots — exactly the + // skeleton's reserved geometry, so failed/loading/loaded states all + // occupy the same space. + expect((error.match(/flex h-5 items-center/g) ?? []).length).toBe(2); + expect((error.match(/h-2 rounded/g) ?? []).length).toBe(2); + }); + + it("the body shows the error fallback instead of the skeleton on a failed query", () => { + const html = renderBodyCompliance({ + compliance: false, + complianceError: true, + }); + expect(html).toContain("Adherence could not be loaded"); + expect(html).toContain("Retry"); + // Not the aria-hidden loading skeleton. + expect(html).not.toContain('aria-hidden="true">
{ + const html = renderBodyCompliance({ compliance: false }); + expect(html).not.toContain("Adherence could not be loaded"); + }); + + it("resolved data still renders the bars even when a stale error flag lingers", () => { + // A successful refetch clears the failure visually: data wins. + const html = renderBodyCompliance({ + compliance: true, + complianceError: true, + }); + expect(html).toContain('data-slot="progress"'); + expect(html).not.toContain("Adherence could not be loaded"); + }); +}); diff --git a/src/components/medications/card-parts/medication-card-body.tsx b/src/components/medications/card-parts/medication-card-body.tsx index c2cd0c74d..bc695e3b7 100644 --- a/src/components/medications/card-parts/medication-card-body.tsx +++ b/src/components/medications/card-parts/medication-card-body.tsx @@ -9,6 +9,7 @@ import { MedicationCardHeader } from "@/components/medications/MedicationCardHea import { MedicationStatusPill } from "@/components/medications/card-parts/medication-status-pill"; import { MedicationComplianceBars, + MedicationComplianceError, MedicationComplianceSkeleton, } from "@/components/medications/card-parts/medication-compliance-bars"; import { MedicationCycleStatus } from "@/components/medications/card-parts/medication-cycle-status"; @@ -95,6 +96,16 @@ export interface MedicationCardBodyProps { longDays: number; } | null; + /** + * True when the batched compliance query FAILED (post-retry). The body + * then renders the quiet same-footprint error fallback instead of the + * skeleton — a permanent skeleton reads as "still loading" forever, + * while a failed read needs a visible (but calm) retry affordance. + */ + complianceError?: boolean; + /** Refetch the shared compliance query (the error fallback's retry). */ + onRetryCompliance?: () => void; + /** The open-cycle descriptor, or null when the display block is absent. */ currentCycle: CurrentCycle | null; @@ -121,6 +132,8 @@ export function MedicationCardBody({ nextLine, lastLine, compliance, + complianceError = false, + onRetryCompliance, currentCycle, intakeLoading, onRecordIntake, @@ -183,7 +196,10 @@ export function MedicationCardBody({ {/* Compliance bars — always two rows; constant-height skeleton holds - the slot while the query is in flight so the grid row stays even. */} + the slot while the query is in flight so the grid row stays even. + A FAILED query swaps the skeleton for the same-footprint quiet + error fallback (no bars, one notice line + retry) instead of + sitting on the skeleton forever. */} {active && (compliance ? ( + ) : complianceError && onRetryCompliance ? ( + ) : ( ))} diff --git a/src/components/medications/card-parts/medication-compliance-bars.tsx b/src/components/medications/card-parts/medication-compliance-bars.tsx index fb44fe67d..9423f24b8 100644 --- a/src/components/medications/card-parts/medication-compliance-bars.tsx +++ b/src/components/medications/card-parts/medication-compliance-bars.tsx @@ -1,4 +1,4 @@ -import { Flame } from "lucide-react"; +import { Flame, RotateCw } from "lucide-react"; import { ComplianceInfoTip } from "@/components/medications/card-parts/compliance-info-tip"; import { Progress } from "@/components/ui/progress"; @@ -145,3 +145,45 @@ export function MedicationComplianceSkeleton() {
); } + +/** + * Quiet error fallback for the compliance block, shown when the batched + * `/api/medications/compliance` query failed after its retries. Mirrors + * the skeleton's two-row footprint exactly (same `h-5` label lines, same + * `h-2` bar slots, same gaps) so the card height never jumps between the + * loading, loaded, and failed states. No bars are drawn — a failed read + * must not paint a 0 % adherence — just one muted notice line and a small + * retry affordance that refetches the shared query for every card at once. + */ +export function MedicationComplianceError({ + onRetry, +}: { + onRetry: () => void; +}) { + const { t } = useTranslations(); + return ( +
+
+
+ + {t("medications.complianceUnavailable")} + +
+
+
+
+
+ +
+
+
+
+ ); +} diff --git a/src/components/medications/glp1-medication-card.tsx b/src/components/medications/glp1-medication-card.tsx index df99aaa17..800867bf4 100644 --- a/src/components/medications/glp1-medication-card.tsx +++ b/src/components/medications/glp1-medication-card.tsx @@ -192,7 +192,13 @@ export function Glp1MedicationCard({ // v1.16.8 — same batched compliance source as the generic card: one // `GET /api/medications/compliance` round trip shared by every card on // the page (the per-id endpoint stays for the detail page's heatmap). - const { data: compliance } = useMedicationComplianceSummary(medication.id); + // `isError` swaps the compliance slot to the quiet retry fallback so a + // failed batch read never leaves the card on a permanent skeleton. + const { + data: compliance, + isError: complianceError, + refetch: refetchCompliance, + } = useMedicationComplianceSummary(medication.id); // v1.4.37 W4b — same reminder-thresholds source as the generic // medication card so the take-now / overdue / very-overdue pill @@ -442,6 +448,8 @@ export function Glp1MedicationCard({ compliance={ compliance ? { rate7, rate30, streak, shortDays, longDays } : null } + complianceError={complianceError} + onRetryCompliance={refetchCompliance} currentCycle={display?.currentCycle ?? null} intakeLoading={intakeLoading} onRecordIntake={(skipped) => recordIntake(skipped, displayedSlot)} diff --git a/src/components/medications/medication-card.tsx b/src/components/medications/medication-card.tsx index a9cac69e2..af72a7006 100644 --- a/src/components/medications/medication-card.tsx +++ b/src/components/medications/medication-card.tsx @@ -131,7 +131,13 @@ export function MedicationCard({ // v1.16.8 — the per-card compliance read rides the ONE batched // `GET /api/medications/compliance` round trip every card on the page // shares (the per-id endpoint stays for the detail page's heatmap). - const { data: compliance } = useMedicationComplianceSummary(medication.id); + // `isError` swaps the compliance slot to the quiet retry fallback so a + // failed batch read never leaves the card on a permanent skeleton. + const { + data: compliance, + isError: complianceError, + refetch: refetchCompliance, + } = useMedicationComplianceSummary(medication.id); const [, forceUpdate] = useReducer((x: number) => x + 1, 0); @@ -386,6 +392,8 @@ export function MedicationCard({ compliance={ compliance ? { rate7, rate30, streak, shortDays, longDays } : null } + complianceError={complianceError} + onRetryCompliance={refetchCompliance} currentCycle={display?.currentCycle ?? null} intakeLoading={intakeLoading} onRecordIntake={(skipped) => recordIntake(skipped, displayedSlot)} diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts index 78af5b4b4..a144309cc 100644 --- a/src/lib/ai/coach/snapshot.ts +++ b/src/lib/ai/coach/snapshot.ts @@ -770,7 +770,15 @@ async function buildCoachSnapshotImpl( deletedAt: null, }, orderBy: { measuredAt: "asc" }, - select: { value: true, measuredAt: true, sleepStage: true, source: true }, + // Writer-level collapse: two HealthKit apps behind one source + // (watch stages vs phone in-bed) must not blend into one night. + select: { + value: true, + measuredAt: true, + sleepStage: true, + source: true, + deviceType: true, + }, }) : null; const workoutRowsPromise = sources.has("workouts") diff --git a/src/lib/insights/__tests__/comprehensive-generate-force.test.ts b/src/lib/insights/__tests__/comprehensive-generate-force.test.ts index 4824c0f22..21277ec13 100644 --- a/src/lib/insights/__tests__/comprehensive-generate-force.test.ts +++ b/src/lib/insights/__tests__/comprehensive-generate-force.test.ts @@ -70,9 +70,13 @@ import { compactSections } from "@/lib/ai/prompts/compact-sections"; const FEATURES = { weight: { count: 12, latest: 81.4, mean30: 82.1 }, }; -const FEATURES_HASH = hashInsightSnapshot( - compactSections(FEATURES as unknown as Record), -); +// The fingerprint covers the compacted features AND the about-me text +// (null here — the about-me module is mocked to no text), matching the +// composite shape both the gate and the POST route hash. +const FEATURES_HASH = hashInsightSnapshot({ + features: compactSections(FEATURES as unknown as Record), + aboutMe: null, +}); beforeEach(() => { vi.clearAllMocks(); diff --git a/src/lib/insights/__tests__/invalidate-status-insights.test.ts b/src/lib/insights/__tests__/invalidate-status-insights.test.ts index 72cf19705..07d96e582 100644 --- a/src/lib/insights/__tests__/invalidate-status-insights.test.ts +++ b/src/lib/insights/__tests__/invalidate-status-insights.test.ts @@ -18,6 +18,7 @@ const deleteMany = vi.fn(); const auditFindMany = vi.fn(); const enqueueStatusGeneration = vi.fn(); const userFindUnique = vi.fn(); +const measurementFindMany = vi.fn(); vi.mock("@/lib/db", () => ({ prisma: { @@ -26,6 +27,7 @@ vi.mock("@/lib/db", () => ({ findMany: (...a: unknown[]) => auditFindMany(...a), }, user: { findUnique: (...a: unknown[]) => userFindUnique(...a) }, + measurement: { findMany: (...a: unknown[]) => measurementFindMany(...a) }, }, })); @@ -33,7 +35,10 @@ vi.mock("@/lib/jobs/insight-status-generate-shared", () => ({ enqueueStatusGeneration: (...a: unknown[]) => enqueueStatusGeneration(...a), })); -import { invalidateStatusInsightsForTypes } from "../comprehensive-generate"; +import { + enqueueStatusRefillForUser, + invalidateStatusInsightsForTypes, +} from "../comprehensive-generate"; beforeEach(() => { vi.clearAllMocks(); @@ -43,6 +48,7 @@ beforeEach(() => { auditFindMany.mockResolvedValue([]); enqueueStatusGeneration.mockResolvedValue(undefined); userFindUnique.mockResolvedValue({ locale: "de" }); + measurementFindMany.mockResolvedValue([]); }); /** Distinct scopes the invalidator enqueued a regenerate for. */ @@ -189,7 +195,7 @@ describe("invalidateStatusInsightsForTypes", () => { ).toBe(false); }); - describe("ingest-invalidation debounce (v1.9.0, 6 h window since v1.16.8)", () => { + describe("ingest-invalidation debounce (v1.9.0; a 1 h minimum gap since v1.16.8)", () => { /** Build a recent (within-window) real assessment cache row. */ function freshRow(scope: string, locale = "de") { return { @@ -198,14 +204,26 @@ describe("invalidateStatusInsightsForTypes", () => { }; } - it("skips a scope whose assessment was warmed within the window", async () => { - // `general` was regenerated within the window; a fresh WEIGHT sample + it("skips a scope whose assessment was warmed within the gap", async () => { + // `general` was regenerated within the gap; a fresh WEIGHT sample // must not re-enqueue it. weight + bmi are still stale, so they refresh. auditFindMany.mockResolvedValue([freshRow("general")]); await invalidateStatusInsightsForTypes("u1", ["WEIGHT"]); expect(enqueuedScopes()).toEqual(["bmi", "weight"]); }); + it("re-enqueues a scope warmed longer than the gap ago (the hash gate meters cost)", async () => { + // The DB probe is cutoff-filtered, so a scope last warmed before the + // gap simply does not come back as fresh — it re-enqueues even though + // a cache row exists. The worker's forced run lands on the content- + // hash gate, which makes a no-change run a free timestamp refresh — + // this is what lets same-day data be narrated same-day without + // reopening the per-batch regeneration storm. + auditFindMany.mockResolvedValue([]); + await invalidateStatusInsightsForTypes("u1", ["WEIGHT"]); + expect(enqueuedScopes()).toEqual(["bmi", "general", "weight"]); + }); + it("is a complete no-op (no enqueue) when every scope is fresh", async () => { auditFindMany.mockResolvedValue([ freshRow("weight"), @@ -229,7 +247,7 @@ describe("invalidateStatusInsightsForTypes", () => { expect(enqueuedScopes()).toEqual(["bmi", "general", "weight"]); }); - it("scopes the freshness probe to the user's resolved locale and a 6 h window cutoff", async () => { + it("scopes the freshness probe to the user's resolved locale and a 1 h gap cutoff", async () => { userFindUnique.mockResolvedValue({ locale: "en" }); const before = Date.now(); await invalidateStatusInsightsForTypes("u1", ["WEIGHT"]); @@ -243,14 +261,86 @@ describe("invalidateStatusInsightsForTypes", () => { "insights.general-status.en", ]), ); - // The recency floor is six hours in the past (v1.16.8 — widened - // from 30 min so an all-day syncer refreshes a scope at most a few - // times, with the hash gate dropping unchanged refreshes to zero - // LLM calls). + // The recency floor is ONE hour in the past. The 6 h wall this + // started as kept same-day data from being narrated same-day (the + // nightly warm restarted the window, so a morning reading stayed + // un-narrated until tomorrow); the 1 h gap only bounds worker-run + // frequency, while the worker's content-hash gate keeps an + // unchanged re-run at zero LLM calls. const cutoff = (where.createdAt.gte as Date).getTime(); - const sixHours = 6 * 60 * 60 * 1000; - expect(before - cutoff).toBeGreaterThanOrEqual(sixHours - 5_000); - expect(before - cutoff).toBeLessThanOrEqual(sixHours + 60_000); + const oneHour = 60 * 60 * 1000; + expect(before - cutoff).toBeGreaterThanOrEqual(oneHour - 5_000); + expect(before - cutoff).toBeLessThanOrEqual(oneHour + 60_000); }); }); }); + +// v1.16.8 — the manual comprehensive regenerate enqueues a hash-gated +// refill of the assessment cards instead of blanket-evicting them. The +// worker forces each enqueued scope past its same-day cache read, so the +// content-hash gate regenerates changed cards and refreshes unchanged +// ones for free — the hash baseline rows are never deleted. +describe("enqueueStatusRefillForUser", () => { + it("enqueues the seven specialised scopes plus the user's data-bearing generic scopes", async () => { + measurementFindMany.mockResolvedValue([ + { type: "WEIGHT" }, // specialised — no generic registry entry + { type: "BLOOD_GLUCOSE" }, // generic card + { type: "ACTIVE_ENERGY_BURNED" }, // generic card under a remapped id + ]); + + const count = await enqueueStatusRefillForUser("u1", "de"); + + expect(enqueuedScopes()).toEqual([ + "blood-pressure", + "bmi", + "general", + "medication-compliance", + "metric:ACTIVE_ENERGY", + "metric:BLOOD_GLUCOSE", + "mood", + "pulse", + "weight", + ]); + expect(count).toBe(9); + const locales = new Set( + enqueueStatusGeneration.mock.calls.map( + (c) => (c[0] as { locale: string }).locale, + ), + ); + expect([...locales]).toEqual(["de"]); + }); + + it("never deletes cache rows (the hash baselines survive the refill)", async () => { + await enqueueStatusRefillForUser("u1", "en"); + expect(deleteMany).not.toHaveBeenCalled(); + }); + + it("bypasses the ingest debounce — an explicit regenerate refills even freshly-warmed scopes", async () => { + // Every scope reads as freshly warmed; the refill must NOT consult the + // freshness probe at all (the user is explicitly asking). + auditFindMany.mockResolvedValue([ + { + action: "insights.general-status.en", + details: JSON.stringify({ text: "fresh assessment", model: "gpt" }), + }, + ]); + await enqueueStatusRefillForUser("u1", "en"); + expect(auditFindMany).not.toHaveBeenCalled(); + expect(enqueuedScopes()).toContain("general"); + }); + + it("still refills the specialised scopes when the generic-scope discovery read fails", async () => { + measurementFindMany.mockRejectedValue(new Error("pool exhausted")); + const count = await enqueueStatusRefillForUser("u1", "de"); + expect(count).toBe(7); + expect(enqueuedScopes()).toEqual([ + "blood-pressure", + "bmi", + "general", + "medication-compliance", + "mood", + "pulse", + "weight", + ]); + }); +}); diff --git a/src/lib/insights/__tests__/snapshot-hash.test.ts b/src/lib/insights/__tests__/snapshot-hash.test.ts index 9538da8b1..c335855a4 100644 --- a/src/lib/insights/__tests__/snapshot-hash.test.ts +++ b/src/lib/insights/__tests__/snapshot-hash.test.ts @@ -4,14 +4,16 @@ * The hash must be: deterministic across key order (so two structurally * equal snapshots always match), locale-agnostic and day-label-agnostic * (so a midnight rollover or a locale switch alone never forces a - * regeneration), clock-offset-agnostic (dayOffset / *DaysAgo shift with - * `now`, not with the data), and sensitive to every actual data value - * the prompt narrates. All assertions use fixed inputs — no clocks, no - * timezones. + * regeneration), clock-offset-agnostic for positional offsets (dayOffset + * shifts with `now`, not with the data), tier-bucketed for the `*DaysAgo` + * recency keys (stable within a staleness tier, flipping exactly at the + * 1/2, 7/8 and 30/31 boundaries so the staleness caveat can re-enter the + * text), and sensitive to every actual data value the prompt narrates. + * All assertions use fixed inputs — no clocks, no timezones. */ import { describe, it, expect } from "vitest"; -import { hashInsightSnapshot } from "../snapshot-hash"; +import { hashInsightSnapshot, stalenessTier } from "../snapshot-hash"; describe("hashInsightSnapshot", () => { it("is a sha256 hex digest", () => { @@ -50,7 +52,7 @@ describe("hashInsightSnapshot", () => { expect(de).toBe(en); }); - it("ignores clock-relative offsets (dayOffset, *DaysAgo) but not the values", () => { + it("ignores positional offsets (dayOffset) but not the values", () => { const yesterday = hashInsightSnapshot({ dataCoverage: { totalMeasurements: 12, newestMeasurementDaysAgo: 0 }, daily: [ @@ -65,7 +67,8 @@ describe("hashInsightSnapshot", () => { { dayOffset: 2, value: 80.4, n: 2 }, ], }); - // Same readings, one day later, nothing new logged → same fingerprint. + // Same readings, one day later, nothing new logged, recency still in + // the fresh tier (0 → 1 days) → same fingerprint. expect(yesterday).toBe(today); const newReading = hashInsightSnapshot({ @@ -79,6 +82,72 @@ describe("hashInsightSnapshot", () => { expect(newReading).not.toBe(today); }); + describe("staleness tiers for *DaysAgo keys", () => { + /** Snapshot with identical data values and a varying recency. */ + function snap(newestMeasurementDaysAgo: number | null) { + return { + dataCoverage: { totalMeasurements: 12, newestMeasurementDaysAgo }, + weight: { mean: 80.2, n: 12 }, + }; + } + + it("maps day counts onto the 0-1 / 2-7 / 8-30 / 30+ tiers", () => { + expect(stalenessTier(0)).toBe("0-1d"); + expect(stalenessTier(1)).toBe("0-1d"); + expect(stalenessTier(2)).toBe("2-7d"); + expect(stalenessTier(7)).toBe("2-7d"); + expect(stalenessTier(8)).toBe("8-30d"); + expect(stalenessTier(30)).toBe("8-30d"); + expect(stalenessTier(31)).toBe("30d+"); + expect(stalenessTier(365)).toBe("30d+"); + }); + + it("is stable while the recency stays within one tier", () => { + expect(hashInsightSnapshot(snap(0))).toBe(hashInsightSnapshot(snap(1))); + expect(hashInsightSnapshot(snap(2))).toBe(hashInsightSnapshot(snap(7))); + expect(hashInsightSnapshot(snap(8))).toBe(hashInsightSnapshot(snap(30))); + expect(hashInsightSnapshot(snap(31))).toBe( + hashInsightSnapshot(snap(120)), + ); + }); + + it("flips exactly when the recency crosses a tier boundary", () => { + // The day a user's data goes from "fresh" to "going stale" to + // "stale" the fingerprint must change, so the cached text picks up + // (or escalates) the out-of-date caveat instead of being re-stamped + // as current forever. + expect(hashInsightSnapshot(snap(1))).not.toBe( + hashInsightSnapshot(snap(2)), + ); + expect(hashInsightSnapshot(snap(7))).not.toBe( + hashInsightSnapshot(snap(8)), + ); + expect(hashInsightSnapshot(snap(30))).not.toBe( + hashInsightSnapshot(snap(31)), + ); + }); + + it("keeps a null recency (no dated reading) distinct from every tier", () => { + const noReading = hashInsightSnapshot(snap(null)); + expect(noReading).not.toBe(hashInsightSnapshot(snap(0))); + expect(noReading).not.toBe(hashInsightSnapshot(snap(31))); + }); + + it("buckets nested *DaysAgo keys the same way (per-series, signal block)", () => { + const fresh = hashInsightSnapshot({ + signal: { metric: "Pulse", newestDaysAgo: 3, current: 61 }, + }); + const sameTier = hashInsightSnapshot({ + signal: { metric: "Pulse", newestDaysAgo: 6, current: 61 }, + }); + const staleTier = hashInsightSnapshot({ + signal: { metric: "Pulse", newestDaysAgo: 9, current: 61 }, + }); + expect(fresh).toBe(sameTier); + expect(fresh).not.toBe(staleTier); + }); + }); + it("keeps absolute day keys in the fingerprint", () => { const a = hashInsightSnapshot({ recent: [{ date: "2026-06-09", mean: 80 }] }); const b = hashInsightSnapshot({ recent: [{ date: "2026-06-10", mean: 80 }] }); diff --git a/src/lib/insights/comprehensive-generate.ts b/src/lib/insights/comprehensive-generate.ts index 7f064d98d..0bb38a4bc 100644 --- a/src/lib/insights/comprehensive-generate.ts +++ b/src/lib/insights/comprehensive-generate.ts @@ -81,9 +81,10 @@ import { annotate } from "@/lib/logging/context"; const CACHE_TTL_MS = 24 * 60 * 60 * 1000; /** - * Debounce window for ingest-driven status invalidation. A scope whose - * cached assessment was (re)generated within this window is left intact on - * a fresh measurement ingest — not re-enqueued. + * Minimum gap between a scope's last cached (re)generation and the next + * ingest-driven re-enqueue. A scope whose cached assessment was + * (re)generated within this window is left intact on a fresh measurement + * ingest — not re-enqueued. * * A constantly-syncing client (Apple Health drips batches every few * minutes) used to delete + re-enqueue every dirtied scope on every batch, @@ -95,15 +96,24 @@ const CACHE_TTL_MS = 24 * 60 * 60 * 1000; * still refreshes immediately, but a scope refreshed inside the window is * skipped, so a fresh assessment survives the day's sync drip. * - * v1.16.8 — widened from 30 min to 6 h. An assessment is a daily prose - * summary, not a live chart; a card that re-narrates the same day's data - * every half hour burns provider budget with no informational gain. Six - * hours bounds an all-day syncer to at most ~3 ingest-driven refreshes per - * scope, and the content-hash gate in each generator drops even those to - * zero LLM calls when the snapshot did not actually change. The 24 h TTL - * and the nightly warm still guarantee a fresh narration every morning. + * v1.16.8 — first widened from 30 min to 6 h to bound provider spend, but + * a 6 h wall meant same-day data was not narrated same-day: the nightly + * warm at 04:30 restarted the window, so a notable 08:00 reading never + * re-enqueued its scopes and the card showed the pre-reading text until + * the next nightly tick. The window is now a ONE-HOUR minimum gap, and + * the budget role the 6 h wall carried moves to the worker's content-hash + * gate: every ingest that lands past the gap re-enqueues the dirtied + * scopes, the worker's forced run re-gathers the snapshot, and the gate + * (`refreshUnchangedStatusInsight`) turns an unchanged snapshot into a + * timestamp refresh with zero provider cost. So this clock only bounds + * the SQL-gather frequency — at most one worker run per scope per hour + * under a constant sync drip — while provider spend tracks actual data + * change, which is what the gate exists to meter. Comparing the hash + * inline at invalidation time was rejected: it would run the full + * per-scope data gather inside the ingest path, which is exactly the + * work the queue exists to keep off that path. */ -const INGEST_INVALIDATE_DEBOUNCE_MS = 6 * 60 * 60 * 1000; +const INGEST_INVALIDATE_MIN_GAP_MS = 60 * 60 * 1000; const MAX_DOWNGRADE_TOKENS: ReadonlyArray = [ "anthropometrics", @@ -181,6 +191,9 @@ export async function buildComparisonSnapshotForUser( value: true, sleepStage: true, source: true, + // Writer-level collapse: two HealthKit apps behind one + // source (watch stages vs phone in-bed) must not blend. + deviceType: true, }, }), resolveUserTimezone(userId), @@ -332,10 +345,12 @@ export async function invalidateStatusInsightsForTypes( // scope on every batch regenerated the same card several times an hour // (the per-(user,scope,locale) enqueue singleton is only 120 s, so it // could not coalesce across batches arriving minutes apart). Skip any - // scope whose cached assessment was (re)generated within the debounce - // window — leave its row intact and do NOT re-enqueue. A genuinely stale + // scope whose cached assessment was (re)generated within the minimum + // gap — leave its row intact and do NOT re-enqueue. A genuinely stale // or missing scope still refreshes immediately, so correctness holds; - // only the redundant churn is removed. + // only the redundant churn is removed. Past the gap the enqueue always + // goes through — the worker's content-hash gate decides whether the + // batch actually changed anything (see the constant's doc). const freshScopes = await findRecentlyWarmedScopes(userId, locale, scopes); const staleScopes = Array.from(scopes).filter( (scope) => !freshScopes.has(scope), @@ -366,9 +381,54 @@ export async function invalidateStatusInsightsForTypes( }); } +/** + * v1.16.8 — enqueue a hash-gated refill of every assessment card one user + * actually has: the seven specialised scopes plus the generic + * `metric:` scope of every measurement type with live rows. The + * worker regenerates each enqueued scope with `force: true`, so every + * card skips its same-day cache read, re-gathers its snapshot, and lands + * on the content-hash gate — a card whose data changed regenerates, an + * unchanged card gets a free timestamp refresh. + * + * This is the manual-regenerate path's card story: the POST regenerate + * used to blanket-evict every per-status row, which deleted the hash + * baselines and force-paid ~45 regenerations per click. Enqueuing through + * the gate keeps the baseline rows intact, so a user who noticed a stale + * card gets exactly the changed cards re-narrated — and nothing else. + * Deliberately NOT routed through the ingest debounce: an explicit + * regenerate is a user action, already bounded by the route's hourly + * rate limit and the queue's per-(user,scope,locale) singleton. + * + * Returns the number of scopes enqueued (best-effort — the generic-scope + * discovery read failing still refills the seven specialised cards). + */ +export async function enqueueStatusRefillForUser( + userId: string, + locale: "de" | "en", +): Promise { + const scopes = new Set(PER_STATUS_SCOPES); + try { + const rows = await prisma.measurement.findMany({ + where: { userId, deletedAt: null }, + distinct: ["type"], + select: { type: true }, + }); + for (const row of rows) { + const metricId = metricIdForMeasurementType(row.type); + if (metricId) scopes.add(metricStatusScope(metricId)); + } + } catch { + // Discovery is best-effort; the specialised scopes still refill. + } + for (const scope of scopes) { + void enqueueStatusGeneration({ userId, metric: scope, locale }); + } + return scopes.size; +} + /** * v1.9.0 — return the subset of `scopes` whose cached assessment for - * `locale` was generated within `INGEST_INVALIDATE_DEBOUNCE_MS` and is a + * `locale` was generated within `INGEST_INVALIDATE_MIN_GAP_MS` and is a * real (non-stub) assessment. Those scopes are skipped by the ingest * invalidator so a fresh assessment survives the sync drip. * @@ -383,7 +443,7 @@ async function findRecentlyWarmedScopes( locale: "de" | "en", scopes: ReadonlySet, ): Promise> { - const cutoff = new Date(Date.now() - INGEST_INVALIDATE_DEBOUNCE_MS); + const cutoff = new Date(Date.now() - INGEST_INVALIDATE_MIN_GAP_MS); // Match exactly the cache actions for the candidate scopes in this locale. const candidateActions = Array.from(scopes, (scope) => statusCacheAction(scope, locale), @@ -546,7 +606,19 @@ export async function generateComprehensiveInsight( // sees has changed — refresh the cache timestamp and skip the provider // call. This runs on the forced paths too (nightly tick, on-demand // warm), which is exactly where the same-data regeneration waste lived. - const snapshotHash = hashInsightSnapshot(compactFeatures); + // + // The user-authored "about me" self-description joins the fingerprint: + // it is the one prompt input that can change with NO data change (the + // Coach remember action, a Settings → AI edit), and excluding it would + // pin the briefing to the pre-edit self-context until a measurement + // happens to move. The comparison + plateau blocks stay out — both are + // derived from the same measurement rows the feature snapshot already + // fingerprints. Fetched once here and reused for the prompt below. + const aboutMe = await getSelfContextTextForUser(userId, locale); + const snapshotHash = hashInsightSnapshot({ + features: compactFeatures, + aboutMe: aboutMe ?? null, + }); if ( dbUser?.insightsCachedText && dbUser.insightsSnapshotHash === snapshotHash @@ -576,7 +648,7 @@ export async function generateComprehensiveInsight( // v1.15.20 — fold the user-authored "about me" self-description // (Settings → AI) into the nightly briefing exactly like the // on-demand route. Null (no text / undecryptable) costs nothing. - const aboutMe = await getSelfContextTextForUser(userId, locale); + // (Fetched above the hash gate — it is part of the fingerprint.) if (aboutMe) { userPrompt += buildAboutMeInsightBlock(aboutMe, locale); } diff --git a/src/lib/insights/metric-status.ts b/src/lib/insights/metric-status.ts index 2effffdcc..67eac88eb 100644 --- a/src/lib/insights/metric-status.ts +++ b/src/lib/insights/metric-status.ts @@ -245,7 +245,15 @@ export async function generateMetricStatus(args: { }, orderBy: { measuredAt: "desc" }, take: 365 * 6, // ≈ 5 stage rows/night × source-count for a year - select: { value: true, measuredAt: true, sleepStage: true, source: true }, + // Writer-level collapse: two HealthKit apps behind one source + // (watch stages vs phone in-bed) must not blend into one night. + select: { + value: true, + measuredAt: true, + sleepStage: true, + source: true, + deviceType: true, + }, }), ]); const nights = reconstructSleepNights( diff --git a/src/lib/insights/snapshot-hash.ts b/src/lib/insights/snapshot-hash.ts index 1369179c7..605f2c589 100644 --- a/src/lib/insights/snapshot-hash.ts +++ b/src/lib/insights/snapshot-hash.ts @@ -21,14 +21,27 @@ * the underlying data is locale-independent and the calendar label * changes at midnight without any data change. Excluding them keeps * the gate locale-agnostic and stable across the day rollover. - * - clock-relative offsets (`dayOffset`, keys ending in `DaysAgo`): - * these shift with `now`, not with the data. The bucket VALUES (and - * the absolute `date` / `weekISO` / `month` keys the graded series - * carry) stay in the hash, so a genuinely new or changed reading - * still changes the fingerprint. Sliding windows still move at the - * Berlin day boundary, so an active account converges to roughly one - * content change per scope per day — which is the regeneration - * budget the gate is meant to enforce. + * - clock-relative offsets (`dayOffset`): these shift with `now`, not + * with the data. The bucket VALUES (and the absolute `date` / + * `weekISO` / `month` keys the graded series carry) stay in the + * hash, so a genuinely new or changed reading still changes the + * fingerprint. Sliding windows still move at the Berlin day + * boundary, so an active account converges to roughly one content + * change per scope per day — which is the regeneration budget the + * gate is meant to enforce. + * + * Keys ending in `DaysAgo` (`newestMeasurementDaysAgo`, the per-series + * `newestDaysAgo` / `oldestDaysAgo`, the signal block's recency) get a + * third treatment: they are hashed as a coarse STALENESS TIER, not as the + * raw day count and not excluded. The raw number shifts with `now` every + * midnight, but the prompt's staleness caveat ("values may be out of + * date" past ~7 days) is driven by exactly these keys — excluding them + * wholesale meant an account that stopped logging kept an unchanged + * fingerprint forever, so the daily refresh re-stamped a text that + * narrated week-old readings as current. Bucketing keeps the fingerprint + * stable across ordinary day rollovers within a tier and flips it + * precisely when the data crosses a staleness boundary — i.e. when the + * narrative should change. * * Serialisation is canonical: object keys are sorted, arrays keep their * order, `Date` collapses to ISO-8601, `undefined` members are dropped @@ -48,7 +61,36 @@ const VOLATILE_KEYS = new Set([ ]); function isVolatileKey(key: string): boolean { - return VOLATILE_KEYS.has(key) || key.endsWith("DaysAgo"); + return VOLATILE_KEYS.has(key); +} + +/** + * Staleness tiers for `*DaysAgo` keys. The boundaries follow the prompt + * contract: 0-1 days is "current" (the prompts phrase today / 1 day ago + * specially), 2-7 days is recent (no caveat yet — the data-strength line + * flags staleness strictly past 7 days), 8-30 days carries the + * "values may be out of date" caveat, and past 30 days the picture is + * long stale. Crossing a boundary flips the fingerprint so the cached + * text regenerates with the right framing; movement within a tier keeps + * it stable so an ordinary midnight rollover stays free. + */ +export type StalenessTier = "0-1d" | "2-7d" | "8-30d" | "30d+"; + +/** + * Map a days-ago count to its staleness tier. Pure integer bucketing — + * the caller computed the day count, so no clock and no timezone enter + * here and the tier is stable wherever the hash is computed. + */ +export function stalenessTier(daysAgo: number): StalenessTier { + if (daysAgo <= 1) return "0-1d"; + if (daysAgo <= 7) return "2-7d"; + if (daysAgo <= 30) return "8-30d"; + return "30d+"; +} + +/** True for the day-count recency keys that hash as a staleness tier. */ +function isStalenessKey(key: string): boolean { + return key.endsWith("DaysAgo"); } /** Canonical, key-sorted serialisation with volatile keys dropped. */ @@ -64,7 +106,16 @@ function canonicalise(value: unknown): string { const entries = Object.entries(value as Record) .filter(([key, v]) => v !== undefined && !isVolatileKey(key)) .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) - .map(([key, v]) => `${JSON.stringify(key)}:${canonicalise(v)}`); + .map(([key, v]) => { + // A days-ago recency hashes as its coarse tier (see module doc); + // a null / non-numeric value falls through to plain canonical + // form so "no dated reading" stays distinct from every tier. + const canonical = + isStalenessKey(key) && typeof v === "number" && Number.isFinite(v) + ? JSON.stringify(stalenessTier(v)) + : canonicalise(v); + return `${JSON.stringify(key)}:${canonical}`; + }); return `{${entries.join(",")}}`; } // string | number | boolean — JSON.stringify is canonical for these. diff --git a/src/lib/queries/__tests__/use-medication-compliance-summary.test.ts b/src/lib/queries/__tests__/use-medication-compliance-summary.test.ts new file mode 100644 index 000000000..d2934e996 --- /dev/null +++ b/src/lib/queries/__tests__/use-medication-compliance-summary.test.ts @@ -0,0 +1,95 @@ +/** + * v1.16.8 — batched per-card compliance read, error contract. + * + * The hook's queryFn used to swallow every failure to `null`. TanStack + * caches that `null` as a SUCCESS, so a failed / aborted batch read + * neither retried nor refetched and every medication card sat on the + * loading skeleton until a full invalidation. The contract now: the + * queryFn lets the rejection propagate (default retry/backoff applies), + * `isError` + `refetch` surface to the cards so the compliance slot can + * swap to the quiet retry fallback, and the per-card `select` keeps the + * one-request / many-cards collapse intact. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const useQueryMock = vi.fn<(opts: Record) => unknown>(); + +vi.mock("@tanstack/react-query", () => ({ + useQuery: (opts: Record) => useQueryMock(opts), +})); + +const apiGetMock = vi.fn(); + +vi.mock("@/lib/api/api-fetch", () => ({ + apiGet: (...a: unknown[]) => apiGetMock(...a), +})); + +import { useMedicationComplianceSummary } from "../use-medication-compliance-summary"; +import { queryKeys } from "@/lib/query-keys"; + +interface CapturedOptions { + queryKey: unknown; + queryFn: () => Promise; + select: (rows: Array<{ medicationId: string }>) => unknown; + retry?: unknown; +} + +function lastOpts(): CapturedOptions { + expect(useQueryMock).toHaveBeenCalledTimes(1); + return useQueryMock.mock.calls[0][0] as unknown as CapturedOptions; +} + +beforeEach(() => { + vi.clearAllMocks(); + useQueryMock.mockReturnValue({ + data: undefined, + isError: false, + refetch: vi.fn(), + }); +}); + +describe("useMedicationComplianceSummary", () => { + it("registers under the centralised factory key", () => { + useMedicationComplianceSummary("med-1"); + expect(lastOpts().queryKey).toEqual( + queryKeys.medicationComplianceSummary(), + ); + }); + + it("lets a failed batch read REJECT instead of resolving to null", async () => { + useMedicationComplianceSummary("med-1"); + const failure = new Error("Request failed (500)"); + apiGetMock.mockRejectedValue(failure); + + // No catch in the queryFn: TanStack sees the rejection, applies its + // default retry, and lands the query in error state — the previous + // swallowed-`null` version cached a fake success forever. + await expect(lastOpts().queryFn()).rejects.toBe(failure); + }); + + it("does not disable the default retry behaviour", () => { + useMedicationComplianceSummary("med-1"); + expect(lastOpts().retry).toBeUndefined(); + }); + + it("selects the card's own row from the shared batch (null when absent)", () => { + useMedicationComplianceSummary("med-1"); + const opts = lastOpts(); + const rows = [ + { medicationId: "med-0" }, + { medicationId: "med-1" }, + { medicationId: "med-2" }, + ]; + expect(opts.select(rows)).toEqual({ medicationId: "med-1" }); + expect(opts.select([{ medicationId: "med-9" }])).toBeNull(); + }); + + it("surfaces isError and refetch to the card", () => { + const refetch = vi.fn(); + useQueryMock.mockReturnValue({ data: undefined, isError: true, refetch }); + const result = useMedicationComplianceSummary("med-1"); + expect(result.isError).toBe(true); + result.refetch(); + expect(refetch).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/lib/queries/use-medication-compliance-summary.ts b/src/lib/queries/use-medication-compliance-summary.ts index 0bba1d1bd..ed9fb1d1d 100644 --- a/src/lib/queries/use-medication-compliance-summary.ts +++ b/src/lib/queries/use-medication-compliance-summary.ts @@ -34,28 +34,31 @@ export interface MedicationComplianceSummaryEntry { * Lives in one shared hook (not per-card `useQuery` calls) so the two * card variants can never register the same key with diverging fetch * shapes. + * + * Errors propagate — the queryFn deliberately does NOT catch. An earlier + * revision swallowed failures to `null`, which froze every card on the + * loading skeleton with no retry: the cached `null` looked like a + * resolved success, so TanStack neither retried nor refetched. Letting + * the rejection through restores the default retry/backoff, and `isError` + * lets the cards swap the skeleton for a quiet same-footprint fallback + * with a manual retry. */ export function useMedicationComplianceSummary(medicationId: string): { data: MedicationComplianceSummaryEntry | null | undefined; + isError: boolean; + refetch: () => void; } { - const { data } = useQuery({ + const { data, isError, refetch } = useQuery({ queryKey: queryKeys.medicationComplianceSummary(), - queryFn: async () => { - try { - return await apiGet( - "/api/medications/compliance", - ); - } catch { - return null; - } - }, + queryFn: () => + apiGet("/api/medications/compliance"), // Dose actions invalidate the key explicitly through // `medicationDependentKeys`; a shorter window would only re-fire the // batch on every list visit. Matches the reminder-thresholds query // the cards mount alongside. staleTime: 5 * 60 * 1000, - select: (rows: MedicationComplianceSummaryEntry[] | null) => - rows?.find((row) => row.medicationId === medicationId) ?? null, + select: (rows: MedicationComplianceSummaryEntry[]) => + rows.find((row) => row.medicationId === medicationId) ?? null, }); - return { data }; + return { data, isError, refetch }; }