From 23f7c74c04382e4e8de2c2ab27cbf75a90e5f782 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 11 Jun 2026 21:17:05 +0200 Subject: [PATCH 01/12] perf(insights): regenerate texts only when the underlying data changed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A sha256 fingerprint of the compacted feature snapshot is stored beside every generated text; nightly, forced and ingest-triggered regeneration all skip the model call when the fingerprint matches and refresh the timestamp instead. Warms cover only the user's resolved locale and the blanket per-status eviction after a comprehensive generation is gone — a lazily-read second locale regenerates on its own miss path. The ingest debounce widens from thirty minutes to six hours and no longer deletes cache rows, the visit-triggered warm re-checks freshness at job start, backs off for an hour after a failure and is capped at two forced runs per user per day, and its dedup window now outlives the client poll horizon. An active day drops from hundreds of model calls to a handful tied to actual data change. --- .../migration.sql | 15 + prisma/schema.prisma | 11 + .../insights/generate/__tests__/route.test.ts | 44 +- src/app/api/insights/generate/route.ts | 42 +- .../comprehensive-generate-force.test.ts | 191 ++++++++- .../invalidate-status-insights.test.ts | 78 ++-- .../insights/__tests__/snapshot-hash.test.ts | 104 +++++ .../insights/__tests__/status-cache.test.ts | 128 +++++- .../insights/__tests__/weight-status.test.ts | 114 +++++ src/lib/insights/blood-pressure-status.ts | 21 + src/lib/insights/bmi-status.ts | 21 + src/lib/insights/comprehensive-generate.ts | 156 +++---- src/lib/insights/general-status.ts | 21 + .../insights/medication-compliance-status.ts | 53 +++ src/lib/insights/metric-status.ts | 21 + src/lib/insights/mood-status.ts | 21 + src/lib/insights/pulse-status.ts | 21 + src/lib/insights/snapshot-hash.ts | 83 ++++ src/lib/insights/status-cache.ts | 65 +++ src/lib/insights/status-shared.ts | 8 + src/lib/insights/weight-status.ts | 21 + .../__tests__/insight-pregenerate.test.ts | 391 ++++++++++++------ src/lib/jobs/insight-pregenerate-shared.ts | 26 +- src/lib/jobs/insight-pregenerate.ts | 351 ++++++++++------ 24 files changed, 1596 insertions(+), 411 deletions(-) create mode 100644 prisma/migrations/0148_v1168_insight_snapshot_hash/migration.sql create mode 100644 src/lib/insights/__tests__/snapshot-hash.test.ts create mode 100644 src/lib/insights/snapshot-hash.ts diff --git a/prisma/migrations/0148_v1168_insight_snapshot_hash/migration.sql b/prisma/migrations/0148_v1168_insight_snapshot_hash/migration.sql new file mode 100644 index 000000000..e667cdb13 --- /dev/null +++ b/prisma/migrations/0148_v1168_insight_snapshot_hash/migration.sql @@ -0,0 +1,15 @@ +-- v1.16.8 — content-hash gate for insight regeneration. +-- +-- `insights_snapshot_hash` stores the SHA-256 fingerprint of the +-- compacted feature snapshot the cached comprehensive insight was +-- generated from. Nightly, forced, and lazy regenerations compare the +-- fresh snapshot's hash against it and skip the provider call +-- (refreshing only `insights_cached_at`) when nothing the prompt sees +-- has changed. +-- +-- `insights_warm_failed_at` records the last failed/timed-out forced +-- warm of the comprehensive insight, so the forced path can back off +-- for an hour instead of re-attempting a broken provider chain on +-- every page-open. Additive + non-destructive. +ALTER TABLE "users" ADD COLUMN "insights_snapshot_hash" TEXT; +ALTER TABLE "users" ADD COLUMN "insights_warm_failed_at" TIMESTAMP(3); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2809aeaea..439ea78ad 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -60,6 +60,17 @@ model User { insightsPrivacyMode String @default("aggregated") @map("insights_privacy_mode") // "aggregated" | "raw" insightsCachedAt DateTime? @map("insights_cached_at") insightsCachedText String? @map("insights_cached_text") // cached JSON output + // v1.16.8 — SHA-256 fingerprint of the compacted feature snapshot the + // cached comprehensive text was generated from. Nightly / forced / lazy + // regenerations compare the fresh snapshot's hash against this and skip + // the provider call (refreshing only `insightsCachedAt`) when nothing + // the prompt sees has changed. + insightsSnapshotHash String? @map("insights_snapshot_hash") + // v1.16.8 — last time a forced full warm's comprehensive step failed or + // timed out. The forced warm path backs off for an hour after a failure + // so a persistently broken provider chain cannot be re-driven into a + // full generation attempt by every page-open. + insightsWarmFailedAt DateTime? @map("insights_warm_failed_at") // v1.4.36 W3 T3 — per-user AI Insights exclude-metrics list, mirrors // `coachPrefsJson.excludeMetrics`. The /api/insights/generate route // filters matching keys off `features` before JSON.stringify so the diff --git a/src/app/api/insights/generate/__tests__/route.test.ts b/src/app/api/insights/generate/__tests__/route.test.ts index 1bb7c077d..531ad9acf 100644 --- a/src/app/api/insights/generate/__tests__/route.test.ts +++ b/src/app/api/insights/generate/__tests__/route.test.ts @@ -342,32 +342,39 @@ describe("POST /api/insights/generate — rate limit (v1.4.16 A7.1)", () => { }); }); -// v1.4.16 A7.2: every fresh comprehensive insight evicts the per- -// scope status cache so the dashboard and the insights-page status -// cards never disagree. Without this, force-regeneration repaints -// `/api/insights/generate` while `/api/insights/-status` keeps -// returning yesterday's text until midnight Berlin time. -describe("POST /api/insights/generate — per-status cache eviction (A7.2)", () => { - it("deletes per-status audit-log cache rows after a successful generation", async () => { +// v1.16.8 — a fresh comprehensive insight no longer nukes the per-scope +// status cache (the old A7.2 sweep). The cards track their own data via +// the ingest invalidator + per-card content-hash gates; the route's job +// is to store the snapshot fingerprint so the off-request regeneration +// paths can detect "nothing changed" and skip the provider. +describe("POST /api/insights/generate — cache write (v1.16.8)", () => { + it("does NOT delete per-status audit-log cache rows after a successful generation", async () => { makeWorkingProvider(); const res = await POST(jsonRequest({ force: true }) as never); expect(res.status).toBe(200); - expect(prisma.auditLog.deleteMany).toHaveBeenCalledTimes(1); - const args = vi.mocked(prisma.auditLog.deleteMany).mock.calls[0][0]; - expect(args).toMatchObject({ - where: { - userId: "u-1", - action: { startsWith: "insights." }, - AND: [{ action: { contains: "-status." } }], - }, - }); + expect(prisma.auditLog.deleteMany).not.toHaveBeenCalled(); }); - it("does NOT delete per-status cache when serving from the 24h DB cache", async () => { + it("stores the snapshot fingerprint alongside the cached text", async () => { + makeWorkingProvider(); + + const res = await POST(jsonRequest({ force: true }) as never); + expect(res.status).toBe(200); + + expect(prisma.user.update).toHaveBeenCalledTimes(1); + const args = vi.mocked(prisma.user.update).mock.calls[0][0] as { + data: Record; + }; + expect(args.data.insightsCachedText).toEqual(expect.any(String)); + // SHA-256 hex digest of the compacted feature snapshot. + expect(args.data.insightsSnapshotHash).toMatch(/^[0-9a-f]{64}$/); + }); + + it("does NOT touch the cache when serving from the 24h DB cache", async () => { // Cached path: route returns early without touching the LLM or the - // cache-eviction helper. + // cache write. vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({ insightsPrivacyMode: "aggregated", insightsCachedAt: new Date(), @@ -379,6 +386,7 @@ describe("POST /api/insights/generate — per-status cache eviction (A7.2)", () expect(res.status).toBe(200); const body = (await res.json()) as { data: { cached: boolean } }; expect(body.data.cached).toBe(true); + expect(prisma.user.update).not.toHaveBeenCalled(); expect(prisma.auditLog.deleteMany).not.toHaveBeenCalled(); }); }); diff --git a/src/app/api/insights/generate/route.ts b/src/app/api/insights/generate/route.ts index 3f0c016af..e1e1c4ee4 100644 --- a/src/app/api/insights/generate/route.ts +++ b/src/app/api/insights/generate/route.ts @@ -59,6 +59,7 @@ import { invalidateUserInsights } from "@/lib/cache/invalidate"; import { annotate } from "@/lib/logging/context"; 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"; export const dynamic = "force-dynamic"; @@ -91,32 +92,6 @@ export function resolveInsightsRateLimit(): number { return parsed; } -/** - * Per-status insight cache rows live in `audit_logs` keyed by - * `action = "insights.{scope}-status.{locale}"` (see - * `src/lib/insights/memory.ts` for the canonical scope list and - * `src/lib/insights/{general,blood-pressure,weight,pulse,bmi, - * mood,medication-compliance}-status.ts` for the writers). Each scope - * has its own per-day eviction, so before v1.4.16 a force-regeneration - * of the comprehensive insight would leave yesterday's per-status - * cards visible on the insights page until the next calendar day - * flipped. Drop them here so the next status fetch has to call the - * LLM again with the same fresh feature set the comprehensive blob - * just used. - */ -async function evictPerStatusInsightCache(userId: string): Promise { - await prisma.auditLog.deleteMany({ - where: { - userId, - action: { startsWith: "insights." }, - // Keep the `insights.generate` row this request just wrote and - // the `insights.settings.*` rows — only the per-status cache - // entries (`insights.-status.`) carry stale text. - AND: [{ action: { contains: "-status." } }], - }, - }); -} - /** * v1.4.16 phase B8 — fetch the user's persisted comparison toggle and * build a `ComparisonSnapshot` for the prompt builder. Returns null @@ -739,14 +714,19 @@ export const POST = apiHandler(async (request: NextRequest) => { data: { insightsCachedAt: new Date(), insightsCachedText: JSON.stringify(insights), + // v1.16.8 — store the snapshot fingerprint so the nightly / forced + // 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), }, }); - // v1.4.16 A7: a fresh comprehensive insight always supersedes the - // per-status cache. Otherwise the dashboard re-paints itself with the - // newly generated comprehensive while the insights-page status cards - // still show yesterday's text until midnight Berlin time. - await evictPerStatusInsightCache(userId); + // 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.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/lib/insights/__tests__/comprehensive-generate-force.test.ts b/src/lib/insights/__tests__/comprehensive-generate-force.test.ts index 7115fe690..4824c0f22 100644 --- a/src/lib/insights/__tests__/comprehensive-generate-force.test.ts +++ b/src/lib/insights/__tests__/comprehensive-generate-force.test.ts @@ -7,24 +7,72 @@ * budget bucket is wasted with no actual regeneration. These tests pin * that `force: true` skips the TTL re-check (and `force: false` still * honours a fresh cache). + * + * v1.16.8 — the content-hash gate sits behind the force flag: even a + * forced generation skips the provider call (and only refreshes the + * cache timestamp) when the compacted feature snapshot is unchanged + * since the cached text was generated. These tests pin the skip, the + * timestamp-only refresh, and that a changed snapshot still generates + * and stores the new fingerprint without the old per-status eviction. */ import { describe, it, expect, vi, beforeEach } from "vitest"; const findUnique = vi.fn(); +const userUpdate = vi.fn(); +const auditDeleteMany = vi.fn(); const resolveProviderChain = vi.fn(); const resolveProvider = vi.fn(); +const runRawCompletionWithFallback = vi.fn(); +const extractFeatures = vi.fn(); vi.mock("@/lib/db", () => ({ prisma: { - user: { findUnique: (...a: unknown[]) => findUnique(...a) }, + user: { + findUnique: (...a: unknown[]) => findUnique(...a), + update: (...a: unknown[]) => userUpdate(...a), + }, + auditLog: { + deleteMany: (...a: unknown[]) => auditDeleteMany(...a), + }, }, })); vi.mock("@/lib/ai/provider", () => ({ resolveProviderChain: (...a: unknown[]) => resolveProviderChain(...a), resolveProvider: (...a: unknown[]) => resolveProvider(...a), })); +vi.mock("@/lib/ai/provider-runner", () => ({ + AllProvidersFailedError: class extends Error {}, + runRawCompletionWithFallback: (...a: unknown[]) => + runRawCompletionWithFallback(...a), +})); +vi.mock("@/lib/insights/features", () => ({ + FeaturesPayloadTooLargeError: class extends Error { + sizeBytes = 0; + }, + extractFeatures: (...a: unknown[]) => extractFeatures(...a), +})); +// The post-gate prompt assembly reads GLP-1 + about-me context from the +// DB; stub both so the changed-hash path stays DB-free in this test. +vi.mock("@/lib/insights/glp1-plateau", () => ({ + detectGlp1Plateau: vi.fn(async () => null), + buildGlp1PlateauPrompt: vi.fn(() => ""), +})); +vi.mock("@/lib/ai/coach/about-me", () => ({ + getSelfContextTextForUser: vi.fn(async () => null), + buildAboutMeInsightBlock: vi.fn(() => ""), +})); import { generateComprehensiveInsight } from "../comprehensive-generate"; +import { hashInsightSnapshot } from "../snapshot-hash"; +import { compactSections } from "@/lib/ai/prompts/compact-sections"; + +/** A small data-bearing feature set that survives `compactSections`. */ +const FEATURES = { + weight: { count: 12, latest: 81.4, mean30: 82.1 }, +}; +const FEATURES_HASH = hashInsightSnapshot( + compactSections(FEATURES as unknown as Record), +); beforeEach(() => { vi.clearAllMocks(); @@ -32,6 +80,8 @@ beforeEach(() => { // past the cache short-circuit, which is enough to prove the branch. resolveProviderChain.mockResolvedValue([]); resolveProvider.mockResolvedValue({ type: "none" }); + extractFeatures.mockResolvedValue(FEATURES); + userUpdate.mockResolvedValue({}); }); describe("generateComprehensiveInsight — cache short-circuit", () => { @@ -41,6 +91,7 @@ describe("generateComprehensiveInsight — cache short-circuit", () => { insightsCachedAt: new Date(Date.now() - 21 * 60 * 60 * 1000), insightsCachedText: "{}", insightsExcludeMetrics: [], + insightsSnapshotHash: null, }); const outcome = await generateComprehensiveInsight("u1", { @@ -59,6 +110,7 @@ describe("generateComprehensiveInsight — cache short-circuit", () => { insightsCachedAt: new Date(Date.now() - 21 * 60 * 60 * 1000), insightsCachedText: "{}", insightsExcludeMetrics: [], + insightsSnapshotHash: null, }); const outcome = await generateComprehensiveInsight("u1", { @@ -73,3 +125,140 @@ describe("generateComprehensiveInsight — cache short-circuit", () => { expect(outcome).toEqual({ status: "skipped", reason: "no-provider" }); }); }); + +describe("generateComprehensiveInsight — content-hash gate (v1.16.8)", () => { + function makeByokChain() { + // A BYOK chain never trips the server-managed consent gate. + resolveProviderChain.mockResolvedValue([ + { providerType: "openai", instance: {} }, + ]); + } + + it("skips the provider and refreshes only the timestamp when the snapshot is unchanged — even when forced", async () => { + makeByokChain(); + findUnique.mockResolvedValue({ + insightsPrivacyMode: "aggregated", + insightsCachedAt: new Date(Date.now() - 26 * 60 * 60 * 1000), + insightsCachedText: JSON.stringify({ dailyBriefing: { p: "old" } }), + insightsExcludeMetrics: [], + insightsSnapshotHash: FEATURES_HASH, + }); + + const outcome = await generateComprehensiveInsight("u1", { + locale: "de", + force: true, + }); + + expect(outcome).toEqual({ status: "unchanged" }); + // No completion ran. + expect(runRawCompletionWithFallback).not.toHaveBeenCalled(); + // Timestamp-only refresh: no new text, no new hash, no eviction. + expect(userUpdate).toHaveBeenCalledTimes(1); + const args = userUpdate.mock.calls[0][0] as { + data: Record; + }; + expect(args.data.insightsCachedAt).toEqual(expect.any(Date)); + expect(args.data).not.toHaveProperty("insightsCachedText"); + expect(args.data).not.toHaveProperty("insightsSnapshotHash"); + expect(auditDeleteMany).not.toHaveBeenCalled(); + }); + + it("generates when the snapshot hash differs, stores the new fingerprint, and runs no per-status eviction", async () => { + makeByokChain(); + findUnique.mockResolvedValue({ + insightsPrivacyMode: "aggregated", + insightsCachedAt: new Date(Date.now() - 26 * 60 * 60 * 1000), + insightsCachedText: JSON.stringify({ dailyBriefing: { p: "old" } }), + insightsExcludeMetrics: [], + // Stored fingerprint from an older data state. + insightsSnapshotHash: "0".repeat(64), + }); + runRawCompletionWithFallback.mockResolvedValue({ + result: { + content: JSON.stringify({ dailyBriefing: { p: "new" } }), + tokensUsed: 10, + providerType: "openai", + model: "m", + }, + workingProvider: { providerType: "openai" }, + fallbackHops: [], + }); + + const outcome = await generateComprehensiveInsight("u1", { + locale: "de", + force: true, + }); + + expect(outcome).toEqual({ status: "generated", providerType: "openai" }); + expect(runRawCompletionWithFallback).toHaveBeenCalledTimes(1); + // Find the cache write among the user updates. + const write = userUpdate.mock.calls.find( + (c) => + (c[0] as { data: Record }).data.insightsCachedText !== + undefined, + ); + expect(write).toBeTruthy(); + const data = (write![0] as { data: Record }).data; + expect(data.insightsSnapshotHash).toBe(FEATURES_HASH); + // v1.16.8 — the blanket per-status eviction is gone. + expect(auditDeleteMany).not.toHaveBeenCalled(); + }); + + it("generates when no fingerprint is stored yet (first run after the gate shipped)", async () => { + makeByokChain(); + findUnique.mockResolvedValue({ + insightsPrivacyMode: "aggregated", + insightsCachedAt: new Date(Date.now() - 26 * 60 * 60 * 1000), + insightsCachedText: JSON.stringify({ dailyBriefing: { p: "old" } }), + insightsExcludeMetrics: [], + insightsSnapshotHash: null, + }); + runRawCompletionWithFallback.mockResolvedValue({ + result: { + content: JSON.stringify({ dailyBriefing: { p: "new" } }), + tokensUsed: 10, + providerType: "openai", + model: "m", + }, + workingProvider: { providerType: "openai" }, + fallbackHops: [], + }); + + const outcome = await generateComprehensiveInsight("u1", { + locale: "de", + force: true, + }); + + expect(outcome).toEqual({ status: "generated", providerType: "openai" }); + expect(runRawCompletionWithFallback).toHaveBeenCalledTimes(1); + }); + + it("does not treat a matching hash as unchanged when there is no cached text to serve", async () => { + makeByokChain(); + findUnique.mockResolvedValue({ + insightsPrivacyMode: "aggregated", + insightsCachedAt: null, + insightsCachedText: null, + insightsExcludeMetrics: [], + insightsSnapshotHash: FEATURES_HASH, + }); + runRawCompletionWithFallback.mockResolvedValue({ + result: { + content: JSON.stringify({ dailyBriefing: { p: "new" } }), + tokensUsed: 10, + providerType: "openai", + model: "m", + }, + workingProvider: { providerType: "openai" }, + fallbackHops: [], + }); + + const outcome = await generateComprehensiveInsight("u1", { + locale: "de", + force: true, + }); + + expect(outcome).toEqual({ status: "generated", providerType: "openai" }); + expect(runRawCompletionWithFallback).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/lib/insights/__tests__/invalidate-status-insights.test.ts b/src/lib/insights/__tests__/invalidate-status-insights.test.ts index 8ba5550e5..72cf19705 100644 --- a/src/lib/insights/__tests__/invalidate-status-insights.test.ts +++ b/src/lib/insights/__tests__/invalidate-status-insights.test.ts @@ -1,12 +1,16 @@ /** * v1.8.0 — measurement-driven per-metric assessment invalidation. * - * A fresh measurement of a given type must drop the cached + * A fresh measurement of a given type must re-warm the cached * `insights.-status.` rows for the scopes that reading - * dirties, so the next mount / nightly warm pass regenerates them - * against the new data instead of serving the pre-measurement text for - * the rest of the day. These tests pin the type → scope mapping and the - * delete shape without a live DB. + * dirties, so the next mount (or the next nightly warm pass) reflects + * the new data instead of serving the pre-measurement text for the rest + * of the day. These tests pin the type → scope mapping, the debounce, + * and (v1.16.8) that the invalidator enqueues regenerations WITHOUT + * deleting the cache rows — the worker's forced generation runs the + * generator's content-hash gate, which decides whether the data + * actually changed; keeping the row preserves stale-while-revalidate + * and lets a re-synced unchanged batch cost zero LLM calls. */ import { describe, it, expect, vi, beforeEach } from "vitest"; @@ -52,20 +56,23 @@ function enqueuedScopes(): string[] { ].sort(); } -function deletedScopes(): string[] { - const arg = deleteMany.mock.calls[0][0]; - // Each OR clause is `{ action: { startsWith: "insights.-status." } }`. - return (arg.where.OR as Array<{ action: { startsWith: string } }>) - .map((c) => c.action.startsWith.replace(/^insights\.(.+)-status\.$/, "$1")) - .sort(); -} - describe("invalidateStatusInsightsForTypes", () => { it("maps WEIGHT to weight + bmi + general", async () => { await invalidateStatusInsightsForTypes("u1", ["WEIGHT"]); - expect(deleteMany).toHaveBeenCalledTimes(1); - expect(deletedScopes()).toEqual(["bmi", "general", "weight"]); - expect(deleteMany.mock.calls[0][0].where.userId).toBe("u1"); + expect(enqueuedScopes()).toEqual(["bmi", "general", "weight"]); + expect( + enqueueStatusGeneration.mock.calls.every( + (c) => (c[0] as { userId: string }).userId === "u1", + ), + ).toBe(true); + }); + + it("never deletes the cached assessment rows (v1.16.8 — hash gate decides)", async () => { + await invalidateStatusInsightsForTypes("u1", ["WEIGHT"]); + // The rows stay for stale-while-revalidate AND for the content-hash + // gate the worker's forced regeneration runs; deleting them here would + // force a full LLM regeneration even for a re-synced unchanged batch. + expect(deleteMany).not.toHaveBeenCalled(); }); it("maps both blood-pressure components to blood-pressure + general", async () => { @@ -73,7 +80,7 @@ describe("invalidateStatusInsightsForTypes", () => { "BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA", ]); - expect(deletedScopes()).toEqual(["blood-pressure", "general"]); + expect(enqueuedScopes()).toEqual(["blood-pressure", "general"]); }); it("maps PULSE and RESTING_HEART_RATE to pulse + general (+ the resting-HR generic scope)", async () => { @@ -84,7 +91,7 @@ describe("invalidateStatusInsightsForTypes", () => { // PULSE feeds the specialised pulse + general scopes; RESTING_HEART_RATE // feeds those too AND carries its own generic assessment card // (`metric:RESTING_HEART_RATE`), so all three are dirtied. - expect(deletedScopes()).toEqual([ + expect(enqueuedScopes()).toEqual([ "general", "metric:RESTING_HEART_RATE", "pulse", @@ -96,7 +103,7 @@ describe("invalidateStatusInsightsForTypes", () => { // generic `metric:BLOOD_GLUCOSE` card, so a fresh reading dirties the // general overview AND that generic scope. await invalidateStatusInsightsForTypes("u1", ["BLOOD_GLUCOSE"]); - expect(deletedScopes()).toEqual(["general", "metric:BLOOD_GLUCOSE"]); + expect(enqueuedScopes()).toEqual(["general", "metric:BLOOD_GLUCOSE"]); }); it("dedupes scopes across a mixed batch", async () => { @@ -107,7 +114,7 @@ describe("invalidateStatusInsightsForTypes", () => { ]); // weight + bmi + general (WEIGHT) ∪ pulse + general (PULSE) ∪ // general + metric:BLOOD_GLUCOSE (BLOOD_GLUCOSE) - expect(deletedScopes()).toEqual([ + expect(enqueuedScopes()).toEqual([ "bmi", "general", "metric:BLOOD_GLUCOSE", @@ -118,7 +125,7 @@ describe("invalidateStatusInsightsForTypes", () => { it("is a no-op for an empty type set (no DB call)", async () => { await invalidateStatusInsightsForTypes("u1", []); - expect(deleteMany).not.toHaveBeenCalled(); + expect(auditFindMany).not.toHaveBeenCalled(); expect(enqueueStatusGeneration).not.toHaveBeenCalled(); }); @@ -147,12 +154,11 @@ describe("invalidateStatusInsightsForTypes", () => { expect([...locales]).toEqual(["en"]); }); - it("does not blanket-evict general for an unrelated synced type", async () => { + it("does not blanket-dirty general for an unrelated synced type", async () => { // A steps sample touches only the general overview, never the weight / // pulse / bp scopes — the constant Apple-Health sync must not thrash // every card's cache. await invalidateStatusInsightsForTypes("u1", ["ACTIVITY_STEPS"]); - expect(deletedScopes()).toEqual(["general", "metric:STEPS"]); expect(enqueuedScopes()).toEqual(["general", "metric:STEPS"]); }); @@ -162,7 +168,6 @@ describe("invalidateStatusInsightsForTypes", () => { // freshly-synced metric's card refreshes in the background instead of // lagging until the nightly warm pass. await invalidateStatusInsightsForTypes("u1", ["BLOOD_GLUCOSE"]); - expect(deletedScopes()).toEqual(["general", "metric:BLOOD_GLUCOSE"]); expect(enqueuedScopes()).toEqual(["general", "metric:BLOOD_GLUCOSE"]); }); @@ -184,7 +189,7 @@ describe("invalidateStatusInsightsForTypes", () => { ).toBe(false); }); - describe("ingest-invalidation debounce (v1.9.0)", () => { + describe("ingest-invalidation debounce (v1.9.0, 6 h window since v1.16.8)", () => { /** Build a recent (within-window) real assessment cache row. */ function freshRow(scope: string, locale = "de") { return { @@ -194,22 +199,20 @@ describe("invalidateStatusInsightsForTypes", () => { } it("skips a scope whose assessment was warmed within the window", async () => { - // `general` was regenerated minutes ago; a fresh WEIGHT sample must not - // delete or re-enqueue it. weight + bmi are still stale, so they refresh. + // `general` was regenerated within the window; 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(deletedScopes()).toEqual(["bmi", "weight"]); expect(enqueuedScopes()).toEqual(["bmi", "weight"]); }); - it("is a complete no-op (no delete, no enqueue) when every scope is fresh", async () => { + it("is a complete no-op (no enqueue) when every scope is fresh", async () => { auditFindMany.mockResolvedValue([ freshRow("weight"), freshRow("bmi"), freshRow("general"), ]); await invalidateStatusInsightsForTypes("u1", ["WEIGHT"]); - expect(deleteMany).not.toHaveBeenCalled(); expect(enqueueStatusGeneration).not.toHaveBeenCalled(); }); @@ -223,12 +226,12 @@ describe("invalidateStatusInsightsForTypes", () => { }, ]); await invalidateStatusInsightsForTypes("u1", ["WEIGHT"]); - expect(deletedScopes()).toEqual(["bmi", "general", "weight"]); expect(enqueuedScopes()).toEqual(["bmi", "general", "weight"]); }); - it("scopes the freshness probe to the user's resolved locale and the window cutoff", async () => { + it("scopes the freshness probe to the user's resolved locale and a 6 h window cutoff", async () => { userFindUnique.mockResolvedValue({ locale: "en" }); + const before = Date.now(); await invalidateStatusInsightsForTypes("u1", ["WEIGHT"]); const where = auditFindMany.mock.calls[0][0].where; expect(where.userId).toBe("u1"); @@ -240,9 +243,14 @@ describe("invalidateStatusInsightsForTypes", () => { "insights.general-status.en", ]), ); - // A recency floor is applied (createdAt >= cutoff in the past). - expect(where.createdAt.gte).toBeInstanceOf(Date); - expect(where.createdAt.gte.getTime()).toBeLessThan(Date.now()); + // 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). + 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); }); }); }); diff --git a/src/lib/insights/__tests__/snapshot-hash.test.ts b/src/lib/insights/__tests__/snapshot-hash.test.ts new file mode 100644 index 000000000..9538da8b1 --- /dev/null +++ b/src/lib/insights/__tests__/snapshot-hash.test.ts @@ -0,0 +1,104 @@ +/** + * v1.16.8 — the snapshot fingerprint that gates insight regeneration. + * + * 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. + */ +import { describe, it, expect } from "vitest"; + +import { hashInsightSnapshot } from "../snapshot-hash"; + +describe("hashInsightSnapshot", () => { + it("is a sha256 hex digest", () => { + expect(hashInsightSnapshot({ a: 1 })).toMatch(/^[0-9a-f]{64}$/); + }); + + it("is stable across object key order", () => { + const a = hashInsightSnapshot({ weight: { mean: 80, n: 3 }, pulse: 60 }); + const b = hashInsightSnapshot({ pulse: 60, weight: { n: 3, mean: 80 } }); + expect(a).toBe(b); + }); + + it("changes when a data value changes", () => { + const a = hashInsightSnapshot({ weight: { mean: 80.0 } }); + const b = hashInsightSnapshot({ weight: { mean: 80.1 } }); + expect(a).not.toBe(b); + }); + + it("changes when an array gains an entry or reorders", () => { + const base = hashInsightSnapshot({ series: [1, 2, 3] }); + expect(hashInsightSnapshot({ series: [1, 2, 3, 4] })).not.toBe(base); + expect(hashInsightSnapshot({ series: [3, 2, 1] })).not.toBe(base); + }); + + it("ignores locale and day-label fields", () => { + const de = hashInsightSnapshot({ + locale: "de", + generatedForDay: "2026-06-10", + weight: { mean: 80 }, + }); + const en = hashInsightSnapshot({ + locale: "en", + generatedForDay: "2026-06-11", + weight: { mean: 80 }, + }); + expect(de).toBe(en); + }); + + it("ignores clock-relative offsets (dayOffset, *DaysAgo) but not the values", () => { + const yesterday = hashInsightSnapshot({ + dataCoverage: { totalMeasurements: 12, newestMeasurementDaysAgo: 0 }, + daily: [ + { dayOffset: 0, value: 80.2, n: 1 }, + { dayOffset: 1, value: 80.4, n: 2 }, + ], + }); + const today = hashInsightSnapshot({ + dataCoverage: { totalMeasurements: 12, newestMeasurementDaysAgo: 1 }, + daily: [ + { dayOffset: 1, value: 80.2, n: 1 }, + { dayOffset: 2, value: 80.4, n: 2 }, + ], + }); + // Same readings, one day later, nothing new logged → same fingerprint. + expect(yesterday).toBe(today); + + const newReading = hashInsightSnapshot({ + dataCoverage: { totalMeasurements: 13, newestMeasurementDaysAgo: 0 }, + daily: [ + { dayOffset: 0, value: 79.9, n: 1 }, + { dayOffset: 1, value: 80.2, n: 1 }, + { dayOffset: 2, value: 80.4, n: 2 }, + ], + }); + expect(newReading).not.toBe(today); + }); + + 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 }] }); + expect(a).not.toBe(b); + }); + + it("serialises Date values as ISO strings", () => { + const a = hashInsightSnapshot({ at: new Date("2026-06-10T00:00:00.000Z") }); + const b = hashInsightSnapshot({ at: "2026-06-10T00:00:00.000Z" }); + expect(a).toBe(b); + }); + + it("treats null and missing distinctly, and drops undefined members like JSON", () => { + const withNull = hashInsightSnapshot({ mood: null, weight: 80 }); + const withoutKey = hashInsightSnapshot({ weight: 80 }); + const withUndefined = hashInsightSnapshot({ + mood: undefined, + weight: 80, + } as Record); + expect(withNull).not.toBe(withoutKey); + expect(withUndefined).toBe(withoutKey); + }); +}); diff --git a/src/lib/insights/__tests__/status-cache.test.ts b/src/lib/insights/__tests__/status-cache.test.ts index ba798514f..744937aad 100644 --- a/src/lib/insights/__tests__/status-cache.test.ts +++ b/src/lib/insights/__tests__/status-cache.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; vi.mock("@/lib/db", () => ({ prisma: { - auditLog: { findFirst: vi.fn(), findMany: vi.fn() }, + auditLog: { findFirst: vi.fn(), findMany: vi.fn(), create: vi.fn() }, }, })); @@ -20,6 +20,7 @@ import { prisma } from "@/lib/db"; import { isTimeoutStub, readFreshStatusText, + refreshUnchangedStatusInsight, resolveReadOnlyStatusMiss, } from "../status-cache"; @@ -243,3 +244,128 @@ describe("resolveReadOnlyStatusMiss", () => { expect(enqueueStatusGeneration).toHaveBeenCalledTimes(1); }); }); + +describe("refreshUnchangedStatusInsight (v1.16.8)", () => { + const HASH = "a".repeat(64); + + it("re-persists the row under today's dateKey and returns the text on a hash match", async () => { + vi.mocked(prisma.auditLog.findFirst).mockResolvedValue( + cacheRow({ + dateKey: "2026-05-30", + locale: "en", + text: "Stable weight, no concerns.", + providerType: "openai", + model: "gpt-4o-mini", + tokensUsed: 321, + snapshotHash: HASH, + }) as never, + ); + vi.mocked(prisma.auditLog.create).mockResolvedValue({ + createdAt: new Date("2026-05-31T04:30:00.000Z"), + } as never); + + const hit = await refreshUnchangedStatusInsight({ + userId: "u1", + cacheAction: "insights.weight-status.en", + todayKey: TODAY, + snapshotHash: HASH, + }); + + expect(hit?.text).toBe("Stable weight, no concerns."); + expect(hit?.updatedAt).toBe("2026-05-31T04:30:00.000Z"); + // The refresh row carries the same payload re-keyed to today, so the + // read path and the ingest debounce both see a current assessment. + expect(prisma.auditLog.create).toHaveBeenCalledTimes(1); + const arg = vi.mocked(prisma.auditLog.create).mock.calls[0][0] as { + data: { userId: string; action: string; details: string }; + }; + expect(arg.data.userId).toBe("u1"); + expect(arg.data.action).toBe("insights.weight-status.en"); + const details = JSON.parse(arg.data.details) as Record; + expect(details.dateKey).toBe(TODAY); + expect(details.text).toBe("Stable weight, no concerns."); + expect(details.snapshotHash).toBe(HASH); + }); + + it("misses (and writes nothing) when the stored hash differs", async () => { + vi.mocked(prisma.auditLog.findFirst).mockResolvedValue( + cacheRow({ + dateKey: "2026-05-30", + text: "Older text.", + model: "gpt-4o-mini", + snapshotHash: "b".repeat(64), + }) as never, + ); + const hit = await refreshUnchangedStatusInsight({ + userId: "u1", + cacheAction: "insights.weight-status.en", + todayKey: TODAY, + snapshotHash: HASH, + }); + expect(hit).toBeNull(); + expect(prisma.auditLog.create).not.toHaveBeenCalled(); + }); + + it("misses when the latest row carries no hash (pre-gate rows)", async () => { + vi.mocked(prisma.auditLog.findFirst).mockResolvedValue( + cacheRow({ + dateKey: "2026-05-30", + text: "Older text.", + model: "gpt-4o-mini", + }) as never, + ); + const hit = await refreshUnchangedStatusInsight({ + userId: "u1", + cacheAction: "insights.weight-status.en", + todayKey: TODAY, + snapshotHash: HASH, + }); + expect(hit).toBeNull(); + expect(prisma.auditLog.create).not.toHaveBeenCalled(); + }); + + it("never refreshes off a timeout stub, even with a matching hash", async () => { + vi.mocked(prisma.auditLog.findFirst).mockResolvedValue( + cacheRow({ + dateKey: "2026-05-30", + text: "Generic fallback advice.", + model: "timeout-stub", + timeout: true, + snapshotHash: HASH, + }) as never, + ); + const hit = await refreshUnchangedStatusInsight({ + userId: "u1", + cacheAction: "insights.weight-status.en", + todayKey: TODAY, + snapshotHash: HASH, + }); + expect(hit).toBeNull(); + expect(prisma.auditLog.create).not.toHaveBeenCalled(); + }); + + it("misses on no prior row and on malformed payloads", async () => { + vi.mocked(prisma.auditLog.findFirst).mockResolvedValueOnce(null as never); + expect( + await refreshUnchangedStatusInsight({ + userId: "u1", + cacheAction: "insights.weight-status.en", + todayKey: TODAY, + snapshotHash: HASH, + }), + ).toBeNull(); + + vi.mocked(prisma.auditLog.findFirst).mockResolvedValueOnce({ + details: "{not json", + } as never); + expect( + await refreshUnchangedStatusInsight({ + userId: "u1", + cacheAction: "insights.weight-status.en", + todayKey: TODAY, + snapshotHash: HASH, + }), + ).toBeNull(); + expect(prisma.auditLog.create).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/insights/__tests__/weight-status.test.ts b/src/lib/insights/__tests__/weight-status.test.ts index 07b454a62..dfa1a2c5c 100644 --- a/src/lib/insights/__tests__/weight-status.test.ts +++ b/src/lib/insights/__tests__/weight-status.test.ts @@ -184,6 +184,120 @@ describe("generateWeightStatusForUser — cache-read skips a stub", () => { }); }); +describe("generateWeightStatusForUser — content-hash gate (v1.16.8)", () => { + it("skips the completion and refreshes the cache row when the snapshot is unchanged", async () => { + // Fixed clock so both generator runs build the identical snapshot. + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-06-10T10:00:00.000Z")); + try { + const now = new Date(); + const records = [ + { type: "WEIGHT", value: 82, measuredAt: new Date(now.getTime() - dayMs) }, + { type: "WEIGHT", value: 81.6, measuredAt: new Date(now.getTime() - 2 * dayMs) }, + ]; + // Fresh copy per call — the generator reverses the result array in + // place, and a shared fixture would flip order between the two runs. + vi.mocked(prisma.measurement.findMany).mockImplementation( + (async () => records.map((r) => ({ ...r }))) as never, + ); + vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); + vi.mocked(prisma.auditLog.create).mockResolvedValue({ + createdAt: now, + } as never); + stubCompletion('{"summary":"First real assessment."}'); + + // First run: a real generation persists the snapshot fingerprint. + await generateWeightStatusForUser("user-1", { + locale: "en", + force: true, + }); + expect(runStatusCompletion).toHaveBeenCalledTimes(1); + const persisted = JSON.parse( + ( + vi.mocked(prisma.auditLog.create).mock.calls[0][0] as { + data: { details: string }; + } + ).data.details, + ) as { text: string; snapshotHash: string }; + expect(persisted.snapshotHash).toMatch(/^[0-9a-f]{64}$/); + + // Second run, same data: the gate finds the matching fingerprint, + // re-persists the same text under today's dateKey, and never calls + // the provider — even though the run is forced. + vi.mocked(runStatusCompletion).mockClear(); + vi.mocked(prisma.auditLog.create).mockClear(); + vi.mocked(prisma.auditLog.findFirst).mockResolvedValue({ + createdAt: now, + details: JSON.stringify({ + // Yesterday's row — outside the same-day cache read. + dateKey: "2026-06-09", + locale: "en", + text: persisted.text, + providerType: "anthropic", + model: "x", + tokensUsed: 1, + snapshotHash: persisted.snapshotHash, + }), + } as never); + + const result = await generateWeightStatusForUser("user-1", { + locale: "en", + force: true, + }); + + expect(runStatusCompletion).not.toHaveBeenCalled(); + expect(result.cached).toBe(true); + expect(result.text).toBe(persisted.text); + // The refresh row re-keys the payload to today. + const refreshed = JSON.parse( + ( + vi.mocked(prisma.auditLog.create).mock.calls[0][0] as { + data: { details: string }; + } + ).data.details, + ) as { dateKey: string; snapshotHash: string }; + expect(refreshed.snapshotHash).toBe(persisted.snapshotHash); + expect(refreshed.dateKey).not.toBe("2026-06-09"); + } finally { + vi.useRealTimers(); + } + }); + + it("regenerates when the stored fingerprint differs from the fresh snapshot", async () => { + const now = new Date(); + vi.mocked(prisma.measurement.findMany).mockResolvedValue([ + { type: "WEIGHT", value: 82, measuredAt: now }, + ] as never); + vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.auditLog.findFirst).mockResolvedValue({ + createdAt: now, + details: JSON.stringify({ + dateKey: "2026-06-09", + locale: "en", + text: "Older assessment.", + providerType: "anthropic", + model: "x", + tokensUsed: 1, + snapshotHash: "f".repeat(64), + }), + } as never); + vi.mocked(prisma.auditLog.create).mockResolvedValue({ + createdAt: now, + } as never); + stubCompletion('{"summary":"Fresh assessment for changed data."}'); + + const result = await generateWeightStatusForUser("user-1", { + locale: "en", + force: true, + }); + + expect(runStatusCompletion).toHaveBeenCalledTimes(1); + expect(result.text).toBe("Fresh assessment for changed data."); + expect(result.cached).toBe(false); + }); +}); + 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 28683a4af..f779671bd 100644 --- a/src/lib/insights/blood-pressure-status.ts +++ b/src/lib/insights/blood-pressure-status.ts @@ -51,9 +51,11 @@ import { import { runStatusCompletion } from "@/lib/insights/status-provider"; import { readFreshStatusText, + refreshUnchangedStatusInsight, resolveReadOnlyStatusMiss, statusCacheAction, } from "@/lib/insights/status-cache"; +import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash"; import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback"; import { annotate } from "@/lib/logging/context"; import { toBerlinDayKey } from "@/lib/tz/resolver"; @@ -542,6 +544,24 @@ export async function generateBloodPressureStatusForUser( }, }); + // Content-hash gate (v1.16.8): when the snapshot is unchanged since the + // last real assessment, refresh the cache timestamp and skip the LLM. + const snapshotHash = hashInsightSnapshot(snapshot); + const unchanged = await refreshUnchangedStatusInsight({ + userId, + cacheAction, + todayKey, + snapshotHash, + }); + if (unchanged) { + return { + hasProvider: true, + text: unchanged.text, + cached: true, + updatedAt: unchanged.updatedAt, + }; + } + const previousContext = await getPreviousInsightContext( userId, "blood-pressure-status", @@ -628,6 +648,7 @@ export async function generateBloodPressureStatusForUser( providerType: outcome.providerType, model: outcome.model, tokensUsed: outcome.tokensUsed, + snapshotHash, }); return { diff --git a/src/lib/insights/bmi-status.ts b/src/lib/insights/bmi-status.ts index f30555b77..6bf7244b9 100644 --- a/src/lib/insights/bmi-status.ts +++ b/src/lib/insights/bmi-status.ts @@ -29,9 +29,11 @@ import { import { runStatusCompletion } from "@/lib/insights/status-provider"; import { readFreshStatusText, + refreshUnchangedStatusInsight, resolveReadOnlyStatusMiss, statusCacheAction, } from "@/lib/insights/status-cache"; +import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash"; import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback"; import { annotate } from "@/lib/logging/context"; import { toBerlinDayKey } from "@/lib/tz/resolver"; @@ -234,6 +236,24 @@ export async function generateBmiStatusForUser( }, }); + // Content-hash gate (v1.16.8): when the snapshot is unchanged since the + // last real assessment, refresh the cache timestamp and skip the LLM. + const snapshotHash = hashInsightSnapshot(snapshot); + const unchanged = await refreshUnchangedStatusInsight({ + userId, + cacheAction, + todayKey, + snapshotHash, + }); + if (unchanged) { + return { + hasProvider: true, + text: unchanged.text, + cached: true, + updatedAt: unchanged.updatedAt, + }; + } + const previousContext = await getPreviousInsightContext( userId, "bmi-status", @@ -313,6 +333,7 @@ export async function generateBmiStatusForUser( providerType: outcome.providerType, model: outcome.model, tokensUsed: outcome.tokensUsed, + snapshotHash, }); return { diff --git a/src/lib/insights/comprehensive-generate.ts b/src/lib/insights/comprehensive-generate.ts index 907a175b4..7f064d98d 100644 --- a/src/lib/insights/comprehensive-generate.ts +++ b/src/lib/insights/comprehensive-generate.ts @@ -70,8 +70,8 @@ import { import { isTimeoutStub, statusCacheAction, - statusCacheActionPrefix, } from "@/lib/insights/status-cache"; +import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash"; import { metricIdForMeasurementType, metricStatusScope, @@ -83,7 +83,7 @@ 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 — neither evicted nor re-enqueued. + * 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, @@ -93,14 +93,17 @@ const CACHE_TTL_MS = 24 * 60 * 60 * 1000; * times an hour and the assessment felt "regenerated on every visit". This * window coalesces the storm: a genuinely stale scope (no fresh assessment) * still refreshes immediately, but a scope refreshed inside the window is - * skipped, so a fresh assessment survives the day's sync drip and a burst - * of batches costs at most one regeneration per scope per window. + * skipped, so a fresh assessment survives the day's sync drip. * - * 30 min is comfortably longer than a typical sync cadence yet far shorter - * than the 24 h assessment TTL, so a card still tracks new data within the - * same session without thrashing the provider. + * 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. */ -const INGEST_INVALIDATE_DEBOUNCE_MS = 30 * 60 * 1000; +const INGEST_INVALIDATE_DEBOUNCE_MS = 6 * 60 * 60 * 1000; const MAX_DOWNGRADE_TOKENS: ReadonlyArray = [ "anthropometrics", @@ -115,6 +118,13 @@ const MAX_DOWNGRADE_TOKENS: ReadonlyArray = [ export type GenerateOutcome = | { status: "cached" } | { status: "generated"; providerType: string } + /** + * v1.16.8 — the content-hash gate found the compacted feature snapshot + * unchanged since the cached text was generated. No provider call was + * made; only `insightsCachedAt` was refreshed so the freshness windows + * (GET read, nightly discovery) treat the cache as current. + */ + | { status: "unchanged" } | { status: "skipped"; reason: "no-provider" | "no-consent" } | { status: "failed"; reason: string }; @@ -225,25 +235,12 @@ export async function buildComparisonSnapshotForUser( return { baseline, metrics }; } -/** Evict the per-status insight cache rows (`insights.-status.`). */ -export async function evictPerStatusInsightCache( - userId: string, -): Promise { - await prisma.auditLog.deleteMany({ - where: { - userId, - action: { startsWith: "insights." }, - AND: [{ action: { contains: "-status." } }], - }, - }); -} - /** * The seven per-metric assessment scopes. Each generator persists its - * cached text under `insights.-status.`; the warm-pass in - * the nightly cron re-fills these after `evictPerStatusInsightCache` - * clears them, and the targeted invalidator below drops only the scopes - * a fresh measurement of a given type actually dirties. + * cached text under `insights.-status.`; the nightly warm + * pass refreshes them through each generator's content-hash gate, and the + * targeted invalidator below re-enqueues only the scopes a fresh + * measurement of a given type actually dirties. */ export const PER_STATUS_SCOPES = [ "blood-pressure", @@ -281,16 +278,19 @@ function statusScopesForMeasurementType(type: MeasurementType): PerStatusScope[] } /** - * Drop the cached per-metric assessment rows that a batch of fresh + * Re-warm the cached per-metric assessments that a batch of fresh * measurements dirties, so the next mount (or the next nightly warm - * pass) regenerates them against the new data instead of serving the - * pre-measurement text for the rest of the day. + * pass) reflects the new data instead of serving the pre-measurement + * text for the rest of the day. * - * Fire-and-forget from the measurement ingest path — idempotent (a - * redundant delete costs nothing) and never a blocker on the user's - * write. Deletes every locale variant of each affected scope in one - * sweep; the `-status.` substring guard keeps it from touching the - * comprehensive cache or any unrelated `insights.*` audit row. + * Fire-and-forget from the measurement ingest path — idempotent and + * never a blocker on the user's write. v1.16.8 — the invalidator no + * longer DELETES the cache rows: the worker regenerates each enqueued + * scope with `force: true`, and the generator's content-hash gate + * decides whether the data actually changed. Keeping the row intact + * preserves the stale-while-revalidate read AND lets the gate skip the + * LLM entirely when the dirtying batch turned out to be a re-sync of + * known data. */ export async function invalidateStatusInsightsForTypes( userId: string, @@ -319,8 +319,8 @@ export async function invalidateStatusInsightsForTypes( // v1.8.7 — regenerate only the user's resolved locale, matching the // read-path (every `*-status` GET serves `normalizeLocale(user.locale)`). // Warming both locales doubled provider spend on every sync, half of it - // for a language the user never opens. The nightly warm pass still covers - // both locales for the rare locale switch. + // for a language the user never opens. A second locale a client actually + // reads warms lazily through the read-path enqueue on its first miss. const localeRow = await prisma.user.findUnique({ where: { id: userId }, select: { locale: true }, @@ -328,15 +328,14 @@ export async function invalidateStatusInsightsForTypes( const locale = normalizeLocale(localeRow?.locale); // v1.9.0 — debounce the ingest-invalidation storm. A constantly-syncing - // client drips batches every few minutes; deleting + re-enqueuing every - // dirtied 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, and each - // delete dropped the row a fresh enqueue would otherwise have found warm). - // 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 or missing scope still refreshes immediately, so - // correctness holds; only the redundant churn is removed. + // client drips batches every few minutes; re-enqueuing every dirtied + // 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 + // or missing scope still refreshes immediately, so correctness holds; + // only the redundant churn is removed. const freshScopes = await findRecentlyWarmedScopes(userId, locale, scopes); const staleScopes = Array.from(scopes).filter( (scope) => !freshScopes.has(scope), @@ -349,28 +348,14 @@ export async function invalidateStatusInsightsForTypes( return; } - // Drop the cached per-metric assessment rows that are actually stale, so - // the next mount / nightly warm pass regenerates them against the new - // data. The `-status.` substring guard keeps the sweep off the - // comprehensive cache and any unrelated `insights.*` audit row. - await prisma.auditLog.deleteMany({ - where: { - userId, - OR: staleScopes.map((scope) => ({ - action: { startsWith: statusCacheActionPrefix(scope) }, - })), - }, - }); - - // v1.8.7 — regenerate-on-invalidate. Deleting the today-row alone left - // the card to re-warm only on the user's next category open (a miss → a - // worker round-trip while they wait) or the nightly cron. Instead, - // proactively enqueue a debounced regenerate for each dirtied scope so - // the cache is re-warmed in the background. The enqueue is coalesced per - // `(user, metric, locale)` via the queue's `singletonKey` (120 s window); - // the debounce above is the second, wider coalescing layer that survives - // across the sync drip. The stale-while-revalidate read keeps the - // previous assessment visible until the fresh one lands. + // v1.8.7 — regenerate-on-invalidate: enqueue a debounced regenerate for + // each dirtied scope so the cache is re-warmed in the background. The + // enqueue is coalesced per `(user, metric, locale)` via the queue's + // `singletonKey` (120 s window); the debounce above is the second, wider + // coalescing layer that survives across the sync drip. The cache row + // stays in place (stale-while-revalidate keeps the previous assessment + // visible) — the worker forces the generator, whose content-hash gate + // skips the LLM when the batch did not actually change the snapshot. for (const scope of staleScopes) { void enqueueStatusGeneration({ userId, metric: scope, locale }); } @@ -454,9 +439,8 @@ interface GenerateOptions { * The on-demand `forceWarmUser` path bounds the comprehensive step with a * timeout and then proceeds to warm the per-status caches itself. Without * this signal a comprehensive that resolves AFTER the timeout would still - * reach `evictPerStatusInsightCache` and delete the rows the warm passes - * had just written — undoing its own work. The signal lets the caller cut - * the generation off before the eviction so the warmed rows survive. + * write a cache row + timestamp the caller no longer expects. The signal + * lets the caller cut the generation off before the cache write. */ signal?: AbortSignal; } @@ -487,6 +471,7 @@ export async function generateComprehensiveInsight( insightsCachedAt: true, insightsCachedText: true, insightsExcludeMetrics: true, + insightsSnapshotHash: true, }, }); @@ -555,6 +540,28 @@ export async function generateComprehensiveInsight( ); const featuresJson = JSON.stringify(compactFeatures, null, 2); + // v1.16.8 — content-hash gate. When the compacted feature snapshot is + // byte-equivalent (modulo clock-relative offsets, see snapshot-hash.ts) + // to the one the cached text was generated from, nothing the prompt + // 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); + if ( + dbUser?.insightsCachedText && + dbUser.insightsSnapshotHash === snapshotHash + ) { + await prisma.user.update({ + where: { id: userId }, + data: { insightsCachedAt: new Date() }, + }); + annotate({ + action: { name: "insights.generate.skipped_unchanged" }, + meta: { locale }, + }); + return { status: "unchanged" }; + } + const comparisonSnapshot = await buildComparisonSnapshotForUser(userId); const plateauContext = await detectGlp1Plateau(userId); let userPrompt = buildUserPrompt( @@ -622,8 +629,8 @@ export async function generateComprehensiveInsight( // v1.9.0 — the caller abandoned this generation (its bounded timeout // fired and it has moved on to warm the per-status caches itself). Bail - // out before the write + evict so a late-resolving comprehensive can never - // delete the rows the warm passes have since written. The provider call + // out before the cache write so a late-resolving comprehensive cannot + // stamp a timestamp/text the caller no longer expects. The provider call // already cost what it cost; what we must NOT do now is touch the cache. if (signal?.aborted) { return { status: "failed", reason: "aborted" }; @@ -634,9 +641,14 @@ export async function generateComprehensiveInsight( data: { insightsCachedAt: new Date(), insightsCachedText: JSON.stringify(insights), + insightsSnapshotHash: snapshotHash, }, }); - await evictPerStatusInsightCache(userId); + // 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; a fresh comprehensive briefing does not change + // what a per-metric card should say, and the old sweep was the reason + // every warm had to regenerate ~45 cards across both locales. invalidateUserInsights(userId); return { status: "generated", providerType: workingProviderType }; diff --git a/src/lib/insights/general-status.ts b/src/lib/insights/general-status.ts index a0c325324..f1aeec4df 100644 --- a/src/lib/insights/general-status.ts +++ b/src/lib/insights/general-status.ts @@ -31,9 +31,11 @@ import { import { runStatusCompletion } from "@/lib/insights/status-provider"; import { readFreshStatusText, + refreshUnchangedStatusInsight, resolveReadOnlyStatusMiss, statusCacheAction, } from "@/lib/insights/status-cache"; +import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash"; import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback"; import { annotate } from "@/lib/logging/context"; import { toBerlinDayKey } from "@/lib/tz/resolver"; @@ -361,6 +363,24 @@ export async function generateGeneralStatusForUser( }, }); + // Content-hash gate (v1.16.8): when the snapshot is unchanged since the + // last real assessment, refresh the cache timestamp and skip the LLM. + const snapshotHash = hashInsightSnapshot(snapshot); + const unchanged = await refreshUnchangedStatusInsight({ + userId, + cacheAction, + todayKey, + snapshotHash, + }); + if (unchanged) { + return { + hasProvider: true, + text: unchanged.text, + cached: true, + updatedAt: unchanged.updatedAt, + }; + } + // v1.4: pull the previous cached general-status into the prompt so // the model can compare to the user's last analysis. Falls back // gracefully when there's no history (first-run users). @@ -443,6 +463,7 @@ export async function generateGeneralStatusForUser( providerType: outcome.providerType, model: outcome.model, tokensUsed: outcome.tokensUsed, + snapshotHash, }); return { diff --git a/src/lib/insights/medication-compliance-status.ts b/src/lib/insights/medication-compliance-status.ts index 47000993e..cb6bc1897 100644 --- a/src/lib/insights/medication-compliance-status.ts +++ b/src/lib/insights/medication-compliance-status.ts @@ -37,6 +37,7 @@ import { resolveReadOnlyStatusMiss, statusCacheAction, } from "@/lib/insights/status-cache"; +import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash"; import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback"; import { annotate } from "@/lib/logging/context"; import { toBerlinDayKey } from "@/lib/tz/resolver"; @@ -350,6 +351,57 @@ export async function generateMedicationComplianceStatusForUser( }, }); + // Content-hash gate (v1.16.8): when the snapshot is unchanged since the + // last real assessment, refresh the cache timestamp and skip the LLM. + // This generator caches the richer `{ summary, medications }` envelope, + // so it carries its own gate rather than the shared text-only one. + const snapshotHash = hashInsightSnapshot(snapshot); + const latestRow = await prisma.auditLog.findFirst({ + where: { userId, action: cacheAction }, + orderBy: { createdAt: "desc" }, + select: { details: true }, + }); + if (latestRow?.details) { + try { + const parsed = JSON.parse(latestRow.details) as { + summary?: string; + medications?: MedicationSummaryItem[]; + model?: string; + timeout?: boolean; + snapshotHash?: string; + }; + if ( + !isTimeoutStub(parsed) && + typeof parsed.summary === "string" && + parsed.summary.trim().length > 0 && + Array.isArray(parsed.medications) && + parsed.snapshotHash === snapshotHash + ) { + const refreshed = await prisma.auditLog.create({ + data: { + userId, + action: cacheAction, + details: JSON.stringify({ ...parsed, dateKey: todayKey }), + }, + select: { createdAt: true }, + }); + annotate({ + action: { name: "insights.status.skipped_unchanged" }, + meta: { cache_action: cacheAction }, + }); + return { + hasProvider: true, + summary: parsed.summary, + medications: parsed.medications, + cached: true, + updatedAt: refreshed.createdAt.toISOString(), + }; + } + } catch { + // Malformed cache payload — fall through to a real generation. + } + } + const previousContext = await getPreviousInsightContext( userId, "medication-compliance-status", @@ -463,6 +515,7 @@ export async function generateMedicationComplianceStatusForUser( providerType: outcome.providerType, model: outcome.model, tokensUsed: outcome.tokensUsed, + snapshotHash, }), }, select: { createdAt: true }, diff --git a/src/lib/insights/metric-status.ts b/src/lib/insights/metric-status.ts index 631aa5472..2effffdcc 100644 --- a/src/lib/insights/metric-status.ts +++ b/src/lib/insights/metric-status.ts @@ -70,9 +70,11 @@ import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; import { resolveUserTimezone } from "@/lib/tz/resolver"; import { readFreshStatusText, + refreshUnchangedStatusInsight, resolveReadOnlyStatusMiss, statusCacheAction, } from "@/lib/insights/status-cache"; +import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash"; import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback"; import { annotate } from "@/lib/logging/context"; import { toBerlinDayKey } from "@/lib/tz/resolver"; @@ -385,6 +387,24 @@ export async function generateMetricStatus(args: { }, }); + // Content-hash gate (v1.16.8): when the snapshot is unchanged since the + // last real assessment, refresh the cache timestamp and skip the LLM. + const snapshotHash = hashInsightSnapshot(snapshot); + const unchanged = await refreshUnchangedStatusInsight({ + userId: args.userId, + cacheAction, + todayKey, + snapshotHash, + }); + if (unchanged) { + return { + hasProvider: true, + text: unchanged.text, + cached: true, + updatedAt: unchanged.updatedAt, + }; + } + const previousContext = await getPreviousInsightContext( args.userId, // memory.ts keys previous context by the `insights..` @@ -480,6 +500,7 @@ export async function generateMetricStatus(args: { providerType: outcome.providerType, model: outcome.model, tokensUsed: outcome.tokensUsed, + snapshotHash, }); return { hasProvider: true, text, cached: false, updatedAt }; diff --git a/src/lib/insights/mood-status.ts b/src/lib/insights/mood-status.ts index 0da269f7c..2fefef2ef 100644 --- a/src/lib/insights/mood-status.ts +++ b/src/lib/insights/mood-status.ts @@ -48,9 +48,11 @@ import { import { runStatusCompletion } from "@/lib/insights/status-provider"; import { readFreshStatusText, + refreshUnchangedStatusInsight, resolveReadOnlyStatusMiss, statusCacheAction, } from "@/lib/insights/status-cache"; +import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash"; import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback"; import { annotate } from "@/lib/logging/context"; import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; @@ -552,6 +554,24 @@ export async function generateMoodStatusForUser( }, }); + // Content-hash gate (v1.16.8): when the snapshot is unchanged since the + // last real assessment, refresh the cache timestamp and skip the LLM. + const snapshotHash = hashInsightSnapshot(snapshot); + const unchanged = await refreshUnchangedStatusInsight({ + userId, + cacheAction, + todayKey, + snapshotHash, + }); + if (unchanged) { + return { + hasProvider: true, + text: unchanged.text, + cached: true, + updatedAt: unchanged.updatedAt, + }; + } + const previousContext = await getPreviousInsightContext( userId, "mood-status", @@ -632,6 +652,7 @@ export async function generateMoodStatusForUser( providerType: outcome.providerType, model: outcome.model, tokensUsed: outcome.tokensUsed, + snapshotHash, }); return { diff --git a/src/lib/insights/pulse-status.ts b/src/lib/insights/pulse-status.ts index 4eb63d8a7..17b658e89 100644 --- a/src/lib/insights/pulse-status.ts +++ b/src/lib/insights/pulse-status.ts @@ -37,9 +37,11 @@ import { import { runStatusCompletion } from "@/lib/insights/status-provider"; import { readFreshStatusText, + refreshUnchangedStatusInsight, resolveReadOnlyStatusMiss, statusCacheAction, } from "@/lib/insights/status-cache"; +import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash"; import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback"; import { annotate } from "@/lib/logging/context"; import { toBerlinDayKey, userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/resolver"; @@ -318,6 +320,24 @@ export async function generatePulseStatusForUser( }, }); + // Content-hash gate (v1.16.8): when the snapshot is unchanged since the + // last real assessment, refresh the cache timestamp and skip the LLM. + const snapshotHash = hashInsightSnapshot(snapshot); + const unchanged = await refreshUnchangedStatusInsight({ + userId, + cacheAction, + todayKey, + snapshotHash, + }); + if (unchanged) { + return { + hasProvider: true, + text: unchanged.text, + cached: true, + updatedAt: unchanged.updatedAt, + }; + } + const previousContext = await getPreviousInsightContext( userId, "pulse-status", @@ -395,6 +415,7 @@ export async function generatePulseStatusForUser( providerType: outcome.providerType, model: outcome.model, tokensUsed: outcome.tokensUsed, + snapshotHash, }); return { diff --git a/src/lib/insights/snapshot-hash.ts b/src/lib/insights/snapshot-hash.ts new file mode 100644 index 000000000..1369179c7 --- /dev/null +++ b/src/lib/insights/snapshot-hash.ts @@ -0,0 +1,83 @@ +/** + * v1.16.8 — content hash over the data snapshot that feeds an insight + * generation. + * + * Every insight generator (the comprehensive briefing, the seven + * specialised status cards, the generic metric cards) builds a compacted + * data snapshot and embeds it in the prompt. Before this hash existed, + * every nightly tick, forced warm, and ingest-driven regeneration ran the + * full LLM round-trip even when that snapshot was byte-identical to the + * one the cached text was generated from — a same-data regeneration that + * produced a near-identical paragraph at full provider cost, dozens of + * times per user per day. + * + * `hashInsightSnapshot` turns the snapshot into a deterministic SHA-256 + * fingerprint. Callers store it alongside the cached text and skip the + * provider call whenever the stored hash matches the fresh snapshot's + * hash (refreshing only the cache timestamp). The fingerprint covers the + * data the prompt actually sees, with two deliberate exclusions: + * + * - `locale` + day labels (`generatedForDay`, `dateKey`, `todayKey`): + * 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. + * + * Serialisation is canonical: object keys are sorted, arrays keep their + * order, `Date` collapses to ISO-8601, `undefined` members are dropped + * (matching `JSON.stringify`). Two snapshots that would render the same + * prompt data therefore always hash equal, regardless of property + * insertion order. + */ +import { createHash } from "crypto"; + +/** Keys excluded from the fingerprint — see the module doc for why. */ +const VOLATILE_KEYS = new Set([ + "locale", + "generatedForDay", + "dateKey", + "todayKey", + "dayOffset", +]); + +function isVolatileKey(key: string): boolean { + return VOLATILE_KEYS.has(key) || key.endsWith("DaysAgo"); +} + +/** Canonical, key-sorted serialisation with volatile keys dropped. */ +function canonicalise(value: unknown): string { + if (value === null) return "null"; + if (value instanceof Date) return JSON.stringify(value.toISOString()); + if (Array.isArray(value)) { + return `[${value + .map((entry) => canonicalise(entry === undefined ? null : entry)) + .join(",")}]`; + } + if (typeof value === "object") { + 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)}`); + return `{${entries.join(",")}}`; + } + // string | number | boolean — JSON.stringify is canonical for these. + // (undefined / function / symbol never appear in the JSON-ready + // snapshots; a top-level undefined canonicalises like JSON's null.) + return JSON.stringify(value) ?? "null"; +} + +/** + * SHA-256 hex fingerprint of an insight data snapshot. Deterministic + * across key order, locale, and intra-day clock drift; changes whenever + * a value the prompt actually narrates changes. + */ +export function hashInsightSnapshot(value: unknown): string { + return createHash("sha256").update(canonicalise(value)).digest("hex"); +} diff --git a/src/lib/insights/status-cache.ts b/src/lib/insights/status-cache.ts index 352929dc2..960bd8a4d 100644 --- a/src/lib/insights/status-cache.ts +++ b/src/lib/insights/status-cache.ts @@ -59,6 +59,8 @@ interface ParsedStatusCache { timeout?: boolean; /** v1.8.3 — ISO timestamp before which a timeout stub suppresses re-enqueue. */ retryAt?: string; + /** v1.16.8 — fingerprint of the data snapshot the text was generated from. */ + snapshotHash?: string; } /** @@ -121,6 +123,69 @@ export async function readFreshStatusText(args: { } } +/** + * v1.16.8 — the content-hash regeneration gate for the per-status and + * generic metric cards. + * + * A generator that has already gathered its data snapshot calls this + * BEFORE the provider round-trip. When the latest cached assessment for + * `(userId, cacheAction)` is a real (non-stub) text whose stored + * `snapshotHash` equals the fresh snapshot's hash, nothing the prompt + * sees has changed — so the gate re-persists the same text under + * today's `dateKey` (a pure timestamp refresh that keeps the read path + * and the ingest debounce satisfied) and returns it, and the caller + * skips the LLM call entirely. Returns `null` on any miss (no prior + * row, stub, empty text, missing or differing hash) — every one of + * those means the caller should generate for real. + * + * This single gate is what turns the nightly warm, the forced warm, and + * the ingest-driven regeneration into no-ops on unchanged data: each of + * those paths forces past the same-day cache read, gathers the + * snapshot, and lands here. + */ +export async function refreshUnchangedStatusInsight(args: { + userId: string; + cacheAction: string; + todayKey: string; + snapshotHash: string; +}): Promise { + const latest = await prisma.auditLog.findFirst({ + where: { userId: args.userId, action: args.cacheAction }, + orderBy: { createdAt: "desc" }, + select: { details: true }, + }); + if (!latest?.details) return null; + + let parsed: ParsedStatusCache; + try { + parsed = JSON.parse(latest.details) as ParsedStatusCache; + } catch { + return null; + } + if (isTimeoutStub(parsed)) return null; + if (typeof parsed.text !== "string" || parsed.text.trim().length === 0) { + return null; + } + if (parsed.snapshotHash !== args.snapshotHash) return null; + + // Same data, valid text — refresh the cache row's day key so the + // read path serves it as today's assessment and the ingest debounce + // window restarts, without touching the provider. + const created = await prisma.auditLog.create({ + data: { + userId: args.userId, + action: args.cacheAction, + details: JSON.stringify({ ...parsed, dateKey: args.todayKey }), + }, + select: { createdAt: true }, + }); + annotate({ + action: { name: "insights.status.skipped_unchanged" }, + meta: { cache_action: args.cacheAction }, + }); + return { text: parsed.text, updatedAt: created.createdAt.toISOString() }; +} + export interface LastGoodStatusHit { text: string; updatedAt: string; diff --git a/src/lib/insights/status-shared.ts b/src/lib/insights/status-shared.ts index 04599c6d1..7e6f4ad7b 100644 --- a/src/lib/insights/status-shared.ts +++ b/src/lib/insights/status-shared.ts @@ -166,6 +166,13 @@ export async function persistStatusInsight(args: { providerType: string; model: string; tokensUsed: number | null; + /** + * v1.16.8 — fingerprint of the data snapshot this assessment was + * generated from (see `snapshot-hash.ts`). The regeneration gate + * compares the fresh snapshot's hash against this and skips the + * provider call when nothing changed. + */ + snapshotHash?: string; }): Promise { const created = await prisma.auditLog.create({ data: { @@ -178,6 +185,7 @@ export async function persistStatusInsight(args: { providerType: args.providerType, model: args.model, tokensUsed: args.tokensUsed, + ...(args.snapshotHash ? { snapshotHash: args.snapshotHash } : {}), }), }, select: { createdAt: true }, diff --git a/src/lib/insights/weight-status.ts b/src/lib/insights/weight-status.ts index 14c9faca4..6363cee8c 100644 --- a/src/lib/insights/weight-status.ts +++ b/src/lib/insights/weight-status.ts @@ -40,9 +40,11 @@ import { import { runStatusCompletion } from "@/lib/insights/status-provider"; import { readFreshStatusText, + refreshUnchangedStatusInsight, resolveReadOnlyStatusMiss, statusCacheAction, } from "@/lib/insights/status-cache"; +import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash"; import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback"; import { annotate } from "@/lib/logging/context"; import { toBerlinDayKey } from "@/lib/tz/resolver"; @@ -426,6 +428,24 @@ export async function generateWeightStatusForUser( }, }); + // Content-hash gate (v1.16.8): when the snapshot is unchanged since the + // last real assessment, refresh the cache timestamp and skip the LLM. + const snapshotHash = hashInsightSnapshot(snapshot); + const unchanged = await refreshUnchangedStatusInsight({ + userId, + cacheAction, + todayKey, + snapshotHash, + }); + if (unchanged) { + return { + hasProvider: true, + text: unchanged.text, + cached: true, + updatedAt: unchanged.updatedAt, + }; + } + const previousContext = await getPreviousInsightContext( userId, "weight-status", @@ -507,6 +527,7 @@ export async function generateWeightStatusForUser( providerType: outcome.providerType, model: outcome.model, tokensUsed: outcome.tokensUsed, + snapshotHash, }); return { diff --git a/src/lib/jobs/__tests__/insight-pregenerate.test.ts b/src/lib/jobs/__tests__/insight-pregenerate.test.ts index 440ae942b..17dd2a900 100644 --- a/src/lib/jobs/__tests__/insight-pregenerate.test.ts +++ b/src/lib/jobs/__tests__/insight-pregenerate.test.ts @@ -6,7 +6,10 @@ * - the per-user budget gate blocks a user already generated today; * - the master assistant kill-switch short-circuits the whole run; * - the generator outcomes tally correctly (generated / cached / - * skipped / failed); + * unchanged / skipped / failed); + * - the warm pass is single-locale + refill-only (v1.16.8); + * - the forced single-user warm is idempotent (freshness re-check), + * backs off after a failure, and is capped per day (v1.16.8); * - the queue is registered in `allQueues` AND scheduled in * reminder-worker.ts (the v1.4.37 W10 unregistered-queue catch). */ @@ -68,15 +71,26 @@ import { forceWarmUser, findPregenerateCandidates, PREGENERATE_STALE_MS, + FORCE_WARM_DAILY_LIMIT, INSIGHT_PREGENERATE_QUEUE, INSIGHT_PREGENERATE_CRON, } from "../insight-pregenerate"; function makePrisma(users: Array<{ id: string; locale: string | null }>) { const findMany = vi.fn().mockResolvedValue(users); + // forceWarmUser reads `insightsCachedAt` / `insightsWarmFailedAt` at job + // start and maintains the failure marker; default: never warmed, never + // failed. + const findUnique = vi.fn().mockResolvedValue({ + insightsCachedAt: null, + insightsWarmFailedAt: null, + }); + const update = vi.fn().mockResolvedValue({}); return { - prisma: { user: { findMany } }, + prisma: { user: { findMany, findUnique, update } }, findMany, + findUnique, + update, }; } @@ -170,7 +184,7 @@ describe("runInsightPregenerate — budget gate", () => { // assumption that the 04:30 pass warms their cards. A budget-blocked // candidate must therefore still get the warm — refill-only // (`force: false`), so a card already generated today is a cheap - // cache read while a cold card generates. + // cache read while a cold card generates behind its hash gate. const { prisma } = makePrisma([{ id: "u1", locale: "de" }]); checkRateLimit.mockResolvedValue({ allowed: false }); const generate = vi.fn(); @@ -187,12 +201,13 @@ describe("runInsightPregenerate — budget gate", () => { expect(generate).not.toHaveBeenCalled(); expect(result.budgetBlocked).toBe(1); + // v1.16.8 — single-locale warm: only the user's resolved locale. for (const g of statusGenerators) { + expect(g).toHaveBeenCalledTimes(1); expect(g).toHaveBeenCalledWith("u1", { locale: "de", force: false }); - expect(g).toHaveBeenCalledWith("u1", { locale: "en", force: false }); } - expect(warmGenericMetrics).toHaveBeenCalledWith("u1", ["de", "en"], false); - expect(result.assessmentsWarmed).toBe(14); + expect(warmGenericMetrics).toHaveBeenCalledWith("u1", ["de"]); + expect(result.assessmentsWarmed).toBe(7); expect(result.metricAssessmentsWarmed).toBe(2); }); }); @@ -209,9 +224,10 @@ describe("runInsightPregenerate — force flag", () => { // The cron's discovery window (20 h) is shorter than the // generator's 24 h TTL; `force: true` bypasses the TTL re-check so // a 20-24h-old cache actually regenerates rather than returning - // `cached` and wasting the budget bucket. The nightly loop also - // threads an AbortSignal so its bounded budget can cut a stalled - // generation off. + // `cached` and wasting the budget bucket. The generator's own + // content-hash gate is what prevents a same-data force from + // reaching the provider. The nightly loop also threads an + // AbortSignal so its bounded budget can cut a stalled generation off. expect(generate).toHaveBeenCalledWith("u1", { locale: "de", force: true, @@ -221,17 +237,19 @@ describe("runInsightPregenerate — force flag", () => { }); describe("runInsightPregenerate — outcome tally", () => { - it("tallies generated / cached / skipped / failed", async () => { + it("tallies generated / cached / unchanged / skipped / failed", async () => { const { prisma } = makePrisma([ { id: "a", locale: "de" }, { id: "b", locale: "de" }, { id: "c", locale: "de" }, - { id: "d", locale: null }, + { id: "d", locale: "de" }, + { id: "e", locale: null }, ]); const generate = vi .fn() .mockResolvedValueOnce({ status: "generated", providerType: "x" }) .mockResolvedValueOnce({ status: "cached" }) + .mockResolvedValueOnce({ status: "unchanged" }) .mockResolvedValueOnce({ status: "skipped", reason: "no-provider" }) .mockResolvedValueOnce({ status: "failed", reason: "provider-error" }); @@ -240,16 +258,17 @@ describe("runInsightPregenerate — outcome tally", () => { }); expect(result).toMatchObject({ - total: 4, + total: 5, generated: 1, cached: 1, + unchanged: 1, skipped: 1, failed: 1, budgetBlocked: 0, }); // Locale defaulting: v1.15.20 — non-German (incl. null) → "en", // matching the no-key fallback routing. - expect(generate).toHaveBeenLastCalledWith("d", { + expect(generate).toHaveBeenLastCalledWith("e", { locale: "en", force: true, signal: expect.any(AbortSignal), @@ -285,19 +304,15 @@ describe("runInsightPregenerate — outcome tally", () => { const result = await promise; // The timeout fired, aborted the still-running generation (so its - // late resolve cannot evict the rows the warm pass writes), and the - // refill-only warm still ran. + // late resolve cannot write a cache row the loop no longer expects), + // and the refill-only warm still ran. expect(observedSignal?.aborted).toBe(true); expect(result.failed).toBe(1); expect(result.generated).toBe(0); for (const g of statusGenerators) { expect(g).toHaveBeenCalledWith("u1", { locale: "de", force: false }); } - expect(warmGenericMetrics).toHaveBeenCalledWith( - "u1", - ["de", "en"], - false, - ); + expect(warmGenericMetrics).toHaveBeenCalledWith("u1", ["de"]); } finally { vi.useRealTimers(); } @@ -309,7 +324,7 @@ describe("runInsightPregenerate — per-metric warm pass", () => { return vi.fn().mockResolvedValue(result); } - it("forces all seven status generators in BOTH locales after a successful comprehensive generation", async () => { + it("runs all seven status generators refill-only in the user's locale after a successful comprehensive generation", async () => { const { prisma } = makePrisma([{ id: "u1", locale: "de" }]); const generate = vi .fn() @@ -323,21 +338,42 @@ describe("runInsightPregenerate — per-metric warm pass", () => { statusGenerators, }); - // v1.8.3 — each generator is forced once per supported locale (de + en) - // so the cache the client reads against (active UI locale, not the - // persisted User.locale) is always warm. + // v1.16.8 — single-locale, refill-only. The comprehensive write no + // longer evicts the per-status rows, so there is nothing to force; + // each generator's content-hash gate decides whether its data changed. for (const g of statusGenerators) { - expect(g).toHaveBeenCalledTimes(2); - expect(g).toHaveBeenCalledWith("u1", { locale: "de", force: true }); - expect(g).toHaveBeenCalledWith("u1", { locale: "en", force: true }); + expect(g).toHaveBeenCalledTimes(1); + expect(g).toHaveBeenCalledWith("u1", { locale: "de", force: false }); } - // Seven generators × two locales = 14 fresh, provider-backed assessments. - expect(result.assessmentsWarmed).toBe(14); + expect(result.assessmentsWarmed).toBe(7); }); - it("warms when the comprehensive pass returned `cached` (its write still evicted the per-status caches)", async () => { + it("warms in the user's resolved locale, not both families (v1.16.8)", async () => { + const { prisma } = makePrisma([{ id: "u1", locale: "en" }]); + const generate = vi + .fn() + .mockResolvedValue({ status: "generated", providerType: "x" }); + const statusGenerators = Array.from({ length: 7 }, () => + warmGen({ hasProvider: true, cached: false }), + ); + const warmGenericMetrics = vi.fn().mockResolvedValue(0); + + await runInsightPregenerate(prisma as never, { + generate, + statusGenerators, + warmGenericMetrics, + }); + + for (const g of statusGenerators) { + expect(g).toHaveBeenCalledTimes(1); + expect(g).toHaveBeenCalledWith("u1", { locale: "en", force: false }); + } + expect(warmGenericMetrics).toHaveBeenCalledWith("u1", ["en"]); + }); + + it("warms when the comprehensive pass returned `unchanged` (hash gate skipped the provider)", async () => { const { prisma } = makePrisma([{ id: "u1", locale: "de" }]); - const generate = vi.fn().mockResolvedValue({ status: "cached" }); + const generate = vi.fn().mockResolvedValue({ status: "unchanged" }); const statusGenerators = Array.from({ length: 7 }, () => warmGen({ hasProvider: true, cached: false }), ); @@ -347,10 +383,12 @@ describe("runInsightPregenerate — per-metric warm pass", () => { statusGenerators, }); + expect(result.unchanged).toBe(1); for (const g of statusGenerators) { - expect(g).toHaveBeenCalledTimes(2); + expect(g).toHaveBeenCalledTimes(1); + expect(g).toHaveBeenCalledWith("u1", { locale: "de", force: false }); } - expect(result.assessmentsWarmed).toBe(14); + expect(result.assessmentsWarmed).toBe(7); }); it("does NOT warm when the comprehensive pass skipped (no provider)", async () => { @@ -373,11 +411,11 @@ describe("runInsightPregenerate — per-metric warm pass", () => { expect(result.assessmentsWarmed).toBe(0); }); - it("warms refill-only (force:false) when the comprehensive pass failed (v1.16.1)", async () => { - // A failed comprehensive means no eviction ran, so today's rows (if - // any) are still valid — the warm refills only the cold cards. Before - // v1.16.1 this path skipped the warm entirely, leaving every card of a - // 02:xx-skipped candidate cold until the first on-visit generation. + it("warms refill-only when the comprehensive pass failed (v1.16.1)", async () => { + // A failed comprehensive leaves today's rows (if any) valid — the warm + // refills only the cold cards. Before v1.16.1 this path skipped the + // warm entirely, leaving every card of a 02:xx-skipped candidate cold + // until the first on-visit generation. const { prisma } = makePrisma([{ id: "u1", locale: "de" }]); const generate = vi .fn() @@ -392,11 +430,10 @@ describe("runInsightPregenerate — per-metric warm pass", () => { }); for (const g of statusGenerators) { - expect(g).toHaveBeenCalledTimes(2); + expect(g).toHaveBeenCalledTimes(1); expect(g).toHaveBeenCalledWith("u1", { locale: "de", force: false }); - expect(g).toHaveBeenCalledWith("u1", { locale: "en", force: false }); } - expect(result.assessmentsWarmed).toBe(14); + expect(result.assessmentsWarmed).toBe(7); }); it("warms refill-only when the comprehensive pass skipped for missing consent (v1.16.1)", async () => { @@ -418,10 +455,10 @@ describe("runInsightPregenerate — per-metric warm pass", () => { }); for (const g of statusGenerators) { - expect(g).toHaveBeenCalledTimes(2); + expect(g).toHaveBeenCalledTimes(1); expect(g).toHaveBeenCalledWith("u1", { locale: "de", force: false }); } - expect(result.assessmentsWarmed).toBe(14); + expect(result.assessmentsWarmed).toBe(7); }); it("counts only fresh provider-backed assessments and survives a thrown generator", async () => { @@ -443,12 +480,12 @@ describe("runInsightPregenerate — per-metric warm pass", () => { }); // The throw must not abort the loop — every generator was attempted - // once per supported locale (de + en). + // once (single-locale warm). for (const g of statusGenerators) { - expect(g).toHaveBeenCalledTimes(2); + expect(g).toHaveBeenCalledTimes(1); } - // Two counting generators × two locales = 4 fresh assessments. - expect(result.assessmentsWarmed).toBe(4); + // Two counting generators × one locale = 2 fresh assessments. + expect(result.assessmentsWarmed).toBe(2); }); }); @@ -472,9 +509,8 @@ describe("runInsightPregenerate — generic metric warm pass (v1.8.7.1)", () => }); expect(warmGenericMetrics).toHaveBeenCalledTimes(1); - // Third arg `true` — the comprehensive write evicted the caches, so - // the warm forces fresh generations. - expect(warmGenericMetrics).toHaveBeenCalledWith("u1", ["de", "en"], true); + // Single-locale, refill-only (v1.16.8) — no force mode left. + expect(warmGenericMetrics).toHaveBeenCalledWith("u1", ["de"]); expect(result.metricAssessmentsWarmed).toBe(9); }); @@ -500,7 +536,7 @@ describe("runInsightPregenerate — generic metric warm pass (v1.8.7.1)", () => }); describe("forceWarmUser — on-demand single-user warm (v1.8.7.1)", () => { - it("warms BOTH locale families, bypasses the budget gate, and tallies counts", async () => { + it("warms the caller's locale only, under the daily forced-warm bucket", async () => { const { prisma } = makePrisma([]); const generate = vi .fn() @@ -516,49 +552,50 @@ describe("forceWarmUser — on-demand single-user warm (v1.8.7.1)", () => { warmGenericMetrics, }); - // Comprehensive forced once, in the caller's active locale. The forced - // path also threads an AbortSignal so a timeout can cut the generation - // off. + // Comprehensive forced once, in the caller's active locale, with an + // AbortSignal so a timeout can cut the generation off. expect(generate).toHaveBeenCalledTimes(1); expect(generate).toHaveBeenCalledWith("u1", { locale: "en", force: true, signal: expect.any(AbortSignal), }); - // v1.16.7 — each specialised generator runs once PER locale family - // (de+en, like the nightly cron): the comprehensive write above - // evicted both families, so refilling only one left the other cold. - // The generated comprehensive means eviction happened → force: true. + // v1.16.8 — single-locale warm, refill-only: the comprehensive write + // no longer evicts the per-status rows, and the second locale family + // warms lazily through the read-path enqueue when actually read. for (const g of statusGenerators) { - expect(g).toHaveBeenCalledTimes(2); - expect(g).toHaveBeenCalledWith("u1", { locale: "de", force: true }); - expect(g).toHaveBeenCalledWith("u1", { locale: "en", force: true }); + expect(g).toHaveBeenCalledTimes(1); + expect(g).toHaveBeenCalledWith("u1", { locale: "en", force: false }); } - // Generic warm pass covers both locale families too. - expect(warmGenericMetrics).toHaveBeenCalledWith("u1", ["de", "en"], true); - // No 20 h budget bucket consulted on the forced path. - expect(checkRateLimit).not.toHaveBeenCalled(); + expect(warmGenericMetrics).toHaveBeenCalledWith("u1", ["en"]); + // The 20 h nightly bucket is bypassed; the forced path consults its + // own daily cap instead. + expect(checkRateLimit).toHaveBeenCalledTimes(1); + expect(checkRateLimit).toHaveBeenCalledWith( + "insight-pregenerate-daily:u1", + FORCE_WARM_DAILY_LIMIT, + expect.any(Number), + ); expect(result).toMatchObject({ comprehensive: "generated", - assessmentsWarmed: 14, + assessmentsWarmed: 7, metricAssessmentsWarmed: 5, }); }); - it("still runs the per-status + generic warm even when the comprehensive pass skipped (no provider)", async () => { - // v1.9.0 — the three sections are decoupled. A skipped comprehensive - // (no provider) must NOT short-circuit the per-status + generic passes: - // each generator resolves its own provider chain and no-ops cheaply when - // none is configured, so running them is safe and is what warms the cards - // when the comprehensive briefing is the only thing missing a provider. - const { prisma } = makePrisma([]); - const generate = vi - .fn() - .mockResolvedValue({ status: "skipped", reason: "no-provider" }); - // Each generator independently reports no-provider — nothing warmed, but - // it WAS invoked (it is the generator, not the caller, that decides). + it("skips the comprehensive when the cache was warmed within the freshness window (idempotent re-enqueue)", async () => { + // A revalidation poll that outlives the enqueue singleton can stack + // several force jobs; the job-start freshness re-check collapses the + // stack into one real warm. + const now = new Date("2026-06-10T12:00:00.000Z"); + const { prisma, findUnique } = makePrisma([]); + findUnique.mockResolvedValue({ + insightsCachedAt: new Date(now.getTime() - 10 * 60 * 1000), + insightsWarmFailedAt: null, + }); + const generate = vi.fn(); const statusGenerators = Array.from({ length: 7 }, () => - vi.fn().mockResolvedValue({ hasProvider: false, cached: true }), + vi.fn().mockResolvedValue({ hasProvider: true, cached: true }), ); const warmGenericMetrics = vi.fn().mockResolvedValue(0); @@ -566,29 +603,80 @@ describe("forceWarmUser — on-demand single-user warm (v1.8.7.1)", () => { generate, statusGenerators, warmGenericMetrics, + now, }); + expect(generate).not.toHaveBeenCalled(); + // The daily cap is not consumed by a freshness skip. + expect(checkRateLimit).not.toHaveBeenCalled(); + expect(result.comprehensive).toBe("fresh"); + // The refill-only card warm still runs (cheap cache reads). for (const g of statusGenerators) { - // Both locale families, refill-only: a skipped comprehensive ran no - // eviction, so cards already generated today short-circuit cheaply. - expect(g).toHaveBeenCalledTimes(2); - expect(g).toHaveBeenCalledWith("u1", { locale: "de", force: false }); - expect(g).toHaveBeenCalledWith("u1", { locale: "en", force: false }); + expect(g).toHaveBeenCalledTimes(1); } - expect(warmGenericMetrics).toHaveBeenCalledWith("u1", ["de", "en"], false); - expect(result).toMatchObject({ - comprehensive: "skipped", - assessmentsWarmed: 0, - metricAssessmentsWarmed: 0, + }); + + it("backs off after a recent failed attempt instead of re-driving the provider chain", async () => { + const now = new Date("2026-06-10T12:00:00.000Z"); + const { prisma, findUnique } = makePrisma([]); + findUnique.mockResolvedValue({ + insightsCachedAt: null, + insightsWarmFailedAt: new Date(now.getTime() - 10 * 60 * 1000), + }); + const generate = vi.fn(); + const statusGenerators = Array.from({ length: 7 }, () => + vi.fn().mockResolvedValue({ hasProvider: true, cached: true }), + ); + const warmGenericMetrics = vi.fn().mockResolvedValue(0); + + const result = await forceWarmUser(prisma as never, "u1", "de", { + generate, + statusGenerators, + warmGenericMetrics, + now, + }); + + expect(generate).not.toHaveBeenCalled(); + expect(checkRateLimit).not.toHaveBeenCalled(); + expect(result.comprehensive).toBe("backoff"); + for (const g of statusGenerators) { + expect(g).toHaveBeenCalledTimes(1); + } + }); + + it("retries after the backoff window has elapsed and clears the marker on success", async () => { + const now = new Date("2026-06-10T12:00:00.000Z"); + const { prisma, findUnique, update } = makePrisma([]); + findUnique.mockResolvedValue({ + insightsCachedAt: null, + insightsWarmFailedAt: new Date(now.getTime() - 2 * 60 * 60 * 1000), + }); + const generate = vi + .fn() + .mockResolvedValue({ status: "generated", providerType: "x" }); + const statusGenerators = Array.from({ length: 7 }, () => + vi.fn().mockResolvedValue({ hasProvider: true, cached: true }), + ); + const warmGenericMetrics = vi.fn().mockResolvedValue(0); + + const result = await forceWarmUser(prisma as never, "u1", "de", { + generate, + statusGenerators, + warmGenericMetrics, + now, + }); + + expect(generate).toHaveBeenCalledTimes(1); + expect(result.comprehensive).toBe("generated"); + expect(update).toHaveBeenCalledWith({ + where: { id: "u1" }, + data: { insightsWarmFailedAt: null }, }); }); - it("warms the per-status + generic caches even when the comprehensive pass fails", async () => { - // v1.9.0 — a failed comprehensive (provider chain unhealthy this run) - // is non-fatal: the cheaper per-status (7) + generic (~30) cards still - // warm so the user's first click is a cache read, not a 30–60 s lazy - // generation. This is the prod regression the stability wave closes. - const { prisma } = makePrisma([]); + it("stamps the failure marker when the comprehensive fails so the next page-open backs off", async () => { + const now = new Date("2026-06-10T12:00:00.000Z"); + const { prisma, update } = makePrisma([]); const generate = vi .fn() .mockResolvedValue({ status: "failed", reason: "all-providers-failed" }); @@ -601,25 +689,85 @@ describe("forceWarmUser — on-demand single-user warm (v1.8.7.1)", () => { generate, statusGenerators, warmGenericMetrics, + now, }); + expect(update).toHaveBeenCalledWith({ + where: { id: "u1" }, + data: { insightsWarmFailedAt: now }, + }); + // The per-status + generic passes still ran (decoupled sections). for (const g of statusGenerators) { - expect(g).toHaveBeenCalledTimes(2); + expect(g).toHaveBeenCalledTimes(1); } - expect(warmGenericMetrics).toHaveBeenCalledWith("u1", ["de", "en"], false); expect(result).toMatchObject({ comprehensive: "failed", - assessmentsWarmed: 14, + assessmentsWarmed: 7, metricAssessmentsWarmed: 4, }); }); - it("reports a timed-out comprehensive distinctly and still warms the rest", async () => { - // A comprehensive generation that exceeds its bounded budget is - // abandoned and reported as `timeout`, but the per-status + generic - // passes run regardless. The prod incident was a 102 s comprehensive - // that aborted the whole warm with `assessments_warmed: 0`. + it("caps forced comprehensive attempts per day and still refills the cards", async () => { const { prisma } = makePrisma([]); + checkRateLimit.mockResolvedValue({ allowed: false }); + const generate = vi.fn(); + const statusGenerators = Array.from({ length: 7 }, () => + vi.fn().mockResolvedValue({ hasProvider: true, cached: true }), + ); + const warmGenericMetrics = vi.fn().mockResolvedValue(0); + + const result = await forceWarmUser(prisma as never, "u1", "de", { + generate, + statusGenerators, + warmGenericMetrics, + }); + + expect(checkRateLimit).toHaveBeenCalledWith( + "insight-pregenerate-daily:u1", + FORCE_WARM_DAILY_LIMIT, + expect.any(Number), + ); + expect(generate).not.toHaveBeenCalled(); + expect(result.comprehensive).toBe("capped"); + for (const g of statusGenerators) { + expect(g).toHaveBeenCalledTimes(1); + } + }); + + it("still runs the per-status + generic warm even when the comprehensive pass skipped (no provider)", async () => { + // v1.9.0 — the sections are decoupled. A skipped comprehensive + // (no provider) must NOT short-circuit the per-status + generic passes: + // each generator resolves its own provider chain and no-ops cheaply when + // none is configured. + const { prisma } = makePrisma([]); + const generate = vi + .fn() + .mockResolvedValue({ status: "skipped", reason: "no-provider" }); + const statusGenerators = Array.from({ length: 7 }, () => + vi.fn().mockResolvedValue({ hasProvider: false, cached: true }), + ); + const warmGenericMetrics = vi.fn().mockResolvedValue(0); + + const result = await forceWarmUser(prisma as never, "u1", "de", { + generate, + statusGenerators, + warmGenericMetrics, + }); + + for (const g of statusGenerators) { + expect(g).toHaveBeenCalledTimes(1); + expect(g).toHaveBeenCalledWith("u1", { locale: "de", force: false }); + } + expect(warmGenericMetrics).toHaveBeenCalledWith("u1", ["de"]); + expect(result).toMatchObject({ + comprehensive: "skipped", + assessmentsWarmed: 0, + metricAssessmentsWarmed: 0, + }); + }); + + it("reports a timed-out comprehensive distinctly and still warms the rest", async () => { + const { prisma, update } = makePrisma([]); const generate = vi.fn().mockImplementation( () => new Promise((resolve) => { @@ -648,16 +796,17 @@ describe("forceWarmUser — on-demand single-user warm (v1.8.7.1)", () => { const result = await promise; for (const g of statusGenerators) { - expect(g).toHaveBeenCalledTimes(2); + expect(g).toHaveBeenCalledTimes(1); } - expect(warmGenericMetrics).toHaveBeenCalledWith( - "u1", - ["de", "en"], - false, - ); + expect(warmGenericMetrics).toHaveBeenCalledWith("u1", ["de"]); + // A timeout counts as a failure for the backoff marker. + expect(update).toHaveBeenCalledWith({ + where: { id: "u1" }, + data: { insightsWarmFailedAt: expect.any(Date) }, + }); expect(result).toMatchObject({ comprehensive: "timeout", - assessmentsWarmed: 14, + assessmentsWarmed: 7, metricAssessmentsWarmed: 3, }); } finally { @@ -715,24 +864,24 @@ describe("forceWarmUser — on-demand single-user warm (v1.8.7.1)", () => { // Comprehensive belongs to the `briefing` surface — skipped here. expect(generate).not.toHaveBeenCalled(); - // But the per-status + generic passes warm, both locale families. + // But the per-status + generic passes warm, in the caller's locale. for (const g of statusGenerators) { - expect(g).toHaveBeenCalledTimes(2); + expect(g).toHaveBeenCalledTimes(1); } - expect(warmGenericMetrics).toHaveBeenCalledWith("u1", ["de", "en"], false); + expect(warmGenericMetrics).toHaveBeenCalledWith("u1", ["de"]); expect(result).toMatchObject({ comprehensive: "skipped", - assessmentsWarmed: 14, + assessmentsWarmed: 7, metricAssessmentsWarmed: 2, }); }); - it("aborts the comprehensive on timeout so its late resolve cannot evict the warmed rows (M-1)", async () => { + it("aborts the comprehensive on timeout so its late resolve cannot write a stale cache row (M-1)", async () => { // v1.9.0 race fix: `withTimeout` cannot cancel the detached generation, - // so a comprehensive that resolves AFTER the timeout would reach its own - // `evictPerStatusInsightCache` and delete the rows the warm passes wrote. - // The forced path threads an AbortController; on timeout it must abort, - // and the generation observes `signal.aborted` before its evict. + // so a comprehensive that resolves AFTER the timeout would still write + // its cache row + timestamp. The forced path threads an AbortController; + // on timeout it must abort, and the generation observes `signal.aborted` + // before its cache write. const { prisma } = makePrisma([]); let observedSignal: AbortSignal | undefined; const generate = vi.fn().mockImplementation( @@ -768,9 +917,9 @@ describe("forceWarmUser — on-demand single-user warm (v1.8.7.1)", () => { expect(result.comprehensive).toBe("timeout"); // The warm passes ran and are not undone by the abandoned comprehensive. for (const g of statusGenerators) { - expect(g).toHaveBeenCalledTimes(2); + expect(g).toHaveBeenCalledTimes(1); } - expect(result.assessmentsWarmed).toBe(14); + expect(result.assessmentsWarmed).toBe(7); expect(result.metricAssessmentsWarmed).toBe(3); } finally { vi.useRealTimers(); diff --git a/src/lib/jobs/insight-pregenerate-shared.ts b/src/lib/jobs/insight-pregenerate-shared.ts index 3f5ef20e5..4ca7cfaa2 100644 --- a/src/lib/jobs/insight-pregenerate-shared.ts +++ b/src/lib/jobs/insight-pregenerate-shared.ts @@ -38,14 +38,13 @@ export interface InsightPregeneratePayload { * `singletonKey` per user collapses repeated requests within a short * window into one queued job, so a client that taps the button twice * (or the auto-trigger races a manual tap) can't double-warm. The key - * is deliberately locale-free: `forceWarmUser` warms BOTH locale - * families per job (v1.16.7), so a per-(user, locale) key would let a - * German web session and an English Accept-Language client enqueue two - * near-identical full warms back to back — the second one force- - * regenerating the comprehensive briefing and re-evicting the - * per-status rows the first job just wrote. No-ops cleanly when the - * global boss instance is not available (e.g. a web process without an - * embedded worker) — the nightly cron remains the catch-net. + * is deliberately locale-free: a German web session and an English + * Accept-Language client racing each other would otherwise enqueue two + * near-identical full warms back to back; the first job's locale wins + * the window and the other locale warms lazily through the read-path + * enqueue (v1.16.8). No-ops cleanly when the global boss instance is + * not available (e.g. a web process without an embedded worker) — the + * nightly cron remains the catch-net. */ export async function enqueueForceWarm(payload: { userId: string; @@ -69,9 +68,14 @@ export async function enqueueForceWarm(payload: { } satisfies InsightPregeneratePayload, { singletonKey: `force:${payload.userId}`, - // De-dupe within a 2-minute window so a double tap or an - // auto-trigger racing a manual tap enqueues one warm, not two. - singletonSeconds: 120, + // De-dupe within a 6-minute window. v1.16.8 — widened from 120 s: + // the client's revalidation poll runs up to ~250 s after a stale + // page-open, so a 2-minute singleton let the tail of one poll + // cycle enqueue a second (and third) full warm while the first + // was still running. 360 s outlives the poll horizon, so a stale + // page-open collapses to exactly one queued warm; the worker-side + // freshness re-check in `forceWarmUser` is the second guard. + singletonSeconds: 360, // Transient provider / pool failures retry with backoff instead // of leaving every cache cold until the nightly cron. retryLimit: 3, diff --git a/src/lib/jobs/insight-pregenerate.ts b/src/lib/jobs/insight-pregenerate.ts index 379cead21..eb7c7c8e8 100644 --- a/src/lib/jobs/insight-pregenerate.ts +++ b/src/lib/jobs/insight-pregenerate.ts @@ -10,15 +10,17 @@ * snapshot embeds a `briefingState: "ready"` block instead of * `"preparing"`. * - * v1.8.0 — warm the per-metric assessment caches too. The comprehensive - * generator evicts the seven `insights.-status.` caches - * as part of its write (so a stale comprehensive insight can never leave - * a stale per-metric card behind) but does NOT re-fill them. Before this, - * the morning after the cron ran the per-metric status caches were - * empty, so the first `/insights` mount still ran one live status - * generation per card. After a successful comprehensive generation this - * cron now forces the seven `*StatusForUser` generators so their caches - * are warm, not empty, by the user's first visit. + * v1.8.0 — warm the per-metric assessment caches too, so the first + * `/insights` mount of the morning is a cache read instead of one live + * status generation per card. + * + * v1.16.8 — the warm is single-locale (the user's resolved locale) and + * refill-only: every generator runs its own content-hash gate, so a card + * whose underlying data did not change since its last real assessment + * costs a cache-row refresh, not an LLM call. The comprehensive write no + * longer evicts the per-status rows, so there is nothing to force-refill; + * a second locale (e.g. an English iOS client against a German account) + * warms lazily through the read-path enqueue on its first visit. * * Runs once nightly at 04:30 Europe/Berlin — inside the existing * 03:xx–04:xx maintenance window, after the feedback aggregator (04:00) @@ -111,6 +113,33 @@ export const PREGENERATE_BATCH_CAP = 200; /** Per-user budget bucket window — one pre-generation per 20 h. */ const PREGENERATE_BUDGET_WINDOW_MS = 20 * 60 * 60 * 1000; +/** + * v1.16.8 — freshness re-check for the forced single-user warm. The + * enqueue side already de-dupes via `singletonKey`, but a poll horizon + * that outlives the singleton window can stack several force jobs back + * to back; re-checking `insightsCachedAt` at job start collapses the + * stack into one real warm. + */ +export const FORCE_WARM_FRESH_MS = 60 * 60 * 1000; + +/** + * v1.16.8 — backoff after a failed / timed-out forced comprehensive. + * While `insightsWarmFailedAt` is younger than this, the forced path + * skips the comprehensive attempt instead of re-driving a broken + * provider chain on every page-open. The nightly tick (its own budget + * bucket) and the user's manual regenerate are unaffected. + */ +export const FORCE_WARM_FAILURE_BACKOFF_MS = 60 * 60 * 1000; + +/** + * v1.16.8 — daily cap on forced full warms. At most two forced + * comprehensive attempts per user per rolling 24 h; the nightly tick + * rides its own 20 h bucket, so a capped user still gets the overnight + * refresh. Reuses the Postgres rate-limit bucket mechanism. + */ +export const FORCE_WARM_DAILY_LIMIT = 2; +const FORCE_WARM_DAILY_WINDOW_MS = 24 * 60 * 60 * 1000; + /** * Concurrency for the per-user warm pass. The seven `*StatusForUser` * generators are independent (each writes its own cache row), so they run @@ -145,8 +174,20 @@ export interface ForceWarmResult { * distinct from `"failed"`: the comprehensive step exceeded its own * bounded budget (see `COMPREHENSIVE_WARM_TIMEOUT_MS`) and was * abandoned so the per-status + generic-metric passes could still run. + * + * v1.16.8 — three pre-flight outcomes that never reach the generator: + * `"fresh"` (the cache was warmed within `FORCE_WARM_FRESH_MS`), + * `"backoff"` (a forced attempt failed within + * `FORCE_WARM_FAILURE_BACKOFF_MS`), and `"capped"` (the per-user daily + * forced-warm budget is exhausted). All three still run the refill-only + * card warm below. */ - comprehensive: GenerateOutcome["status"] | "timeout"; + comprehensive: + | GenerateOutcome["status"] + | "timeout" + | "fresh" + | "backoff" + | "capped"; /** Count of specialised per-status assessments written warm. */ assessmentsWarmed: number; /** Count of generic per-HealthKit-metric assessments written warm. */ @@ -157,6 +198,11 @@ export interface PregenerateRunResult { total: number; generated: number; cached: number; + /** + * v1.16.8 — comprehensives whose content-hash gate found the feature + * snapshot unchanged: timestamp refreshed, no provider call. + */ + unchanged: number; skipped: number; failed: number; budgetBlocked: number; @@ -210,38 +256,33 @@ const DEFAULT_STATUS_GENERATORS: ReadonlyArray = [ ]; /** - * Re-fill the per-metric assessment caches for one user, forcing a fresh - * generation so the row the comprehensive generator just evicted is - * written warm. Returns the count of fresh (provider-backed, + * Re-fill the per-metric assessment caches for one user. Refill-only + * (`force: false`): a card already generated today short-circuits to its + * cache row, and a cold card runs its generator — whose content-hash gate + * (v1.16.8) skips the LLM and refreshes the row when the underlying data + * did not change. Returns the count of fresh (provider-backed, * non-cached) assessments written. A generator that has no provider or * fails serves its fallback without persisting it, so it does not count * toward the warmed tally and never throws the batch loop off course. + * + * `locales` carries only the user's resolved locale (v1.16.8). The old + * dual-locale warm existed because the comprehensive write evicted every + * locale family at once; that eviction is gone, and a second locale the + * user actually reads (e.g. an English client against a German account) + * warms lazily through `resolveReadOnlyStatusMiss`'s enqueue on its + * first cache-miss read. */ async function warmPerStatusCaches( userId: string, locales: ReadonlyArray<"de" | "en">, generators: ReadonlyArray, - // v1.16.1 — `force: false` is the refill-only mode the nightly fallback - // warm uses: the per-status cache is keyed on today's dateKey, so a card - // already generated today short-circuits to its cache row while a cold - // card still generates. The eviction path (after a generated/cached - // comprehensive) keeps `force: true` because the rows were just deleted. - force = true, ): Promise { const limit = pLimit(WARM_PASS_CONCURRENCY); - // v1.8.3 — warm every locale the user might request, not just the - // User.locale. The per-status cache key is - // `insights.-status.`, but the client sends the active - // UI-cookie / Accept-Language locale (narrowed to de|en), which can - // differ from the persisted User.locale (e.g. null → "de" in the cron - // while the browser cookie is "en"). Warming only the User.locale left - // the locale the client actually reads cold, so the first visit still - // ran a live generation per card — the exact freeze this release closes. const tasks = generators.flatMap((generate) => locales.map((locale) => limit(async () => { try { - const outcome = await generate(userId, { locale, force }); + const outcome = await generate(userId, { locale, force: false }); return outcome.hasProvider && !outcome.cached ? 1 : 0; } catch { // A single card's generation failing must not abort the rest of @@ -271,8 +312,6 @@ async function warmGenericMetricCaches( prisma: PrismaClient, userId: string, locales: ReadonlyArray<"de" | "en">, - // See `warmPerStatusCaches` — `false` is the refill-only fallback mode. - force = true, ): Promise { // One distinct read: the set of MeasurementTypes the user has live // rows for. Best-effort — a read failure (or a worker prisma surface @@ -303,11 +342,14 @@ async function warmGenericMetricCaches( locales.map((locale) => limit(async () => { try { + // Refill-only (see `warmPerStatusCaches`): a today-cached card is + // a cheap read; a cold card runs the generator, whose content-hash + // gate skips the LLM when its data did not change. const outcome = await generateMetricStatus({ metric, userId, locale, - force, + force: false, }); // `insufficient` never reaches here (we pre-filtered to // metrics with data), but guard anyway: count only a fresh @@ -396,7 +438,6 @@ export async function runInsightPregenerate( warmGenericMetrics?: ( userId: string, locales: ReadonlyArray<"de" | "en">, - force?: boolean, ) => Promise; } = {}, ): Promise { @@ -407,13 +448,14 @@ export async function runInsightPregenerate( options.statusGenerators ?? DEFAULT_STATUS_GENERATORS; const warmGenericMetrics = options.warmGenericMetrics ?? - ((userId: string, locales: ReadonlyArray<"de" | "en">, force?: boolean) => - warmGenericMetricCaches(prisma, userId, locales, force)); + ((userId: string, locales: ReadonlyArray<"de" | "en">) => + warmGenericMetricCaches(prisma, userId, locales)); const result: PregenerateRunResult = { total: 0, generated: 0, cached: 0, + unchanged: 0, skipped: 0, failed: 0, budgetBlocked: 0, @@ -455,14 +497,15 @@ export async function runInsightPregenerate( // budget bucket, then short-circuit to `cached` with no actual // pre-generation — defeating the "warm the cache before the user's // morning visit" intent for the common case. The per-user budget - // bucket (above) is what bounds cost, not the TTL re-check. + // bucket (above) bounds attempts, and the generator's content-hash + // gate (v1.16.8) turns a same-data force into a timestamp refresh + // (`unchanged`) with no provider call. // // Bounded budget + abort (v1.16.1): one stalled provider must not // pin the whole batch behind a single candidate, and `withTimeout` // alone cannot cancel the detached generation — without the abort a - // late resolve would reach `evictPerStatusInsightCache` and delete - // the rows the warm passes write below (the same race the forced - // single-user warm closes). + // late resolve would still write a cache row + timestamp after the + // loop moved on (the same race the forced single-user warm closes). const controller = new AbortController(); const bounded = await withTimeout( () => @@ -486,6 +529,9 @@ export async function runInsightPregenerate( case "cached": result.cached++; break; + case "unchanged": + result.unchanged++; + break; case "skipped": result.skipped++; break; @@ -496,20 +542,15 @@ export async function runInsightPregenerate( } } - // Warm the per-metric assessment caches. Two modes: - // - // - After a `generated` or `cached` comprehensive the generator's - // write evicted (`evictPerStatusInsightCache`) every per-status - // row, so the warm forces a fresh generation per card. - // - On every other path (budget-blocked, failed, timed-out, - // consent-skipped comprehensive) the warm still runs, but - // refill-only (`force: false`): the per-status cache is keyed on - // today's dateKey, so a card already generated today is a cheap - // cache read while a cold card generates. Before v1.16.1 these - // paths skipped the warm entirely — and because the 02:xx status - // crons skip every pregenerate candidate, a single failed or - // budget-blocked comprehensive left ALL of that user's cards cold - // until the first on-visit generation. + // Warm the per-metric assessment caches — refill-only, single locale + // (v1.16.8). The comprehensive write no longer evicts the per-status + // rows, so there is nothing to force-refill: a card already generated + // today is a cheap cache read, a cold card runs its generator, and the + // generator's content-hash gate turns a same-data regeneration into a + // timestamp refresh with no provider call. The warm runs on every + // comprehensive outcome (budget-blocked, failed, timed-out, + // consent-skipped included) because the 02:xx status crons skip every + // pregenerate candidate on the assumption that THIS pass covers them. // // The only outcome with nothing to warm is a missing provider // (`skipped`/`no-provider`): the per-card generators would no-op to @@ -518,24 +559,17 @@ export async function runInsightPregenerate( if (outcome?.status === "skipped" && outcome.reason === "no-provider") { continue; } - const evicted = - outcome?.status === "generated" || outcome?.status === "cached"; result.assessmentsWarmed += await warmPerStatusCaches( candidate.id, - // Warm both supported locales — the client reads against the active - // UI locale, which can differ from the persisted User.locale. - ["de", "en"], + [locale], statusGenerators, - evicted, ); // v1.8.7.1 — warm the generic per-HealthKit-metric caches too, for // the user's data-bearing metrics only (the helper filters via one // grouped count, so an empty metric never reaches the provider). - result.metricAssessmentsWarmed += await warmGenericMetrics( - candidate.id, - ["de", "en"], - evicted, - ); + result.metricAssessmentsWarmed += await warmGenericMetrics(candidate.id, [ + locale, + ]); } // Wide-event tally so the nightly dashboard can track how many @@ -546,6 +580,7 @@ export async function runInsightPregenerate( total: result.total, generated: result.generated, cached: result.cached, + unchanged: result.unchanged, skipped: result.skipped, failed: result.failed, budget_blocked: result.budgetBlocked, @@ -564,25 +599,26 @@ export async function runInsightPregenerate( * comprehensive insight (which carries the daily briefing), the seven * specialised `*-status` assessments, and every data-bearing generic * `metric:` assessment — but for a single explicit `userId` and - * WITHOUT the per-user 20 h budget bucket. The endpoint that enqueues this - * job carries its own short anti-spam rate-limit (one warm per few - * minutes), so the forced path can skip the nightly budget while staying - * bounded against abuse. + * WITHOUT the per-user 20 h budget bucket. The forced path carries its + * own bounds instead (v1.16.8): a freshness re-check at job start, an + * hour of backoff after a failed attempt, and a daily cap of + * `FORCE_WARM_DAILY_LIMIT` forced comprehensive attempts — on top of the + * enqueuing endpoint's short anti-spam rate-limit. * * Safety is preserved by the helpers it reuses, not by a budget gate: * - The comprehensive generator no-ops to `{ status: "skipped" }` when * no provider is configured (no LLM call). * - `warmGenericMetricCaches` filters to metrics the user has rows for * via one grouped read, so an empty metric never reaches the provider. - * - Each generator forces a fresh write but is idempotent — re-running - * it just rewrites the same cache row; concurrent enqueues collapse via - * the queue `singletonKey`. + * - Each generator is idempotent — re-running it just rewrites the same + * cache row; concurrent enqueues collapse via the queue + * `singletonKey`, and the content-hash gates collapse a same-data + * re-run into a timestamp refresh. * - * Like the nightly cron, the forced path warms BOTH locales (de+en): its - * own comprehensive step evicts every per-status row across both locale - * families, and the refill-only mode keeps the second family's cost at - * exactly the cards that are cold. The caller's `locale` still picks the - * language of the comprehensive briefing itself. + * Like the nightly cron (v1.16.8), the forced path warms ONLY the + * caller's resolved locale — the cross-locale per-status eviction that + * once required dual-locale refills is gone, and a second locale a user + * actually reads warms lazily through the read-path enqueue. * * The three sections are decoupled: the comprehensive step runs under its * own bounded budget (`COMPREHENSIVE_WARM_TIMEOUT_MS`) and its @@ -610,8 +646,9 @@ export async function forceWarmUser( warmGenericMetrics?: ( userId: string, locales: ReadonlyArray<"de" | "en">, - force?: boolean, ) => Promise; + /** Injected for the test — defaults to `new Date()`. */ + now?: Date; } = {}, ): Promise { const generate = options.generate ?? generateComprehensiveInsight; @@ -619,8 +656,9 @@ export async function forceWarmUser( options.statusGenerators ?? DEFAULT_STATUS_GENERATORS; const warmGenericMetrics = options.warmGenericMetrics ?? - ((id: string, locales: ReadonlyArray<"de" | "en">, force?: boolean) => - warmGenericMetricCaches(prisma, id, locales, force)); + ((id: string, locales: ReadonlyArray<"de" | "en">) => + warmGenericMetricCaches(prisma, id, locales)); + const now = options.now ?? new Date(); const result: ForceWarmResult = { comprehensive: "skipped", @@ -640,17 +678,12 @@ export async function forceWarmUser( const flags = await getAssistantFlags(); if (!flags.briefing && !flags.insightStatus) return result; - // Warm BOTH supported locales, matching the nightly pass. A fresh - // comprehensive generation evicts every per-status cache row in both - // locale families (`evictPerStatusInsightCache` deletes by scope, not - // by locale) — refilling only the caller's active locale left the - // other family cold, so an account read through two surfaces (German - // web UI + an English Accept-Language client) regenerated the second - // family card-by-card on its next visits, all day, every day. The - // refill mode below short-circuits to the cache for any card already - // generated today, so the second locale costs only the cards that are - // actually cold. - const locales: ReadonlyArray<"de" | "en"> = ["de", "en"]; + // v1.16.8 — warm only the caller's resolved locale. The old dual-locale + // warm compensated for the comprehensive write's cross-locale per-status + // eviction; that eviction is gone, so the other locale family's rows + // survive untouched and a client actually reading the second locale + // warms it lazily through the read-path enqueue on its first miss. + const locales: ReadonlyArray<"de" | "en"> = [locale]; // The comprehensive step gets its own bounded budget and its failure is // non-fatal. A slow or stalled briefing generation must not short-circuit @@ -658,27 +691,112 @@ export async function forceWarmUser( // user actually clicks first, and warming them is independent of the // comprehensive insight (each generator resolves its own provider chain // and writes its own cache row). A timeout / error / skipped comprehensive - // now falls through to the warm passes instead of aborting the whole job. + // falls through to the warm passes instead of aborting the whole job. // // On a timeout `withTimeout` cannot cancel the detached generation; without - // a cut-off a late-resolving comprehensive would reach its own - // `evictPerStatusInsightCache` and delete the rows the warm passes write - // below. Thread an AbortController so the timeout aborts the generation - // before it can touch the cache. + // a cut-off a late-resolving comprehensive would still write its cache row + // + timestamp after the job moved on. Thread an AbortController so the + // timeout aborts the generation before it can touch the cache. + // + // v1.16.8 — three pre-flight gates make the forced path idempotent and + // bounded (the enqueue-side singleton alone cannot, because the client's + // revalidation poll outlives any reasonable singleton window): + // 1. freshness — a comprehensive warmed within the last hour is + // current; re-forcing it would at best re-discover an unchanged + // hash and at worst burn a generation on intra-day noise. + // 2. failure backoff — a forced attempt that failed within the last + // hour stays failed for most causes (broken key, dead local + // endpoint); re-attempting on every page-open re-runs the full + // feature extraction + provider stall each time. + // 3. daily cap — at most FORCE_WARM_DAILY_LIMIT forced attempts per + // rolling 24 h, so even a client bug that defeats 1+2 cannot turn + // the forced path into an unmetered generation loop. if (flags.briefing) { - const controller = new AbortController(); - const comprehensive = await withTimeout( - () => generate(userId, { locale, force: true, signal: controller.signal }), - COMPREHENSIVE_WARM_TIMEOUT_MS, - null, - () => controller.abort(), - ); - if (comprehensive.timedOut) { - result.comprehensive = "timeout"; - } else if (comprehensive.errored || comprehensive.value === null) { - result.comprehensive = "failed"; + let freshness: { insightsCachedAt: Date | null; insightsWarmFailedAt: Date | null } | null = + null; + try { + freshness = await prisma.user.findUnique({ + where: { id: userId }, + select: { insightsCachedAt: true, insightsWarmFailedAt: true }, + }); + } catch { + // Best-effort pre-flight — on a read failure fall through to the + // budgeted generation attempt below. + } + + const cachedAt = freshness?.insightsCachedAt ?? null; + const failedAt = freshness?.insightsWarmFailedAt ?? null; + const isFresh = + cachedAt !== null && now.getTime() - cachedAt.getTime() < FORCE_WARM_FRESH_MS; + const inBackoff = + !isFresh && + failedAt !== null && + now.getTime() - failedAt.getTime() < FORCE_WARM_FAILURE_BACKOFF_MS; + + if (isFresh) { + result.comprehensive = "fresh"; + annotate({ + action: { name: "insights.pregenerate.force.fresh" }, + meta: { cached_at: cachedAt?.toISOString() ?? null }, + }); + } else if (inBackoff) { + result.comprehensive = "backoff"; + annotate({ + action: { name: "insights.pregenerate.force.backoff" }, + meta: { failed_at: failedAt?.toISOString() ?? null }, + }); } else { - result.comprehensive = comprehensive.value.status; + const budget = await checkRateLimit( + `insight-pregenerate-daily:${userId}`, + FORCE_WARM_DAILY_LIMIT, + FORCE_WARM_DAILY_WINDOW_MS, + ); + if (!budget.allowed) { + result.comprehensive = "capped"; + annotate({ + action: { name: "insights.pregenerate.force.capped" }, + meta: { daily_limit: FORCE_WARM_DAILY_LIMIT }, + }); + } else { + const controller = new AbortController(); + const comprehensive = await withTimeout( + () => + generate(userId, { locale, force: true, signal: controller.signal }), + COMPREHENSIVE_WARM_TIMEOUT_MS, + null, + () => controller.abort(), + ); + if (comprehensive.timedOut) { + result.comprehensive = "timeout"; + } else if (comprehensive.errored || comprehensive.value === null) { + result.comprehensive = "failed"; + } else { + result.comprehensive = comprehensive.value.status; + } + + // Stamp / clear the failure marker for the backoff gate above. A + // `skipped` outcome (no provider / no consent) is not a failure — + // it costs nothing and should not delay a user who configures a + // provider a minute later. + const failed = + result.comprehensive === "failed" || + result.comprehensive === "timeout"; + try { + if (failed) { + await prisma.user.update({ + where: { id: userId }, + data: { insightsWarmFailedAt: now }, + }); + } else if (failedAt !== null) { + await prisma.user.update({ + where: { id: userId }, + data: { insightsWarmFailedAt: null }, + }); + } + } catch { + // Marker maintenance is best-effort; never fail the warm on it. + } + } } } @@ -691,26 +809,17 @@ export async function forceWarmUser( // outcome that has nothing to warm is a missing provider, and the // generators detect that themselves at near-zero cost. if (flags.insightStatus) { - // Mirror the nightly loop's force/refill split: only a `generated` - // comprehensive ran `evictPerStatusInsightCache`, so only then must - // every card re-generate. (The forced generation above never returns - // `cached` — `force: true` skips the 24 h cache-hit branch.) On a - // failed / timed-out / skipped comprehensive the rows survived — - // refill-only (`force: false`) lets a card already generated today - // short-circuit to its cache, which is what keeps the second locale - // family cheap. - const evicted = result.comprehensive === "generated"; + // Refill-only (v1.16.8): a card already generated today is a cheap + // cache read; a cold card runs its generator, whose content-hash gate + // skips the LLM when the underlying data did not change. There is no + // force mode left to mirror — the comprehensive write no longer evicts + // the per-status rows. result.assessmentsWarmed = await warmPerStatusCaches( userId, locales, statusGenerators, - evicted, - ); - result.metricAssessmentsWarmed = await warmGenericMetrics( - userId, - locales, - evicted, ); + result.metricAssessmentsWarmed = await warmGenericMetrics(userId, locales); } annotate({ From b346e78cc77bc4ede588e9d8fab80cb7a578cabb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 11 Jun 2026 21:17:17 +0200 Subject: [PATCH 02/12] perf(medications): one compliance round trip and a memoised formatter hot path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Intl.DateTimeFormat instances are memoised per locale, timezone and options signature — construction dominated the 365-day ledger build and the hot path drops about fivefold. A batched GET /api/medications/compliance serves every card from one request through the same per-medication cache cells the detail page reads, replacing the per-card fan-out; the list response stays untouched so the due line never waits on a ledger build. The medications and compliance buckets serve stale-while-revalidate, interactive writes evict while background bulk sync marks stale, the per-id route defers ownership and schedule reads until a cache miss, and the card body reserves constant slots for the status and cycle lines so late data stops shifting the layout. --- docs/api/openapi.yaml | 204 +++++++----- .../[id]/compliance/__tests__/route.test.ts | 66 +++- .../api/medications/[id]/compliance/route.ts | 306 ++++-------------- .../[id]/intake/[eventId]/route.ts | 4 +- .../[id]/intake/bulk-delete/route.ts | 2 +- .../medications/[id]/intake/import/route.ts | 2 +- .../[id]/intake/purge/__tests__/route.test.ts | 6 +- .../medications/[id]/intake/purge/route.ts | 2 +- src/app/api/medications/[id]/intake/route.ts | 4 +- src/app/api/medications/[id]/route.ts | 4 +- .../api/medications/__tests__/route.test.ts | 3 + .../compliance/__tests__/route.test.ts | 196 +++++++++++ src/app/api/medications/compliance/route.ts | 82 +++++ src/app/api/medications/intake/bulk/route.ts | 6 +- src/app/api/medications/intake/route.ts | 2 +- src/app/api/medications/route.ts | 19 +- .../medications/__tests__/card-parts.test.tsx | 17 +- .../__tests__/medication-card-glp1.test.tsx | 21 +- .../medication-card-next-due.test.tsx | 17 +- .../medication-card-symmetry.test.tsx | 18 +- .../__tests__/use-medication-intake.test.ts | 4 +- .../card-parts/medication-card-body.tsx | 42 ++- .../medications/glp1-medication-card.tsx | 35 +- .../medications/medication-card.tsx | 43 +-- src/lib/cache/__tests__/invalidate.test.ts | 51 +++ src/lib/cache/invalidate.ts | 51 ++- src/lib/cache/server-cache.ts | 22 ++ src/lib/medications/compliance-payload.ts | 240 ++++++++++++++ src/lib/openapi/routes/medications.ts | 35 ++ src/lib/queries/prefetch-medications.ts | 18 +- .../use-medication-compliance-summary.ts | 61 ++++ src/lib/query-keys/medications.ts | 12 + src/lib/tz/__tests__/intl-cache.test.ts | 185 +++++++++++ src/lib/tz/format.ts | 63 ++-- src/lib/tz/intl-cache.ts | 77 +++++ src/lib/tz/wall-clock.ts | 33 +- 36 files changed, 1453 insertions(+), 500 deletions(-) create mode 100644 src/app/api/medications/compliance/__tests__/route.test.ts create mode 100644 src/app/api/medications/compliance/route.ts create mode 100644 src/lib/medications/compliance-payload.ts create mode 100644 src/lib/queries/use-medication-compliance-summary.ts create mode 100644 src/lib/tz/__tests__/intl-cache.test.ts create mode 100644 src/lib/tz/intl-cache.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index be5d6ee14..4cc34596c 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -2366,6 +2366,27 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a3 "429": *a4 + /api/medications/compliance: + get: + tags: + - Medications + summary: Batched adherence read for every medication of the caller + description: Returns one compact adherence row per medication the caller owns (active + paused), ordered by `createdAt + DESC` — the single round trip the medication cards consume instead of fanning out one + `/api/medications/{id}/compliance` request per card. Each row carries the 7-/30-day summaries and the + cadence-scaled display block; the per-day grid stays on the per-medication endpoint. Pure computation — no + writes. Served through the same per-medication server cache as the per-id read, so the two endpoints warm each + other. + responses: + "200": + description: One adherence row per medication. + content: + application/json: + schema: + $ref: "#/components/schemas/ListMedicationComplianceResponse" + "401": *a1 + "422": *a3 + "429": *a4 /api/medications/{id}/compliance: get: tags: @@ -8897,11 +8918,13 @@ components: additionalProperties: false description: One expected-vs-actual dose slot for the cadence timeline chart. `status` is one of `taken | skipped | missed | pending | future` and drives the chip colour. - GetMedicationComplianceResponse: + ListMedicationComplianceResponse: type: object properties: data: - $ref: "#/components/schemas/MedicationComplianceResponse" + type: array + items: + $ref: "#/components/schemas/MedicationComplianceSummaryEntry" error: type: "null" meta: @@ -8914,33 +8937,26 @@ components: - data - error additionalProperties: false - MedicationComplianceResponse: + MedicationComplianceSummaryEntry: type: object properties: + medicationId: + type: string compliance7: $ref: "#/components/schemas/ComplianceResult" compliance30: $ref: "#/components/schemas/ComplianceResult" - dailyCompliance: - type: object - propertyNames: - type: string - additionalProperties: - $ref: "#/components/schemas/DailyComplianceEntry" - description: Flat per-day map keyed `YYYY-MM-DD` in the user timezone, one entry per day for up to 90 days back, clamped - to the medication's `createdAt` (so a recently-created med has fewer entries). No weekly/monthly collapse — - this is the raw daily grid. complianceDisplay: $ref: "#/components/schemas/ComplianceDisplay" required: + - medicationId - compliance7 - compliance30 - - dailyCompliance - complianceDisplay additionalProperties: false - description: Adherence read for a single medication. `compliance30` is the authoritative 30-day taken-vs-expected - summary; `dailyCompliance` is the per-day grid for the history glyph track. The graded raw→week→month→year - series used elsewhere for AI prompts does NOT apply here — this response is never downsampled. + description: "Compact per-medication adherence row for the batched card read: the 7-/30-day summaries plus the + cadence-scaled display block. The per-day `dailyCompliance` grid is NOT on this shape — read the per-medication + `/compliance` endpoint for the history glyph track." ComplianceResult: type: object properties: @@ -8984,62 +9000,6 @@ components: description: Rolling-window adherence summary. `compliance30` is the authoritative 'last 30 days, taken vs expected' read — clients should display `rate` and use `totalExpected` as the denominator rather than re-deriving it from the daily map. - DailyComplianceEntry: - type: object - properties: - expected: - type: integer - minimum: 0 - maximum: 9007199254740991 - description: Engine-computed due-slot count for the day. Equals `expectedCount`; kept for existing consumers. - expectedCount: - type: integer - minimum: 0 - maximum: 9007199254740991 - description: True due-slot count for the day (additive field clients key off so they don't infer due-ness from - `expected`). - due: - type: boolean - description: "`expectedCount > 0`. Paint a per-day glyph as expected/missed ONLY when `due === true`; off-cadence / - pre-creation / PRN days are not misses." - taken: - type: integer - minimum: 0 - maximum: 9007199254740991 - skipped: - type: integer - minimum: 0 - maximum: 9007199254740991 - onTime: - type: integer - minimum: 0 - maximum: 9007199254740991 - description: Doses taken in the on-time band, including the `early` bucket (early counts as compliant). - late: - type: integer - minimum: 0 - maximum: 9007199254740991 - veryLate: - type: integer - minimum: 0 - maximum: 9007199254740991 - early: - description: Doses taken before the on-time band's grace start; already folded into `onTime`, surfaced separately for - consumers that differentiate. - type: integer - minimum: 0 - maximum: 9007199254740991 - required: - - expected - - expectedCount - - due - - taken - - skipped - - onTime - - late - - veryLate - additionalProperties: false - description: Per-day compliance cell with the timing breakdown that drives the history glyph track. ComplianceDisplay: type: object properties: @@ -9206,6 +9166,106 @@ components: additionalProperties: false description: The two-row card block whose windows scale with dosing cadence (dense meds keep 7 / 30 days, sparse meds step both windows up). NOT the 30-day denominator — read `compliance30.totalExpected` for that. + GetMedicationComplianceResponse: + type: object + properties: + data: + $ref: "#/components/schemas/MedicationComplianceResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + MedicationComplianceResponse: + type: object + properties: + compliance7: + $ref: "#/components/schemas/ComplianceResult" + compliance30: + $ref: "#/components/schemas/ComplianceResult" + dailyCompliance: + type: object + propertyNames: + type: string + additionalProperties: + $ref: "#/components/schemas/DailyComplianceEntry" + description: Flat per-day map keyed `YYYY-MM-DD` in the user timezone, one entry per day for up to 90 days back, clamped + to the medication's `createdAt` (so a recently-created med has fewer entries). No weekly/monthly collapse — + this is the raw daily grid. + complianceDisplay: + $ref: "#/components/schemas/ComplianceDisplay" + required: + - compliance7 + - compliance30 + - dailyCompliance + - complianceDisplay + additionalProperties: false + description: Adherence read for a single medication. `compliance30` is the authoritative 30-day taken-vs-expected + summary; `dailyCompliance` is the per-day grid for the history glyph track. The graded raw→week→month→year + series used elsewhere for AI prompts does NOT apply here — this response is never downsampled. + DailyComplianceEntry: + type: object + properties: + expected: + type: integer + minimum: 0 + maximum: 9007199254740991 + description: Engine-computed due-slot count for the day. Equals `expectedCount`; kept for existing consumers. + expectedCount: + type: integer + minimum: 0 + maximum: 9007199254740991 + description: True due-slot count for the day (additive field clients key off so they don't infer due-ness from + `expected`). + due: + type: boolean + description: "`expectedCount > 0`. Paint a per-day glyph as expected/missed ONLY when `due === true`; off-cadence / + pre-creation / PRN days are not misses." + taken: + type: integer + minimum: 0 + maximum: 9007199254740991 + skipped: + type: integer + minimum: 0 + maximum: 9007199254740991 + onTime: + type: integer + minimum: 0 + maximum: 9007199254740991 + description: Doses taken in the on-time band, including the `early` bucket (early counts as compliant). + late: + type: integer + minimum: 0 + maximum: 9007199254740991 + veryLate: + type: integer + minimum: 0 + maximum: 9007199254740991 + early: + description: Doses taken before the on-time band's grace start; already folded into `onTime`, surfaced separately for + consumers that differentiate. + type: integer + minimum: 0 + maximum: 9007199254740991 + required: + - expected + - expectedCount + - due + - taken + - skipped + - onTime + - late + - veryLate + additionalProperties: false + description: Per-day compliance cell with the timing breakdown that drives the history glyph track. ListScheduleRevisionsResponse: type: object properties: diff --git a/src/app/api/medications/[id]/compliance/__tests__/route.test.ts b/src/app/api/medications/[id]/compliance/__tests__/route.test.ts index 7a644fb26..e89faabaa 100644 --- a/src/app/api/medications/[id]/compliance/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/compliance/__tests__/route.test.ts @@ -29,16 +29,14 @@ vi.mock("@/lib/db", () => ({ vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); -vi.mock("@/lib/medications/route-guards", () => ({ - assertMedicationOwnership: vi.fn().mockResolvedValue(null), -})); - // v1.15.20 — the route reads through the per-user compliance cache and a // per-user rate limit. Pass-through mocks keep each test's fixture // isolated (the real module-scope cache would leak state across tests); -// the rate-limit tests below flip `allowed` explicitly. +// the rate-limit tests below flip `allowed` explicitly. v1.16.8 — the +// read is SWR (`cachedSwr`) and the ownership guard lives INSIDE the +// builder, so the pass-through exercises the guard on every call. vi.mock("@/lib/cache/server-cache", () => ({ - cached: vi.fn( + cachedSwr: vi.fn( async (_cache: unknown, _key: string, builder: () => Promise) => builder(), ), @@ -71,7 +69,7 @@ vi.mock("next/headers", () => ({ import { GET } from "../route"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; -import { cached } from "@/lib/cache/server-cache"; +import { cachedSwr } from "@/lib/cache/server-cache"; import { checkRateLimit } from "@/lib/rate-limit"; import { getUserTodayBounds } from "@/lib/tz/local-day"; import { userDayKey } from "@/lib/tz/format"; @@ -130,7 +128,7 @@ beforeEach(() => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); // `resetAllMocks` clears the factory implementations — re-prime the // pass-through cache and the allowing rate limit for every test. - vi.mocked(cached).mockImplementation( + vi.mocked(cachedSwr).mockImplementation( async (_cache: unknown, _key: string, builder: () => Promise) => builder(), ); @@ -436,8 +434,8 @@ describe("GET /api/medications/[id]/compliance — caching and limits", () => { const res = await GET(new Request("http://localhost"), ROUTE_PARAMS); expect(res.status).toBe(200); - expect(vi.mocked(cached)).toHaveBeenCalledTimes(1); - const [, key] = vi.mocked(cached).mock.calls[0]; + expect(vi.mocked(cachedSwr)).toHaveBeenCalledTimes(1); + const [, key] = vi.mocked(cachedSwr).mock.calls[0]; // `${userId}|...` prefix is what `invalidateUserMedications` sweeps. // The trailing tz segment (v1.16.1) makes a timezone change miss the // old entry instead of serving day buckets computed for the prior zone. @@ -471,3 +469,51 @@ describe("GET /api/medications/[id]/compliance — caching and limits", () => { }); }); }); + +// v1.16.8 — the ownership guard + medication read moved INSIDE the cache +// builder: the key is `${userId}|${medicationId}|…`, so a warm hit is by +// construction a payload this caller built for a medication they own, and +// the pre-cache session→rate-limit path no longer pays two row reads per +// card. The guard itself still runs on every cold build. +describe("GET /api/medications/[id]/compliance — ownership inside the builder", () => { + it("returns the sealed 404 when the medication belongs to another user", async () => { + vi.mocked(prisma.medication.findUnique).mockResolvedValue({ + ...medication([]), + userId: "user-2", + } as never); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( + [] as never, + ); + + const res = await GET(new Request("http://localhost"), ROUTE_PARAMS); + expect(res.status).toBe(404); + const body = await res.json(); + // Same envelope as the absent case — the existence channel stays sealed. + expect(body.error).toBe("Medication not found"); + }); + + it("returns the sealed 404 when the medication does not exist", async () => { + vi.mocked(prisma.medication.findUnique).mockResolvedValue(null as never); + + const res = await GET(new Request("http://localhost"), ROUTE_PARAMS); + expect(res.status).toBe(404); + const body = await res.json(); + expect(body.error).toBe("Medication not found"); + }); + + it("never touches the medication tables on a warm cache hit", async () => { + // Simulate a warm cell: the SWR read resolves without invoking the + // builder, so neither the medication nor the intake table is read. + vi.mocked(cachedSwr).mockResolvedValue({ + compliance7: { rate: 100 }, + compliance30: { rate: 100 }, + dailyCompliance: {}, + complianceDisplay: { shortDays: 7, longDays: 30 }, + } as never); + + const res = await GET(new Request("http://localhost"), ROUTE_PARAMS); + expect(res.status).toBe(200); + expect(prisma.medication.findUnique).not.toHaveBeenCalled(); + expect(prisma.medicationIntakeEvent.findMany).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/medications/[id]/compliance/route.ts b/src/app/api/medications/[id]/compliance/route.ts index 2265171a5..ba7f2ef57 100644 --- a/src/app/api/medications/[id]/compliance/route.ts +++ b/src/app/api/medications/[id]/compliance/route.ts @@ -3,234 +3,41 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { apiSuccess, apiError } from "@/lib/api-response"; import { - buildComplianceMedicationContext, - buildMedicationComplianceBundle, - lastNonSkippedTakenAt, - type ComplianceDisplay, - type ComplianceResult, - type DailyComplianceEntry, -} from "@/lib/analytics/compliance"; -import type { DoseHistoryRow } from "@/lib/medications/scheduling/dose-history"; -import { assertMedicationOwnership } from "@/lib/medications/route-guards"; -import { cached, caches, type ServerCache } from "@/lib/cache/server-cache"; + buildCompliancePayload, + complianceCacheKey, + type CompliancePayload, +} from "@/lib/medications/compliance-payload"; +import { + cachedSwr, + caches, + type ServerCache, +} from "@/lib/cache/server-cache"; import { checkRateLimit } from "@/lib/rate-limit"; -import { userDayKey } from "@/lib/tz/format"; type RouteParams = { params: Promise<{ id: string }> }; -const DAY_MS = 24 * 60 * 60 * 1000; - /** - * Lower bound for the intake-event read. The widest window any served - * block needs is the 365-day display rung; one extra day absorbs the - * inclusive-boundary edge so the ledger's own 365-day clamp never sits - * on a half-fetched day. + * Builder-internal sentinel for "no such medication for this caller". + * Thrown from inside the cache builder so a rejected build is never + * persisted; the handler maps it onto the canonical 404 envelope (the + * same "Medication not found" shape `assertMedicationOwnership` mints, + * so the existence channel stays sealed). */ -const EVENT_FETCH_WINDOW_DAYS = 366; - -/** The cached response body (the exact public wire shape). */ -interface CompliancePayload { - compliance7: ComplianceResult; - compliance30: ComplianceResult; - dailyCompliance: Record; - complianceDisplay: ComplianceDisplay; -} - -/** - * v1.15.18 — fold one dose-history ledger row into a per-day heatmap entry. - * - * Slot rows contribute to `expected` / `expectedCount` (the day's due-slot - * count): a taken slot adds to `taken` and its timing bucket; a missed slot - * stays uncounted in `taken`; a skipped slot lands in `skipped`; an upcoming - * slot is still due but not yet acted on. An ad-hoc row is a real off-schedule - * take — it counts as `taken` AND adds its own `expected` slot (so the - * heatmap's `missed = expected − taken − skipped` math stays non-negative) and - * reads on-time (a logged dose colours green). - */ -function bucketLedgerRow( - entry: DailyComplianceEntry, - row: DoseHistoryRow, -): void { - switch (row.status) { - case "taken_on_time": - entry.expected++; - entry.expectedCount++; - entry.taken++; - entry.onTime++; - break; - case "taken_late": - entry.expected++; - entry.expectedCount++; - entry.taken++; - entry.late++; - break; - case "missed": - entry.expected++; - entry.expectedCount++; - break; - case "skipped": - entry.expected++; - entry.expectedCount++; - entry.skipped++; - break; - case "upcoming": - // A future / still-takeable slot is due but not yet acted on. It counts - // toward the day's expected/due grid but not toward taken or missed. - entry.expected++; - entry.expectedCount++; - break; - case "ad_hoc": - // An off-schedule take: a real taken dose with no scheduled slot. Count - // it as taken + its own expected slot so the heatmap missed math holds. - entry.expected++; - entry.expectedCount++; - entry.taken++; - entry.onTime++; - break; +class MedicationNotFoundError extends Error { + constructor() { + super("Medication not found"); } } -/** - * The full compliance computation for one medication. Lifted out of the - * handler so the server cache can wrap it: ONE bounded intake-event read - * (only the four columns the math consumes, floored at the 366-day fetch - * window) feeds ONE shared band-expansion pass - * (`buildMedicationComplianceBundle`) that serves the 7-/30-day blocks, - * the cadence-scaled display rows AND the 90-day heatmap — replacing the - * historical five-plus per-request expansions over an unbounded, - * all-columns event read. - */ -async function buildCompliancePayload( - medication: { - id: string; - createdAt: Date; - startsOn: Date | null; - endsOn: Date | null; - oneShot: boolean; - schedules: Parameters[1]; - }, - userId: string, - userTz: string, -): Promise { - // v1.15.9 — pin a single `now` and thread it into every cadence - // computation so no block can straddle a day boundary on a slow request. - const now = new Date(); - const createdAt = medication.createdAt; - - const fetchFrom = new Date( - Math.max( - createdAt.getTime(), - now.getTime() - EVENT_FETCH_WINDOW_DAYS * DAY_MS, - ), - ); - const events = await prisma.medicationIntakeEvent.findMany({ - // v1.7.0 sync — exclude tombstoned rows from the compliance read. - // Bounded to the 366-day fetch window: every served block is a suffix - // of the 365-day ledger window, so older rows can never change the - // response. - where: { - medicationId: medication.id, - userId, - deletedAt: null, - scheduledFor: { gte: fetchFrom }, - }, - orderBy: { scheduledFor: "desc" }, - select: { - takenAt: true, - skipped: true, - scheduledFor: true, - // v1.15.9 — a forgotten dose the auto-miss cron flipped counts as a - // miss, not a neutral skip. - autoMissed: true, - // v1.15.20 — pinned takes bind by anchor in the unified ledger so the - // % agrees with the history view on a "zugeordnet" dose (taken-late). - attributionSource: true, - }, - }); - - // v1.7.0 SB-SCHED-2 — thread the medication context so the denominator - // routes through the canonical engine (RRULE / rolling / one-shot / PRN / - // cyclic). `lastIntakeAt` is the latest non-skipped takenAt inside the - // fetch window (rolling cadences re-anchor on it). - const lastIntakeAt = lastNonSkippedTakenAt(events); - const medicationContext = buildComplianceMedicationContext( - medication, - lastIntakeAt, - userTz, - ); - - // ONE shared expansion pass for every served block (the v1.15.20 - // performance fix): bands + ledger + timeline are minted once over the - // widest window and each sub-window is a tally over the same rows. - const bundle = buildMedicationComplianceBundle( - events, - medication.schedules, - medicationContext, - now, - ); - - // v1.15.18 — the heatmap / line-chart daily map is bucketed from the ONE - // unified dose-history ledger (the same bands the % + the history view - // read), so the per-day timing split (on-time / late) and the per-day - // missed marks can never disagree with the headline rate. The 90-day - // window is carved out of the shared ledger by `row.at`; the window - // floor clamps to the medication's creation so pre-existence days never - // mint phantom slots. - const heatmapFrom = new Date( - Math.max(now.getTime() - 90 * DAY_MS, createdAt.getTime()), - ); - - const dailyCompliance: Record = {}; - const byDay = new Map(); - const ensureDay = (key: string): DailyComplianceEntry => { - let entry = byDay.get(key); - if (!entry) { - entry = { - expected: 0, - expectedCount: 0, - due: false, - taken: 0, - skipped: 0, - onTime: 0, - late: 0, - veryLate: 0, - early: 0, - }; - byDay.set(key, entry); - } - return entry; - }; - - // Bucket each ledger row into its user-tz day. A slot row buckets on its - // anchor; an ad-hoc row on its real take time. - for (const row of bundle.ledgerRows) { - if (row.at.getTime() < heatmapFrom.getTime()) continue; - const key = userDayKey(row.at, userTz); - const entry = ensureDay(key); - bucketLedgerRow(entry, row); - } - - for (const [key, entry] of byDay) { - entry.due = entry.expectedCount > 0; - dailyCompliance[key] = entry; - } - - return { - compliance7: bundle.compliance7, - compliance30: bundle.compliance30, - dailyCompliance, - complianceDisplay: bundle.complianceDisplay, - }; -} - export const GET = apiHandler( async (_request: Request, { params }: RouteParams) => { const { user } = await requireAuth(); const { id } = await params; - // The medications list fans this endpoint out once per card, so the - // per-user budget is generous — it only caps a runaway client loop. + // The per-user budget is generous — it only caps a runaway client + // loop (the cards read the batched `/api/medications/compliance` + // sibling; this endpoint serves the detail page). const rl = await checkRateLimit( `medication-compliance:${user.id}`, 60, @@ -240,39 +47,50 @@ export const GET = apiHandler( return apiError("Too many compliance requests. Please retry later.", 429); } - // v1.4.25 W21 Fix-N — privacy gate hoisted to the shared helper. - const guard = await assertMedicationOwnership(id, user.id); - if (guard) return guard; - - const medication = await prisma.medication.findUnique({ - where: { id }, - include: { - schedules: true, - // v1.16.3 — archived schedule eras for era-aware compliance. - scheduleRevisions: { orderBy: { validFrom: "asc" } }, - }, - }); - - if (!medication) { - return apiError("Medication not found", 404); - } - const userTz = user.timezone || "Europe/Berlin"; - // Read-through the per-user compliance cache (15 min TTL). Every - // intake / medication write flushes the `${userId}|` prefix via - // `invalidateUserMedications`, so a warm entry can only be stale - // relative to wall-clock drift, never to a user action. The key - // carries the timezone the payload was bucketed in: day keys, the - // heatmap, and the overdue cut all derive from `userTz`, so a - // timezone change must miss the old entry instead of serving day - // buckets computed for the previous zone until the TTL lapses. - const payload = await cached( - caches.medicationCompliance as ServerCache, - `${user.id}|${id}|compliance|${userTz}`, - () => buildCompliancePayload(medication, user.id, userTz), - annotate, - ); + // Read-through the per-user compliance cache (15 min fresh TTL + + // SWR window). The ownership guard and the medication read live + // INSIDE the builder: the cache key is `${userId}|${medicationId}|…`, + // so a warm or stale hit is by construction a payload this caller + // already built for a medication they own — re-running the guard on + // every hit only re-paid two row reads per card. A cross-user id + // misses this user's prefix, runs the builder, and 404s there. + // + // Interactive intake / medication writes EVICT the `${userId}|` + // prefix via `invalidateUserMedications({ evict: true })`, so a warm + // entry can only be stale relative to wall-clock drift, never to the + // user's own action; background sync paths mark the bucket stale and + // the SWR read serves the prior payload while one coalesced rebuild + // warms a fresh one. + let payload: CompliancePayload; + try { + payload = await cachedSwr( + caches.medicationCompliance as ServerCache, + complianceCacheKey(user.id, id, userTz), + async () => { + const medication = await prisma.medication.findUnique({ + where: { id }, + include: { + schedules: true, + // v1.16.3 — archived schedule eras for era-aware compliance. + scheduleRevisions: { orderBy: { validFrom: "asc" } }, + }, + }); + // Privacy gate — same sealed 404 for "absent" and "not yours". + if (!medication || medication.userId !== user.id) { + throw new MedicationNotFoundError(); + } + return buildCompliancePayload(medication, user.id, userTz); + }, + annotate, + ); + } catch (err) { + if (err instanceof MedicationNotFoundError) { + return apiError("Medication not found", 404); + } + throw err; + } annotate({ action: { diff --git a/src/app/api/medications/[id]/intake/[eventId]/route.ts b/src/app/api/medications/[id]/intake/[eventId]/route.ts index b8896c325..47b93df37 100644 --- a/src/app/api/medications/[id]/intake/[eventId]/route.ts +++ b/src/app/api/medications/[id]/intake/[eventId]/route.ts @@ -285,7 +285,7 @@ export const PUT = apiHandler( // v1.4.34 IW-G — bust per-user medications + compliance + achievement // caches so the next read reflects the edited event. - invalidateUserMedications(user.id); + invalidateUserMedications(user.id, { evict: true }); // v1.4.39 W-MED — refresh the rollup row for both the old + new // scheduledFor day-keys when the dose moved across a day boundary @@ -364,7 +364,7 @@ export const DELETE = apiHandler( // v1.4.34 IW-G — bust per-user medications + compliance + achievement // caches so the next read reflects the deletion. - invalidateUserMedications(user.id); + invalidateUserMedications(user.id, { evict: true }); // v1.4.39 W-MED — refresh the rollup row for the day the deleted // event sat on. When that day now holds zero events the helper diff --git a/src/app/api/medications/[id]/intake/bulk-delete/route.ts b/src/app/api/medications/[id]/intake/bulk-delete/route.ts index 46732457d..29eb45e76 100644 --- a/src/app/api/medications/[id]/intake/bulk-delete/route.ts +++ b/src/app/api/medications/[id]/intake/bulk-delete/route.ts @@ -180,7 +180,7 @@ export const POST = apiHandler( meta: { deleted_count: count, day_buckets: dayKeysToRefresh.size }, }); - invalidateUserMedications(user.id); + invalidateUserMedications(user.id, { evict: true }); return apiSuccess({ deleted: count }); }, diff --git a/src/app/api/medications/[id]/intake/import/route.ts b/src/app/api/medications/[id]/intake/import/route.ts index 9765c07ac..96e618490 100644 --- a/src/app/api/medications/[id]/intake/import/route.ts +++ b/src/app/api/medications/[id]/intake/import/route.ts @@ -163,7 +163,7 @@ export const POST = apiHandler( // (list, intake caches, compliance payload). This write path was the // one intake surface missing the flush. if (imported > 0) { - invalidateUserMedications(user.id); + invalidateUserMedications(user.id, { evict: true }); } await auditLog("medication.intake.import", { diff --git a/src/app/api/medications/[id]/intake/purge/__tests__/route.test.ts b/src/app/api/medications/[id]/intake/purge/__tests__/route.test.ts index 7328792f0..351f3c821 100644 --- a/src/app/api/medications/[id]/intake/purge/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/intake/purge/__tests__/route.test.ts @@ -89,7 +89,11 @@ describe("DELETE /api/medications/[id]/intake/purge", () => { it("invalidates the user medication caches on success (F-1 C-3)", async () => { await DELETE(deleteReq(), ROUTE_PARAMS); - expect(invalidateUserMedications).toHaveBeenCalledWith("user-1"); + // v1.16.8 — the purge is an interactive write, so it hard-evicts the + // SWR buckets instead of marking them stale. + expect(invalidateUserMedications).toHaveBeenCalledWith("user-1", { + evict: true, + }); }); it("returns the 404 from the shared ownership helper without invalidating", async () => { diff --git a/src/app/api/medications/[id]/intake/purge/route.ts b/src/app/api/medications/[id]/intake/purge/route.ts index 61be38f6d..1988e879c 100644 --- a/src/app/api/medications/[id]/intake/purge/route.ts +++ b/src/app/api/medications/[id]/intake/purge/route.ts @@ -64,7 +64,7 @@ export const DELETE = apiHandler( // post-purge counts rather than reading stale values for the // TTL of each cache. Matches the sibling routes (POST intake, // PUT medication, bulk-delete) that all fire this bundle. - invalidateUserMedications(user.id); + invalidateUserMedications(user.id, { evict: true }); return apiSuccess({ purged: true, count }); }, diff --git a/src/app/api/medications/[id]/intake/route.ts b/src/app/api/medications/[id]/intake/route.ts index 1283727bc..faa5beb26 100644 --- a/src/app/api/medications/[id]/intake/route.ts +++ b/src/app/api/medications/[id]/intake/route.ts @@ -467,7 +467,7 @@ async function postIntake(request: NextRequest, { params }: RouteParams) { // v1.4.34 IW-G — bust per-user medications + compliance + achievement // caches so the next read reflects the dose event. - invalidateUserMedications(user.id); + invalidateUserMedications(user.id, { evict: true }); // v1.4.39 W-MED — refresh the persistent compliance rollup for the // affected day. The hook is best-effort; failures annotate but never @@ -488,7 +488,7 @@ async function postIntake(request: NextRequest, { params }: RouteParams) { // actually receive its dose. const reconcileAction = await reconcileOneShotState(prisma, id, user.id); if (reconcileAction !== "noop") { - invalidateUserMedications(user.id); + invalidateUserMedications(user.id, { evict: true }); } return apiSuccess(event, 201); diff --git a/src/app/api/medications/[id]/route.ts b/src/app/api/medications/[id]/route.ts index cbca72a9d..919c01e26 100644 --- a/src/app/api/medications/[id]/route.ts +++ b/src/app/api/medications/[id]/route.ts @@ -618,7 +618,7 @@ export const PUT = apiHandler( // v1.4.34 IW-G — bust per-user medications + compliance + achievement // caches so the next read reflects the schedule change. - invalidateUserMedications(user.id); + invalidateUserMedications(user.id, { evict: true }); return apiSuccess({ ...medication, @@ -675,7 +675,7 @@ export const DELETE = apiHandler( // v1.4.34 IW-G — bust per-user medications + compliance + achievement // caches so the next read reflects the deletion. - invalidateUserMedications(user.id); + invalidateUserMedications(user.id, { evict: true }); return apiSuccess({ deleted: true }); }, diff --git a/src/app/api/medications/__tests__/route.test.ts b/src/app/api/medications/__tests__/route.test.ts index cf47f9795..69c198e46 100644 --- a/src/app/api/medications/__tests__/route.test.ts +++ b/src/app/api/medications/__tests__/route.test.ts @@ -31,6 +31,9 @@ vi.mock("@/lib/cache/server-cache", () => ({ cached: vi.fn(async (_c: unknown, _k: string, f: () => Promise) => f(), ), + cachedSwr: vi.fn(async (_c: unknown, _k: string, f: () => Promise) => + f(), + ), caches: { medicationsList: {} }, __resetAllCachesForTests: vi.fn(), })); diff --git a/src/app/api/medications/compliance/__tests__/route.test.ts b/src/app/api/medications/compliance/__tests__/route.test.ts new file mode 100644 index 000000000..b947205d6 --- /dev/null +++ b/src/app/api/medications/compliance/__tests__/route.test.ts @@ -0,0 +1,196 @@ +/** + * v1.16.8 — batched card-compliance read (`GET /api/medications/compliance`). + * + * One round trip returns a compact adherence row per medication the + * caller owns, replacing the per-card fan-out over the per-id endpoint. + * The rows build through the SAME per-medication cache cells + * (`${userId}|${medicationId}|compliance|${userTz}`) the per-id route + * reads, so the two endpoints warm each other and invalidate together. + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + medication: { findMany: vi.fn() }, + medicationIntakeEvent: { findMany: vi.fn() }, + }, +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); + +vi.mock("@/lib/cache/server-cache", () => ({ + cachedSwr: vi.fn( + async (_cache: unknown, _key: string, builder: () => Promise) => + builder(), + ), + caches: { medicationCompliance: {} }, +})); + +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn().mockResolvedValue({ + allowed: true, + remaining: 29, + resetAt: Date.now() + 60_000, + }), +})); + +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { GET } from "../route"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; +import { cachedSwr } from "@/lib/cache/server-cache"; +import { checkRateLimit } from "@/lib/rate-limit"; + +const TZ = "UTC"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { + id: "user-1", + username: "testuser", + role: "USER" as const, + timezone: TZ, + }, +}; + +function dailySchedule(id: string) { + return { + id, + windowStart: "08:00", + windowEnd: "09:00", + timesOfDay: ["08:00"], + daysOfWeek: null, + rrule: "FREQ=DAILY", + rollingIntervalDays: null, + reminderGraceMinutes: null, + scheduleType: "SCHEDULED", + cyclicOnWeeks: null, + cyclicOffWeeks: null, + }; +} + +function medication(id: string) { + return { + id, + userId: "user-1", + createdAt: new Date(Date.now() - 200 * 24 * 60 * 60 * 1000), + startsOn: null, + endsOn: null, + oneShot: false, + schedules: [dailySchedule(`${id}-sched`)], + scheduleRevisions: [], + }; +} + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(cachedSwr).mockImplementation( + async (_cache: unknown, _key: string, builder: () => Promise) => + builder(), + ); + vi.mocked(checkRateLimit).mockResolvedValue({ + allowed: true, + remaining: 29, + resetAt: Date.now() + 60_000, + }); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( + [] as never, + ); +}); + +describe("GET /api/medications/compliance", () => { + it("returns one compact row per medication, without the heatmap grid", async () => { + vi.mocked(prisma.medication.findMany).mockResolvedValue([ + medication("med-1"), + medication("med-2"), + ] as never); + + const res = await (GET as (req: Request) => Promise)( + new Request("http://localhost"), + ); + expect(res.status).toBe(200); + const body = await res.json(); + const items = body.data as Array>; + + expect(items).toHaveLength(2); + expect(items.map((i) => i.medicationId)).toEqual(["med-1", "med-2"]); + for (const item of items) { + expect(item.compliance7).toBeDefined(); + expect(item.compliance30).toBeDefined(); + expect(item.complianceDisplay).toBeDefined(); + // The heavy per-day grid stays on the per-id endpoint. + expect(item).not.toHaveProperty("dailyCompliance"); + } + }); + + it("reads each medication through the shared per-medication cache cell", async () => { + vi.mocked(prisma.medication.findMany).mockResolvedValue([ + medication("med-1"), + medication("med-2"), + ] as never); + + const res = await (GET as (req: Request) => Promise)( + new Request("http://localhost"), + ); + expect(res.status).toBe(200); + + // Same key shape as the per-id route, so the two endpoints warm each + // other and `invalidateUserMedications` sweeps both with one prefix. + const keys = vi.mocked(cachedSwr).mock.calls.map(([, key]) => key); + expect(keys).toEqual([ + "user-1|med-1|compliance|UTC", + "user-1|med-2|compliance|UTC", + ]); + }); + + it("scopes the medication read to the calling user", async () => { + vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never); + + const res = await (GET as (req: Request) => Promise)( + new Request("http://localhost"), + ); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data).toEqual([]); + + const call = vi.mocked(prisma.medication.findMany).mock.calls[0][0] as { + where: { userId: string }; + }; + expect(call.where.userId).toBe("user-1"); + }); + + it("returns 429 when the per-user rate limit is exhausted", async () => { + vi.mocked(checkRateLimit).mockResolvedValue({ + allowed: false, + remaining: 0, + resetAt: Date.now() + 60_000, + }); + + const res = await (GET as (req: Request) => Promise)( + new Request("http://localhost"), + ); + expect(res.status).toBe(429); + expect(vi.mocked(checkRateLimit)).toHaveBeenCalledWith( + "medication-compliance-summary:user-1", + 30, + 60_000, + ); + expect(prisma.medication.findMany).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/medications/compliance/route.ts b/src/app/api/medications/compliance/route.ts new file mode 100644 index 000000000..2bf720e01 --- /dev/null +++ b/src/app/api/medications/compliance/route.ts @@ -0,0 +1,82 @@ +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { apiSuccess, apiError } from "@/lib/api-response"; +import { + buildCompliancePayload, + complianceCacheKey, + type CompliancePayload, +} from "@/lib/medications/compliance-payload"; +import { + cachedSwr, + caches, + type ServerCache, +} from "@/lib/cache/server-cache"; +import { checkRateLimit } from "@/lib/rate-limit"; + +/** + * Batched card-compliance read: one round trip for every medication the + * caller owns, replacing the per-card fan-out over + * `GET /api/medications/{id}/compliance` (one request per card, ~1 s + * cold each). The per-medication payload still builds and caches through + * the SAME `medicationCompliance` cells the per-id route reads, so this + * read warms the detail page (and vice versa) and both invalidate + * together on a write. + * + * The heavy `dailyCompliance` heatmap map is intentionally NOT on this + * wire shape — the cards render rates / streak / display block only; the + * detail page keeps the per-id route for the grid. + */ +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + + const rl = await checkRateLimit( + `medication-compliance-summary:${user.id}`, + 30, + 60_000, + ); + if (!rl.allowed) { + return apiError("Too many compliance requests. Please retry later.", 429); + } + + const userTz = user.timezone || "Europe/Berlin"; + + // Same ordering as the medications list so the page's card order and + // this payload walk the same sequence. + const medications = await prisma.medication.findMany({ + where: { userId: user.id }, + include: { + schedules: true, + // v1.16.3 — archived schedule eras for era-aware compliance. + scheduleRevisions: { orderBy: { validFrom: "asc" } }, + }, + 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, + }); + } + + annotate({ + action: { name: "medication.compliance_summary.read" }, + meta: { count: items.length }, + }); + + return apiSuccess(items); +}); diff --git a/src/app/api/medications/intake/bulk/route.ts b/src/app/api/medications/intake/bulk/route.ts index d806fdc60..6b7b42759 100644 --- a/src/app/api/medications/intake/bulk/route.ts +++ b/src/app/api/medications/intake/bulk/route.ts @@ -569,7 +569,11 @@ async function postBulk(request: NextRequest): Promise { // v1.4.34 IW-G — bust per-user medications + compliance + achievement // caches when at least one row landed so the next read reflects the - // ingested batch. + // ingested batch. v1.16.8 — this is the background sync path (iOS bulk + // ingest), so the SWR buckets are marked stale rather than hard-evicted: + // the readers keep serving the prior list / compliance payloads while + // one coalesced recompute warms each cell, instead of a high-frequency + // sync busting every card into an inline cold rebuild. if (inserted > 0 || updated > 0) { invalidateUserMedications(user.id); } diff --git a/src/app/api/medications/intake/route.ts b/src/app/api/medications/intake/route.ts index ff9ab8407..50891c00e 100644 --- a/src/app/api/medications/intake/route.ts +++ b/src/app/api/medications/intake/route.ts @@ -599,7 +599,7 @@ export const POST = apiHandler(async (request: NextRequest) => { // v1.4.34 IW-G — bust the medications + compliance + achievement // caches for this user so the next read reflects the dose change. - invalidateUserMedications(user.id); + invalidateUserMedications(user.id, { evict: true }); // v1.4.39 W-MED — refresh the persistent compliance rollup row for // the affected day so the next read after the cache miss returns diff --git a/src/app/api/medications/route.ts b/src/app/api/medications/route.ts index ee3f6b32d..34f8c1418 100644 --- a/src/app/api/medications/route.ts +++ b/src/app/api/medications/route.ts @@ -21,7 +21,7 @@ import { } from "@/lib/medications/scheduling/next-due"; import { getUserTodayBounds } from "@/lib/tz/local-day"; import { invalidateUserMedications } from "@/lib/cache/invalidate"; -import { cached, caches, type ServerCache } from "@/lib/cache/server-cache"; +import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache"; import { NextRequest } from "next/server"; type MedicationsListResult = Array>; @@ -187,11 +187,16 @@ export const GET = apiHandler(async () => { const userTz = user.timezone || "Europe/Berlin"; - // Cache the list shape on userId. The 60s TTL bounds the cross-midnight - // staleness window for `todayEventCount` (the only time-of-day-derived - // field in the response); writes via POST/PUT/DELETE flush through - // `invalidateUserMedications` before the next read lands. - const result = await cached( + // Cache the list shape on userId. The 60 s fresh TTL bounds the + // cross-midnight staleness window for `todayEventCount` and the + // next-due fields; v1.16.8 reads through `cachedSwr`, so a visit + // minutes after the last one serves the prior list immediately while + // one background recompute warms a fresh entry (the 10-minute stale + // window bounds how old that served list can be). Interactive writes + // via POST/PUT/DELETE hard-evict through + // `invalidateUserMedications({ evict: true })` before the next read + // lands; background sync paths mark the bucket stale instead. + const result = await cachedSwr( caches.medications as ServerCache, user.id, () => buildMedicationsList(user.id, userTz), @@ -397,7 +402,7 @@ export const POST = apiHandler(async (request: NextRequest) => { // v1.4.34 IW-G — bust per-user medications + compliance + achievement // caches so the next read reflects the new schedule. - invalidateUserMedications(user.id); + invalidateUserMedications(user.id, { evict: true }); return apiSuccess( { diff --git a/src/components/medications/__tests__/card-parts.test.tsx b/src/components/medications/__tests__/card-parts.test.tsx index e0a9902a3..0c8c712d0 100644 --- a/src/components/medications/__tests__/card-parts.test.tsx +++ b/src/components/medications/__tests__/card-parts.test.tsx @@ -327,11 +327,20 @@ describe("streak-token parity — generic vs GLP-1 card", () => { ], }; + // v1.16.8 — the cards read ONE batched summary key and `select` their + // own row, so seeding merges into the shared array. function seedStreak(client: QueryClient, medId: string) { - client.setQueryData(["medications", medId, "compliance"], { - compliance7: { rate: 90, streak: 4, totalExpected: 7, taken: 6 }, - compliance30: { rate: 88 }, - }); + const key = ["medications", "compliance-summary"]; + const existing = + (client.getQueryData(key) as Array<{ medicationId: string }>) ?? []; + client.setQueryData(key, [ + ...existing.filter((row) => row.medicationId !== medId), + { + medicationId: medId, + compliance7: { rate: 90, streak: 4, totalExpected: 7, taken: 6 }, + compliance30: { rate: 88 }, + }, + ]); } it("both cards render the flame with the canonical token, never the drift", () => { diff --git a/src/components/medications/__tests__/medication-card-glp1.test.tsx b/src/components/medications/__tests__/medication-card-glp1.test.tsx index fb5517007..4ddaa4688 100644 --- a/src/components/medications/__tests__/medication-card-glp1.test.tsx +++ b/src/components/medications/__tests__/medication-card-glp1.test.tsx @@ -140,13 +140,22 @@ function seedCompliance( streak?: number; } = {}, ) { - client.setQueryData(["medications", medId, "compliance"], { - compliance7: { - rate: payload.rate7 ?? 85, - streak: payload.streak ?? 0, + // v1.16.8 — the cards read ONE batched summary key and `select` their + // own row, so seeding merges into the shared array. + const key = ["medications", "compliance-summary"]; + const existing = + (client.getQueryData(key) as Array<{ medicationId: string }>) ?? []; + client.setQueryData(key, [ + ...existing.filter((row) => row.medicationId !== medId), + { + medicationId: medId, + compliance7: { + rate: payload.rate7 ?? 85, + streak: payload.streak ?? 0, + }, + compliance30: { rate: payload.rate30 ?? 82 }, }, - compliance30: { rate: payload.rate30 ?? 82 }, - }); + ]); } function render( diff --git a/src/components/medications/__tests__/medication-card-next-due.test.tsx b/src/components/medications/__tests__/medication-card-next-due.test.tsx index 0f14c672c..3c01dcf44 100644 --- a/src/components/medications/__tests__/medication-card-next-due.test.tsx +++ b/src/components/medications/__tests__/medication-card-next-due.test.tsx @@ -34,11 +34,20 @@ function render(node: React.ReactNode, client: QueryClient) { ); } +// v1.16.8 — the cards read ONE batched summary key and `select` their +// own row, so seeding merges into the shared array. function seedCompliance(client: QueryClient, medId: string) { - client.setQueryData(["medications", medId, "compliance"], { - compliance7: { rate: 90, streak: 0, totalExpected: 7, taken: 6 }, - compliance30: { rate: 88 }, - }); + const key = ["medications", "compliance-summary"]; + const existing = + (client.getQueryData(key) as Array<{ medicationId: string }>) ?? []; + client.setQueryData(key, [ + ...existing.filter((row) => row.medicationId !== medId), + { + medicationId: medId, + compliance7: { rate: 90, streak: 0, totalExpected: 7, taken: 6 }, + compliance30: { rate: 88 }, + }, + ]); } // A schedule whose window has already passed for today (01:00–02:00) so diff --git a/src/components/medications/__tests__/medication-card-symmetry.test.tsx b/src/components/medications/__tests__/medication-card-symmetry.test.tsx index 12c6af0b4..5059cda57 100644 --- a/src/components/medications/__tests__/medication-card-symmetry.test.tsx +++ b/src/components/medications/__tests__/medication-card-symmetry.test.tsx @@ -59,11 +59,21 @@ function render( ); } +// v1.16.8 — the cards read ONE batched summary key and `select` their +// own row, so seeding merges into the shared array instead of writing a +// per-medication key. function seedCompliance(client: QueryClient, medId: string) { - client.setQueryData(["medications", medId, "compliance"], { - compliance7: { rate: 90, streak: 0, totalExpected: 7, taken: 6 }, - compliance30: { rate: 88 }, - }); + const key = ["medications", "compliance-summary"]; + const existing = + (client.getQueryData(key) as Array<{ medicationId: string }>) ?? []; + client.setQueryData(key, [ + ...existing.filter((row) => row.medicationId !== medId), + { + medicationId: medId, + compliance7: { rate: 90, streak: 0, totalExpected: 7, taken: 6 }, + compliance30: { rate: 88 }, + }, + ]); } function seedGlp1Details(client: QueryClient, medId: string) { diff --git a/src/components/medications/__tests__/use-medication-intake.test.ts b/src/components/medications/__tests__/use-medication-intake.test.ts index 72466325a..0c4791549 100644 --- a/src/components/medications/__tests__/use-medication-intake.test.ts +++ b/src/components/medications/__tests__/use-medication-intake.test.ts @@ -379,9 +379,11 @@ describe("runUndoIntake — shared soft-delete", () => { await runUndoIntake({ medication, eventId: "evt-1", t, queryClient }); + // Method + path are the contract; the shared fetch wrapper may attach + // transport details (e.g. an abort signal) the undo path doesn't own. expect(fetchMock).toHaveBeenCalledWith( "/api/medications/med-1/intake/evt-1", - { method: "DELETE" }, + expect.objectContaining({ method: "DELETE" }), ); expect(toast.success).toHaveBeenCalledWith("medications.intakeUndone"); expect(queryClient.invalidateQueries).toHaveBeenCalled(); diff --git a/src/components/medications/card-parts/medication-card-body.tsx b/src/components/medications/card-parts/medication-card-body.tsx index a0850c332..c2cd0c74d 100644 --- a/src/components/medications/card-parts/medication-card-body.tsx +++ b/src/components/medications/card-parts/medication-card-body.tsx @@ -160,19 +160,24 @@ export function MedicationCardBody({ {/* Top status line. The in-window take-now pill (success) and the overdue escalation are mutually exclusive — a dose is either still in its take-window or past it. The overdue line wins when present - so a heavily-overdue dose reads loud at the top of the card. */} - {overdueLabel ? ( -

-

- ) : windowStatus ? ( - - ) : null} + so a heavily-overdue dose reads loud at the top of the card. + The slot keeps a constant one-line height even when empty: the + overdue tier arrives with the compliance query, and a line + popping in late used to shift every row below it (CLS). */} +
+ {overdueLabel ? ( +

+

+ ) : windowStatus ? ( + + ) : null} +
{/* Next / last intake — the two decisive lines, each shown once. */} @@ -192,9 +197,14 @@ export function MedicationCardBody({ ))} - {/* Open-cycle status line — calm, rate-decoupled. */} - {active && currentCycle && ( - + {/* Open-cycle status line — calm, rate-decoupled. The slot reserves + one xs text line on every active card: the descriptor rides the + compliance query, and a line appearing after the fetch resolved + used to grow the card and shift the grid row (CLS). */} + {active && ( +
+ {currentCycle && } +
)} {/* Quick actions — bottom-pinned so the action rows align across a diff --git a/src/components/medications/glp1-medication-card.tsx b/src/components/medications/glp1-medication-card.tsx index cc807610b..df99aaa17 100644 --- a/src/components/medications/glp1-medication-card.tsx +++ b/src/components/medications/glp1-medication-card.tsx @@ -26,7 +26,7 @@ import { toBerlinDate, } from "@/lib/medications/window-status"; import { resolveDisplayedSlotInstant } from "@/components/medications/card-parts/displayed-slot-instant"; -import type { ComplianceDisplay } from "@/lib/analytics/compliance"; +import { useMedicationComplianceSummary } from "@/lib/queries/use-medication-compliance-summary"; /** * v1.4.25 W4d — GLP-1 medication card variant. @@ -110,19 +110,6 @@ export interface Glp1Medication { schedules: ScheduleLite[]; } -interface ComplianceData { - compliance7: { rate: number; streak: number }; - compliance30: { rate: number }; - /** - * v1.8.6 — the two compliance windows scaled to the dosing cadence. GLP-1 - * injections are commonly weekly, so this card's windows step up to the - * 30-/90-day rung where the bars stay meaningful. Additive — older mocks - * omit it, in which case the card falls back to the static 7-/30-day - * fields. - */ - complianceDisplay?: ComplianceDisplay; -} - interface DetailsResponse { doseChanges: DoseChangeLite[]; recentIntakes: IntakeLite[]; @@ -202,22 +189,10 @@ export function Glp1MedicationCard({ }, }); - const { data: compliance } = useQuery({ - queryKey: queryKeys.medicationCompliance(medication.id), - queryFn: async () => { - try { - return await apiGet( - `/api/medications/${medication.id}/compliance`, - ); - } catch { - return null; - } - }, - // v1.15.20 — same 5-minute window as the generic card: dose actions - // invalidate the key explicitly, so a short staleTime only re-fired - // a request per card on every list visit. - staleTime: 5 * 60 * 1000, - }); + // 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); // v1.4.37 W4b — same reminder-thresholds source as the generic // medication card so the take-now / overdue / very-overdue pill diff --git a/src/components/medications/medication-card.tsx b/src/components/medications/medication-card.tsx index 724960d9b..a9cac69e2 100644 --- a/src/components/medications/medication-card.tsx +++ b/src/components/medications/medication-card.tsx @@ -23,10 +23,10 @@ import { queryKeys, } from "@/lib/query-keys"; import { apiGet, apiPost } from "@/lib/api/api-fetch"; +import { useMedicationComplianceSummary } from "@/lib/queries/use-medication-compliance-summary"; import { LogInjectionSiteDialog } from "@/components/medications/log-injection-site-dialog"; import { useGlobalExcludedInjectionSites } from "@/lib/medications/use-injection-site-prefs"; import type { InjectionSiteKey } from "@/lib/medications/injection-sites"; -import type { ComplianceDisplay } from "@/lib/analytics/compliance"; interface Schedule { id: string; @@ -87,25 +87,6 @@ interface Medication { schedules: Schedule[]; } -interface ComplianceData { - compliance7: { - rate: number; - streak: number; - }; - compliance30: { - rate: number; - }; - /** - * v1.8.6 — the two compliance windows scaled to the dosing cadence. The - * card always shows two percentage rows; `shortDays` / `longDays` name the - * windows and `short` / `long` carry their rates. v1.15.9 also carries the - * open dose's `currentDose.status` for the green / overdue presentation. - * Additive — older mocks omit it, in which case the card falls back to the - * static 7-/30-day `compliance7` / `compliance30` fields and a calm state. - */ - complianceDisplay?: ComplianceDisplay; -} - interface MedicationCardProps { medication: Medication; onEdit: (med: Medication) => void; @@ -147,24 +128,10 @@ export function MedicationCard({ }, }); - const { data: compliance } = useQuery({ - queryKey: queryKeys.medicationCompliance(medication.id), - queryFn: async () => { - try { - return await apiGet( - `/api/medications/${medication.id}/compliance`, - ); - } catch { - return null; - } - }, - // v1.15.20 — the card renders two percentage rows that move on a - // dose action (which invalidates this key through - // `medicationDependentKeys`) or over hours of wall-clock drift; a - // 30 s window only re-fired one request per card on every list - // visit. Five minutes matches the reminder-thresholds query below. - staleTime: 5 * 60 * 1000, - }); + // 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); const [, forceUpdate] = useReducer((x: number) => x + 1, 0); diff --git a/src/lib/cache/__tests__/invalidate.test.ts b/src/lib/cache/__tests__/invalidate.test.ts index f0cb8941e..15bf74a86 100644 --- a/src/lib/cache/__tests__/invalidate.test.ts +++ b/src/lib/cache/__tests__/invalidate.test.ts @@ -213,6 +213,57 @@ describe("invalidateUserMedications", () => { caches.medicationCompliance.get(`${USER_B}|med-1|compliance`), ).not.toBeNull(); }); + + it("marks the SWR buckets stale by default — background sync posture", async () => { + await primeAllCaches(); + invalidateUserMedications(USER_A); + + // v1.16.8 — the default (bulk sync / cron) path marks stale: the + // entries are gone for plain `get()` (asserted above) but stay + // serveable for the `cachedSwr` readers (medications list, both + // compliance routes, the dashboard snapshot), so a high-frequency + // iOS sync never busts every card into an inline cold rebuild. + expect(caches.medications.getAllowStale(USER_A)).toEqual({ + value: { m: 1 }, + stale: true, + }); + expect( + caches.medicationCompliance.getAllowStale(`${USER_A}|med-1|compliance`), + ).toEqual({ value: { mc: 1 }, stale: true }); + expect( + caches.analytics.getAllowStale(dashboardSnapshotCacheKey(USER_A)), + ).toEqual({ value: { snap: 1 }, stale: true }); + + // User B stays fresh. + expect(caches.medications.getAllowStale(USER_B)).toEqual({ + value: { m: 2 }, + stale: false, + }); + }); + + it("hard-evicts the SWR buckets with { evict: true } — interactive write posture", async () => { + await primeAllCaches(); + invalidateUserMedications(USER_A, { evict: true }); + + // v1.16.8 — an interactive take / skip / CRUD write must NOT leave a + // stale-serveable body behind: the SWR readers would hand the user + // back the pre-write card rates. The evict drops the entries. + expect(caches.medications.getAllowStale(USER_A)).toBeNull(); + expect( + caches.medicationCompliance.getAllowStale(`${USER_A}|med-1|compliance`), + ).toBeNull(); + expect( + caches.analytics.getAllowStale(dashboardSnapshotCacheKey(USER_A)), + ).toBeNull(); + + // User B stays warm; the non-SWR buckets evict as before. + expect(caches.medications.getAllowStale(USER_B)).not.toBeNull(); + expect( + caches.medicationCompliance.getAllowStale(`${USER_B}|med-1|compliance`), + ).not.toBeNull(); + expect(caches.medicationsIntake.get(`${USER_A}|compliance|30`)).toBeNull(); + expect(caches.achievements.get(USER_A)).toBeNull(); + }); }); describe("invalidateUserDashboardWidgets", () => { diff --git a/src/lib/cache/invalidate.ts b/src/lib/cache/invalidate.ts index e315a8dc6..f1672e151 100644 --- a/src/lib/cache/invalidate.ts +++ b/src/lib/cache/invalidate.ts @@ -117,24 +117,51 @@ export function invalidateUserMood(userId: string): void { * Invalidate every cache that may reflect a user's medication state. * * Covers the medications cache, the medication-intake compliance cache - * (cached daily), and achievement progress. + * (cached daily), the per-medication compliance payload, and achievement + * progress. + * + * v1.16.8 — `evict` splits the SWR buckets (medications, the + * per-medication compliance payload, analytics) by write origin, the + * `invalidateUserMeasurements` pattern. Interactive writes (a card take / + * skip, a CRUD edit, an intake edit / delete / purge / import) pass + * `{ evict: true }`: the user must see their own action on the very next + * read, and a stale-serveable entry would hand back the pre-write body. + * Background paths (the iOS bulk-intake sync, the auto-miss cron, slot + * dedup) keep the default mark-stale so a high-frequency sync never busts + * the buckets into inline cold-rebuild storms — the `cachedSwr` readers + * serve the prior payload while one coalesced recompute warms each cell. */ -export function invalidateUserMedications(userId: string): void { - caches.medications.deleteByPrefix(userId); +export function invalidateUserMedications( + userId: string, + opts?: { evict?: boolean }, +): void { + if (opts?.evict) { + caches.medications.deleteByPrefix(userId); + // v1.15.20 — the per-medication compliance payload is cached 15 min; + // an interactive intake / medication / schedule write must flush it + // so the card rates and the heatmap reflect the action on the next + // read. + caches.medicationCompliance.deleteByPrefix(`${userId}|`); + // v1.4.38 W-F — `/api/dashboard/summary` lives under the analytics + // cache and reads `medicationIntakeEvent` for both today's compliance + // tally and the 365-day streak feed. A taken / skipped event must + // evict the user-bucket so the next iOS poll reflects the change. + caches.analytics.deleteByPrefix(`${userId}|`); + } else { + // Mark-stale collapses the fresh TTL to "now": the SWR readers (the + // medications list, both compliance routes, the dashboard snapshot) + // serve the prior value while a single background recompute warms a + // fresh one; the plain `cached` readers in the analytics bucket see + // a clean miss — identical to the old evict for those keys. + caches.medications.markStaleByPrefix(userId); + caches.medicationCompliance.markStaleByPrefix(`${userId}|`); + caches.analytics.markStaleByPrefix(`${userId}|`); + } caches.medicationsIntake.deleteByPrefix(`${userId}|`); - // v1.15.20 — the per-medication compliance payload is cached 15 min; - // every intake / medication / schedule write must flush it so the card - // rates and the heatmap reflect the action on the next read. - caches.medicationCompliance.deleteByPrefix(`${userId}|`); caches.achievements.deleteByPrefix(userId); // v1.4.36 W1 — medication writes change the MEDICATION_COMPLIANCE // target rollup (compliance7 / compliance30 / consistency strip). caches.insightsTargets.deleteByPrefix(userId); - // v1.4.38 W-F — `/api/dashboard/summary` lives under the analytics - // cache and reads `medicationIntakeEvent` for both today's compliance - // tally and the 365-day streak feed. A taken / skipped event must - // evict the user-bucket so the next iOS poll reflects the change. - caches.analytics.deleteByPrefix(`${userId}|`); } /** diff --git a/src/lib/cache/server-cache.ts b/src/lib/cache/server-cache.ts index 9ecfa729c..44dcffd22 100644 --- a/src/lib/cache/server-cache.ts +++ b/src/lib/cache/server-cache.ts @@ -363,10 +363,22 @@ export const caches = { ttlMs: 60_000, staleTtlMs: 600_000, }), + /** + * v1.16.8 — stale-while-revalidate on the list bucket. The 60 s fresh + * TTL meant every visit minutes apart paid the full cold list build + * (five parallel reads + the next-due engine per medication). The list + * GET reads via `cachedSwr` now: inside the 10-minute stale window the + * prior list serves immediately while one background recompute warms a + * fresh one. Interactive writes hard-evict the bucket through + * `invalidateUserMedications({ evict: true })`, so the window only + * bounds wall-clock drift (`todayEventCount`, `nextDueAt`), never + * user-action staleness. + */ medications: new ServerCache({ name: "medications", maxEntries: 1000, ttlMs: 60_000, + staleTtlMs: 600_000, }), achievements: new ServerCache({ name: "achievements", @@ -405,11 +417,21 @@ export const caches = { * `invalidateUserMedications`, so the 15-minute TTL only bounds * wall-clock drift (a dose flipping overdue), never user-action * staleness. Sized for a few hundred users × a handful of meds. + * + * v1.16.8 — stale-while-revalidate. Both compliance routes (the per-id + * detail read and the batched card read) consume via `cachedSwr`: + * inside the 10-minute stale window an expired cell serves the prior + * payload immediately while one coalesced rebuild warms it. Interactive + * intake / medication writes hard-evict (the user must see their own + * dose on the next read); background sync paths (iOS bulk intake, the + * auto-miss cron, slot dedup) mark stale so a high-frequency sync never + * busts every card into an inline cold rebuild. */ medicationCompliance: new ServerCache({ name: "medicationCompliance", maxEntries: 2000, ttlMs: 900_000, + staleTtlMs: 600_000, }), moodAnalytics: new ServerCache({ name: "moodAnalytics", diff --git a/src/lib/medications/compliance-payload.ts b/src/lib/medications/compliance-payload.ts new file mode 100644 index 000000000..927eccdba --- /dev/null +++ b/src/lib/medications/compliance-payload.ts @@ -0,0 +1,240 @@ +/** + * The full compliance computation for one medication, shared by + * `GET /api/medications/[id]/compliance` (detail page, full payload incl. + * the 90-day heatmap) and `GET /api/medications/compliance` (the batched + * card read). Lifted out of the per-id route so the server cache can wrap + * one canonical builder from both entry points: ONE bounded intake-event + * read (only the columns the math consumes, floored at the 366-day fetch + * window) feeds ONE shared band-expansion pass + * (`buildMedicationComplianceBundle`) that serves the 7-/30-day blocks, + * the cadence-scaled display rows AND the 90-day heatmap. + */ +import { prisma } from "@/lib/db"; +import { + buildComplianceMedicationContext, + buildMedicationComplianceBundle, + lastNonSkippedTakenAt, + type ComplianceDisplay, + type ComplianceResult, + type DailyComplianceEntry, +} from "@/lib/analytics/compliance"; +import type { DoseHistoryRow } from "@/lib/medications/scheduling/dose-history"; +import { userDayKey } from "@/lib/tz/format"; + +const DAY_MS = 24 * 60 * 60 * 1000; + +/** + * Lower bound for the intake-event read. The widest window any served + * block needs is the 365-day display rung; one extra day absorbs the + * inclusive-boundary edge so the ledger's own 365-day clamp never sits + * on a half-fetched day. + */ +const EVENT_FETCH_WINDOW_DAYS = 366; + +/** The cached response body (the exact public wire shape). */ +export interface CompliancePayload { + compliance7: ComplianceResult; + compliance30: ComplianceResult; + dailyCompliance: Record; + complianceDisplay: ComplianceDisplay; +} + +/** The medication slice the payload builder consumes. */ +export interface ComplianceMedicationInput { + id: string; + createdAt: Date; + startsOn: Date | null; + endsOn: Date | null; + oneShot: boolean; + schedules: Parameters[1]; + /** v1.16.3 — archived schedule eras for era-aware compliance. */ + scheduleRevisions?: Parameters< + typeof buildComplianceMedicationContext + >[0]["scheduleRevisions"]; +} + +/** + * One canonical cache key per `(user, medication, timezone)` cell. The + * key carries the timezone the payload was bucketed in: day keys, the + * heatmap, and the overdue cut all derive from `userTz`, so a timezone + * change must miss the old entry instead of serving day buckets computed + * for the previous zone until the TTL lapses. The `${userId}|` prefix is + * what `invalidateUserMedications` sweeps. + */ +export function complianceCacheKey( + userId: string, + medicationId: string, + userTz: string, +): string { + return `${userId}|${medicationId}|compliance|${userTz}`; +} + +/** + * v1.15.18 — fold one dose-history ledger row into a per-day heatmap entry. + * + * Slot rows contribute to `expected` / `expectedCount` (the day's due-slot + * count): a taken slot adds to `taken` and its timing bucket; a missed slot + * stays uncounted in `taken`; a skipped slot lands in `skipped`; an upcoming + * slot is still due but not yet acted on. An ad-hoc row is a real off-schedule + * take — it counts as `taken` AND adds its own `expected` slot (so the + * heatmap's `missed = expected − taken − skipped` math stays non-negative) and + * reads on-time (a logged dose colours green). + */ +function bucketLedgerRow( + entry: DailyComplianceEntry, + row: DoseHistoryRow, +): void { + switch (row.status) { + case "taken_on_time": + entry.expected++; + entry.expectedCount++; + entry.taken++; + entry.onTime++; + break; + case "taken_late": + entry.expected++; + entry.expectedCount++; + entry.taken++; + entry.late++; + break; + case "missed": + entry.expected++; + entry.expectedCount++; + break; + case "skipped": + entry.expected++; + entry.expectedCount++; + entry.skipped++; + break; + case "upcoming": + // A future / still-takeable slot is due but not yet acted on. It counts + // toward the day's expected/due grid but not toward taken or missed. + entry.expected++; + entry.expectedCount++; + break; + case "ad_hoc": + // An off-schedule take: a real taken dose with no scheduled slot. Count + // it as taken + its own expected slot so the heatmap missed math holds. + entry.expected++; + entry.expectedCount++; + entry.taken++; + entry.onTime++; + break; + } +} + +export async function buildCompliancePayload( + medication: ComplianceMedicationInput, + userId: string, + userTz: string, +): Promise { + // v1.15.9 — pin a single `now` and thread it into every cadence + // computation so no block can straddle a day boundary on a slow request. + const now = new Date(); + const createdAt = medication.createdAt; + + const fetchFrom = new Date( + Math.max( + createdAt.getTime(), + now.getTime() - EVENT_FETCH_WINDOW_DAYS * DAY_MS, + ), + ); + const events = await prisma.medicationIntakeEvent.findMany({ + // v1.7.0 sync — exclude tombstoned rows from the compliance read. + // Bounded to the 366-day fetch window: every served block is a suffix + // of the 365-day ledger window, so older rows can never change the + // response. + where: { + medicationId: medication.id, + userId, + deletedAt: null, + scheduledFor: { gte: fetchFrom }, + }, + orderBy: { scheduledFor: "desc" }, + select: { + takenAt: true, + skipped: true, + scheduledFor: true, + // v1.15.9 — a forgotten dose the auto-miss cron flipped counts as a + // miss, not a neutral skip. + autoMissed: true, + // v1.15.20 — pinned takes bind by anchor in the unified ledger so the + // % agrees with the history view on a "zugeordnet" dose (taken-late). + attributionSource: true, + }, + }); + + // v1.7.0 SB-SCHED-2 — thread the medication context so the denominator + // routes through the canonical engine (RRULE / rolling / one-shot / PRN / + // cyclic). `lastIntakeAt` is the latest non-skipped takenAt inside the + // fetch window (rolling cadences re-anchor on it). + const lastIntakeAt = lastNonSkippedTakenAt(events); + const medicationContext = buildComplianceMedicationContext( + medication, + lastIntakeAt, + userTz, + ); + + // ONE shared expansion pass for every served block (the v1.15.20 + // performance fix): bands + ledger + timeline are minted once over the + // widest window and each sub-window is a tally over the same rows. + const bundle = buildMedicationComplianceBundle( + events, + medication.schedules, + medicationContext, + now, + ); + + // v1.15.18 — the heatmap / line-chart daily map is bucketed from the ONE + // unified dose-history ledger (the same bands the % + the history view + // read), so the per-day timing split (on-time / late) and the per-day + // missed marks can never disagree with the headline rate. The 90-day + // window is carved out of the shared ledger by `row.at`; the window + // floor clamps to the medication's creation so pre-existence days never + // mint phantom slots. + const heatmapFrom = new Date( + Math.max(now.getTime() - 90 * DAY_MS, createdAt.getTime()), + ); + + const dailyCompliance: Record = {}; + const byDay = new Map(); + const ensureDay = (key: string): DailyComplianceEntry => { + let entry = byDay.get(key); + if (!entry) { + entry = { + expected: 0, + expectedCount: 0, + due: false, + taken: 0, + skipped: 0, + onTime: 0, + late: 0, + veryLate: 0, + early: 0, + }; + byDay.set(key, entry); + } + return entry; + }; + + // Bucket each ledger row into its user-tz day. A slot row buckets on its + // anchor; an ad-hoc row on its real take time. + for (const row of bundle.ledgerRows) { + if (row.at.getTime() < heatmapFrom.getTime()) continue; + const key = userDayKey(row.at, userTz); + const entry = ensureDay(key); + bucketLedgerRow(entry, row); + } + + for (const [key, entry] of byDay) { + entry.due = entry.expectedCount > 0; + dailyCompliance[key] = entry; + } + + return { + compliance7: bundle.compliance7, + compliance30: bundle.compliance30, + dailyCompliance, + complianceDisplay: bundle.complianceDisplay, + }; +} diff --git a/src/lib/openapi/routes/medications.ts b/src/lib/openapi/routes/medications.ts index 0890be136..fbbde4171 100644 --- a/src/lib/openapi/routes/medications.ts +++ b/src/lib/openapi/routes/medications.ts @@ -507,6 +507,19 @@ const medicationComplianceResponse = z "Adherence read for a single medication. `compliance30` is the authoritative 30-day taken-vs-expected summary; `dailyCompliance` is the per-day grid for the history glyph track. The graded raw→week→month→year series used elsewhere for AI prompts does NOT apply here — this response is never downsampled.", }); +const medicationComplianceSummaryEntry = z + .object({ + medicationId: z.string(), + compliance7: complianceResult, + compliance30: complianceResult, + complianceDisplay, + }) + .meta({ + id: "MedicationComplianceSummaryEntry", + description: + "Compact per-medication adherence row for the batched card read: the 7-/30-day summaries plus the cadence-scaled display block. The per-day `dailyCompliance` grid is NOT on this shape — read the per-medication `/compliance` endpoint for the history glyph track.", + }); + // v1.16.5 — schedule-era management (the Zeitplan-tab history timeline). // Archived eras come from two provenances: the wholesale-replace write // path (`ARCHIVED`, immutable) and the user-entered pre-tracking flow @@ -856,6 +869,28 @@ export const medicationPaths: NonNullable = { }, }, }, + "/api/medications/compliance": { + get: { + tags: ["Medications"], + summary: "Batched adherence read for every medication of the caller", + description: + "Returns one compact adherence row per medication the caller owns (active + paused), ordered by `createdAt DESC` — the single round trip the medication cards consume instead of fanning out one `/api/medications/{id}/compliance` request per card. Each row carries the 7-/30-day summaries and the cadence-scaled display block; the per-day grid stays on the per-medication endpoint. Pure computation — no writes. Served through the same per-medication server cache as the per-id read, so the two endpoints warm each other.", + responses: { + "200": { + description: "One adherence row per medication.", + content: { + "application/json": { + schema: dataEnvelope( + z.array(medicationComplianceSummaryEntry), + "ListMedicationComplianceResponse", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, "/api/medications/{id}/compliance": { get: { tags: ["Medications"], diff --git a/src/lib/queries/prefetch-medications.ts b/src/lib/queries/prefetch-medications.ts index e6f1bd6b6..4ac9bd05a 100644 --- a/src/lib/queries/prefetch-medications.ts +++ b/src/lib/queries/prefetch-medications.ts @@ -12,11 +12,20 @@ import { apiGet } from "@/lib/api/api-fetch"; * puts `/api/medications` (the response carries the per-medication * `nextDueAt` due times) in flight while the navigation commits. * + * v1.16.8 — the batched card-compliance read + * (`/api/medications/compliance`) prefetches alongside the list, so the + * compliance bars + the status line resolve in the same paint as the + * cards instead of swapping in late. Two independent requests on + * purpose: the list response must stay fast (the due line never waits + * on a compliance ledger build), and the compliance prefetch rides the + * same server-side cache cells the page query would warm anyway. + * * The 15 s prefetch window only bounds how old a cache entry may be * before an intent re-fires the request — the mounted page query rides * the provider-default `staleTime` (5 min) and consumes whatever this - * prefetch parked. The server caches the list 60 s per user, so even a - * missed window stays cheap. + * prefetch parked. The server caches both reads per user (60 s list / + * 15 min compliance, each with an SWR window), so even a missed window + * stays cheap. */ export const MEDICATIONS_LIST_STALE_TIME_MS = 15_000; @@ -26,6 +35,11 @@ export function prefetchMedicationsList(queryClient: QueryClient): void { queryFn: () => apiGet("/api/medications"), staleTime: MEDICATIONS_LIST_STALE_TIME_MS, }); + void queryClient.prefetchQuery({ + queryKey: queryKeys.medicationComplianceSummary(), + queryFn: () => apiGet("/api/medications/compliance"), + staleTime: MEDICATIONS_LIST_STALE_TIME_MS, + }); } /** diff --git a/src/lib/queries/use-medication-compliance-summary.ts b/src/lib/queries/use-medication-compliance-summary.ts new file mode 100644 index 000000000..0bba1d1bd --- /dev/null +++ b/src/lib/queries/use-medication-compliance-summary.ts @@ -0,0 +1,61 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; + +import { apiGet } from "@/lib/api/api-fetch"; +import { queryKeys } from "@/lib/query-keys"; +import type { ComplianceDisplay } from "@/lib/analytics/compliance"; + +/** + * One batched-summary row — the compact slice of the per-medication + * compliance payload the cards render (rates, streak, the cadence-scaled + * display block with `currentDose` / `currentCycle`). The heavy + * `dailyCompliance` heatmap stays on the per-id endpoint the detail page + * reads. + */ +export interface MedicationComplianceSummaryEntry { + medicationId: string; + compliance7: { rate: number; streak: number }; + compliance30: { rate: number }; + complianceDisplay?: ComplianceDisplay; +} + +/** + * v1.16.8 — the per-card compliance read, batched. + * + * Every medication card used to fire its own + * `GET /api/medications/{id}/compliance` (N+1 over the grid, ~1 s cold + * each). All cards now share ONE `GET /api/medications/compliance` + * round trip under a single query key; each card `select`s its own row, + * so a grid of mounted cards collapses into one request and one cache + * entry. The key rides the `["medications"]` prefix, so every intake / + * CRUD mutation that invalidates `medicationDependentKeys` refreshes it. + * + * 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. + */ +export function useMedicationComplianceSummary(medicationId: string): { + data: MedicationComplianceSummaryEntry | null | undefined; +} { + const { data } = useQuery({ + queryKey: queryKeys.medicationComplianceSummary(), + queryFn: async () => { + try { + return await apiGet( + "/api/medications/compliance", + ); + } catch { + return null; + } + }, + // 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, + }); + return { data }; +} diff --git a/src/lib/query-keys/medications.ts b/src/lib/query-keys/medications.ts index 278ef062e..99ecee37f 100644 --- a/src/lib/query-keys/medications.ts +++ b/src/lib/query-keys/medications.ts @@ -16,6 +16,18 @@ export const medicationKeys = { */ medicationCompliance: (medicationId: string) => ["medications", medicationId, "compliance"] as const, + /** + * v1.16.8 — the batched card-compliance read + * (`GET /api/medications/compliance`, one round trip for every card on + * the medications page). Replaces the per-card fan-out over the per-id + * endpoint; `medicationCompliance(id)` above stays for the detail page, + * which needs the per-id payload's heatmap grid. Rides under the + * `["medications"]` prefix so every intake / CRUD mutation reaches it + * through `medicationDependentKeys`. The static second segment cannot + * collide with `medicationDetail(id)` — ids are cuids. + */ + medicationComplianceSummary: () => + ["medications", "compliance-summary"] as const, medicationCadence: (medicationId: string) => ["medications", medicationId, "cadence"] as const, medicationGlp1Details: (medicationId: string) => diff --git a/src/lib/tz/__tests__/intl-cache.test.ts b/src/lib/tz/__tests__/intl-cache.test.ts new file mode 100644 index 000000000..feb8768b2 --- /dev/null +++ b/src/lib/tz/__tests__/intl-cache.test.ts @@ -0,0 +1,185 @@ +/** + * v1.16.8 — memoised `Intl.DateTimeFormat` construction. + * + * Constructing a formatter dominates the CPU cost of the wall-clock + * helpers (band expansion calls them in a tight loop), so the memo must + * (a) return byte-identical output to a freshly constructed formatter + * for every timezone / option shape the tz helpers use, and (b) actually + * share one instance per signature. + */ + +import { describe, it, expect, beforeEach } from "vitest"; + +import { + getDateTimeFormat, + __resetIntlCacheForTests, + __intlCacheSizeForTests, +} from "../intl-cache"; +import { wallClockInTz } from "../wall-clock"; +import { formatInUserTz, userDayKey, isValidTimezone } from "../format"; +import { getUserTodayBounds, localHmAsUtc } from "../local-day"; + +const ZONES = [ + "UTC", + "Europe/Berlin", + "America/New_York", + "Asia/Tokyo", + "Pacific/Kiritimati", // +14, no DST + "America/St_Johns", // -03:30 fractional offset +]; + +// Instants chosen to straddle DST transitions and a midnight boundary. +const INSTANTS = [ + new Date("2026-01-15T03:30:00Z"), + new Date("2026-03-29T01:30:00Z"), // EU spring-forward window + new Date("2026-06-10T23:45:00Z"), + new Date("2026-10-25T01:30:00Z"), // EU fall-back window +]; + +beforeEach(() => { + __resetIntlCacheForTests(); +}); + +describe("getDateTimeFormat", () => { + it("matches a freshly constructed formatter across zones and option shapes", () => { + const optionShapes: Omit[] = [ + { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + weekday: "short", + }, + { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + }, + { hour: "2-digit", minute: "2-digit", hour12: false }, + ]; + for (const tz of ZONES) { + for (const options of optionShapes) { + const memoised = getDateTimeFormat("en-CA", tz, options); + const fresh = new Intl.DateTimeFormat("en-CA", { + ...options, + timeZone: tz, + }); + for (const instant of INSTANTS) { + expect(memoised.format(instant)).toBe(fresh.format(instant)); + expect(memoised.formatToParts(instant)).toEqual( + fresh.formatToParts(instant), + ); + } + } + } + }); + + it("returns the same instance for the same (locale, timeZone, options) signature", () => { + const options = { + hour: "2-digit", + minute: "2-digit", + hour12: false, + } as const; + const a = getDateTimeFormat("en-GB", "Europe/Berlin", options); + const b = getDateTimeFormat("en-GB", "Europe/Berlin", options); + expect(a).toBe(b); + // A structurally equal but distinct options object still hits the + // same cache cell (signature-keyed, not identity-keyed). + const c = getDateTimeFormat("en-GB", "Europe/Berlin", { + hour: "2-digit", + minute: "2-digit", + hour12: false, + } as const); + expect(c).toBe(a); + }); + + it("splits cells on locale, timezone, and options", () => { + const options = { + hour: "2-digit", + minute: "2-digit", + hour12: false, + } as const; + const base = getDateTimeFormat("en-GB", "Europe/Berlin", options); + expect(getDateTimeFormat("en-US", "Europe/Berlin", options)).not.toBe(base); + expect(getDateTimeFormat("en-GB", "Asia/Tokyo", options)).not.toBe(base); + expect( + getDateTimeFormat("en-GB", "Europe/Berlin", { + hour: "2-digit", + minute: "2-digit", + hour12: true, + } as const), + ).not.toBe(base); + expect(__intlCacheSizeForTests()).toBe(4); + }); + + it("throws on an invalid timezone without caching the failure", () => { + expect(() => getDateTimeFormat("en-US", "Not/AZone", {})).toThrow(); + expect(__intlCacheSizeForTests()).toBe(0); + }); +}); + +describe("memoised consumers stay output-identical to the pre-memo helpers", () => { + it("wallClockInTz decomposes DST-transition instants correctly", () => { + // 2026-03-29 01:30 UTC = 03:30 CEST (the EU spring-forward morning). + const parts = wallClockInTz(new Date("2026-03-29T01:30:00Z"), "Europe/Berlin"); + expect(parts).toEqual({ + year: 2026, + month: 3, + day: 29, + hour: 3, + minute: 30, + second: 0, + weekday: 0, + }); + // Same instant read from Tokyo. + const tokyo = wallClockInTz(new Date("2026-03-29T01:30:00Z"), "Asia/Tokyo"); + expect(tokyo).toEqual({ + year: 2026, + month: 3, + day: 29, + hour: 10, + minute: 30, + second: 0, + weekday: 0, + }); + }); + + it("formatInUserTz keeps every shape stable across repeat (memo-warm) calls", () => { + const instant = new Date("2026-06-10T23:45:00Z"); + for (let i = 0; i < 2; i++) { + expect(formatInUserTz(instant, "Europe/Berlin", "iso-with-offset")).toBe( + "2026-06-11T01:45:00+02:00", + ); + expect(formatInUserTz(instant, "Europe/Berlin", "wall-clock")).toBe( + "01:45", + ); + expect(formatInUserTz(instant, "Europe/Berlin", "date")).toBe( + "2026-06-11", + ); + expect(userDayKey(instant, "America/New_York")).toBe("2026-06-10"); + } + }); + + it("getUserTodayBounds + localHmAsUtc agree with the wall-clock read", () => { + const now = new Date("2026-06-10T23:45:00Z"); // 01:45 on the 11th in Berlin + const { start, end } = getUserTodayBounds(now, "Europe/Berlin"); + expect(start.toISOString()).toBe("2026-06-10T22:00:00.000Z"); + expect(end.toISOString()).toBe("2026-06-11T21:59:59.999Z"); + const eight = localHmAsUtc(now, "Europe/Berlin", 8, 0); + expect(eight.toISOString()).toBe("2026-06-11T06:00:00.000Z"); + }); + + it("isValidTimezone accepts real zones and rejects junk through the memo", () => { + expect(isValidTimezone("Europe/Berlin")).toBe(true); + expect(isValidTimezone("Pacific/Kiritimati")).toBe(true); + expect(isValidTimezone("Not/AZone")).toBe(false); + expect(isValidTimezone("")).toBe(false); + }); +}); diff --git a/src/lib/tz/format.ts b/src/lib/tz/format.ts index 112c65dcc..f54d91e0a 100644 --- a/src/lib/tz/format.ts +++ b/src/lib/tz/format.ts @@ -12,8 +12,35 @@ * lookup. */ +import { getDateTimeFormat } from "./intl-cache"; + export const DEFAULT_TIMEZONE = "Europe/Berlin"; +/** + * Hoisted option constants so the formatter memo's WeakMap signature + * lookup hits the same object identity on every call — see + * `./intl-cache`. `WALL_CLOCK_PARTS_OPTIONS` is shared by + * `wallClockParts` AND `tzOffsetMinutes`, so both resolve to the SAME + * memoised formatter per timezone. + */ +const VALIDATION_OPTIONS: Omit = {}; + +const HOUR_MINUTE_OPTIONS: Omit = { + hour: "2-digit", + minute: "2-digit", + hour12: false, +}; + +const WALL_CLOCK_PARTS_OPTIONS: Omit = { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, +}; + /** * Validate a timezone string by asking `Intl.DateTimeFormat` to use it. * Returns `true` if the runtime accepts the zone, `false` otherwise. @@ -24,7 +51,10 @@ export function isValidTimezone(tz: string): boolean { return false; } try { - new Intl.DateTimeFormat("en-US", { timeZone: tz }); + // The memo only caches successful constructions, so probing an + // invalid zone through it cannot poison the formatter map — and a + // valid zone leaves its formatter warm for the real callers. + getDateTimeFormat("en-US", tz, VALIDATION_OPTIONS); return true; } catch { return false; @@ -133,12 +163,9 @@ export function formatInUserTz( case "iso-with-offset": return formatIsoWithOffset(date, safeTz); case "wall-clock": - return new Intl.DateTimeFormat("en-GB", { - timeZone: safeTz, - hour: "2-digit", - minute: "2-digit", - hour12: false, - }).format(date); + return getDateTimeFormat("en-GB", safeTz, HOUR_MINUTE_OPTIONS).format( + date, + ); case "datetime": { const parts = wallClockParts(date, safeTz); return `${parts.year}-${parts.month}-${parts.day} ${parts.hour}:${parts.minute}`; @@ -171,16 +198,7 @@ interface WallClockParts { } function wallClockParts(date: Date, tz: string): WallClockParts { - const fmt = new Intl.DateTimeFormat("en-CA", { - timeZone: tz, - year: "numeric", - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - hour12: false, - }); + const fmt = getDateTimeFormat("en-CA", tz, WALL_CLOCK_PARTS_OPTIONS); const parts = fmt.formatToParts(date); const get = (type: Intl.DateTimeFormatPartTypes): string => parts.find((p) => p.type === type)?.value ?? "00"; @@ -223,16 +241,7 @@ function formatIsoWithOffset(date: Date, tz: string): string { * difference between the wall-clock parts and the UTC parts. */ function tzOffsetMinutes(date: Date, tz: string): number { - const fmt = new Intl.DateTimeFormat("en-CA", { - timeZone: tz, - year: "numeric", - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - hour12: false, - }); + const fmt = getDateTimeFormat("en-CA", tz, WALL_CLOCK_PARTS_OPTIONS); const parts = fmt.formatToParts(date); const get = (type: Intl.DateTimeFormatPartTypes): number => Number(parts.find((p) => p.type === type)?.value ?? "0"); diff --git a/src/lib/tz/intl-cache.ts b/src/lib/tz/intl-cache.ts new file mode 100644 index 000000000..d029a126b --- /dev/null +++ b/src/lib/tz/intl-cache.ts @@ -0,0 +1,77 @@ +/** + * Module-level memo for `Intl.DateTimeFormat` construction. + * + * Constructing a formatter resolves locale + timezone data on every call + * and dominates the CPU cost of the wall-clock helpers (~10x the + * `format` / `formatToParts` call it feeds). The instances are stateless + * for `format` / `formatToParts`, so one shared formatter per + * `(locale, timeZone, options)` signature is safe to hand to every + * caller — compliance band expansion, rollups, the dose-history ledger + * and the export formatters all hit the same small set of signatures. + * + * `timeZone` is a first-class key segment (not folded into `options`) so + * call sites can hoist their options object to a module constant; a + * WeakMap memoises that constant's serialised signature, leaving a warm + * lookup as two string concats + one Map get. + */ + +const formatterCache = new Map(); + +/** + * Hard cap. The realistic population is bounded (valid IANA zones × a + * handful of option shapes), but the timezone segment is user-supplied + * upstream of validation in `isValidTimezone`, so the memo carries a + * safety valve: on overflow the whole map resets and re-warms. + */ +const MAX_FORMATTERS = 1000; + +const signatureByOptions = new WeakMap< + Omit, + string +>(); + +function optionsSignature( + options: Omit, +): string { + const memoised = signatureByOptions.get(options); + if (memoised !== undefined) return memoised; + const record = options as Record; + const signature = Object.keys(record) + .sort() + .map((key) => `${key}=${String(record[key])}`) + .join(","); + signatureByOptions.set(options, signature); + return signature; +} + +/** + * Shared, memoised `Intl.DateTimeFormat`. Throws (like the constructor) + * on an invalid `timeZone` — failures are never cached, so + * `isValidTimezone` can probe through this without poisoning the map. + */ +export function getDateTimeFormat( + locale: string, + timeZone: string | undefined, + options: Omit = {}, +): Intl.DateTimeFormat { + const key = `${locale}|${timeZone ?? ""}|${optionsSignature(options)}`; + const hit = formatterCache.get(key); + if (hit) return hit; + const formatter = new Intl.DateTimeFormat( + locale, + timeZone === undefined ? options : { ...options, timeZone }, + ); + if (formatterCache.size >= MAX_FORMATTERS) formatterCache.clear(); + formatterCache.set(key, formatter); + return formatter; +} + +/** Test-only escape hatch — clear the memo between assertions. */ +export function __resetIntlCacheForTests(): void { + formatterCache.clear(); +} + +/** Test-only introspection — current memo population. */ +export function __intlCacheSizeForTests(): number { + return formatterCache.size; +} diff --git a/src/lib/tz/wall-clock.ts b/src/lib/tz/wall-clock.ts index 4ed3626b2..11de1fd20 100644 --- a/src/lib/tz/wall-clock.ts +++ b/src/lib/tz/wall-clock.ts @@ -18,6 +18,8 @@ * staying in lock-step; keeping them as separate definitions made * silent drift a real failure mode. */ +import { getDateTimeFormat } from "./intl-cache"; + export interface WallClockParts { year: number; /** 1–12 (January = 1, December = 12). */ @@ -42,6 +44,23 @@ const WEEKDAY_MAP: Record = { Sat: 6, }; +/** + * Hoisted so the formatter memo's WeakMap signature lookup hits the same + * object identity on every call — a warm `wallClockInTz` no longer pays + * `Intl.DateTimeFormat` construction (the dominant cost of the band + * expansion + rollup paths that call this in a tight loop). + */ +const WALL_CLOCK_OPTIONS: Omit = { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + weekday: "short", +}; + export function wallClockInTz( date: Date, tz: string | undefined, @@ -57,17 +76,9 @@ export function wallClockInTz( weekday: date.getDay(), }; } - const parts = new Intl.DateTimeFormat("en-US", { - timeZone: tz, - year: "numeric", - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - hour12: false, - weekday: "short", - }).formatToParts(date); + const parts = getDateTimeFormat("en-US", tz, WALL_CLOCK_OPTIONS).formatToParts( + date, + ); const get = (type: Intl.DateTimeFormatPartTypes): string => parts.find((p) => p.type === type)?.value ?? "0"; let hour = Number(get("hour")); From b5e799c2d619ba414b04e5fca6f2a0e532c76aea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 11 Jun 2026 21:17:27 +0200 Subject: [PATCH 03/12] fix(charts): degrade chart failures to one card and heal stale shells per version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The update poller and the chunk-error reload guard key on the target version instead of disarming for the whole session, so a second deploy re-arms the self-heal and a stale shell can no longer strand with 404ing chunks. Lazy chart imports retry once and mount inside a per-card error boundary with a reload affordance — a failed chunk degrades to one card instead of replacing the page. The JSON API client attaches a fifteen-second timeout unless the caller brings its own signal (the raw streaming path is exempt), chart queries surface errors with a retry instead of rendering as an empty period, per-type fetch failures reject instead of caching an empty series as fresh, and the session and snapshot queries retry once on transient network or server errors. --- messages/de.json | 3 + messages/en.json | 3 + messages/es.json | 3 + messages/fr.json | 3 + messages/it.json | 3 + messages/pl.json | 3 + .../error-chunk-reload-guard.test.ts | 41 ++++++ src/app/error.tsx | 44 ++++++- src/app/page.tsx | 35 +++-- .../__tests__/version-poller.test.ts | 50 +++++++ .../__tests__/chart-error-state.test.tsx | 115 ++++++++++++++++ src/components/charts/chart-error-state.tsx | 124 ++++++++++++++++++ .../charts/health-chart-dynamic.tsx | 49 +++++-- src/components/charts/health-chart.tsx | 51 ++++--- .../charts/medication-compliance-chart.tsx | 13 +- src/components/charts/mood-chart.tsx | 18 ++- src/components/version-poller.tsx | 57 ++++++-- src/hooks/__tests__/use-auth-retry.test.ts | 43 ++++++ src/hooks/use-auth.ts | 6 +- src/lib/__tests__/retry-import.test.ts | 57 ++++++++ src/lib/api/__tests__/api-fetch.test.ts | 75 ++++++++++- src/lib/api/api-fetch.ts | 27 +++- .../queries/__tests__/retry-transient.test.ts | 52 ++++++++ .../__tests__/use-dashboard-snapshot.test.tsx | 39 +++--- src/lib/queries/retry-transient.ts | 19 +++ src/lib/queries/use-dashboard-snapshot.ts | 16 ++- src/lib/retry-import.ts | 32 +++++ 27 files changed, 892 insertions(+), 89 deletions(-) create mode 100644 src/app/__tests__/error-chunk-reload-guard.test.ts create mode 100644 src/components/__tests__/version-poller.test.ts create mode 100644 src/components/charts/__tests__/chart-error-state.test.tsx create mode 100644 src/components/charts/chart-error-state.tsx create mode 100644 src/hooks/__tests__/use-auth-retry.test.ts create mode 100644 src/lib/__tests__/retry-import.test.ts create mode 100644 src/lib/queries/__tests__/retry-transient.test.ts create mode 100644 src/lib/queries/retry-transient.ts create mode 100644 src/lib/retry-import.ts diff --git a/messages/de.json b/messages/de.json index 36652cfe3..8efde3a24 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1740,6 +1740,9 @@ "needMoreDistinctDaysDescription": "Erfasse an mehreren Tagen, um eine Trendlinie freizuschalten.", "noDataInRangeTitle": "Keine Daten in diesem Zeitraum", "noDataInRangeDescription": "Für den gewählten Bereich liegen keine Messungen vor. Wechsle den Zeitraum oder erfasse einen neuen Wert.", + "errorTitle": "Daten konnten nicht geladen werden", + "loadErrorTitle": "Diagramm konnte nicht geladen werden", + "loadErrorReload": "Neu laden", "sparseDataCaption": "Mehr Messtage füllen den Trend.", "personalBaseline": "Dein Mittel", "deltaVsBaseline": "{delta} vs. dein Mittel", diff --git a/messages/en.json b/messages/en.json index 83da91264..db4e7e809 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1740,6 +1740,9 @@ "needMoreDistinctDaysDescription": "Log on more days to unlock the trend line.", "noDataInRangeTitle": "No data in this range", "noDataInRangeDescription": "No measurements fall inside the selected range. Switch the range or log a new measurement.", + "errorTitle": "Data could not be loaded", + "loadErrorTitle": "Chart could not be loaded", + "loadErrorReload": "Reload", "sparseDataCaption": "More days will fill out the trend.", "personalBaseline": "Your normal", "deltaVsBaseline": "{delta} vs. your normal", diff --git a/messages/es.json b/messages/es.json index 8f816c012..fed72c593 100644 --- a/messages/es.json +++ b/messages/es.json @@ -1740,6 +1740,9 @@ "needMoreDistinctDaysDescription": "Registra en más días para desbloquear la línea de tendencia.", "noDataInRangeTitle": "Sin datos en este rango", "noDataInRangeDescription": "No hay mediciones en el rango seleccionado. Cambia el rango o registra una nueva medición.", + "errorTitle": "No se pudieron cargar los datos", + "loadErrorTitle": "No se pudo cargar el gráfico", + "loadErrorReload": "Recargar", "sparseDataCaption": "Más días completarán la tendencia.", "personalBaseline": "Tu media", "deltaVsBaseline": "{delta} vs. tu media", diff --git a/messages/fr.json b/messages/fr.json index 69e0638d8..0ee754a1d 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -1740,6 +1740,9 @@ "needMoreDistinctDaysDescription": "Saisis sur plusieurs jours pour débloquer la courbe de tendance.", "noDataInRangeTitle": "Aucune donnée sur cette période", "noDataInRangeDescription": "Aucune mesure ne tombe dans la plage sélectionnée. Change de plage ou saisis une nouvelle mesure.", + "errorTitle": "Impossible de charger les données", + "loadErrorTitle": "Impossible de charger le graphique", + "loadErrorReload": "Recharger", "sparseDataCaption": "Davantage de jours étofferont la tendance.", "personalBaseline": "Ta moyenne", "deltaVsBaseline": "{delta} vs. ta moyenne", diff --git a/messages/it.json b/messages/it.json index c43b80afa..de35bd3df 100644 --- a/messages/it.json +++ b/messages/it.json @@ -1740,6 +1740,9 @@ "needMoreDistinctDaysDescription": "Registra in più giorni per sbloccare la linea di tendenza.", "noDataInRangeTitle": "Nessun dato in questo intervallo", "noDataInRangeDescription": "Nessuna misurazione rientra nell'intervallo selezionato. Cambia intervallo o registra una nuova misurazione.", + "errorTitle": "Impossibile caricare i dati", + "loadErrorTitle": "Impossibile caricare il grafico", + "loadErrorReload": "Ricarica", "sparseDataCaption": "Più giorni completeranno il trend.", "personalBaseline": "La tua media", "deltaVsBaseline": "{delta} vs. la tua media", diff --git a/messages/pl.json b/messages/pl.json index 48785c4f9..3787a51e6 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -1740,6 +1740,9 @@ "needMoreDistinctDaysDescription": "Rejestruj w więcej dni, aby odblokować linię trendu.", "noDataInRangeTitle": "Brak danych w tym zakresie", "noDataInRangeDescription": "Żadne pomiary nie mieszczą się w wybranym zakresie. Zmień zakres lub dodaj nowy pomiar.", + "errorTitle": "Nie udało się załadować danych", + "loadErrorTitle": "Nie udało się załadować wykresu", + "loadErrorReload": "Załaduj ponownie", "sparseDataCaption": "Więcej dni dopełni trend.", "personalBaseline": "Twoja średnia", "deltaVsBaseline": "{delta} vs. twoja średnia", diff --git a/src/app/__tests__/error-chunk-reload-guard.test.ts b/src/app/__tests__/error-chunk-reload-guard.test.ts new file mode 100644 index 000000000..5e2c5a55a --- /dev/null +++ b/src/app/__tests__/error-chunk-reload-guard.test.ts @@ -0,0 +1,41 @@ +/** + * Unit tests for the chunk-error auto-reload guard in `src/app/error.tsx`. + * + * The guard is keyed on the running shell's build version: one reload + * attempt per BROKEN SHELL, not one per session. After a successful + * heal the reloaded page carries the new version and the guard re-arms + * by construction; the pre-v1.16.8 once-per-session key exhausted on + * multi-deploy days and stranded the user on the error page. + */ + +import { describe, expect, it } from "vitest"; + +import { chunkReloadGuardValue, shouldAttemptChunkReload } from "../error"; + +describe("shouldAttemptChunkReload", () => { + it("reloads when no attempt is recorded", () => { + expect(shouldAttemptChunkReload(null, "1.16.8")).toBe(true); + }); + + it("suppresses a second reload for the SAME shell version", () => { + const stored = chunkReloadGuardValue("1.16.8"); + expect(shouldAttemptChunkReload(stored, "1.16.8")).toBe(false); + }); + + it("re-arms after a deploy — the reloaded shell carries a NEW version", () => { + const stored = chunkReloadGuardValue("1.16.8"); + expect(shouldAttemptChunkReload(stored, "1.16.9")).toBe(true); + }); + + it("treats a legacy timestamp guard value as no attempt", () => { + // Pre-v1.16.8 sessions stored `String(Date.now())`; it never matches + // a guard value, so a stranded session heals on its next chunk error. + expect(shouldAttemptChunkReload("1765400000000", "1.16.8")).toBe(true); + }); + + it("degrades to once-per-shell-lifetime when no version is injected", () => { + expect(chunkReloadGuardValue("")).toBe("unversioned"); + expect(shouldAttemptChunkReload(null, "")).toBe(true); + expect(shouldAttemptChunkReload("unversioned", "")).toBe(false); + }); +}); diff --git a/src/app/error.tsx b/src/app/error.tsx index 0077dc484..00463ac36 100644 --- a/src/app/error.tsx +++ b/src/app/error.tsx @@ -4,6 +4,7 @@ import { useEffect } from "react"; import { ErrorDetails } from "@/components/error-details"; const CHUNK_RELOAD_KEY = "healthlog:chunk-reload-attempted"; +const SHELL_VERSION = process.env.NEXT_PUBLIC_APP_VERSION ?? ""; function isChunkLoadError(err: Error & { digest?: string }): boolean { if (err.name === "ChunkLoadError") return true; @@ -16,6 +17,33 @@ function isChunkLoadError(err: Error & { digest?: string }): boolean { ); } +/** + * Guard value recorded per auto-reload attempt — the running shell's + * build version, so the suppression is scoped to ONE shell. After a + * successful heal the reloaded page carries the NEW version and the + * guard re-arms by construction; only a shell that reloaded and is + * STILL broken (the new build is also missing the chunk) stays + * suppressed. Builds without an injected version degrade to the legacy + * once-per-session behaviour via the "unversioned" sentinel. + */ +export function chunkReloadGuardValue(shellVersion: string): string { + return shellVersion || "unversioned"; +} + +/** + * Pure reload decision so the guard semantics carry direct unit + * coverage. `stored` is the sessionStorage value from the last attempt + * (`null` when none happened); legacy guard values (the pre-v1.16.8 + * timestamp) never match a version string, so a session stuck from + * before the change heals on its next chunk error. + */ +export function shouldAttemptChunkReload( + stored: string | null, + shellVersion: string, +): boolean { + return stored !== chunkReloadGuardValue(shellVersion); +} + export default function AppError({ error, reset, @@ -36,13 +64,19 @@ export default function AppError({ // After a deploy the cached shell still references old chunk // filenames; they 404 and Next.js surfaces them as `ChunkLoadError`. // A single hard reload fetches the new shell + chunk graph. - // sessionStorage gates the auto-reload so an error we can't recover - // from (e.g. the new build is also missing the chunk) doesn't loop. + // The sessionStorage guard is keyed on the shell version (see + // `chunkReloadGuardValue`) so it suppresses a loop within ONE + // broken shell but re-arms after every deploy — the pre-v1.16.8 + // once-per-session guard exhausted on multi-deploy days and left + // the user on the error page with no automatic way out. if (typeof window !== "undefined" && isChunkLoadError(error)) { try { - const already = window.sessionStorage.getItem(CHUNK_RELOAD_KEY); - if (!already) { - window.sessionStorage.setItem(CHUNK_RELOAD_KEY, String(Date.now())); + const stored = window.sessionStorage.getItem(CHUNK_RELOAD_KEY); + if (shouldAttemptChunkReload(stored, SHELL_VERSION)) { + window.sessionStorage.setItem( + CHUNK_RELOAD_KEY, + chunkReloadGuardValue(SHELL_VERSION), + ); window.location.reload(); } } catch { diff --git a/src/app/page.tsx b/src/app/page.tsx index 25e254d44..6fdd0c709 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -28,8 +28,10 @@ import type { DashboardAnalyticsData as AnalyticsData } from "@/types/analytics" import dynamic from "next/dynamic"; import { Button } from "@/components/ui/button"; import { EmptyState } from "@/components/ui/empty-state"; +import { ChartErrorBoundary } from "@/components/charts/chart-error-state"; import { ChartSkeleton } from "@/components/charts/chart-skeleton"; import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic"; +import { importWithRetry } from "@/lib/retry-import"; import { DashboardChartCell, useDashboardChartReveal, @@ -64,20 +66,37 @@ const DASHBOARD_QUERY_OPTS = { refetchOnWindowFocus: false, } as const; -const MoodChart = dynamic( +// v1.16.8 — the loaders retry a rejected chunk import once (a lazy +// import caches its rejection permanently, so a transient 404 from a +// stale shell used to brick the card for the session) and each mount +// wraps in `` so a chunk that still fails degrades +// to ONE error card instead of bubbling to the route-level `error.tsx`. +const MoodChartLazy = dynamic( () => - import("@/components/charts/mood-chart").then((mod) => ({ - default: mod.MoodChart, - })), + importWithRetry(() => import("@/components/charts/mood-chart")).then( + (mod) => ({ default: mod.MoodChart }), + ), { ssr: false, loading: () => }, ); -const MedicationComplianceChart = dynamic( +const MoodChart = (props: React.ComponentProps) => ( + + + +); +const MedicationComplianceChartLazy = dynamic( () => - import("@/components/charts/medication-compliance-chart").then((mod) => ({ - default: mod.MedicationComplianceChart, - })), + importWithRetry( + () => import("@/components/charts/medication-compliance-chart"), + ).then((mod) => ({ default: mod.MedicationComplianceChart })), { ssr: false, loading: () => }, ); +const MedicationComplianceChart = ( + props: React.ComponentProps, +) => ( + + + +); import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query"; diff --git a/src/components/__tests__/version-poller.test.ts b/src/components/__tests__/version-poller.test.ts new file mode 100644 index 000000000..b079fea7a --- /dev/null +++ b/src/components/__tests__/version-poller.test.ts @@ -0,0 +1,50 @@ +/** + * Unit tests for the version-poller reload decision. + * + * The guard is keyed on the TARGET (live) version: a repeat mismatch + * against the SAME version is suppressed (no reload loop on a misset + * server), but a SECOND deploy in one session moves the live version + * past the recorded one and the heal flow re-arms. The pre-v1.16.8 + * guard disabled all polling after one attempt, which stranded stale + * shells on multi-deploy days. + */ + +import { describe, expect, it } from "vitest"; + +import { resolveVersionPollDecision } from "../version-poller"; + +describe("resolveVersionPollDecision", () => { + it("is up-to-date when live matches the shell", () => { + expect(resolveVersionPollDecision("1.16.8", "1.16.8", null)).toBe( + "up-to-date", + ); + }); + + it("is up-to-date when the live version cannot be read", () => { + expect(resolveVersionPollDecision(null, "1.16.8", null)).toBe("up-to-date"); + }); + + it("reloads on a mismatch with no prior attempt", () => { + expect(resolveVersionPollDecision("1.16.9", "1.16.8", null)).toBe("reload"); + }); + + it("suppresses a repeat reload for the SAME target version", () => { + expect(resolveVersionPollDecision("1.16.9", "1.16.8", "1.16.9")).toBe( + "already-attempted", + ); + }); + + it("re-arms when a SECOND deploy moves the live version past the recorded one", () => { + expect(resolveVersionPollDecision("1.16.10", "1.16.8", "1.16.9")).toBe( + "reload", + ); + }); + + it("treats a legacy timestamp guard value as no attempt", () => { + // Pre-v1.16.8 sessions stored `String(Date.now())`; it never matches + // a real version string, so a stranded session heals on its next poll. + expect( + resolveVersionPollDecision("1.16.9", "1.16.8", "1765400000000"), + ).toBe("reload"); + }); +}); diff --git a/src/components/charts/__tests__/chart-error-state.test.tsx b/src/components/charts/__tests__/chart-error-state.test.tsx new file mode 100644 index 000000000..258fa9c54 --- /dev/null +++ b/src/components/charts/__tests__/chart-error-state.test.tsx @@ -0,0 +1,115 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +// vi.doMock below invalidates the module registry, so the re-imported +// provider gets a fresh (unprimed) locale cache — pass the DE bundle +// explicitly instead of relying on the vitest.setup.ts seeding. +import deMessages from "../../../../messages/de.json"; + +/** + * v1.16.8 — failed chart queries must look like ERRORS, not "no data". + * + * Pre-fix all three dashboard charts destructured only + * `{ data, isLoading }`: a failed query fell into the empty-state copy + * ("Keine Daten in diesem Zeitraum" / "Keine Daten verfügbar") — and + * the mood chart unmounted its card entirely. The fix surfaces + * `isError` as a `` card with a retry affordance in + * each chart, keeping the card chrome mounted so the dashboard + * composition stays intact. + */ + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + isAuthenticated: true, + user: null, + isLoading: false, + }), +})); + +function mockQueryError() { + vi.doMock("@tanstack/react-query", () => ({ + useQuery: () => ({ + data: undefined, + isLoading: false, + isError: true, + refetch: () => Promise.resolve(), + }), + useQueryClient: () => ({ + cancelQueries: () => Promise.resolve(), + getQueryData: () => undefined, + setQueryData: () => undefined, + invalidateQueries: () => Promise.resolve(), + }), + useMutation: () => ({ mutate: () => undefined, isPending: false }), + })); +} + +describe("chart isError rendering — error state, not empty state", () => { + beforeEach(() => { + vi.resetModules(); + }); + + it(" paints the error card with retry, keeps the card chrome", async () => { + mockQueryError(); + + const { I18nProvider } = await import("@/lib/i18n/context"); + const { HealthChart } = await import("../health-chart"); + + const html = renderToStaticMarkup( + + + , + ); + + expect(html).toContain('data-slot="chart-error-state"'); + expect(html).toContain("Daten konnten nicht geladen werden"); + expect(html).toContain("Erneut versuchen"); + // Card chrome must stay mounted. + expect(html).toContain("Pulse"); + // The empty-state copy must NOT paint — an outage is not "no data". + expect(html).not.toContain("Keine Daten in diesem Zeitraum"); + + vi.doUnmock("@tanstack/react-query"); + }); + + it(" keeps the card mounted and paints the error card", async () => { + mockQueryError(); + + const { I18nProvider } = await import("@/lib/i18n/context"); + const { MoodChart } = await import("../mood-chart"); + + const html = renderToStaticMarkup( + + + , + ); + + // Pre-fix the component returned null here — html was empty. + expect(html).toContain('data-slot="chart-error-state"'); + expect(html).toContain("Daten konnten nicht geladen werden"); + expect(html).toContain("Erneut versuchen"); + + vi.doUnmock("@tanstack/react-query"); + }); + + it(" paints the error card, not the no-data hint", async () => { + mockQueryError(); + + const { I18nProvider } = await import("@/lib/i18n/context"); + const { MedicationComplianceChart } = await import( + "../medication-compliance-chart" + ); + + const html = renderToStaticMarkup( + + + , + ); + + expect(html).toContain('data-slot="chart-error-state"'); + expect(html).toContain("Daten konnten nicht geladen werden"); + expect(html).toContain("Erneut versuchen"); + expect(html).not.toContain("Keine Daten im gewählten Zeitraum"); + + vi.doUnmock("@tanstack/react-query"); + }); +}); diff --git a/src/components/charts/chart-error-state.tsx b/src/components/charts/chart-error-state.tsx new file mode 100644 index 000000000..749368b10 --- /dev/null +++ b/src/components/charts/chart-error-state.tsx @@ -0,0 +1,124 @@ +"use client"; + +import { Component, type ReactElement, type ReactNode } from "react"; +import { AlertTriangle, RotateCw } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { useTranslations } from "@/lib/i18n/context"; + +/** + * Error-state card + per-chart error boundary. + * + * Two failure classes used to look like something else: + * - A failed data query fell into the "no data in this range" empty + * state (the queries swallowed per-type failures as empty success), + * so an outage read as "you have no measurements". + * - A rejected lazy chunk import (stale shell after a deploy) bubbled + * to the route-level `error.tsx` and replaced the WHOLE page. + * + * `` is the shared presentational card — same dashed + * footprint as `` so the dashboard layout never + * reflows — with a compact action button. `` wraps + * each lazy chart mount so a failed chunk degrades to one card with a + * reload affordance instead of taking the page down. + */ + +export interface ChartErrorStateProps { + /** Translated headline, e.g. "Data could not be loaded". */ + title: string; + /** Translated action-button label, e.g. "Retry". */ + actionLabel: string; + /** Action handler — query `refetch` or a full page reload. */ + onAction: () => void; + /** + * Optional explicit height in pixels to match the surrounding chart + * card. Defaults to 240 (matches ``). + */ + height?: number; +} + +export function ChartErrorState({ + title, + actionLabel, + onAction, + height = 240, +}: ChartErrorStateProps): ReactElement { + return ( +
+
+ ); +} + +interface BoundaryProps { + fallback: ReactNode; + children: ReactNode; +} + +interface BoundaryState { + failed: boolean; +} + +class Boundary extends Component { + state: BoundaryState = { failed: false }; + + static getDerivedStateFromError(): BoundaryState { + return { failed: true }; + } + + componentDidCatch(error: Error): void { + // Surface to the client-side error tracker the same way the + // route-level boundary does — the card degrades quietly but the + // failure still lands in monitoring. + if (typeof window !== "undefined") { + const g = window as typeof window & { + __healthlog_onError?: (err: Error) => void; + }; + g.__healthlog_onError?.(error); + } + } + + render(): ReactNode { + return this.state.failed ? this.props.fallback : this.props.children; + } +} + +/** + * Per-card boundary for lazy chart mounts. A chunk that still rejects + * after the `importWithRetry` attempt (see `src/lib/retry-import.ts`) + * renders this card instead of bubbling to the route boundary; the + * reload affordance fetches the fresh shell + chunk graph (a rejected + * lazy import is cached, so a remount alone cannot recover). + */ +export function ChartErrorBoundary({ + children, +}: { + children: ReactNode; +}): ReactElement { + const { t } = useTranslations(); + return ( + window.location.reload()} + /> + } + > + {children} + + ); +} diff --git a/src/components/charts/health-chart-dynamic.tsx b/src/components/charts/health-chart-dynamic.tsx index 03212e315..06c92e7b2 100644 --- a/src/components/charts/health-chart-dynamic.tsx +++ b/src/components/charts/health-chart-dynamic.tsx @@ -1,8 +1,11 @@ "use client"; import dynamic from "next/dynamic"; +import type { ComponentProps, ReactElement } from "react"; +import { ChartErrorBoundary } from "@/components/charts/chart-error-state"; import { ChartSkeleton } from "@/components/charts/chart-skeleton"; +import { importWithRetry } from "@/lib/retry-import"; /** * v1.4.28 (BK-F-M2) — single `next/dynamic` boundary for the @@ -17,17 +20,33 @@ import { ChartSkeleton } from "@/components/charts/chart-skeleton"; * * Re-exporting through this module gives every consumer the same * pre-configured ``-backed lazy boundary. Consumers - * still forward the same props they did before — the type contract - * mirrors `` exactly via `dynamic()`'s own inference. + * still forward the same props they did before. + * + * v1.16.8 — the loader retries a rejected chunk import once (a lazy + * import caches its rejection permanently, so a transient 404 from a + * stale shell used to brick the card for the session) and every mount + * wraps in `` so a chunk that still fails degrades + * to ONE error card with a reload affordance instead of bubbling to the + * route-level `error.tsx`. */ -export const HealthChartDynamic = dynamic( +const HealthChartLazy = dynamic( () => - import("@/components/charts/health-chart").then((mod) => ({ - default: mod.HealthChart, - })), + importWithRetry(() => import("@/components/charts/health-chart")).then( + (mod) => ({ default: mod.HealthChart }), + ), { ssr: false, loading: () => }, ); +export function HealthChartDynamic( + props: ComponentProps, +): ReactElement { + return ( + + + + ); +} + /** * v1.4.36 W2 — mini variant for the trends-row 140 px chart slot. Same * underlying `` import, but the loading shell renders the @@ -38,10 +57,20 @@ export const HealthChartDynamic = dynamic( * `trends-row.tsx`) pass `mini` on the chart itself; this wrapper * ensures the next/dynamic loader paints the matching skeleton. */ -export const HealthChartDynamicMini = dynamic( +const HealthChartLazyMini = dynamic( () => - import("@/components/charts/health-chart").then((mod) => ({ - default: mod.HealthChart, - })), + importWithRetry(() => import("@/components/charts/health-chart")).then( + (mod) => ({ default: mod.HealthChart }), + ), { ssr: false, loading: () => }, ); + +export function HealthChartDynamicMini( + props: ComponentProps, +): ReactElement { + return ( + + + + ); +} diff --git a/src/components/charts/health-chart.tsx b/src/components/charts/health-chart.tsx index ea1d4417e..c8c6be1d7 100644 --- a/src/components/charts/health-chart.tsx +++ b/src/components/charts/health-chart.tsx @@ -30,6 +30,7 @@ import { import { Skeleton } from "@/components/ui/skeleton"; import { RichChartTooltip, type RichTooltipRow } from "./chart-tooltip"; import { ChartEmptyState } from "./chart-empty-state"; +import { ChartErrorState } from "./chart-error-state"; import { TileHeader } from "@/components/insights/tile-header"; import { prefersReducedMotion } from "@/lib/charts/reduced-motion"; import { computePaddedYDomain } from "@/lib/insights/chart-y-domain"; @@ -631,7 +632,7 @@ export function HealthChart({ }; }, [rangePoints, effectiveCompareBaseline]); - const { data, isLoading } = useQuery({ + const { data, isLoading, isError, refetch } = useQuery({ // v1.4.40 W-RSC — route the chart-data key through `queryKeys.chartData` // so the `["chart-data"]` prefix lands in `measurementDependentKeys` // and a fresh measurement evicts every per-chart daily cache in one @@ -708,16 +709,14 @@ export function HealthChart({ "days", String(Math.max(1, Math.min(3650, Math.ceil(fetchWindow.windowDays)))), ); - let points: Array<{ at: string; value: number }>; - try { - const data = await apiGet<{ - points?: Array<{ at: string; value: number }>; - }>(`/api/measurements/series?${sleepParams}`); - points = data?.points ?? []; - } catch { - // Sleep overlay is additive — skip it on failure, as before. - return; - } + // v1.16.8 — a failed fetch rejects the whole query instead of + // silently resolving an empty series: the swallowed-catch + // variant cached the empty result as fresh success, so an + // outage painted "no data in this range" with no retry path. + const data = await apiGet<{ + points?: Array<{ at: string; value: number }>; + }>(`/api/measurements/series?${sleepParams}`); + const points = data?.points ?? []; for (const point of points) { const value = point.value * valueScale; if (value == null || !Number.isFinite(value)) continue; @@ -773,16 +772,16 @@ export function HealthChart({ typeParams.set("source", "rollup"); } - let page: MeasurementApiRow[]; - try { - const data = await apiGet<{ measurements?: MeasurementApiRow[] }>( - `/api/measurements?${typeParams}`, - ); - page = data?.measurements ?? []; - } catch { - // Per-type fetch failure degrades that series only, as before. - return; - } + // v1.16.8 — a failed per-type fetch rejects the whole query + // instead of silently dropping the series: the swallowed-catch + // variant cached the partial/empty result as fresh success, so + // an outage painted the empty state (or a half chart) with no + // retry path. Rejecting lets TanStack's retry semantics and the + // in-card error state below do their jobs. + const data = await apiGet<{ measurements?: MeasurementApiRow[] }>( + `/api/measurements?${typeParams}`, + ); + const page = data?.measurements ?? []; for (const measurement of page) { // v1.7.0 — fold the display-time scale into the raw value at @@ -1545,6 +1544,16 @@ export function HealthChart({ // shared-reveal overlay and every insights mount stay // layout-shift-free. + ) : isError ? ( + // v1.16.8 — a failed query paints as an ERROR with a retry + // affordance, not as the "no data in this range" empty state. + // Pre-fix `isError` fell through to the empty branch and an + // outage read as "you have no measurements". + void refetch()} + /> ) : !chartData?.length ? ( // v1.4.43 W11-M6 — empty-window state. // diff --git a/src/components/charts/medication-compliance-chart.tsx b/src/components/charts/medication-compliance-chart.tsx index 3f7b81b92..187386d1b 100644 --- a/src/components/charts/medication-compliance-chart.tsx +++ b/src/components/charts/medication-compliance-chart.tsx @@ -56,6 +56,7 @@ import { makeFormatters } from "@/lib/format-locale"; import { cn } from "@/lib/utils"; import { RichChartTooltip, type RichTooltipRow } from "./chart-tooltip"; import { ChartEmptyState } from "./chart-empty-state"; +import { ChartErrorState } from "./chart-error-state"; import { ChartOverlayControls } from "./chart-overlay-controls"; import { prefersReducedMotion } from "@/lib/charts/reduced-motion"; import { useChartOverlayPrefs } from "@/hooks/use-chart-overlay-prefs"; @@ -242,7 +243,7 @@ export function MedicationComplianceChart({ // `["dashboard-medication-compliance"]` lands in // `medicationDependentKeys` and an intake POST refreshes the chart // immediately rather than waiting for `staleTime` (audit L4). - const { data, isLoading } = useQuery({ + const { data, isLoading, isError, refetch } = useQuery({ queryKey: queryKeys.dashboardMedicationCompliance(days), queryFn: async (): Promise => { return apiGet(`/api/medications/intake?scope=compliance&days=${days}`); @@ -415,6 +416,16 @@ export function MedicationComplianceChart({ // `h-48` spinner box; matches the painted chart body below so // the card never jumps when the data lands. + ) : isError ? ( + // v1.16.8 — a failed query paints as an ERROR with a retry + // affordance, not as the "no data" hint. Pre-fix `isError` fell + // through to the empty branch (data undefined → hasData false) + // and an outage read as "no doses tracked". + void refetch()} + /> ) : !hasData ? (
{t("charts.noData")} diff --git a/src/components/charts/mood-chart.tsx b/src/components/charts/mood-chart.tsx index da8fd1cf5..790f8bb45 100644 --- a/src/components/charts/mood-chart.tsx +++ b/src/components/charts/mood-chart.tsx @@ -28,6 +28,7 @@ import { } from "@/lib/charts/bucket-time-series"; import { RichChartTooltip, type RichTooltipRow } from "./chart-tooltip"; import { ChartEmptyState } from "./chart-empty-state"; +import { ChartErrorState } from "./chart-error-state"; import { prefersReducedMotion } from "@/lib/charts/reduced-motion"; import { shiftDailySeriesForward } from "@/lib/charts/comparison-shift"; import type { @@ -346,7 +347,7 @@ export function MoodChart({ // cache fresh; the chart's own subscription becomes the cache writer // for any insights / mood sub-page that mounts the chart without the // dashboard parent having fetched first. - const { data, isLoading } = useQuery({ + const { data, isLoading, isError, refetch } = useQuery({ queryKey: queryKeys.moodAnalytics(), queryFn: async (): Promise => { return apiGet("/api/mood/analytics"); @@ -580,7 +581,11 @@ export function MoodChart({ const animationsEnabled = !prefersReducedMotion(); - if (!isLoading && !data?.entries?.length) return null; + // v1.16.8 — only an EMPTY success hides the card (the dashboard gates + // the mount on `hasMood` anyway). A failed query must keep the card + // mounted so the error state + retry affordance below can paint; + // pre-fix the `return null` made an outage erase the widget entirely. + if (!isLoading && !isError && !data?.entries?.length) return null; // v1.13.1 — TRENDS-ROW MOOD CARD HEIGHT. In mini mode the title + // chart body render inside the exact same flat shell ` + ) : isError ? ( + // v1.16.8 — a failed query paints as an ERROR with a retry + // affordance instead of unmounting the card (see the gate above). + void refetch()} + height={CHART_HEIGHT_PX} + /> ) : !chartData?.length ? (
{t("charts.noData")} diff --git a/src/components/version-poller.tsx b/src/components/version-poller.tsx index df6e2bbc5..c9baeeafe 100644 --- a/src/components/version-poller.tsx +++ b/src/components/version-poller.tsx @@ -30,15 +30,41 @@ import { apiGet } from "@/lib/api/api-fetch"; * 3. `window.location.reload()` — fetches the fresh shell + new * chunk graph. * - * `sessionStorage` gates the reload to once per session so a misset - * version (server briefly serves a stale image, e.g. mid-deploy - * webhook race) cannot loop. + * The reload guard is keyed on the TARGET version: `sessionStorage` + * records which live version we last reloaded for, and only a repeat + * mismatch against that SAME version is suppressed (a misset version — + * server briefly serves a stale image mid-deploy — cannot loop). + * Polling itself never stops, so on a multi-deploy day a SECOND + * release moves the live version past the recorded one and the heal + * flow re-arms. The pre-v1.16.8 guard disabled all polling for the + * rest of the session after one attempt, which left a stale shell + * with 404ing chunks stuck forever once the guard was spent. */ const POLL_INTERVAL_MS = 60_000; const SESSION_GUARD_KEY = "healthlog:version-reload-attempted"; const SHELL_VERSION = process.env.NEXT_PUBLIC_APP_VERSION ?? ""; +export type VersionPollDecision = "up-to-date" | "reload" | "already-attempted"; + +/** + * Pure reload decision so the guard semantics carry direct unit + * coverage. `lastReloadedFor` is the live version recorded by the most + * recent heal attempt in this session (`null` when none happened); + * legacy guard values (the pre-v1.16.8 timestamp) never match a real + * version string, so a stuck session from before the change heals on + * its next mismatch. + */ +export function resolveVersionPollDecision( + liveVersion: string | null, + shellVersion: string, + lastReloadedFor: string | null, +): VersionPollDecision { + if (!liveVersion || liveVersion === shellVersion) return "up-to-date"; + if (lastReloadedFor === liveVersion) return "already-attempted"; + return "reload"; +} + async function fetchLiveVersion(signal: AbortSignal): Promise { try { const data = await apiGet<{ version?: string } | undefined>( @@ -51,9 +77,9 @@ async function fetchLiveVersion(signal: AbortSignal): Promise { } } -async function evictAndReload(): Promise { +async function evictAndReload(targetVersion: string): Promise { try { - sessionStorage.setItem(SESSION_GUARD_KEY, String(Date.now())); + sessionStorage.setItem(SESSION_GUARD_KEY, targetVersion); } catch { // sessionStorage can throw under strict-privacy modes; fall // through to the reload anyway — worst case the next deploy @@ -85,19 +111,24 @@ export function VersionPoller(): null { useEffect(() => { if (!SHELL_VERSION) return; if (typeof window === "undefined") return; - try { - if (sessionStorage.getItem(SESSION_GUARD_KEY)) return; - } catch { - /* fall through — we'll try the reload anyway if mismatch hits */ - } const controller = new AbortController(); async function checkOnce(): Promise { const live = await fetchLiveVersion(controller.signal); - if (!live) return; - if (live === SHELL_VERSION) return; - await evictAndReload(); + let lastReloadedFor: string | null = null; + try { + lastReloadedFor = sessionStorage.getItem(SESSION_GUARD_KEY); + } catch { + /* fall through — treat as "no attempt recorded" */ + } + const decision = resolveVersionPollDecision( + live, + SHELL_VERSION, + lastReloadedFor, + ); + if (decision !== "reload") return; + await evictAndReload(live as string); } // First check 5 s after mount — gives the app a chance to settle diff --git a/src/hooks/__tests__/use-auth-retry.test.ts b/src/hooks/__tests__/use-auth-retry.test.ts new file mode 100644 index 000000000..a5236c9ce --- /dev/null +++ b/src/hooks/__tests__/use-auth-retry.test.ts @@ -0,0 +1,43 @@ +/** + * v1.16.8 — the auth cell retries ONCE on transient failures. + * + * `useAuth` used to pin `retry: false`, so one transient network blip + * on `/api/auth/me` flipped `isAuthenticated` false and sent the shell + * to the redirect spinner mid-session. The cell now uses the shared + * transient-retry predicate: one retry on network errors / 5xx, never + * on 401/403 (those are real answers). + */ + +import { afterEach, describe, expect, it, vi } from "vitest"; + +const useQueryMock = vi.fn<(opts: Record) => { data: undefined }>( + () => ({ data: undefined }), +); + +vi.mock("@tanstack/react-query", () => ({ + useQuery: (opts: Record) => useQueryMock(opts), + useQueryClient: () => ({}), + useMutation: () => ({ mutate: () => undefined, isPending: false }), +})); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: () => undefined }), +})); + +import { useAuth } from "../use-auth"; +import { retryOnceOnTransientError } from "@/lib/queries/retry-transient"; +import { queryKeys } from "@/lib/query-keys"; + +afterEach(() => { + useQueryMock.mockClear(); +}); + +describe("useAuth — transient-retry softening", () => { + it("routes retry through the shared transient predicate", () => { + useAuth(); + expect(useQueryMock).toHaveBeenCalledTimes(1); + const opts = useQueryMock.mock.calls[0]![0]; + expect(opts.retry).toBe(retryOnceOnTransientError); + expect(opts.queryKey).toEqual(queryKeys.authMe()); + }); +}); diff --git a/src/hooks/use-auth.ts b/src/hooks/use-auth.ts index dab7eb333..6006c06a5 100644 --- a/src/hooks/use-auth.ts +++ b/src/hooks/use-auth.ts @@ -5,6 +5,7 @@ import { useRouter } from "next/navigation"; import { toast } from "sonner"; import { queryKeys } from "@/lib/query-keys"; import { apiGet, apiFetchRaw } from "@/lib/api/api-fetch"; +import { retryOnceOnTransientError } from "@/lib/queries/retry-transient"; import { useTranslations } from "@/lib/i18n/context"; import type { TimeFormatPreference } from "@/lib/format-locale"; import { isTimeFormatPreference, storeTimeFormat } from "@/lib/time-format"; @@ -127,7 +128,10 @@ export function useAuth() { // `queryKeys.auth()` invalidations. queryKey: queryKeys.authMe(), queryFn: fetchMe, - retry: false, + // v1.16.8 — one retry on network errors / 5xx (never 401/403). A + // single transient failure used to flip `isAuthenticated` false and + // send the shell to the redirect spinner mid-session. + retry: retryOnceOnTransientError, staleTime: 5 * 60 * 1000, }); diff --git a/src/lib/__tests__/retry-import.test.ts b/src/lib/__tests__/retry-import.test.ts new file mode 100644 index 000000000..f905cee38 --- /dev/null +++ b/src/lib/__tests__/retry-import.test.ts @@ -0,0 +1,57 @@ +/** + * Unit tests for the lazy-chunk import retry wrapper. + * + * A rejected `import()` inside `React.lazy` / `next/dynamic` caches its + * rejection permanently, so the wrapper must absorb ONE transient + * failure (stale shell right after a deploy, flaky network) before the + * rejection reaches the boundary. + */ + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { IMPORT_RETRY_DELAY_MS, importWithRetry } from "../retry-import"; + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("importWithRetry", () => { + it("resolves immediately when the first attempt succeeds", async () => { + const importer = vi.fn().mockResolvedValue({ default: "ok" }); + + await expect(importWithRetry(importer)).resolves.toEqual({ + default: "ok", + }); + expect(importer).toHaveBeenCalledTimes(1); + }); + + it("retries once after the delay when the first attempt rejects", async () => { + vi.useFakeTimers(); + const importer = vi + .fn() + .mockRejectedValueOnce(new Error("Loading chunk 42 failed")) + .mockResolvedValueOnce({ default: "ok" }); + + const promise = importWithRetry(importer); + // The retry must not fire before the delay elapses. + await vi.advanceTimersByTimeAsync(IMPORT_RETRY_DELAY_MS - 1); + expect(importer).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(1); + + await expect(promise).resolves.toEqual({ default: "ok" }); + expect(importer).toHaveBeenCalledTimes(2); + }); + + it("rejects with the original error once the retry budget is spent", async () => { + vi.useFakeTimers(); + const chunkError = new Error("Loading chunk 42 failed"); + const importer = vi.fn().mockRejectedValue(chunkError); + + const promise = importWithRetry(importer); + promise.catch(() => undefined); // pre-attach so the rejection is handled + await vi.advanceTimersByTimeAsync(IMPORT_RETRY_DELAY_MS); + + await expect(promise).rejects.toBe(chunkError); + expect(importer).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/lib/api/__tests__/api-fetch.test.ts b/src/lib/api/__tests__/api-fetch.test.ts index f3e822aa4..25e0f356a 100644 --- a/src/lib/api/__tests__/api-fetch.test.ts +++ b/src/lib/api/__tests__/api-fetch.test.ts @@ -48,7 +48,9 @@ describe("apiFetch", () => { const data = await apiFetch<{ id: string }>("/api/measurements/m1"); expect(data).toEqual({ id: "m1" }); - expect(fetchMock).toHaveBeenCalledWith("/api/measurements/m1", undefined); + expect(fetchMock).toHaveBeenCalledWith("/api/measurements/m1", { + signal: expect.any(AbortSignal), + }); }); it("passes init through to fetch (signal, cache, headers)", async () => { @@ -171,6 +173,67 @@ describe("apiFetchRaw", () => { await expect(apiFetchRaw("/api/foo")).resolves.toBe(res); }); + + it("applies NO default timeout — SSE streams outlive any fixed window", async () => { + fetchMock.mockResolvedValueOnce(new Response("stream", { status: 200 })); + + await apiFetchRaw("/api/insights/chat", { method: "POST" }); + + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(init.signal).toBeUndefined(); + }); +}); + +describe("default timeout", () => { + it("attaches a default AbortSignal when the caller passes no signal", async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ data: null, error: null })); + + await apiFetch("/api/foo", { cache: "no-store" }); + + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(init.cache).toBe("no-store"); + expect(init.signal).toBeInstanceOf(AbortSignal); + }); + + it("a caller-supplied signal replaces the default", async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ data: null, error: null })); + const controller = new AbortController(); + + await apiFetch("/api/foo", { signal: controller.signal }); + + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(init.signal).toBe(controller.signal); + }); + + it("signal: null opts out of the default entirely", async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ data: null, error: null })); + + await apiFetch("/api/foo", { signal: null }); + + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(init.signal).toBeNull(); + }); + + it("apiFetchEnvelope carries the same default", async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse({ data: [], error: null, meta: {} }), + ); + + await apiFetchEnvelope("/api/items"); + + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(init.signal).toBeInstanceOf(AbortSignal); + }); + + it("the default signal carries through the verb helpers' body merge", async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ data: null, error: null })); + + await apiPost("/api/foo", { a: 1 }); + + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(init.method).toBe("POST"); + expect(init.signal).toBeInstanceOf(AbortSignal); + }); }); describe("verb helpers", () => { @@ -179,7 +242,10 @@ describe("verb helpers", () => { await apiGet("/api/foo"); - expect(fetchMock).toHaveBeenCalledWith("/api/foo", { method: "GET" }); + expect(fetchMock).toHaveBeenCalledWith("/api/foo", { + method: "GET", + signal: expect.any(AbortSignal), + }); }); it.each([ @@ -206,7 +272,10 @@ describe("verb helpers", () => { await apiPost("/api/foo"); - expect(fetchMock).toHaveBeenCalledWith("/api/foo", { method: "POST" }); + expect(fetchMock).toHaveBeenCalledWith("/api/foo", { + method: "POST", + signal: expect.any(AbortSignal), + }); }); it("merges caller headers with the JSON Content-Type", async () => { diff --git a/src/lib/api/api-fetch.ts b/src/lib/api/api-fetch.ts index 6b2afcaf2..e6662274e 100644 --- a/src/lib/api/api-fetch.ts +++ b/src/lib/api/api-fetch.ts @@ -28,6 +28,11 @@ * callers can branch on `err.status === 429` for cooldown UX. * - No toast / no global side effect ever originates here. Surfacing * errors stays the call site's responsibility. + * - Every request carries a default 15 s `AbortSignal.timeout` so a + * stalled response can never hold a query in `isLoading` forever. + * Passing `init.signal` (any value, including `null` to opt out) + * replaces the default entirely — callers that need a longer + * window or their own cancellation own the signal then. * * Verb helpers `apiGet` / `apiPost` / `apiPut` / `apiPatch` / * `apiDelete` JSON-encode an optional body and set `Content-Type: @@ -39,7 +44,9 @@ * few callers that read success-side `meta` (pagination totals). * - `apiFetchRaw` is a plain same-origin `fetch` passthrough for call * sites that need the `Response` itself — blob downloads, SSE - * streams, manual status branching. No `.ok` check, no unwrap. + * streams, manual status branching. No `.ok` check, no unwrap, and + * deliberately NO default timeout: the Coach chat stream and other + * long-lived responses ride this path with their own signals. */ import { readError } from "./read-error"; @@ -91,6 +98,17 @@ async function parseEnvelope( return JSON.parse(text) as Envelope; } +/** Default per-request timeout for the envelope helpers. */ +export const DEFAULT_TIMEOUT_MS = 15_000; + +function withDefaultTimeout(init?: RequestInit): RequestInit { + // An explicit caller signal wins — including `null`, which opts out + // of any timeout. Only the absent case gets the default so a stalled + // response cannot park a TanStack query in `isLoading` forever. + if (init?.signal !== undefined) return init; + return { ...init, signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS) }; +} + /** * Same-origin API fetch — resolves with the unwrapped `data` payload, * throws {@link ApiError} on a non-OK response. @@ -99,7 +117,7 @@ export async function apiFetch( path: string, init?: RequestInit, ): Promise { - const res = await fetch(path, init); + const res = await fetch(path, withDefaultTimeout(init)); if (!res.ok) await throwApiError(res); const envelope = await parseEnvelope(res); return (envelope === undefined ? undefined : envelope.data) as T; @@ -113,7 +131,7 @@ export async function apiFetchEnvelope< T = unknown, M = Record, >(path: string, init?: RequestInit): Promise<{ data: T; meta: M | undefined }> { - const res = await fetch(path, init); + const res = await fetch(path, withDefaultTimeout(init)); if (!res.ok) await throwApiError(res); const envelope = await parseEnvelope(res); return { @@ -125,7 +143,8 @@ export async function apiFetchEnvelope< /** * Plain same-origin `fetch` passthrough for call sites that need the raw * `Response` — blob downloads, SSE streams, manual status branching. - * Performs no `.ok` check and no envelope unwrap. + * Performs no `.ok` check, no envelope unwrap, and applies no default + * timeout (SSE streams legitimately outlive any fixed window). */ export function apiFetchRaw( path: string, diff --git a/src/lib/queries/__tests__/retry-transient.test.ts b/src/lib/queries/__tests__/retry-transient.test.ts new file mode 100644 index 000000000..1e0eb3eb4 --- /dev/null +++ b/src/lib/queries/__tests__/retry-transient.test.ts @@ -0,0 +1,52 @@ +/** + * Unit tests for the shared shell-cell retry predicate. + * + * `useAuth` / `useDashboardSnapshot` used to pin `retry: false`, so one + * transient blip flipped the shell to the redirect spinner / flashed + * the full-dashboard empty state. The predicate grants exactly one + * retry on transport failures and 5xx — never on 4xx, where the server + * gave a real answer. + */ + +import { describe, expect, it } from "vitest"; + +import { ApiError } from "@/lib/api/api-fetch"; +import { retryOnceOnTransientError } from "../retry-transient"; + +describe("retryOnceOnTransientError", () => { + it("retries a network-level failure once", () => { + const netErr = new TypeError("fetch failed"); + expect(retryOnceOnTransientError(0, netErr)).toBe(true); + expect(retryOnceOnTransientError(1, netErr)).toBe(false); + }); + + it("retries a timeout abort once", () => { + const timeoutErr = new DOMException("signal timed out", "TimeoutError"); + expect(retryOnceOnTransientError(0, timeoutErr)).toBe(true); + expect(retryOnceOnTransientError(1, timeoutErr)).toBe(false); + }); + + it("retries a 5xx once", () => { + const serverErr = new ApiError("upstream", 503); + expect(retryOnceOnTransientError(0, serverErr)).toBe(true); + expect(retryOnceOnTransientError(1, serverErr)).toBe(false); + }); + + it("never retries 401/403 — the server answered", () => { + expect(retryOnceOnTransientError(0, new ApiError("Unauthorized", 401))).toBe( + false, + ); + expect(retryOnceOnTransientError(0, new ApiError("Forbidden", 403))).toBe( + false, + ); + }); + + it("never retries other 4xx", () => { + expect(retryOnceOnTransientError(0, new ApiError("Not found", 404))).toBe( + false, + ); + expect(retryOnceOnTransientError(0, new ApiError("Too many", 429))).toBe( + false, + ); + }); +}); diff --git a/src/lib/queries/__tests__/use-dashboard-snapshot.test.tsx b/src/lib/queries/__tests__/use-dashboard-snapshot.test.tsx index 40b943879..f9f3cbd28 100644 --- a/src/lib/queries/__tests__/use-dashboard-snapshot.test.tsx +++ b/src/lib/queries/__tests__/use-dashboard-snapshot.test.tsx @@ -32,9 +32,18 @@ import { prefetchDashboardSnapshot, _resetDashboardSnapshotPreloadForTests, } from "../use-dashboard-snapshot"; +import { retryOnceOnTransientError } from "../retry-transient"; import { queryKeys } from "@/lib/query-keys"; import type { QueryClient } from "@tanstack/react-query"; +/** Envelope-shaped Response — the snapshot fetch rides `apiGet` now. */ +function envelopeResponse(data: unknown): Response { + return new Response(JSON.stringify({ data, error: null }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +} + afterEach(() => { useQueryMock.mockClear(); getQueryDataMock.mockReset(); @@ -70,14 +79,17 @@ describe("useDashboardSnapshot — auto-refresh on an open page", () => { expect(opts.queryKey).toEqual(queryKeys.dashboardSnapshot()); }); + it("retries once on transient failures only (network / 5xx, never 4xx)", () => { + useDashboardSnapshot(); + const opts = lastOpts(); + expect(opts.retry).toBe(retryOnceOnTransientError); + }); + it("seeds the widgets cache from the snapshot layout when the slot is empty", async () => { const layout = { widgets: [] }; vi.stubGlobal( "fetch", - vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ data: { layout, tiles: {} } }), - }), + vi.fn().mockResolvedValue(envelopeResponse({ layout, tiles: {} })), ); getQueryDataMock.mockReturnValue(undefined); useDashboardSnapshot(); @@ -92,10 +104,9 @@ describe("useDashboardSnapshot — auto-refresh on an open page", () => { it("never clobbers an already-populated widgets cache", async () => { vi.stubGlobal( "fetch", - vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ data: { layout: { widgets: [] }, tiles: {} } }), - }), + vi + .fn() + .mockResolvedValue(envelopeResponse({ layout: { widgets: [] }, tiles: {} })), ); getQueryDataMock.mockReturnValue({ widgets: [{ id: "existing" }] }); useDashboardSnapshot(); @@ -117,10 +128,9 @@ describe("prefetchDashboardSnapshot — hydration-safe promise handoff", () => { } function stubFetch(payload: unknown = { layout: { widgets: [] }, tiles: {} }) { - const fetchMock = vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ data: payload }), - }); + const fetchMock = vi + .fn() + .mockImplementation(() => Promise.resolve(envelopeResponse(payload))); vi.stubGlobal("fetch", fetchMock); return fetchMock; } @@ -161,10 +171,7 @@ describe("prefetchDashboardSnapshot — hydration-safe promise handoff", () => { const fetchMock = vi .fn() .mockRejectedValueOnce(new Error("HTTP 401")) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ data: { layout, tiles: {} } }), - }); + .mockResolvedValueOnce(envelopeResponse({ layout, tiles: {} })); vi.stubGlobal("fetch", fetchMock); getQueryDataMock.mockReturnValue({ widgets: [{ id: "existing" }] }); diff --git a/src/lib/queries/retry-transient.ts b/src/lib/queries/retry-transient.ts new file mode 100644 index 000000000..1034ef145 --- /dev/null +++ b/src/lib/queries/retry-transient.ts @@ -0,0 +1,19 @@ +import { ApiError } from "@/lib/api/api-fetch"; + +/** + * Shared TanStack `retry` predicate for the shell-critical cells + * (`useAuth`, `useDashboardSnapshot`): ONE retry on transport-level + * failures (network `TypeError`, abort/timeout `DOMException`) and on + * 5xx, never on a 4xx. A 401/403 is a real answer — retrying it only + * delays the redirect / empty-state decision — while a single transient + * blip used to flip the shell straight to the redirect spinner or flash + * the full-dashboard empty state under the former `retry: false`. + */ +export function retryOnceOnTransientError( + failureCount: number, + error: unknown, +): boolean { + if (failureCount >= 1) return false; + if (error instanceof ApiError) return error.status >= 500; + return true; +} diff --git a/src/lib/queries/use-dashboard-snapshot.ts b/src/lib/queries/use-dashboard-snapshot.ts index 0fad58c30..eaacdbb38 100644 --- a/src/lib/queries/use-dashboard-snapshot.ts +++ b/src/lib/queries/use-dashboard-snapshot.ts @@ -33,15 +33,18 @@ import { useQueryClient, type QueryClient, } from "@tanstack/react-query"; +import { apiGet } from "@/lib/api/api-fetch"; import { queryKeys } from "@/lib/query-keys"; import { DASHBOARD_REFETCH_INTERVAL_MS } from "@/lib/queries/refetch-interval"; +import { retryOnceOnTransientError } from "@/lib/queries/retry-transient"; import type { DashboardSnapshot } from "@/lib/dashboard/snapshot"; async function fetchDashboardSnapshot(): Promise { - const res = await fetch("/api/dashboard/snapshot"); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - const json = await res.json(); - return json.data as DashboardSnapshot; + // Routed through the typed wrapper so a failure carries its HTTP + // status (`ApiError`) for the transient-retry predicate, and the + // default 15 s timeout keeps a stalled response from pinning the + // whole dashboard on its skeleton. + return apiGet("/api/dashboard/snapshot"); } /** Handoff freshness window — mirrors the hook's `staleTime`. */ @@ -162,6 +165,9 @@ export function useDashboardSnapshot(enabled = true) { refetchOnWindowFocus: false, refetchInterval: DASHBOARD_REFETCH_INTERVAL_MS, refetchIntervalInBackground: false, - retry: false, + // v1.16.8 — one retry on network errors / 5xx (never 401/403). A + // single transient failure used to flash the full-dashboard empty + // state under the former `retry: false`. + retry: retryOnceOnTransientError, }); } diff --git a/src/lib/retry-import.ts b/src/lib/retry-import.ts new file mode 100644 index 000000000..f6233a478 --- /dev/null +++ b/src/lib/retry-import.ts @@ -0,0 +1,32 @@ +/** + * Retrying wrapper for lazy chunk imports. + * + * A rejected `import()` inside `React.lazy` / `next/dynamic` caches its + * rejection on the component instance, so one transient chunk 404 (a + * stale shell right after a deploy, a flaky connection) bricks the lazy + * boundary for the rest of the session — remounting never re-attempts + * the import. Wrapping the importer here absorbs the transient class: + * the import retries once after a short delay before the rejection is + * allowed to surface to the boundary's error handling. + */ + +export const IMPORT_RETRY_DELAY_MS = 1_000; + +/** + * Run `importer`, retrying `retries` more times (default once) after + * `delayMs` when it rejects. The final rejection propagates unchanged + * so the caller's error boundary sees the original chunk error. + */ +export async function importWithRetry( + importer: () => Promise, + retries = 1, + delayMs = IMPORT_RETRY_DELAY_MS, +): Promise { + try { + return await importer(); + } catch (err) { + if (retries <= 0) throw err; + await new Promise((resolve) => setTimeout(resolve, delayMs)); + return importWithRetry(importer, retries - 1, delayMs); + } +} From 242d2514ee6d2aec26a479aecf470881383600ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 11 Jun 2026 21:52:36 +0200 Subject: [PATCH 04/12] perf(dashboard,insights): reserve the cold-paint footprint and cache the slow overview reads The dashboard reserves chart-row skeleton cells from the saved layout before the snapshot resolves, so a cold load paints close to its final footprint instead of growing by a screen when data lands; the tile silhouettes count only tile-capable widgets with blood pressure as two, the greeting line keeps its box through name personalisation, and the getting-started checklist moves below the charts where its arrival shifts nothing. On the insights overview the targets build runs its eight reads in parallel with a one-year floor on the glucose scan instead of strictly in sequence over an unbounded history, the derived batch gains a server cache where it previously recomputed sixteen metrics on every read, both serve stale-while-revalidate, and the health-score column reserves its exact footprint while the analytics payload is pending. --- .../dashboard-tile-strip-skeleton.test.ts | 95 +++++- src/app/__tests__/insights-polish.test.ts | 32 ++ .../derived/batch/__tests__/route.test.ts | 80 +++++ src/app/api/insights/derived/batch/route.ts | 90 ++++-- .../insights/targets/__tests__/route.test.ts | 33 ++ src/app/api/insights/targets/route.ts | 298 +++++++++++------- src/app/insights/page.tsx | 9 + src/app/page.tsx | 146 ++++++++- src/components/dashboard/dashboard-header.tsx | 9 +- .../insights/__tests__/hero-strip.test.tsx | 57 ++++ src/components/insights/hero-strip.tsx | 48 ++- src/lib/cache/invalidate.ts | 15 + src/lib/cache/server-cache.ts | 31 ++ 13 files changed, 784 insertions(+), 159 deletions(-) diff --git a/src/app/__tests__/dashboard-tile-strip-skeleton.test.ts b/src/app/__tests__/dashboard-tile-strip-skeleton.test.ts index 8bb77e152..aed76afe3 100644 --- a/src/app/__tests__/dashboard-tile-strip-skeleton.test.ts +++ b/src/app/__tests__/dashboard-tile-strip-skeleton.test.ts @@ -2,7 +2,15 @@ import { describe, it, expect } from "vitest"; import { readFileSync } from "node:fs"; import { join } from "node:path"; -import { resolveDashboardFirstPaintGate } from "../page"; +import { + resolveChartRowPlaceholderCount, + resolveConfiguredTileCount, + resolveDashboardFirstPaintGate, +} from "../page"; +import { + DEFAULT_DASHBOARD_LAYOUT, + type DashboardLayout, +} from "@/lib/dashboard-layout"; /** * v1.4.43 W11-M5 — dashboard tile-strip skeleton during slow first @@ -94,14 +102,95 @@ describe("resolveDashboardFirstPaintGate", () => { }); }); +describe("resolveConfiguredTileCount (v1.16.8)", () => { + it("counts only widgets that paint a strip tile, with bp counting double", () => { + // Default layout: weight, bp, pulse, bodyFat, mood, bpInTarget are + // tile-visible AND tile-capable; medications + recentWorkouts are + // tile-visible in the stored layout but paint NO strip tile, so + // they must not inflate the silhouette. bp paints sys + dia = 2. + expect(resolveConfiguredTileCount(DEFAULT_DASHBOARD_LAYOUT)).toBe(7); + }); + + it("ignores chart-only and iOS-pin-only widget ids", () => { + const layout: DashboardLayout = { + version: 1, + widgets: [ + { id: "medications", visible: true, tileVisible: true, order: 0 }, + { id: "recentWorkouts", visible: true, tileVisible: true, order: 1 }, + { id: "achievements", visible: true, tileVisible: true, order: 2 }, + { id: "cardioRecovery", visible: true, tileVisible: true, order: 3 }, + ], + }; + expect(resolveConfiguredTileCount(layout)).toBe(0); + }); + + it("falls back to `visible` for legacy entries without tileVisible", () => { + const layout: DashboardLayout = { + version: 1, + widgets: [ + { id: "weight", visible: true, order: 0 }, + { id: "pulse", visible: false, order: 1 }, + ], + }; + expect(resolveConfiguredTileCount(layout)).toBe(1); + }); +}); + +describe("resolveChartRowPlaceholderCount (v1.16.8)", () => { + it("reserves one cell per chart-visible chart-capable widget", () => { + // Default layout chart row: weight, bp, pulse, bodyFat, mood, + // medications are `visible`; bpInTarget / achievements / + // recentWorkouts carry no chart-shaped footprint. + expect(resolveChartRowPlaceholderCount(DEFAULT_DASHBOARD_LAYOUT)).toBe(6); + }); + + it("adds the BMI cell riding the weight gate when a height is known", () => { + expect( + resolveChartRowPlaceholderCount(DEFAULT_DASHBOARD_LAYOUT, { + hasHeightCm: true, + }), + ).toBe(7); + }); + + it("respects a trimmed layout", () => { + const layout: DashboardLayout = { + version: 1, + widgets: [ + { id: "weight", visible: true, tileVisible: true, order: 0 }, + { id: "bp", visible: false, tileVisible: true, order: 1 }, + { id: "steps", visible: true, tileVisible: false, order: 2 }, + ], + }; + expect(resolveChartRowPlaceholderCount(layout)).toBe(2); + }); +}); + describe("v1.4.43 W11 — dashboard tile-strip skeleton (wiring)", () => { - it("derives `configuredTileCount` from the resolved layout", () => { + it("derives `configuredTileCount` through the tile-capable resolver", () => { const src = readFileSync(PAGE_PATH, "utf8"); expect(src).toMatch( - /const\s+configuredTileCount\s*=\s*layout\.widgets\.filter\([\s\S]*?w\.tileVisible\s*\?\?\s*w\.visible[\s\S]*?\)\.length;/, + /const\s+configuredTileCount\s*=\s*resolveConfiguredTileCount\(layout\);/, ); }); + it("reserves the chart row while the primary query loads", () => { + const src = readFileSync(PAGE_PATH, "utf8"); + // The placeholder count keys off the layout config minus the + // already-mounted (layout-gated) cells, and only while loading. + expect(src).toMatch( + /const\s+chartRowPlaceholderCount\s*=\s*primaryLoading[\s\S]*?resolveChartRowPlaceholderCount\(layout,[\s\S]*?hasHeightCm:\s*Boolean\(user\?\.heightCm\)/, + ); + expect(src).toMatch(/-\s*charts\.length/); + // The reserved cells render as aria-hidden ChartSkeletons in their + // own slot. + const skeletonBlock = src.match( + /chartRowPlaceholderCount\s*>\s*0\s*&&\s*\([\s\S]*?dashboard-chart-row-skeleton[\s\S]*?\)\}/, + ); + expect(skeletonBlock).not.toBeNull(); + expect(skeletonBlock?.[0]).toContain('aria-hidden="true"'); + expect(skeletonBlock?.[0]).toContain(" { const src = readFileSync(PAGE_PATH, "utf8"); // v1.16.4 — `!mounted` pins SSR + the hydration render to the diff --git a/src/app/__tests__/insights-polish.test.ts b/src/app/__tests__/insights-polish.test.ts index cae362655..ab59ec3a6 100644 --- a/src/app/__tests__/insights-polish.test.ts +++ b/src/app/__tests__/insights-polish.test.ts @@ -259,3 +259,35 @@ describe("v1.4.22 A3 — comparison toggle is global Settings only", () => { expect(hookSrc).toContain("compareBaseline"); }); }); + +describe("v1.16.8 — insights overview cold paint", () => { + it("fires every overview query in parallel — none gated on another query's data", () => { + const src = load(INSIGHTS_PATH); + // The derived batch + analytics + comprehensive reads all gate on + // auth alone. Chaining any of them behind another query's resolved + // payload re-introduces the sequential reveal. + expect(src).toMatch(/useDashboardDerived\(isAuthenticated\)/); + expect(src).toMatch(/const analyticsQuery = useAnalyticsQuery\(\);/); + expect(src).toMatch(/enabled:\s*isAuthenticated/); + expect(src).not.toMatch(/useDashboardDerived\([^)]*data/); + }); + + it("reserves the health-score column while the analytics payload is pending", () => { + const src = load(INSIGHTS_PATH); + // The hero receives the pending flag so the score card's slot holds + // its footprint from first paint (skeleton in ``). + expect(src).toMatch( + /healthScorePending=\{analyticsQuery\.isPending && !analytics\}/, + ); + }); + + it("hero strip renders the score-card skeleton in the reserved column", () => { + const heroSrc = readFileSync( + join(ROOT, "src/components/insights/hero-strip.tsx"), + "utf8", + ); + expect(heroSrc).toContain('data-slot="health-score-card-skeleton"'); + // The two-column split keys on score-or-pending, not score alone. + expect(heroSrc).toMatch(/\(healthScore \|\| healthScorePending\) &&/); + }); +}); diff --git a/src/app/api/insights/derived/batch/__tests__/route.test.ts b/src/app/api/insights/derived/batch/__tests__/route.test.ts index 96f550009..a63189400 100644 --- a/src/app/api/insights/derived/batch/__tests__/route.test.ts +++ b/src/app/api/insights/derived/batch/__tests__/route.test.ts @@ -51,6 +51,11 @@ import { GET } from "../route"; import { getSession } from "@/lib/auth/session"; import { prisma } from "@/lib/db"; import { computeDerivedMetric } from "@/lib/insights/derived"; +import { __resetAllCachesForTests } from "@/lib/cache/server-cache"; +import { + invalidateUserMeasurements, + invalidateUserMood, +} from "@/lib/cache/invalidate"; const SESSION_OK = { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, @@ -66,6 +71,9 @@ function makeReq(metrics?: string): NextRequest { beforeEach(() => { vi.clearAllMocks(); + // v1.16.8 — the route reads through the module-scope `insightsDerived` + // server cache; reset it so every test starts on a cold cell. + __resetAllCachesForTests(); vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never); vi.mocked(prisma.user.findUnique).mockResolvedValue({ dateOfBirth: new Date("1986-01-01"), @@ -144,3 +152,75 @@ describe("GET /api/insights/derived/batch", () => { expect(res.status).toBe(401); }); }); + +describe("GET /api/insights/derived/batch — server cache (v1.16.8)", () => { + it("serves a warm repeat from the per-user cache without recomputing", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const first = await callGet(makeReq("READINESS,BMI")); + expect(first.status).toBe(200); + expect(computeDerivedMetric).toHaveBeenCalledTimes(2); + + const second = await callGet(makeReq("READINESS,BMI")); + expect(second.status).toBe(200); + // Cache hit — no second profile read, no second compute fan-out. + expect(computeDerivedMetric).toHaveBeenCalledTimes(2); + expect(prisma.user.findUnique).toHaveBeenCalledTimes(1); + + const body = (await second.json()) as { + data: { metrics: Record }; + }; + expect(Object.keys(body.data.metrics).sort()).toEqual([ + "BMI", + "READINESS", + ]); + }); + + it("hits the same cell regardless of token order", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + await callGet(makeReq("READINESS,BMI")); + await callGet(makeReq("BMI,READINESS")); + // The key sorts the tokens, so the reordered request is a hit. + expect(computeDerivedMetric).toHaveBeenCalledTimes(2); + }); + + it("never serves one user's cell to another user", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + await callGet(makeReq("BMI")); + expect(computeDerivedMetric).toHaveBeenCalledTimes(1); + + vi.mocked(getSession).mockResolvedValue({ + ...SESSION_OK, + user: { ...SESSION_OK.user, id: "user-2" }, + } as never); + await callGet(makeReq("BMI")); + // Different userId prefix → cold cell → fresh compute. + expect(computeDerivedMetric).toHaveBeenCalledTimes(2); + }); + + it("recomputes after an interactive measurement write evicts the bucket", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + await callGet(makeReq("BMI")); + expect(computeDerivedMetric).toHaveBeenCalledTimes(1); + + invalidateUserMeasurements("user-1", { evict: true }); + await callGet(makeReq("BMI")); + expect(computeDerivedMetric).toHaveBeenCalledTimes(2); + }); + + it("serves stale-while-revalidate after a background write marks the bucket stale", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + await callGet(makeReq("BMI")); + expect(computeDerivedMetric).toHaveBeenCalledTimes(1); + + // Background sync posture (no evict): the entry is marked stale, so + // the next read serves the prior body immediately AND kicks off one + // background recompute. + invalidateUserMood("user-1"); + const res = await callGet(makeReq("BMI")); + expect(res.status).toBe(200); + // The background rebuild runs detached; give the microtask queue a + // tick so the recompute lands before asserting. + await new Promise((resolve) => setImmediate(resolve)); + expect(computeDerivedMetric).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/app/api/insights/derived/batch/route.ts b/src/app/api/insights/derived/batch/route.ts index b9330ff29..1569f4713 100644 --- a/src/app/api/insights/derived/batch/route.ts +++ b/src/app/api/insights/derived/batch/route.ts @@ -26,6 +26,11 @@ import pLimit from "p-limit"; import { apiSuccess, apiError, returnAllZodIssues } from "@/lib/api-response"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; +import { + cachedSwr, + caches, + type ServerCache, +} from "@/lib/cache/server-cache"; import { requireAssistantSurface } from "@/lib/feature-flags"; import { prisma } from "@/lib/db"; import { @@ -139,10 +144,70 @@ export const GET = apiHandler(async (request: NextRequest) => { ); } + // The deterministic per-score assessment is locale-dependent, so the + // locale joins the cache key below — a de / en pair never shares a cell. + const locale = await resolveServerLocale({ + request, + userLocale: user.locale ?? null, + override: request.nextUrl.searchParams.get("locale"), + }); + const assessmentLocale = locale === "de" ? "de" : "en"; + + // v1.16.8 — read-through the per-user derived cache with + // stale-while-revalidate. The cold build below walks the rollup tier + // once per metric (1–2 s wall-clock for the overview's ~16-token + // grid); the production trace showed the full recompute re-paid on + // every read past the client's 60 s staleTime. Tokens sort into the + // key so request order never splits cells; measurement writes evict / + // mark the `${userId}|` prefix stale via `invalidateUserMeasurements` + // and mood writes mark it stale (READINESS reads the mood series). + const cacheKey = `${user.id}|batch|${items + .map((item) => item.key) + .sort() + .join(",")}|${assessmentLocale}`; + const map = await cachedSwr( + caches.insightsDerived as ServerCache< + Awaited> + >, + cacheKey, + () => + buildDerivedBatch({ + userId: user.id, + items, + assessmentLocale, + }), + annotate, + ); + + annotate({ + action: { name: "insights.derived.batch" }, + meta: { + requested: items.length, + ok: Object.values(map).filter((entry) => entry.status === "ok").length, + }, + }); + + // `metrics` is a map keyed by the per-request token so the client reads + // back exactly what it asked for (`VITALS_BASELINE:WEIGHT`, `READINESS`). + return apiSuccess({ metrics: map }); +}); + +/** + * The cacheable batch body — profile load + bounded per-metric fan-out. + * Lifted out of the handler so `cachedSwr` can wrap it; everything here + * is pure compute over the rollup tier (no provider call). + */ +async function buildDerivedBatch(input: { + userId: string; + items: BatchItem[]; + assessmentLocale: "de" | "en"; +}) { + const { userId, items, assessmentLocale } = input; + // Profile read ONCE — the pool-contention mitigation; the same shared // loader the nightly score jobs use, so the batch never re-reads the // User row per metric. - const profile = await loadBaselineProfile(prisma, user.id); + const profile = await loadBaselineProfile(prisma, userId); // Fan out under a bounded limiter so a 17-item dashboard read resolves // four computes at a time against the shared pool, not seventeen. @@ -156,12 +221,6 @@ export const GET = apiHandler(async (request: NextRequest) => { // null for non-assessable ids / non-`ok` status, so a non-score item still // gets `assessment: null`. A best-effort grid extra must NEVER fail the // whole batch, so each call is guarded → null on any unexpected shape. - const locale = await resolveServerLocale({ - request, - userLocale: user.locale ?? null, - override: request.nextUrl.searchParams.get("locale"), - }); - const assessmentLocale = locale === "de" ? "de" : "en"; const safeAssessment = ( metric: DerivedMetricId, derived: Parameters[1], @@ -182,7 +241,7 @@ export const GET = apiHandler(async (request: NextRequest) => { limit(async () => { const derived = await computeDerivedMetric({ metric: item.metric, - userId: user.id, + userId, profile, type: item.type, now, @@ -210,16 +269,5 @@ export const GET = apiHandler(async (request: NextRequest) => { const map: Record = {}; for (const r of results) map[r.key] = r.payload; - - annotate({ - action: { name: "insights.derived.batch" }, - meta: { - requested: items.length, - ok: results.filter((r) => r.payload.status === "ok").length, - }, - }); - - // `metrics` is a map keyed by the per-request token so the client reads - // back exactly what it asked for (`VITALS_BASELINE:WEIGHT`, `READINESS`). - return apiSuccess({ metrics: map }); -}); + return map; +} diff --git a/src/app/api/insights/targets/__tests__/route.test.ts b/src/app/api/insights/targets/__tests__/route.test.ts index b413e80a0..c65c67674 100644 --- a/src/app/api/insights/targets/__tests__/route.test.ts +++ b/src/app/api/insights/targets/__tests__/route.test.ts @@ -277,4 +277,37 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => { expect(ageMs).toBeGreaterThan(oneYearMs - 3_600_000); expect(ageMs).toBeLessThan(oneYearMs + 3_600_000); }); + + it("bounds the glucose read with the same one-year floor (v1.16.8)", async () => { + // The glucose section used to scan the user's entire BLOOD_GLUCOSE + // history on every cold build (no `measuredAt` filter at all) just + // to read the latest value per context. The read now carries the + // same 365-day floor as the latest-ever-per-type query. + await callGet(makeReq()); + + const calls = ( + prisma.measurement.findMany as ReturnType + ).mock.calls.map((c) => c[0]); + const glucoseCall = calls.find((c) => c?.where?.type === "BLOOD_GLUCOSE"); + expect(glucoseCall).toBeDefined(); + expect(glucoseCall?.where?.measuredAt?.gte).toBeInstanceOf(Date); + }); + + it("serves a warm repeat from the server cache without re-querying (v1.16.8)", async () => { + const first = await callGet(makeReq()); + expect(first.status).toBe(200); + const queriesAfterFirst = ( + prisma.measurement.findMany as ReturnType + ).mock.calls.length; + expect(queriesAfterFirst).toBeGreaterThan(0); + + const second = await callGet(makeReq()); + expect(second.status).toBe(200); + // Same user, warm cell — the SWR read serves the cached body and + // issues no further measurement queries inside the fresh TTL. + expect( + (prisma.measurement.findMany as ReturnType).mock.calls + .length, + ).toBe(queriesAfterFirst); + }); }); diff --git a/src/app/api/insights/targets/route.ts b/src/app/api/insights/targets/route.ts index eb0a8a307..1d4504bfa 100644 --- a/src/app/api/insights/targets/route.ts +++ b/src/app/api/insights/targets/route.ts @@ -35,7 +35,7 @@ import type { } from "@/generated/prisma/client"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; -import { cached, caches, type ServerCache } from "@/lib/cache/server-cache"; +import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache"; import { NO_STORE_BUT_BFCACHE } from "@/lib/http/cache-headers"; import { getEffectiveRange, @@ -133,7 +133,14 @@ type AuthedUser = Awaited>["user"]; export const GET = apiHandler(async () => { const { user } = await requireAuth(); - const body = await cached( + // v1.16.8 — stale-while-revalidate read. The cold build is the + // multi-query walk below; past the 60 s fresh TTL the prior body now + // serves immediately while ONE background rebuild warms the cell, + // instead of every Insights mount more than a minute apart re-paying + // the >1 s compute inline. Writes still hard-evict the bucket + // (`invalidateUser*` → `deleteByPrefix`), so user actions always + // reflect on the next read. + const body = await cachedSwr( caches.insightsTargets as ServerCache< Awaited> >, @@ -158,25 +165,6 @@ async function buildTargetsResponse(user: AuthedUser) { const userTz = user.timezone ?? DEFAULT_TIMEZONE; const dayKey = (d: Date): string => userDayKey(d, userTz); - // Fetch user profile - const dbUser = await prisma.user.findUnique({ - where: { id: userId }, - select: { - heightCm: true, - dateOfBirth: true, - gender: true, - glucoseUnit: true, - thresholdsJson: true, - // v1.11.5 — needed to collapse a dual-source sleep night to one - // canonical source before reconstructing per-night asleep totals. - sourcePriorityJson: true, - }, - }); - - const age = dbUser?.dateOfBirth ? getAge(new Date(dbUser.dateOfBirth)) : null; - const gender = (dbUser?.gender as "MALE" | "FEMALE" | null) ?? null; - const heightCm = dbUser?.heightCm ?? null; - // Fetch latest measurements for each type // v1.11.5 — SLEEP_DURATION is handled by a dedicated per-night // reconstruction read below (the generic per-type average treats each @@ -198,47 +186,166 @@ async function buildTargetsResponse(user: AuthedUser) { ]; const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); - - // Fetch all measurements in the last 30 days + the latest for each type - // Soft-delete filter mirrors the W-DELETED pattern: tombstoned rows - // must not contribute to tile averages / latest values once iOS sync - // starts emitting deletions. - const recentMeasurements = await prisma.measurement.findMany({ - where: { - userId, - type: { in: types }, - measuredAt: { gte: thirtyDaysAgo }, - deletedAt: null, - }, - orderBy: { measuredAt: "desc" }, - select: { type: true, value: true, measuredAt: true }, - }); - - // Absolute latest measurement per type within a one-year floor. - // - // Prisma's `distinct` does not compile to Postgres `DISTINCT ON` — the - // driver dedups after pulling the rows, so an unbounded scan on a - // 347 k-row tenant pulls every measurement back to Node only to drop - // all but seven (audit Critical Finding #2, v1.4.40). The 365-day - // floor caps the planner-side walk to the existing - // `(user_id, type, measured_at)` index range; the trade-off is that a - // user who hasn't measured a metric in over a year sees the tile's - // `current` value as null instead of pulling a stale year-old reading - // forward — which is the correct behaviour for the consistency strip - // (the strip already gates on "fewer than 3 readings in 30 days" → - // insufficientData). const oneYearAgo = new Date(Date.now() - 365 * 24 * 60 * 60 * 1000); - const latestEverByType = await prisma.measurement.findMany({ - where: { - userId, - type: { in: types }, - measuredAt: { gte: oneYearAgo }, - deletedAt: null, - }, - orderBy: { measuredAt: "desc" }, - distinct: ["type"], - select: { type: true, value: true }, - }); + + // Mood-rollup warm-up fires before the parallel read below so a legacy + // account converges onto the rollup tier on a subsequent request; the + // read itself never blocks on the warm-up (fire-and-forget). + void ensureUserMoodRollupsFresh(userId); + // Five-year window mirrors `/api/mood/analytics` so existing rollup + // coverage is consulted; the trailing 30-day filter happens in JS + // because the consistency strip already walks per-day buckets. + const moodRollupWindowMs = 5 * 365 * 24 * 60 * 60 * 1000; + const moodSince = new Date(Date.now() - moodRollupWindowMs); + + // v1.16.8 — ONE parallel round of every independent read. The cold + // build used to await eight queries strictly in sequence (profile → + // measurements → latest-per-type → sleep → medications → intakes → + // mood → glucose), stacking their round-trip latencies into the >1 s + // wall-clock the production trace showed. All of them are independent + // (the intake read depends only on the medication ids, so the pair + // chains inside its own slot); fan them out together and the cold + // build pays roughly the slowest query instead of the sum. + const [ + dbUser, + recentMeasurements, + latestEverByType, + sleepStageRows, + medicationBundle, + moodRollups, + latestMoodEntry, + glucoseRows, + ] = await Promise.all([ + // User profile (height / age / gender / thresholds / source priority). + prisma.user.findUnique({ + where: { id: userId }, + select: { + heightCm: true, + dateOfBirth: true, + gender: true, + glucoseUnit: true, + thresholdsJson: true, + // v1.11.5 — needed to collapse a dual-source sleep night to one + // canonical source before reconstructing per-night asleep totals. + sourcePriorityJson: true, + }, + }), + // All measurements in the last 30 days for the generic type set. + // Soft-delete filter mirrors the W-DELETED pattern: tombstoned rows + // must not contribute to tile averages / latest values once iOS sync + // starts emitting deletions. + prisma.measurement.findMany({ + where: { + userId, + type: { in: types }, + measuredAt: { gte: thirtyDaysAgo }, + deletedAt: null, + }, + orderBy: { measuredAt: "desc" }, + select: { type: true, value: true, measuredAt: true }, + }), + // Absolute latest measurement per type within a one-year floor. + // + // Prisma's `distinct` does not compile to Postgres `DISTINCT ON` — the + // driver dedups after pulling the rows, so an unbounded scan on a + // 347 k-row tenant pulls every measurement back to Node only to drop + // all but seven (audit Critical Finding #2, v1.4.40). The 365-day + // floor caps the planner-side walk to the existing + // `(user_id, type, measured_at)` index range; the trade-off is that a + // user who hasn't measured a metric in over a year sees the tile's + // `current` value as null instead of pulling a stale year-old reading + // forward — which is the correct behaviour for the consistency strip + // (the strip already gates on "fewer than 3 readings in 30 days" → + // insufficientData). + prisma.measurement.findMany({ + where: { + userId, + type: { in: types }, + measuredAt: { gte: oneYearAgo }, + deletedAt: null, + }, + orderBy: { measuredAt: "desc" }, + distinct: ["type"], + select: { type: true, value: true }, + }), + // Sleep stage rows for the per-night reconstruction (section 4). + prisma.measurement.findMany({ + where: { + userId, + type: "SLEEP_DURATION", + measuredAt: { gte: thirtyDaysAgo }, + deletedAt: null, + }, + orderBy: { measuredAt: "asc" }, + select: { value: true, measuredAt: true, sleepStage: true, source: true }, + }), + // Active medications + their 30-day intake events (section 8). The + // intake read depends on the medication ids, so the pair chains + // inside this slot while riding the outer fan-out. + (async () => { + const activeMedications = await prisma.medication.findMany({ + where: { userId, active: true }, + // v1.15.20 — schedules through the shared compliance select so the + // configured per-dose windows reach this surface like every other. + include: { + schedules: { select: SCHEDULE_COMPLIANCE_SELECT }, + // v1.16.3 — archived schedule eras for era-aware compliance. + scheduleRevisions: { orderBy: { validFrom: "asc" } }, + }, + orderBy: { name: "asc" }, + }); + if (activeMedications.length === 0) { + return { activeMedications, intakeEvents: [] }; + } + const intakeEvents = await prisma.medicationIntakeEvent.findMany({ + where: { + userId, + // v1.7.0 sync — exclude tombstoned rows. + deletedAt: null, + medicationId: { + in: activeMedications.map((medication) => medication.id), + }, + scheduledFor: { gte: thirtyDaysAgo }, + }, + orderBy: { scheduledFor: "desc" }, + select: { + medicationId: true, + takenAt: true, + skipped: true, + scheduledFor: true, + }, + }); + return { activeMedications, intakeEvents }; + })(), + // Mood day rollups (section 9). + readMoodDayRollups(userId, moodSince), + // Latest raw mood entry — bounded `take: 1` via the index. + prisma.moodEntry.findFirst({ + // v1.7.0 sync — exclude tombstoned rows. + where: { userId, deletedAt: null }, + orderBy: { moodLoggedAt: "desc" }, + select: { score: true, moodLoggedAt: true }, + }), + // Glucose rows (section 10). Same one-year floor as the + // latest-per-type read above — the previous unbounded scan pulled a + // heavy logger's full history back to Node on every cold build; a + // context whose newest reading is over a year old now reports no + // card, matching the per-type tile posture. + prisma.measurement.findMany({ + where: { + userId, + type: "BLOOD_GLUCOSE", + measuredAt: { gte: oneYearAgo }, + deletedAt: null, + }, + orderBy: { measuredAt: "desc" }, + select: { value: true, measuredAt: true, glucoseContext: true }, + }), + ]); + + const age = dbUser?.dateOfBirth ? getAge(new Date(dbUser.dateOfBirth)) : null; + const gender = (dbUser?.gender as "MALE" | "FEMALE" | null) ?? null; + const heightCm = dbUser?.heightCm ?? null; const latestByType: Record = {}; const avg30ByType: Record = {}; @@ -813,16 +920,7 @@ async function buildTargetsResponse(user: AuthedUser) { // overview headline. const sleepRange = getSleepDurationRange(); { - const sleepStageRows = await prisma.measurement.findMany({ - where: { - userId, - type: "SLEEP_DURATION", - measuredAt: { gte: thirtyDaysAgo }, - deletedAt: null, - }, - orderBy: { measuredAt: "asc" }, - select: { value: true, measuredAt: true, sleepStage: true, source: true }, - }); + // `sleepStageRows` resolved in the parallel read at the top. const nights = reconstructSleepNights( sleepStageRows as SleepStageRow[], userTz, @@ -1002,40 +1100,12 @@ async function buildTargetsResponse(user: AuthedUser) { }); } - // 8. Medication Compliance (average across active medications) - const activeMedications = await prisma.medication.findMany({ - where: { userId, active: true }, - // v1.15.20 — schedules through the shared compliance select so the - // configured per-dose windows reach this surface like every other. - include: { - schedules: { select: SCHEDULE_COMPLIANCE_SELECT }, - // v1.16.3 — archived schedule eras for era-aware compliance. - scheduleRevisions: { orderBy: { validFrom: "asc" } }, - }, - orderBy: { name: "asc" }, - }); + // 8. Medication Compliance (average across active medications) — + // `activeMedications` + their 30-day `intakeEvents` resolved in the + // parallel read at the top. + const { activeMedications, intakeEvents } = medicationBundle; if (activeMedications.length > 0) { - const thirtyDaysAgoIntake = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); - const intakeEvents = await prisma.medicationIntakeEvent.findMany({ - where: { - userId, - // v1.7.0 sync — exclude tombstoned rows. - deletedAt: null, - medicationId: { - in: activeMedications.map((medication) => medication.id), - }, - scheduledFor: { gte: thirtyDaysAgoIntake }, - }, - orderBy: { scheduledFor: "desc" }, - select: { - medicationId: true, - takenAt: true, - skipped: true, - scheduledFor: true, - }, - }); - const medicationStats = activeMedications.map((medication) => { const medicationEvents = intakeEvents.filter( (event) => event.medicationId === medication.id, @@ -1187,21 +1257,9 @@ async function buildTargetsResponse(user: AuthedUser) { // rows yet (legacy account before the boot-time backfill caught up) // we run the legacy live walk once and fire the warm-up so the next // request lands on the rollup tier. Same posture as - // `/api/mood/analytics`. - void ensureUserMoodRollupsFresh(userId); + // `/api/mood/analytics`. The warm-up trigger + the `moodRollups` / + // `latestMoodEntry` reads moved into the parallel round at the top. const thirtyDaysAgoMood = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); - // Five-year window mirrors `/api/mood/analytics` so existing rollup - // coverage is consulted; the trailing 30-day filter happens in JS - // because the consistency strip already walks per-day buckets. - const moodRollupWindowMs = 5 * 365 * 24 * 60 * 60 * 1000; - const moodSince = new Date(Date.now() - moodRollupWindowMs); - const moodRollups = await readMoodDayRollups(userId, moodSince); - const latestMoodEntry = await prisma.moodEntry.findFirst({ - // v1.7.0 sync — exclude tombstoned rows. - where: { userId, deletedAt: null }, - orderBy: { moodLoggedAt: "desc" }, - select: { score: true, moodLoggedAt: true }, - }); // Build a per-day event list for the consistency strip + 30-day stats. // Use the rollup tier when populated; fall back to a bounded raw walk @@ -1357,12 +1415,8 @@ async function buildTargetsResponse(user: AuthedUser) { dateOfBirth: dbUser?.dateOfBirth ?? null, gender: dbUser?.gender ?? null, }; + // `glucoseRows` resolved (one-year floor) in the parallel read at the top. const thirtyDaysAgoGlucose = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); - const glucoseRows = await prisma.measurement.findMany({ - where: { userId, type: "BLOOD_GLUCOSE", deletedAt: null }, - orderBy: { measuredAt: "desc" }, - select: { value: true, measuredAt: true, glucoseContext: true }, - }); const labelKeyByContext: Record = { FASTING: "targets.glucoseFasting", diff --git a/src/app/insights/page.tsx b/src/app/insights/page.tsx index 259179bad..4336534f5 100644 --- a/src/app/insights/page.tsx +++ b/src/app/insights/page.tsx @@ -475,6 +475,15 @@ export default function InsightsPage() { coachLaunch ? (prompt) => coachLaunch.askCoach(prompt) : undefined } healthScore={analytics?.healthScore ?? undefined} + // v1.16.8 — reserve the score card's column while the thick + // analytics payload is in flight. Every overview query + // (comprehensive, advisor, analytics, derived batch, layout) + // already fires in parallel at mount; the "greeting first, + // score card later" stagger came from the right column not + // existing until the slowest payload resolved. `isPending` + // settles false on success AND error, so a no-score account + // collapses the column exactly once. + healthScorePending={analyticsQuery.isPending && !analytics} /> {/* v1.15.18 — the inline "Anpassen" toggle was removed. Customising the diff --git a/src/app/page.tsx b/src/app/page.tsx index 6fdd0c709..0e665bbd1 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -21,7 +21,6 @@ import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose"; import { cn } from "@/lib/utils"; import { resolveDashboardLayout, - DASHBOARD_WIDGET_IDS, type DashboardLayout, } from "@/lib/dashboard-layout"; import type { DashboardAnalyticsData as AnalyticsData } from "@/types/analytics"; @@ -146,6 +145,86 @@ export function resolveDashboardFirstPaintGate(input: { return { showTileStripSkeleton, showEmptyState }; } +/** + * v1.16.8 — widget ids that actually paint a tile in the strip on the + * web dashboard, mirrored from the per-id render blocks below. + * `medications` / `recentWorkouts` / `achievements` carry NO strip tile + * (chart-row cards only), and the iOS-pin-only ids have no web render + * path at all — counting any of them over-reserved the loading + * silhouette and made the strip reshuffle when the data landed. + */ +const TILE_CAPABLE_WIDGET_IDS = new Set([ + "weight", + "bp", + "pulse", + "bodyFat", + "mood", + "sleep", + "steps", + "glucose", + "bpInTarget", + "vo2Max", +]); + +/** + * v1.16.8 — silhouette count for the tile-strip skeleton: one card per + * tile-capable, tile-visible widget. `bp` paints TWO tiles (sys + dia, + * see the render block below) so it counts double; `glucose` can fan + * out to one tile per logged context, but the contexts are unknown + * until the snapshot lands, so it reserves one card (the sane floor). + * Pure + exported for direct unit coverage. + */ +export function resolveConfiguredTileCount(layout: DashboardLayout): number { + let count = 0; + for (const widget of layout.widgets) { + if (!TILE_CAPABLE_WIDGET_IDS.has(widget.id)) continue; + if (!(widget.tileVisible ?? widget.visible)) continue; + count += widget.id === "bp" ? 2 : 1; + } + return count; +} + +/** + * v1.16.8 — widget ids with a chart-row surface on the web dashboard, + * mirrored from the `charts[]` entries below. The achievements + + * recent-workouts cards stay out: they self-skeleton, carry no + * chart-shaped footprint, and gate on `layoutResolved`. + */ +const CHART_CAPABLE_WIDGET_IDS = new Set([ + "weight", + "bp", + "pulse", + "bodyFat", + "mood", + "sleep", + "steps", + "medications", +]); + +/** + * v1.16.8 — expected chart-row card count while the snapshot is still + * in flight. Every chart gate below needs `count > 0` from snapshot + * data, so the cold page used to render NO chart row at all and then + * grow ~1000 px when the snapshot landed. The best available signal + * before data arrives is the layout config (the user's saved layout + * when cached, the default otherwise): one card per chart-visible + * widget, plus the BMI card that rides the weight gate when the + * profile carries a height. Pure + exported for direct unit coverage. + */ +export function resolveChartRowPlaceholderCount( + layout: DashboardLayout, + opts?: { hasHeightCm?: boolean }, +): number { + let count = 0; + for (const widget of layout.widgets) { + if (!CHART_CAPABLE_WIDGET_IDS.has(widget.id)) continue; + if (!widget.visible) continue; + count += 1; + if (widget.id === "weight" && opts?.hasHeightCm) count += 1; + } + return count; +} + export default function DashboardPage() { const { isAuthenticated, user } = useAuth(); const mounted = useMounted(); @@ -642,12 +721,6 @@ export default function DashboardPage() { onQuickEntry={setQuickEntryDialog} /> - {/* v1.4: Getting-started checklist for brand-new users. - * Self-gates visibility on (onboardingCompletedAt == null - * || measurementCount < 5) and disappears once dismissed - * or fully complete. See B2 in the v1.4 discovery summary. */} - - {/* v1.4.15 Phase B5 — spotlight tour for first-time users. * Self-gates on `user.onboardingTourCompleted` (DB flag) plus * a session-storage dismiss guard. We pass `ready=true` only @@ -1324,10 +1397,12 @@ export default function DashboardPage() { // its merge workarounds), but the web dashboard has no tile // component for them; including them here would over-reserve the // skeleton silhouette by rows that never paint. - const webWidgetIds = new Set(DASHBOARD_WIDGET_IDS); - const configuredTileCount = layout.widgets.filter( - (w) => webWidgetIds.has(w.id) && (w.tileVisible ?? w.visible), - ).length; + // v1.16.8 — narrowed further to the tile-CAPABLE set (see + // `resolveConfiguredTileCount`): the old count included + // `medications` + `recentWorkouts` (chart-row cards with no + // strip tile) and undercounted `bp` (one widget id, two tiles), + // so the silhouettes reshuffled when the data landed. + const configuredTileCount = resolveConfiguredTileCount(layout); // v1.7.0 — the primary data source differs by flag. In snapshot // mode `analyticsSlimQuery` is `enabled: false`, and a disabled // TanStack query reports `fetchStatus: "idle"` → `isLoading` is @@ -1346,6 +1421,23 @@ export default function DashboardPage() { (snapshotEnabled ? snapshotQuery.isLoading : analyticsSlimQuery.isLoading); + // v1.16.8 — chart-row reservation while the snapshot is in + // flight. The chart visibility gates above need snapshot data + // (`count > 0`), so the cold chart row only carried the + // layout-gated medications card and then grew by ~1000 px when + // the snapshot landed. Reserve layout-stable ChartSkeleton + // cells for the charts the layout expects (minus the ones + // already mounted) so the page holds roughly its final + // footprint from first paint and the snapshot landing swaps + // content instead of growing the page. + const chartRowPlaceholderCount = primaryLoading + ? Math.max( + 0, + resolveChartRowPlaceholderCount(layout, { + hasHeightCm: Boolean(user?.heightCm), + }) - charts.length, + ) + : 0; const { showTileStripSkeleton, showEmptyState } = resolveDashboardFirstPaintGate({ trendCardCount: trendCards.length, @@ -1555,9 +1647,41 @@ export default function DashboardPage() { )}
))} + {/* v1.16.8 — layout-stable chart-row reservation. While the + snapshot is in flight the data-gated chart entries above + cannot exist yet, so these silhouettes carry the row's + footprint; the moment the snapshot lands the real cells + render in their place (same render pass — no growth, a + swap). `aria-hidden` like the tile-strip skeleton: the + mounted charts announce their own loading state. */} + {chartRowPlaceholderCount > 0 && ( + + )} ); })()} + + {/* v1.4: Getting-started checklist for brand-new users. Self-gates + * visibility on (onboardingCompletedAt == null || measurementCount + * < 5) and disappears once dismissed or fully complete. + * v1.16.8 — moved BELOW the tile strip + chart row: the card only + * mounts once the snapshot resolves, and sitting above the strip + * it pushed every tile and chart down when it popped in. At the + * bottom its late arrival shifts nothing — the fold-priority + * content (tiles, charts) keeps a stable position, and the + * near-empty dashboards of the new accounts the card targets keep + * it visible without scrolling anyway. */} +
); } diff --git a/src/components/dashboard/dashboard-header.tsx b/src/components/dashboard/dashboard-header.tsx index 994e54578..a8e7cb5c1 100644 --- a/src/components/dashboard/dashboard-header.tsx +++ b/src/components/dashboard/dashboard-header.tsx @@ -40,7 +40,14 @@ export function DashboardHeader({

{t("dashboard.title")}

-

{welcomeText}

+ {/* v1.16.8 — `min-h-5` reserves the greeting's line box from the + SSR pass on: the text personalises (name appended) on the + first client re-render after hydration, and the reserved + line keeps the header from collapsing/growing around that + swap. */} +

+ {welcomeText} +

{/* Customize shortcut to the dashboard-customization settings diff --git a/src/components/insights/__tests__/hero-strip.test.tsx b/src/components/insights/__tests__/hero-strip.test.tsx index b0d4009d1..797e136b2 100644 --- a/src/components/insights/__tests__/hero-strip.test.tsx +++ b/src/components/insights/__tests__/hero-strip.test.tsx @@ -317,4 +317,61 @@ describe("", () => { const html = render(); expect(html).not.toContain("md:items-stretch"); }); + + // ── v1.16.8 — score-column reservation while analytics is pending ── + it("reserves the score column with a skeleton while the payload is pending", () => { + const html = render( + , + ); + // The placeholder mirrors the card's column footprint so the band + // paints at its final geometry from the first frame. + expect(html).toContain('data-slot="health-score-card-skeleton"'); + expect(html).toContain("md:basis-[22rem]"); + expect(html).toContain('aria-hidden="true"'); + // The real card stays absent until the payload lands. + expect(html).not.toContain('data-slot="health-score-card" '); + }); + + it("keeps the two-column split active while pending", () => { + const html = render( + , + ); + expect(html).toContain("md:flex-row"); + expect(html).toContain("md:items-stretch"); + }); + + it("drops the skeleton once the payload resolves without a score", () => { + const html = render( + , + ); + expect(html).not.toContain('data-slot="health-score-card-skeleton"'); + expect(html).not.toContain("md:items-stretch"); + }); + + it("renders the real card, never the skeleton, when a score is present", () => { + const html = render( + , + ); + expect(html).toContain('data-slot="health-score-card"'); + expect(html).not.toContain('data-slot="health-score-card-skeleton"'); + }); }); diff --git a/src/components/insights/hero-strip.tsx b/src/components/insights/hero-strip.tsx index 83180be62..e412f2fc1 100644 --- a/src/components/insights/hero-strip.tsx +++ b/src/components/insights/hero-strip.tsx @@ -2,6 +2,7 @@ import { Sparkles } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; import { useTranslations } from "@/lib/i18n/context"; import { formatRelativeTime } from "@/lib/i18n/relative-time"; import { useFeatureFlags } from "@/hooks/use-feature-flags"; @@ -89,6 +90,18 @@ interface HeroStripProps { components: HealthScoreCardProps["components"]; delta: HealthScoreCardProps["delta"]; } | null; + /** + * v1.16.8 — true while the analytics payload that carries + * `healthScore` is still in flight. The right column then reserves the + * score card's footprint with a skeleton, so the hero band paints at + * roughly its final size from the first frame and the card landing is + * a swap, not a push. Pre-fix the column collapsed while loading and + * the whole overview reflowed when the (slow, thick-slice) analytics + * response arrived — the "greeting first, score card later" stagger. + * When the payload resolves WITHOUT a score (no data yet) the column + * collapses once, which is the legacy no-score shape. + */ + healthScorePending?: boolean; } /** @@ -118,6 +131,7 @@ export function HeroStrip({ onAskCoach, now, healthScore, + healthScorePending = false, }: HeroStripProps) { const { t } = useTranslations(); // v1.4.37 W5 — operator-level Coach gate. When the global Coach flag @@ -180,10 +194,15 @@ export function HeroStrip({ * `h-full` + `mt-auto` on its disclaimer to redistribute the * recovered space without visual jank. */} + {/* v1.16.8 — the split keys on `reserveScoreColumn`, not on the + resolved payload alone: while the analytics query is in flight + the right column already holds the score card's footprint (the + skeleton below), so the band's two-column geometry is stable + from first paint. */}
@@ -287,6 +306,33 @@ export function HeroStrip({ )}
+ {!healthScore && healthScorePending && ( + /* v1.16.8 — layout-stable placeholder mirroring the score + card's column footprint (same basis / stretch classes as + ``): label row, headline number, progress + bar, four component rows. Decorative — the card announces + nothing while loading. */ + + )} {healthScore && ( 1 s cold). The route reads via + * `cachedSwr` now: inside the 10-minute stale window the prior body + * serves immediately while one coalesced background rebuild warms a + * fresh one. Writes keep their hard evict (`deleteByPrefix`) so a + * user's own measurement / mood / medication action is always + * reflected on the very next read — the window only bounds + * wall-clock drift, never user-action staleness. */ insightsTargets: new ServerCache({ name: "insightsTargets", maxEntries: 1000, ttlMs: 60_000, + staleTtlMs: 600_000, + }), + /** + * v1.16.8 — batched derived-wellness payload + * (`GET /api/insights/derived/batch`). The Insights overview reads + * ~16 metric computes in one request; each cold build walks the + * rollup tier per metric and lands at 1–2 s wall-clock even under the + * bounded `p-limit(4)` fan-out. Keyed + * `${userId}|batch|${sortedTokens}|${locale}` so the overview's one + * canonical token set always lands on one cell. Measurement writes + * cover it through `invalidateUserMeasurements` (evict on interactive + * writes, mark-stale on background syncs); mood writes mark it stale + * (READINESS folds the mood series in). Stale-while-revalidate keeps + * any repeat read inside the 10-minute window instant while one + * background recompute refreshes the cell. + */ + insightsDerived: new ServerCache({ + name: "insightsDerived", + maxEntries: 2000, + ttlMs: 60_000, + staleTtlMs: 600_000, }), /** * v1.5.5 — per-user insights tile layout cache. Mirrors From a6643950217d10385b74016e3cd5dba8f76f3efa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 11 Jun 2026 21:52:51 +0200 Subject: [PATCH 05/12] fix(coach): make remembering reliable and survivable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A remember action under every persisted chat message adopts its content into the matching self-context field — allergy statements land in allergies, diagnosis wording in conditions, everything else in the coach focus — reusing the adopt endpoint's dedupe and encryption, with the result visible and editable where the self-context lives. The deterministic fact patterns accept closed filler-word and possessive variants that previously defeated them, and stored facts survive the snapshot budget squeeze instead of being the first block shed on data-rich accounts. --- .../about-me/adopt/__tests__/route.test.ts | 35 +++++- src/app/api/coach/about-me/adopt/route.ts | 28 +++-- .../__tests__/message-thread.test.tsx | 43 ++++++++ .../insights/coach-panel/message-thread.tsx | 102 +++++++++++++++++- .../__tests__/facts-deterministic.test.ts | 45 ++++++++ .../coach/__tests__/snapshot-budget.test.ts | 41 +++++++ src/lib/ai/coach/facts.ts | 100 +++++++++++++---- src/lib/ai/coach/snapshot.ts | 21 +++- src/lib/validations/about-me.ts | 12 ++- 9 files changed, 387 insertions(+), 40 deletions(-) diff --git a/src/app/api/coach/about-me/adopt/__tests__/route.test.ts b/src/app/api/coach/about-me/adopt/__tests__/route.test.ts index 09e0fea66..f25792089 100644 --- a/src/app/api/coach/about-me/adopt/__tests__/route.test.ts +++ b/src/app/api/coach/about-me/adopt/__tests__/route.test.ts @@ -63,7 +63,7 @@ import { getSelfContextForUser } from "@/lib/ai/coach/about-me"; const transaction = prisma.$transaction as ReturnType; const getCtx = getSelfContextForUser as ReturnType; -function adoptRequest(body: { question: string; answer: string }): Request { +function adoptRequest(body: { question?: string; answer: string }): Request { return new Request("http://localhost/api/coach/about-me/adopt", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -143,6 +143,39 @@ describe("POST /api/coach/about-me/adopt", () => { ); }); + // v1.16.8 — the remember action on a chat message sends only the + // message text; the target field is matched from the text itself. + it("matches the field from the answer text when no question is sent", async () => { + getCtx.mockResolvedValue(emptyCtx); + + const res = await post( + adoptRequest({ answer: "Ich habe eine Erdnussallergie" }), + ); + + expect(res.status).toBe(200); + expect(res.data).toEqual({ adopted: true, field: "allergies" }); + const data = tx.userHealthProfile.update.mock.calls[0][0].data; + expect(Object.keys(data)).toEqual(["allergiesEncrypted"]); + }); + + it("lands a question-less answer without keywords on coachFocus", async () => { + getCtx.mockResolvedValue(emptyCtx); + + const res = await post( + adoptRequest({ answer: "Ich gehe abends gern spazieren" }), + ); + + expect(res.data).toEqual({ adopted: true, field: "coachFocus" }); + const data = tx.userHealthProfile.update.mock.calls[0][0].data; + expect(Object.keys(data)).toEqual(["coachFocusEncrypted"]); + }); + + it("still 422s on an empty answer regardless of the question", async () => { + const res = await post(adoptRequest({ answer: " " })); + expect(res.status).toBe(422); + expect(tx.userHealthProfile.update).not.toHaveBeenCalled(); + }); + it("422s when the overflow target aboutMe is full, still without a write", async () => { getCtx.mockResolvedValue({ ...emptyCtx, diff --git a/src/app/api/coach/about-me/adopt/route.ts b/src/app/api/coach/about-me/adopt/route.ts index ee5463153..5f8c1d8e5 100644 --- a/src/app/api/coach/about-me/adopt/route.ts +++ b/src/app/api/coach/about-me/adopt/route.ts @@ -13,6 +13,10 @@ * - The target field is picked from the QUESTION wording (allergy / * condition keywords across the six UI locales; everything else * lands on `coachFocus`, the "what the Coach should know" slot). + * v1.16.8 — the question is optional: the remember action on a + * chat message sends only the message text, and the field is then + * matched from that text ("ich habe eine Erdnussallergie" lands on + * `allergies` directly). * - Dedupe: an answer already contained in the target field (or in * the free-text `aboutMe`) is a no-op (`adopted: false`) — tapping * the offer twice or re-answering the same chip cannot stack @@ -49,11 +53,12 @@ const POST_WINDOW_MS = 60_000; type StructuredField = "conditions" | "allergies" | "coachFocus"; /** - * Keyword match against the question wording, covering the six UI - * locales the question generator writes in (de / en / es / fr / it / - * pl). Stems, not words, so inflections match. Anything that is not - * clearly an allergy or condition question lands on `coachFocus` — the - * generic "worth knowing for the Coach" slot. + * Keyword match against the question wording — or, for the + * message-remember path, against the message text itself — covering + * the six UI locales the question generator writes in (de / en / es / + * fr / it / pl). Stems, not words, so inflections match. Anything that + * is not clearly an allergy or condition statement lands on + * `coachFocus` — the generic "worth knowing for the Coach" slot. */ function matchSelfContextField(question: string): StructuredField { const q = question.toLowerCase(); @@ -114,6 +119,11 @@ export const POST = apiHandler(async (req: Request) => { const { question, answer } = parsed.data; const normAnswer = normalise(answer); + // Question-led adoptions (guided dialog) match the field from the + // question wording; the message-remember path matches from the + // message text itself. + const matchSource = question ?? answer; + const adoptionSource = question ? "question" : "message"; // Read-modify-write under a row lock: two concurrent adoptions used // to read the same base value and one append silently lost (or the @@ -134,7 +144,7 @@ export const POST = apiHandler(async (req: Request) => { `; const ctx = await getSelfContextForUser(user.id, tx); - let field: StructuredField | "aboutMe" = matchSelfContextField(question); + let field: StructuredField | "aboutMe" = matchSelfContextField(matchSource); // Dedupe BEFORE any write: an answer already present in the target // field or in the free-text aboutMe never stacks a second copy. @@ -181,7 +191,7 @@ export const POST = apiHandler(async (req: Request) => { if (outcome.kind === "duplicate") { annotate({ action: { name: "coach.about_me.adopt_deduped" }, - meta: { field }, + meta: { field, source: adoptionSource }, }); return apiSuccess({ adopted: false, field, reason: "duplicate" }); } @@ -192,12 +202,12 @@ export const POST = apiHandler(async (req: Request) => { await auditLog("coach.about_me.adopted", { userId: user.id, ipAddress: getClientIp(req), - details: { field, answerLength: answer.length }, + details: { field, answerLength: answer.length, source: adoptionSource }, }); annotate({ action: { name: "coach.about_me.adopted" }, - meta: { field, answer_length: answer.length }, + meta: { field, answer_length: answer.length, source: adoptionSource }, }); return apiSuccess({ adopted: true, field }); diff --git a/src/components/insights/coach-panel/__tests__/message-thread.test.tsx b/src/components/insights/coach-panel/__tests__/message-thread.test.tsx index 53dd831fb..d298b3ed1 100644 --- a/src/components/insights/coach-panel/__tests__/message-thread.test.tsx +++ b/src/components/insights/coach-panel/__tests__/message-thread.test.tsx @@ -481,6 +481,49 @@ describe("", () => { expect(userIdx).toBeLessThan(assistantIdx); }); + // v1.16.8 — the remember control under persisted user bubbles. + it("renders the remember control under a persisted user message", () => { + const html = render(); + expect(html).toContain('data-slot="coach-remember-message"'); + expect(html).toContain("Remember"); + }); + + it("localises the remember control under 'de'", () => { + const html = render(, "de"); + expect(html).toContain('data-slot="coach-remember-message"'); + expect(html).toContain("Merken"); + }); + + it("omits the remember control on an optimistic user bubble (no id yet)", () => { + const html = render( + , + ); + expect(html).toContain('data-slot="coach-bubble-user"'); + expect(html).not.toContain('data-slot="coach-remember-message"'); + }); + + it("omits the remember control when the message exceeds the field cap", () => { + const long = { + ...baseConversation, + messages: [ + { + ...baseConversation.messages[0], + content: "x".repeat(501), + }, + ], + }; + const html = render(); + expect(html).toContain('data-slot="coach-bubble-user"'); + expect(html).not.toContain('data-slot="coach-remember-message"'); + }); + // v1.4.25 W5 — distinct daily-limit vs provider-rate-limit copy. it("surfaces the daily-limit copy for coach.budget.exceeded", () => { const html = render( diff --git a/src/components/insights/coach-panel/message-thread.tsx b/src/components/insights/coach-panel/message-thread.tsx index 21bc13e58..f88a40b66 100644 --- a/src/components/insights/coach-panel/message-thread.tsx +++ b/src/components/insights/coach-panel/message-thread.tsx @@ -4,8 +4,11 @@ import { Fragment, useEffect, useId, useMemo, useRef, useState } from "react"; import { useMutation } from "@tanstack/react-query"; import { toast } from "sonner"; import { + BookmarkPlus, Bot, + Check, ChevronRight, + Loader2, Sparkles, ThumbsDown, ThumbsUp, @@ -18,6 +21,7 @@ import { useTranslations } from "@/lib/i18n/context"; import { ApiError, apiPost } from "@/lib/api/api-fetch"; import { stripChartTokens } from "@/lib/insights/chart-tokens"; import { useAuth } from "@/hooks/use-auth"; +import { ABOUT_ME_FIELD_MAX_CHARS } from "@/lib/validations/about-me"; import { SourceChips } from "./source-chips"; import type { @@ -469,15 +473,32 @@ function ChatBubble({ >
- {content} +
+ {content} +
+ {/* v1.16.8 — explicit remember control. Stating an allergy in + chat used to leave no durable trace unless a narrow + pattern pass happened to match the phrasing; this stores + the message into the editable self-context (Settings → AI) + on one tap, so it rides every future system prompt. Only + persisted messages get the control (an optimistic bubble + has no id yet), and only when the text fits the + self-context field cap. */} + {messageId && content.length <= ABOUT_ME_FIELD_MAX_CHARS && ( + + )}
{avatarUrl ? ( // eslint-disable-next-line @next/next/no-img-element @@ -691,6 +712,79 @@ function ChatBubble({ ); } +/** + * v1.16.8 — per-message remember control under the user bubble. + * + * One tap stores the message text into the structured self-context via + * `POST /api/coach/about-me/adopt` (no `question` — the server matches + * the target field from the text itself: an allergy statement lands on + * the allergies field, a stated condition on conditions, everything + * else on the coach-focus slot). The stored text is visible and + * editable under Settings → AI and rides every future Coach system + * prompt. Settled states mirror `SelfContextAdoptOffer`: a short + * confirmation replaces the button after an adoption or a server-side + * dedupe; a failure surfaces a toast and the button stays tappable. + */ +function RememberUserMessage({ content }: { content: string }) { + const { t } = useTranslations(); + const [settled, setSettled] = useState<"adopted" | "duplicate" | null>(null); + + const remember = useMutation({ + mutationFn: async () => { + return apiPost<{ adopted: boolean }>("/api/coach/about-me/adopt", { + answer: content, + }); + }, + onSuccess: (data) => { + setSettled(data.adopted ? "adopted" : "duplicate"); + }, + onError: () => { + toast.error(t("insights.coach.rememberMessage.failed")); + }, + }); + + if (settled) { + return ( +

+

+ ); + } + + return ( + + ); +} + /** * v1.16.1 — classic chat typing indicator: three dots pulsing in * sequence inside the assistant bubble, shown only between submit and diff --git a/src/lib/ai/coach/__tests__/facts-deterministic.test.ts b/src/lib/ai/coach/__tests__/facts-deterministic.test.ts index 05c7e6b6e..6600dd34c 100644 --- a/src/lib/ai/coach/__tests__/facts-deterministic.test.ts +++ b/src/lib/ai/coach/__tests__/facts-deterministic.test.ts @@ -63,6 +63,16 @@ describe("extractDeterministicFacts", () => { ["Ich hab eine Erdnuss-Allergie, seit Jahren.", "Erdnuss"], ["übrigens: ich bin allergisch gegen Penicillin.", "Penicillin"], ["ich habe eine Allergie gegen Hausstaubmilben", "Hausstaubmilben"], + // v1.16.8 — conversational fillers no longer defeat the pass. + ["Ich habe übrigens eine Erdnussallergie.", "Erdnuss"], + ["Ich habe seit Jahren eine Pollenallergie.", "Pollen"], + ["Ich habe auch eine Allergie gegen Nüsse.", "Nüsse"], + ["Übrigens, ich habe noch eine Allergie gegen Penicillin.", "Penicillin"], + ["Ich habe eine starke Erdnussallergie.", "Erdnuss"], + ["Ich bin leider allergisch auf Erdnüsse.", "Erdnüsse"], + // v1.16.8 — first-person possessive. + ["Meine Erdnussallergie macht mir zu schaffen.", "Erdnuss"], + ["Wegen meiner Pollenallergie schlafe ich schlecht.", "Pollen"], ])("matches the German allergy statement %j", (message, subject) => { const facts = extractDeterministicFacts(message, "de"); expect(facts).toHaveLength(1); @@ -81,6 +91,19 @@ describe("extractDeterministicFacts", () => { expect( extractDeterministicFacts("ich bin laktoseintolerant", "de")[0]?.fact, ).toContain("laktose"); + // v1.16.8 — fillers + possessive. + expect( + extractDeterministicFacts( + "ich habe übrigens eine Laktoseintoleranz", + "de", + )[0]?.fact, + ).toContain("Laktose"); + expect( + extractDeterministicFacts( + "wegen meiner Histaminunverträglichkeit", + "de", + )[0]?.fact, + ).toContain("Histamin"); }); it("matches a German self-reported diagnosis", () => { @@ -99,6 +122,11 @@ describe("extractDeterministicFacts", () => { ["I have a peanut allergy", "peanut"], ["I have a lactose intolerance", "lactose"], ["I was diagnosed with asthma", "asthma"], + // v1.16.8 — fillers + possessive no longer defeat the pass. + ["By the way, I also have a severe peanut allergy.", "peanut"], + ["I'm also allergic to penicillin.", "penicillin"], + ["My peanut allergy is acting up again.", "peanut"], + ["I was recently diagnosed with asthma.", "asthma"], ])("matches the English statement %j", (message, subject) => { const facts = extractDeterministicFacts(message, "en"); expect(facts).toHaveLength(1); @@ -116,6 +144,23 @@ describe("extractDeterministicFacts", () => { expect( extractDeterministicFacts("what is an allergy exactly?", "en"), ).toEqual([]); + // v1.16.8 — the filler tolerance is a closed list: open third-party + // clauses still never match. + expect( + extractDeterministicFacts( + "ich habe gehört dass seine Erdnussallergie schlimm ist", + "de", + ), + ).toEqual([]); + expect( + extractDeterministicFacts( + "ich habe eine Frage zu einer Erdnussallergie", + "de", + ), + ).toEqual([]); + expect( + extractDeterministicFacts("my friend has a peanut allergy", "en"), + ).toEqual([]); }); }); diff --git a/src/lib/ai/coach/__tests__/snapshot-budget.test.ts b/src/lib/ai/coach/__tests__/snapshot-budget.test.ts index c0ba7dab0..063506be0 100644 --- a/src/lib/ai/coach/__tests__/snapshot-budget.test.ts +++ b/src/lib/ai/coach/__tests__/snapshot-budget.test.ts @@ -40,6 +40,26 @@ vi.mock("./glp1-snapshot", () => ({ buildGlp1SnapshotBlock: vi.fn().mockResolvedValue(null), })); +// v1.16.8 — the memory block carries durable personal facts (a stated +// allergy). The degrader may shed the bulky narrative recall but must +// keep the facts list; the mock pins both shapes in one block. +const MEMORY_FACT = { + category: "condition", + text: "Allergie: Erdnuss (eigene Angabe)", +}; +vi.mock("../memory-snapshot", () => ({ + buildCoachMemoryBlock: vi.fn().mockResolvedValue({ + trendMemory: {}, + priorNarrative: { headline: "n".repeat(600), drivers: [] }, + facts: [ + { + category: "condition", + text: "Allergie: Erdnuss (eigene Angabe)", + }, + ], + }), +})); + import { prisma } from "@/lib/db"; import { extractFeatures } from "@/lib/insights/features"; @@ -248,6 +268,27 @@ describe("buildCoachSnapshot — budgeting + progressive degradation", () => { expect(droppedBlocks).not.toContain("compliance"); }); + it("keeps the durable facts when the memory block is shed", async () => { + const out = await buildCoachSnapshot("user-1", { + sources: ALL_SOURCES, + window: "allTime", + }); + const truncated = annotateCalls.find( + (c) => c.name === "coach.snapshot.truncated", + ); + const droppedBlocks = (truncated?.meta?.droppedBlocks ?? []) as string[]; + // The memory block sits on the lowest-priority cluster and is shed + // under this dense load … + expect(droppedBlocks).toContain("memory"); + const snapshot = JSON.parse(out.snapshotJson) as { + memory?: { facts?: unknown[]; priorNarrative?: unknown }; + }; + // … but the durable facts survive the drop while the bulky + // narrative recall goes. + expect(snapshot.memory?.facts).toEqual([MEMORY_FACT]); + expect(snapshot.memory?.priorNarrative).toBeUndefined(); + }); + it("does not truncate a small default-scope snapshot", async () => { // A web-only account with no rows produces a tiny snapshot — the // degrader must be a no-op (no truncated annotation). diff --git a/src/lib/ai/coach/facts.ts b/src/lib/ai/coach/facts.ts index 4a73cf943..b12b0133d 100644 --- a/src/lib/ai/coach/facts.ts +++ b/src/lib/ai/coach/facts.ts @@ -405,63 +405,125 @@ interface DeterministicPattern { kind: "allergy" | "intolerance" | "diagnosis"; } +/** + * v1.16.8 — closed filler-word tolerance. The original patterns + * required the compound to follow "ich habe" immediately (modulo the + * article), so a single conversational adverb — "ich habe übrigens + * eine Erdnussallergie", "ich habe seit Jahren eine Pollenallergie" — + * defeated the always-remember pass entirely. The filler list is a + * CLOSED set of adverbs / temporal qualifiers / intensity adjectives: + * an open `\w+` gap would let third-party clauses through ("ich habe + * gehört dass seine …") and break the self-statement contract. + */ +const DE_FILLER = + "(?:übrigens|auch|noch|außerdem|zudem|leider|schon|immer|seit|jahren|jahrzehnten|kindheit|geburt|kurzem|langem|der|eine?|'?ne|starke|leichte|schwere|ausgeprägte|diagnostizierte)"; +const EN_FILLER = + "(?:also|still|actually|unfortunately|always|btw|a|an|severe|mild|bad|slight|known|diagnosed)"; + const DETERMINISTIC_PATTERNS: DeterministicPattern[] = [ // German — allergies. { - // "ich habe eine Erdnussallergie" / "ich hab 'ne Erdnuss-Allergie". - // The capture must be compounded onto "-allergie" (no space) so the - // article in "eine Allergie gegen X" can never be captured — that - // phrasing belongs to the next pattern. - re: /\bich\s+hab(?:e)?\s+(?:eine?\s+|'?ne\s+)?([a-zäöüß][\wäöüß-]*?)-?allergie\b/i, + // "ich habe (übrigens / seit Jahren / …) eine Erdnussallergie" / + // "ich hab 'ne Erdnuss-Allergie". The capture must be compounded + // onto "-allergie" (no space) so the article in "eine Allergie + // gegen X" can never be captured — that phrasing belongs to the + // next pattern. Intervening words come only from the closed filler + // list above. + re: new RegExp( + `\\bich\\s+hab(?:e)?\\s+(?:${DE_FILLER}\\s+){0,5}([a-zäöüß][\\wäöüß-]*?)-?allergie\\b`, + "i", + ), kind: "allergy", }, { - // "ich habe eine Allergie gegen Erdnüsse" / "ich bin allergisch gegen/auf Erdnüsse" - re: /\bich\s+(?:hab(?:e)?\s+(?:eine?\s+)?allergie\s+(?:gegen|auf)|bin\s+allergisch\s+(?:gegen|auf))\s+([a-zäöüß][\wäöüß -]{1,40}?)(?=[.,;!?]|$)/im, + // "ich habe (auch / noch / …) eine Allergie gegen Erdnüsse" / + // "ich bin (leider / auch / …) allergisch gegen/auf Erdnüsse" + re: new RegExp( + `\\bich\\s+(?:hab(?:e)?\\s+(?:${DE_FILLER}\\s+){0,5}allergie\\s+(?:gegen|auf)|bin\\s+(?:${DE_FILLER}\\s+){0,3}allergisch\\s+(?:gegen|auf))\\s+([a-zäöüß][\\wäöüß -]{1,40}?)(?=[.,;!?]|$)`, + "im", + ), + kind: "allergy", + }, + { + // First-person possessive: "meine Erdnussallergie", "wegen meiner + // Pollenallergie". A third-party owner ("die Allergie meiner + // Schwester") never matches — the captured word must itself end in + // "-allergie" right after the possessive. + re: /\bmein(?:e|er)?\s+([a-zäöüß][\wäöüß-]*?)-?allergie\b/i, kind: "allergy", }, // German — intolerances. { // "ich habe eine Laktoseunverträglichkeit / Laktose-Intoleranz". // Compound-only capture, same rationale as the allergy pattern. - re: /\bich\s+hab(?:e)?\s+(?:eine?\s+)?([a-zäöüß][\wäöüß-]*?)-?(?:unverträglichkeit|intoleranz)\b/i, + re: new RegExp( + `\\bich\\s+hab(?:e)?\\s+(?:${DE_FILLER}\\s+){0,5}([a-zäöüß][\\wäöüß-]*?)-?(?:unverträglichkeit|intoleranz)\\b`, + "i", + ), kind: "intolerance", }, { - // "ich bin laktoseintolerant" - re: /\bich\s+bin\s+([a-zäöüß][\wäöüß-]*?)[-\s]?intolerant\b/i, + // "ich bin (übrigens / leider) laktoseintolerant" + re: new RegExp( + `\\bich\\s+bin\\s+(?:${DE_FILLER}\\s+){0,3}([a-zäöüß][\\wäöüß-]*?)[-\\s]?intolerant\\b`, + "i", + ), + kind: "intolerance", + }, + { + // "meine Laktoseintoleranz" / "wegen meiner Histaminunverträglichkeit" + re: /\bmein(?:e|er)?\s+([a-zäöüß][\wäöüß-]*?)-?(?:unverträglichkeit|intoleranz)\b/i, kind: "intolerance", }, // German — explicit self-reported diagnosis. { - // "bei mir wurde Asthma diagnostiziert" - re: /\bbei\s+mir\s+wurde\s+([a-zäöüß][\wäöüß -]{1,40}?)\s+diagnostiziert\b/i, + // "bei mir wurde (kürzlich / übrigens) Asthma diagnostiziert" + re: /\bbei\s+mir\s+wurde\s+(?:übrigens\s+|kürzlich\s+|gerade\s+|neulich\s+|letztes\s+jahr\s+)?([a-zäöüß][\wäöüß -]{1,40}?)\s+diagnostiziert\b/i, kind: "diagnosis", }, // English — allergies. { - // "I'm allergic to peanuts" - re: /\bI(?:'m|\s+am)\s+allergic\s+to\s+([a-z][\w -]{1,40}?)(?=[.,;!?]|$)/im, + // "I'm (also / severely) allergic to peanuts" + re: new RegExp( + `\\bI(?:'m|\\s+am)\\s+(?:(?:also|really|severely|very|quite|unfortunately|actually|still)\\s+){0,2}allergic\\s+to\\s+([a-z][\\w -]{1,40}?)(?=[.,;!?]|$)`, + "im", + ), kind: "allergy", }, { - // "I have a peanut allergy" - re: /\bI\s+have\s+an?\s+([a-z][\w-]*)\s+allergy\b/i, + // "I (also) have a (severe) peanut allergy" + re: new RegExp( + `\\bI\\s+(?:also\\s+|still\\s+|actually\\s+)?have\\s+(?:${EN_FILLER}\\s+){0,3}([a-z][\\w-]*)\\s+allergy\\b`, + "i", + ), + kind: "allergy", + }, + { + // "my peanut allergy" + re: /\bmy\s+([a-z][\w-]*)\s+allergy\b/i, kind: "allergy", }, // English — intolerances. { // "I have a lactose intolerance" / "I'm lactose intolerant" - re: /\bI\s+have\s+an?\s+([a-z][\w-]*)\s+intolerance\b/i, + re: new RegExp( + `\\bI\\s+(?:also\\s+|still\\s+|actually\\s+)?have\\s+(?:${EN_FILLER}\\s+){0,3}([a-z][\\w-]*)\\s+intolerance\\b`, + "i", + ), kind: "intolerance", }, { - re: /\bI(?:'m|\s+am)\s+([a-z][\w-]*)[-\s]intolerant\b/i, + re: /\bI(?:'m|\s+am)\s+(?:(?:also|really|severely|very|quite|unfortunately|actually|still)\s+){0,2}([a-z][\w-]*)[-\s]intolerant\b/i, + kind: "intolerance", + }, + { + // "my lactose intolerance" + re: /\bmy\s+([a-z][\w-]*)\s+intolerance\b/i, kind: "intolerance", }, // English — explicit self-reported diagnosis. { - re: /\bI\s+(?:was|got)\s+diagnosed\s+with\s+([a-z][\w -]{1,40}?)(?=[.,;!?]|$)/im, + re: /\bI\s+(?:was|got)\s+(?:recently\s+|just\s+)?diagnosed\s+with\s+([a-z][\w -]{1,40}?)(?=[.,;!?]|$)/im, kind: "diagnosis", }, ]; diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts index 7d30a5d59..78af5b4b4 100644 --- a/src/lib/ai/coach/snapshot.ts +++ b/src/lib/ai/coach/snapshot.ts @@ -1534,12 +1534,23 @@ function degradeToBudget( // Last resort: replace the whole block with a compact marker so the // model still knows the cluster exists without paying for its rows. + // v1.16.8 — the `memory` block's `facts` list survives the drop: it + // carries the durable personal facts (a stated allergy, a stated + // condition) and is tiny by construction (top-8, ≤160 chars each). + // Shedding it made the Coach forget a stated allergy exactly on the + // data-heavy accounts that hit the char cap; the bulky narrative + + // trend recall still goes. const dropBlock = (key: string): boolean => { - if (key in snapshot) { - snapshot[key] = { omitted: "trimmed for prompt budget" }; - return true; - } - return false; + if (!(key in snapshot)) return false; + const block = asRecord(snapshot[key]); + const facts = + block && Array.isArray(block.facts) && block.facts.length > 0 + ? block.facts + : null; + snapshot[key] = facts + ? { facts, omitted: "trimmed for prompt budget" } + : { omitted: "trimmed for prompt budget" }; + return true; }; // Degrade per-block, LOWEST priority first, collapsing each block as diff --git a/src/lib/validations/about-me.ts b/src/lib/validations/about-me.ts index 33d493ceb..ddc20a025 100644 --- a/src/lib/validations/about-me.ts +++ b/src/lib/validations/about-me.ts @@ -54,10 +54,18 @@ export type AboutMePutInput = z.infer; * stored self-context. The server picks the matching structured field * from the question wording, dedupes against the stored text, and * appends encrypted. + * + * v1.16.8 — `question` is optional: the remember action on a Coach + * chat message sends only the message text, and the server matches the + * target field from the text itself. */ export const aboutMeAdoptSchema = z.object({ - /** The clarifying question the answer belongs to (field matching). */ - question: z.string().trim().min(1).max(500), + /** + * The clarifying question the answer belongs to (field matching). + * Absent for the message-remember path — the field is then matched + * from the answer text. + */ + question: z.string().trim().min(1).max(500).optional(), /** The user's answer, as typed into the Coach composer. */ answer: z.string().trim().min(1).max(ABOUT_ME_FIELD_MAX_CHARS), }); From 657f2323a356692a2fba598ba97b6795ca64c154 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 11 Jun 2026 21:52:52 +0200 Subject: [PATCH 06/12] fix(sleep,mood): pick the richest writer per night and show the whole note MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sleep nights collapse per writer instead of per source: several HealthKit apps land behind the same source value, so a phone's coarse awake and in-bed blocks could blend into or fully constitute a night whose stages another writer had recorded. The winning writer per session is the one with the most distinct granular stages, then the user's source ladder, and its rows are kept whole. The device type is threaded through every sleep read including the stage stacked bar, and the sync overlap for late-arriving nights widens from one day to seven so a night scored after the cursor passed is fetched on the next tick — assembly is read-time, so affected nights heal without manual repair. Mood notes expand to their full text in the list on desktop and touch, replacing the hover-only tooltip, and the mood CSV export carries a note column. --- messages/de.json | 8 + messages/en.json | 8 + messages/es.json | 8 + messages/fr.json | 8 + messages/it.json | 8 + messages/pl.json | 8 + src/app/api/analytics/route.ts | 1 + src/app/api/measurements/route.ts | 3 + src/app/api/measurements/series/route.ts | 3 + src/app/api/sleep/night/route.ts | 3 + .../mood-notes-timeline-removed.test.ts | 9 +- .../__tests__/mood-note-fulltext.test.tsx | 119 ++++++++++++ src/components/mood/mood-list.tsx | 83 +++++--- .../analytics/__tests__/sleep-night.test.ts | 153 +++++++++++++++ src/lib/analytics/sleep-night.ts | 181 ++++++++++++------ src/lib/analytics/summaries-slice.ts | 10 +- src/lib/export.ts | 6 + src/lib/whoop/sync.ts | 15 +- 18 files changed, 548 insertions(+), 86 deletions(-) create mode 100644 src/components/mood/__tests__/mood-note-fulltext.test.tsx diff --git a/messages/de.json b/messages/de.json index 8efde3a24..b35371b88 100644 --- a/messages/de.json +++ b/messages/de.json @@ -562,6 +562,8 @@ "nextPage": "Nächste Seite", "loginRequired": "Bitte anmelden, um Stimmungseinträge zu sehen.", "note": "Notiz", + "noteCollapse": "Notiz einklappen", + "noteExpand": "Notiz vollständig anzeigen", "notePlaceholder": "Notiz dazu, wie du dich fühlst…", "tagPicker": "Tags", "tagCategory": { @@ -2042,6 +2044,12 @@ "duplicate": "Bereits in der Selbstauskunft enthalten", "failed": "Übernahme fehlgeschlagen. Bitte erneut versuchen." }, + "rememberMessage": { + "action": "Merken", + "done": "Gemerkt — in deiner Selbstauskunft gespeichert", + "duplicate": "Schon in deiner Selbstauskunft enthalten", + "failed": "Merken fehlgeschlagen. Bitte erneut versuchen." + }, "guided": { "offerTitle": "Der Coach hat {count} Rückfragen an dich", "offerTitleOne": "Der Coach hat eine Rückfrage an dich", diff --git a/messages/en.json b/messages/en.json index db4e7e809..af05ebee5 100644 --- a/messages/en.json +++ b/messages/en.json @@ -562,6 +562,8 @@ "nextPage": "Next page", "loginRequired": "Please sign in to view mood entries.", "note": "Note", + "noteCollapse": "Collapse note", + "noteExpand": "Show full note", "notePlaceholder": "Add a note about how you're feeling…", "tagPicker": "Tags", "tagCategory": { @@ -2042,6 +2044,12 @@ "duplicate": "Already part of your self-context", "failed": "Could not add. Please try again." }, + "rememberMessage": { + "action": "Remember", + "done": "Saved to your self-context", + "duplicate": "Already part of your self-context", + "failed": "Could not save. Please try again." + }, "guided": { "offerTitle": "The Coach has {count} follow-up questions for you", "offerTitleOne": "The Coach has a follow-up question for you", diff --git a/messages/es.json b/messages/es.json index fed72c593..869f354f4 100644 --- a/messages/es.json +++ b/messages/es.json @@ -562,6 +562,8 @@ "nextPage": "Página siguiente", "loginRequired": "Inicia sesión para ver tus estados de ánimo.", "note": "Nota", + "noteCollapse": "Contraer nota", + "noteExpand": "Mostrar nota completa", "notePlaceholder": "Añade una nota sobre cómo te sientes…", "tagPicker": "Etiquetas", "tagCategory": { @@ -2042,6 +2044,12 @@ "duplicate": "Ya está en tu información personal", "failed": "No se pudo añadir. Inténtalo de nuevo." }, + "rememberMessage": { + "action": "Recordar", + "done": "Guardado en tu información personal", + "duplicate": "Ya está en tu información personal", + "failed": "No se pudo guardar. Inténtalo de nuevo." + }, "guided": { "offerTitle": "El Coach tiene {count} preguntas para ti", "offerTitleOne": "El Coach tiene una pregunta para ti", diff --git a/messages/fr.json b/messages/fr.json index 0ee754a1d..8d6eda75c 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -562,6 +562,8 @@ "nextPage": "Page suivante", "loginRequired": "Connecte-toi pour voir tes entrées d’humeur.", "note": "Note", + "noteCollapse": "Réduire la note", + "noteExpand": "Afficher la note complète", "notePlaceholder": "Ajoutez une note sur votre ressenti…", "tagPicker": "Étiquettes", "tagCategory": { @@ -2042,6 +2044,12 @@ "duplicate": "Déjà présent dans vos informations personnelles", "failed": "Échec de l’ajout. Veuillez réessayer." }, + "rememberMessage": { + "action": "Mémoriser", + "done": "Enregistré dans vos informations personnelles", + "duplicate": "Déjà présent dans vos informations personnelles", + "failed": "Échec de l’enregistrement. Veuillez réessayer." + }, "guided": { "offerTitle": "Le Coach a {count} questions pour vous", "offerTitleOne": "Le Coach a une question pour vous", diff --git a/messages/it.json b/messages/it.json index de35bd3df..9162e2ef0 100644 --- a/messages/it.json +++ b/messages/it.json @@ -562,6 +562,8 @@ "nextPage": "Pagina successiva", "loginRequired": "Accedi per vedere le voci dell’umore.", "note": "Nota", + "noteCollapse": "Comprimi nota", + "noteExpand": "Mostra nota completa", "notePlaceholder": "Aggiungi una nota su come ti senti…", "tagPicker": "Etichette", "tagCategory": { @@ -2042,6 +2044,12 @@ "duplicate": "Già presente nelle tue informazioni personali", "failed": "Aggiunta non riuscita. Riprova." }, + "rememberMessage": { + "action": "Memorizza", + "done": "Salvato nelle tue informazioni personali", + "duplicate": "Già presente nelle tue informazioni personali", + "failed": "Salvataggio non riuscito. Riprova." + }, "guided": { "offerTitle": "Il Coach ha {count} domande per te", "offerTitleOne": "Il Coach ha una domanda per te", diff --git a/messages/pl.json b/messages/pl.json index 3787a51e6..f1bdaca93 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -562,6 +562,8 @@ "nextPage": "Następna strona", "loginRequired": "Zaloguj się, aby zobaczyć wpisy nastroju.", "note": "Notatka", + "noteCollapse": "Zwiń notatkę", + "noteExpand": "Pokaż całą notatkę", "notePlaceholder": "Dodaj notatkę o swoim samopoczuciu…", "tagPicker": "Tagi", "tagCategory": { @@ -2042,6 +2044,12 @@ "duplicate": "Już znajduje się w informacjach o sobie", "failed": "Nie udało się dodać. Spróbuj ponownie." }, + "rememberMessage": { + "action": "Zapamiętaj", + "done": "Zapisano w informacjach o sobie", + "duplicate": "Już znajduje się w informacjach o sobie", + "failed": "Nie udało się zapisać. Spróbuj ponownie." + }, "guided": { "offerTitle": "Coach ma {count} pytania do Ciebie", "offerTitleOne": "Coach ma pytanie do Ciebie", diff --git a/src/app/api/analytics/route.ts b/src/app/api/analytics/route.ts index 821671dfb..e706750fc 100644 --- a/src/app/api/analytics/route.ts +++ b/src/app/api/analytics/route.ts @@ -489,6 +489,7 @@ async function computeSleepStageBreakdown( measuredAt: true, sleepStage: true, source: true, + deviceType: true, }, }); diff --git a/src/app/api/measurements/route.ts b/src/app/api/measurements/route.ts index e4ae2fcc5..4490aaa3d 100644 --- a/src/app/api/measurements/route.ts +++ b/src/app/api/measurements/route.ts @@ -704,6 +704,9 @@ async function sleepListResponse( 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. + deviceType: true, }, }); diff --git a/src/app/api/measurements/series/route.ts b/src/app/api/measurements/series/route.ts index 91a0d7549..620092d55 100644 --- a/src/app/api/measurements/series/route.ts +++ b/src/app/api/measurements/series/route.ts @@ -222,6 +222,9 @@ export const GET = apiHandler(async (request: NextRequest) => { 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. + deviceType: true, }, }); unit = "h"; diff --git a/src/app/api/sleep/night/route.ts b/src/app/api/sleep/night/route.ts index fa9c6a863..0bcdee635 100644 --- a/src/app/api/sleep/night/route.ts +++ b/src/app/api/sleep/night/route.ts @@ -105,6 +105,9 @@ export const GET = apiHandler(async (request: NextRequest) => { 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. + deviceType: true, }, }); diff --git a/src/components/insights/mood/__tests__/mood-notes-timeline-removed.test.ts b/src/components/insights/mood/__tests__/mood-notes-timeline-removed.test.ts index 11eb0d561..cf734acb4 100644 --- a/src/components/insights/mood/__tests__/mood-notes-timeline-removed.test.ts +++ b/src/components/insights/mood/__tests__/mood-notes-timeline-removed.test.ts @@ -60,8 +60,13 @@ describe("mood entries table — long-note truncation", () => { expect(src).toContain("max-w-[18rem]"); }); - it("clamps the note and exposes the full text via a tooltip", () => { + it("clamps the note by default and exposes the full text via the row toggle", () => { + // v1.16.8 — the desktop hover tooltip is replaced by an expandable + // toggle (works on touch, preserves line breaks via pre-wrap). The + // default presentation stays clamped so the table keeps its density. expect(src).toContain("line-clamp-2"); - expect(src).toContain("TooltipContent"); + expect(src).toContain("mood-note-toggle"); + expect(src).toContain("whitespace-pre-wrap"); + expect(src).not.toContain("TooltipContent"); }); }); diff --git a/src/components/mood/__tests__/mood-note-fulltext.test.tsx b/src/components/mood/__tests__/mood-note-fulltext.test.tsx new file mode 100644 index 000000000..66320ca88 --- /dev/null +++ b/src/components/mood/__tests__/mood-note-fulltext.test.tsx @@ -0,0 +1,119 @@ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +/** + * v1.16.8 — full-text mood notes on the management list. The note used to be + * readable in full only through the edit sheet (desktop hover tooltip aside, + * unreachable on touch). Each row now carries a toggle that expands the + * clamped two-line preview into the complete text, line breaks preserved, + * rendered as plain React text children (no markup interpretation). + * + * Project convention: SSR-only component tests (`renderToStaticMarkup`) plus + * source-string structural assertions for the interactive branch. + */ + +const LONG_NOTE = + "Erster Absatz der Notiz mit etwas längerem Text, der über die Vorschau hinausgeht.\n" + + "Zweiter Absatz mit weiteren Details, die nur in der Vollansicht lesbar sind."; + +const baseEntries = [ + { + id: "e-1", + date: "2026-05-09", + mood: "GUT", + score: 4, + tags: ["work"], + tagKeys: [], + note: LONG_NOTE, + source: "MANUAL", + moodLoggedAt: "2026-05-09T20:00:00.000Z", + }, + { + id: "e-2", + date: "2026-05-08", + mood: "OKAY", + score: 3, + tags: [], + tagKeys: [], + note: null, + source: "MANUAL", + moodLoggedAt: "2026-05-08T20:00:00.000Z", + }, +]; + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), + usePathname: () => "/mood", +})); + +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => ({ + data: { entries: baseEntries, meta: { total: 2 } }, + isLoading: false, + }), + useQueryClient: () => ({ invalidateQueries: vi.fn() }), + useMutation: () => ({ + mutate: vi.fn(), + mutateAsync: vi.fn(), + isPending: false, + isError: false, + error: null, + }), +})); + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + user: { id: "u1", username: "testuser", role: "USER" }, + isAuthenticated: true, + isLoading: false, + }), +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { MoodList } from "../mood-list"; + +function render(locale: "en" | "de" = "de") { + return renderToStaticMarkup( + + + , + ); +} + +describe("MoodList — full-text note affordance", () => { + it("renders the note as a collapsed toggle with the COMPLETE text in the DOM", () => { + const html = render("de"); + // One toggle per surface (desktop table + mobile card) for the noted row; + // the note-less row renders none. + const toggles = html.match(/data-testid="mood-note-toggle"/g); + expect(toggles?.length).toBe(2); + // Collapsed by default: clamped preview + expand affordance label. + expect(html).toContain('aria-expanded="false"'); + expect(html).toContain("line-clamp-2"); + expect(html).toContain("Notiz vollständig anzeigen"); + // The complete text is in the row (the clamp is visual), both paragraphs. + expect(html).toContain("Erster Absatz der Notiz"); + expect(html).toContain("Zweiter Absatz mit weiteren Details"); + }); + + it("localises the affordance label", () => { + const html = render("en"); + expect(html).toContain("Show full note"); + }); + + it("expanded branch preserves line breaks and renders plain text children", () => { + const src = readFileSync( + join(process.cwd(), "src/components/mood/mood-list.tsx"), + "utf8", + ); + // The expanded branch must keep the author's line breaks… + expect(src).toContain("whitespace-pre-wrap"); + // …flip the collapse label… + expect(src).toContain('t("mood.noteCollapse")'); + expect(src).toContain('t("mood.noteExpand")'); + // …and never interpret the note as markup. + expect(src).not.toContain("dangerouslySetInnerHTML"); + }); +}); diff --git a/src/components/mood/mood-list.tsx b/src/components/mood/mood-list.tsx index e550e326b..e16372269 100644 --- a/src/components/mood/mood-list.tsx +++ b/src/components/mood/mood-list.tsx @@ -29,12 +29,6 @@ import { } from "@/components/ui/dropdown-menu"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/ui/tooltip"; import { DateTimeInput } from "@/components/ui/date-input"; import { Label } from "@/components/ui/label"; import { @@ -140,6 +134,15 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) { // v1.15.13 — page-scoped multi-select selection (current page ids only). const [selectedIds, setSelectedIds] = useState>(() => new Set()); + // v1.16.8 — per-entry note expansion. Notes were readable in full only + // through the edit sheet (desktop hover tooltip aside); the row now + // toggles between the clamped preview and the complete text. + const [expandedNoteIds, setExpandedNoteIds] = useState>( + () => new Set(), + ); + const toggleNote = (id: string) => + setExpandedNoteIds((prev) => toggleId(prev, id)); + const [editing, setEditing] = useState(null); const [editMood, setEditMood] = useState(""); const { getRadioProps: getEditMoodRadioProps } = useRovingRadioGroup({ @@ -599,20 +602,11 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) { : "-"} {entry.note && ( - - - -

- {entry.note} -

-
- -

- {entry.note} -

-
-
-
+ toggleNote(entry.id)} + /> )} @@ -710,9 +704,11 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {

)} {entry.note && ( -

- {entry.note} -

+ toggleNote(entry.id)} + /> )}
@@ -998,6 +994,47 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) { ); } +/** + * v1.16.8 — expandable free-text note. Collapsed: a two-line clamped + * preview. Expanded: the COMPLETE text, line breaks preserved, rendered as + * plain text children. The whole block is one toggle button so the full + * note is readable in place — previously the only full-text surface was + * the edit sheet (plus a hover tooltip on desktop, unreachable on touch). + */ +function MoodNoteText({ + note, + expanded, + onToggle, +}: { + note: string; + expanded: boolean; + onToggle: () => void; +}) { + const { t } = useTranslations(); + return ( + + ); +} + /** * v1.15.13 — render a `MoodEntry.source` value with its localized label. * MANUAL is shown via the source filter only (a manual row carries no diff --git a/src/lib/analytics/__tests__/sleep-night.test.ts b/src/lib/analytics/__tests__/sleep-night.test.ts index af5875de2..203128283 100644 --- a/src/lib/analytics/__tests__/sleep-night.test.ts +++ b/src/lib/analytics/__tests__/sleep-night.test.ts @@ -669,3 +669,156 @@ describe("reconstructSleepSessions total-safety (A5)", () => { expect(() => reconstructSleepSessions(rows, "UTC")).not.toThrow(); }); }); + +/** Build a stage row tagged with a source AND a writer device-type. */ +function writerRow( + iso: string, + stage: SleepStage | null, + minutes: number, + source: MeasurementSource, + deviceType: string | null, +): SleepStageRow { + return { + measuredAt: new Date(iso), + sleepStage: stage, + value: minutes, + source, + deviceType, + }; +} + +describe("per-night writer richness pick", () => { + it("a coarse awake-heavy source never masks the source that knows the stages", () => { + // The reported night: one source contributes a huge AWAKE block plus a + // sliver of bare ASLEEP (a phone/in-bed detection mis-scoring the night), + // the other carries the full hypnogram. The stage-bearing source must + // win the night — the headline is NOT 400 awake minutes with no phases. + const rows: SleepStageRow[] = [ + // Coarse export: 22:50 → 06:35 local, awake-dominant. + srcRow("2026-06-04T01:00:00.000Z", "IN_BED", 250, "APPLE_HEALTH"), + srcRow("2026-06-04T04:30:00.000Z", "AWAKE", 400, "APPLE_HEALTH"), + srcRow("2026-06-04T04:35:00.000Z", "ASLEEP", 30, "APPLE_HEALTH"), + // Detailed stages for the SAME night. + srcRow("2026-06-04T01:30:00.000Z", "CORE", 240, "WHOOP"), + srcRow("2026-06-04T03:00:00.000Z", "DEEP", 80, "WHOOP"), + srcRow("2026-06-04T04:30:00.000Z", "REM", 90, "WHOOP"), + srcRow("2026-06-04T04:30:00.000Z", "AWAKE", 35, "WHOOP"), + ]; + const nights = reconstructSleepNights(rows, "Europe/Berlin"); + expect(nights).toHaveLength(1); + expect(nights[0].stages.CORE).toBe(240); + expect(nights[0].stages.DEEP).toBe(80); + expect(nights[0].stages.REM).toBe(90); + // The winner's own awake minutes survive; the coarse 400 must not. + expect(nights[0].awakeMinutes).toBe(35); + expect(nights[0].asleepMinutes).toBe(410); + + const sessions = reconstructSleepSessions(rows, "Europe/Berlin"); + expect(sessions).toHaveLength(1); + expect(sessions[0].source).toBe("WHOOP"); + expect(sessions[0].stages.AWAKE).toBe(35); + }); + + it("separates writer apps behind ONE source: phone in-bed must not blend into watch stages", () => { + // Several HealthKit apps write under source = APPLE_HEALTH. The watch + // carries the granular partition; the phone's bedtime detection writes a + // parallel IN_BED + a long AWAKE block. Collapsing per source would blend + // both writers and inflate the night's awake total — the device-type + // refinement must keep only the stage-bearing writer. + const rows: SleepStageRow[] = [ + writerRow("2026-06-04T00:30:00.000Z", "CORE", 120, "APPLE_HEALTH", "watch"), + writerRow("2026-06-04T02:30:00.000Z", "DEEP", 60, "APPLE_HEALTH", "watch"), + writerRow("2026-06-04T04:00:00.000Z", "REM", 80, "APPLE_HEALTH", "watch"), + writerRow("2026-06-04T04:30:00.000Z", "AWAKE", 15, "APPLE_HEALTH", "watch"), + writerRow("2026-06-04T04:30:00.000Z", "IN_BED", 460, "APPLE_HEALTH", "phone"), + writerRow("2026-06-04T00:35:00.000Z", "ASLEEP", 200, "APPLE_HEALTH", "phone"), + writerRow("2026-06-04T03:00:00.000Z", "AWAKE", 385, "APPLE_HEALTH", "phone"), + ]; + const nights = reconstructSleepNights(rows, "Europe/Berlin"); + expect(nights).toHaveLength(1); + expect(nights[0].stages.CORE).toBe(120); + expect(nights[0].stages.DEEP).toBe(60); + expect(nights[0].stages.REM).toBe(80); + // The watch writer's awake sliver, not the phone's 385-minute block. + expect(nights[0].awakeMinutes).toBe(15); + // The phone's bare ASLEEP must not blend into the asleep total either. + expect(nights[0].asleepMinutes).toBe(260); + }); + + it("rows without a device-type collapse per source exactly as before", () => { + // Legacy rows (pre device-type column) and single-writer sources: one + // bucket per source — granular + in-bed from the same source stay one + // night with both facets. + const rows: SleepStageRow[] = [ + srcRow("2026-06-04T00:30:00.000Z", "CORE", 120, "APPLE_HEALTH"), + srcRow("2026-06-04T04:30:00.000Z", "AWAKE", 15, "APPLE_HEALTH"), + srcRow("2026-06-04T04:30:00.000Z", "IN_BED", 460, "APPLE_HEALTH"), + ]; + const nights = reconstructSleepNights(rows, "Europe/Berlin"); + expect(nights).toHaveLength(1); + expect(nights[0].stages.CORE).toBe(120); + expect(nights[0].inBedMinutes).toBe(460); + expect(nights[0].awakeMinutes).toBe(15); + }); + + it("the RICHEST stage set wins: three granular stages beat one, regardless of ladder", () => { + // WHOOP sits first on the default ladder but only knows CORE for this + // night; the watch carries the full three-stage hypnogram. Per-night + // richness must pick the watch. + const rows: SleepStageRow[] = [ + srcRow("2026-06-04T04:30:00.000Z", "CORE", 300, "WHOOP"), + writerRow("2026-06-04T00:30:00.000Z", "CORE", 120, "APPLE_HEALTH", "watch"), + writerRow("2026-06-04T02:30:00.000Z", "DEEP", 60, "APPLE_HEALTH", "watch"), + writerRow("2026-06-04T04:00:00.000Z", "REM", 80, "APPLE_HEALTH", "watch"), + ]; + const nights = reconstructSleepNights(rows, "Europe/Berlin"); + expect(nights).toHaveLength(1); + expect(nights[0].stages.REM).toBe(80); + expect(nights[0].stages.DEEP).toBe(60); + expect(nights[0].stages.CORE).toBe(120); + expect(nights[0].asleepMinutes).toBe(260); + }); + + it("equally rich writers fall back to the ladder (WHOOP beats Apple Health)", () => { + const rows: SleepStageRow[] = [ + srcRow("2026-06-04T01:00:00.000Z", "CORE", 240, "WHOOP"), + srcRow("2026-06-04T03:00:00.000Z", "DEEP", 80, "WHOOP"), + srcRow("2026-06-04T04:30:00.000Z", "REM", 90, "WHOOP"), + writerRow("2026-06-04T00:30:00.000Z", "CORE", 120, "APPLE_HEALTH", "watch"), + writerRow("2026-06-04T02:30:00.000Z", "DEEP", 60, "APPLE_HEALTH", "watch"), + writerRow("2026-06-04T04:00:00.000Z", "REM", 80, "APPLE_HEALTH", "watch"), + ]; + const sessions = reconstructSleepSessions(rows, "Europe/Berlin"); + expect(sessions).toHaveLength(1); + expect(sessions[0].source).toBe("WHOOP"); + expect(sessions[0].asleepMinutes).toBe(410); + }); + + it("a coarse-only night heals on the next read once the stage rows land", () => { + // The reconstruction is read-time: a night first assembled from coarse + // rows (detailed sync lagging) must flip to the stage partition as soon + // as the detailed rows exist — no manual repair, no cache to clear. + const coarseOnly: SleepStageRow[] = [ + srcRow("2026-06-04T01:00:00.000Z", "IN_BED", 250, "APPLE_HEALTH"), + srcRow("2026-06-04T04:30:00.000Z", "AWAKE", 400, "APPLE_HEALTH"), + srcRow("2026-06-04T04:35:00.000Z", "ASLEEP", 30, "APPLE_HEALTH"), + ]; + const before = reconstructSleepNights(coarseOnly, "Europe/Berlin"); + expect(before[0].stages.REM).toBeUndefined(); + expect(before[0].awakeMinutes).toBe(400); + + const afterRows: SleepStageRow[] = [ + ...coarseOnly, + srcRow("2026-06-04T01:30:00.000Z", "CORE", 240, "WHOOP"), + srcRow("2026-06-04T03:00:00.000Z", "DEEP", 80, "WHOOP"), + srcRow("2026-06-04T04:30:00.000Z", "REM", 90, "WHOOP"), + ]; + const after = reconstructSleepNights(afterRows, "Europe/Berlin"); + expect(after).toHaveLength(1); + expect(after[0].stages.REM).toBe(90); + expect(after[0].stages.DEEP).toBe(80); + expect(after[0].stages.CORE).toBe(240); + expect(after[0].awakeMinutes).toBeNull(); + expect(after[0].asleepMinutes).toBe(410); + }); +}); diff --git a/src/lib/analytics/sleep-night.ts b/src/lib/analytics/sleep-night.ts index 15af56f71..08c652648 100644 --- a/src/lib/analytics/sleep-night.ts +++ b/src/lib/analytics/sleep-night.ts @@ -62,18 +62,29 @@ * and every other session on the same wake day is a NAP, surfaced separately * and never folded into the main night's headline. * - * Source de-dup + * Writer de-dup * ------------- * A user paired to more than one sleep source (e.g. WHOOP + Apple Health) * gets per-stage rows for the SAME night from each source. Summing them all * would ~double the night total. Before summing a session we collapse it to - * ONE source using the user's `sleep` source-priority ladder - * (`DEFAULT_SOURCE_PRIORITY.sleep` = WHOOP > APPLE_HEALTH > WITHINGS): pick the - * highest-ranked source present in the session, falling back to the source - * with the most asleep minutes (then source name) when none of the session's - * sources sits on the ladder. Only that source's segments are summed — sources - * are never blended within a night. This mirrors the per-(type, day) collapse - * the rest of the v1.11.x numeric surfaces apply. + * ONE writer. A "writer" is `(source, deviceType)` — finer than `source` + * because several HealthKit writer apps land behind the SAME + * `source = APPLE_HEALTH` (the watch's granular stages, the phone's coarse + * in-bed detection, a wearable vendor app's re-export). Keying the collapse + * on source alone blends those writers into one bucket: the phone's + * awake/in-bed blocks inflate the night's awake total and sit on top of the + * watch's hypnogram. Rows without a `deviceType` collapse per source exactly + * as before, so legacy rows and single-writer sources are unaffected. + * + * The pick is per-night by STAGE RICHNESS first: the writer carrying the + * most distinct granular stages (CORE / DEEP / REM) wins, so a coarse + * awake/asleep-only export can never mask a writer that knows the night's + * phases. Among equally rich writers the user's `sleep` source-priority + * ladder decides (`DEFAULT_SOURCE_PRIORITY.sleep` = WHOOP > APPLE_HEALTH > + * WITHINGS), then most asleep minutes, then a stable key tiebreak. Only the + * winning writer's segments are summed — writers are never blended within a + * session, mirroring the per-(type, day) collapse the rest of the v1.11.x + * numeric surfaces apply. * * Unit * ---- @@ -184,6 +195,15 @@ export interface SleepStageRow { * the de-dup is a no-op for single-source nights). */ source?: MeasurementSource | null; + /** + * Device-type tag of the stage row (`Measurement.deviceType`, v1.4.25 + * W8c). Distinguishes WRITERS behind one source: several HealthKit apps + * write sleep under `source = APPLE_HEALTH` (watch granular stages, phone + * coarse in-bed, vendor-app re-exports), and collapsing on source alone + * blends them. Optional — callers that don't select the column (and every + * row stored before W8c) collapse per source exactly as before. + */ + deviceType?: string | null; } /** One reconstructed night: the asleep total + the per-stage breakdown. */ @@ -206,63 +226,111 @@ export interface SleepNight { const NO_SOURCE = "__none__"; /** - * Pick the one canonical source for a session. STAGE GRANULARITY wins first: - * a source carrying at least one granular stage (CORE / DEEP / REM) always - * beats a coarse ASLEEP/AWAKE-only source, so a wearable's full hypnogram is - * never masked by a parallel coarse export of the same night (e.g. Apple - * Health's 6–9 AWAKE/ASLEEP blocks alongside WHOOP's per-stage rows). Among - * equally granular (or equally coarse) sources the user's `sleep` - * source-priority ladder decides (lowest ladder index = highest priority); - * sources absent from the ladder fall back to "most asleep minutes", then a - * stable source-name tiebreak. Single-source (or source-less) sessions - * resolve to that one bucket with no work. + * Separator between the source and device-type parts of a writer key. + * `` cannot appear in a `MeasurementSource` enum literal or a + * device-type tag, so the split-back is unambiguous. */ -function pickSessionSource( +const WRITER_KEY_SEP = ""; + +/** + * Writer-bucket key of a stage row: the source, refined by the device-type + * tag when one is stored. Two HealthKit writer apps behind the same + * `APPLE_HEALTH` source (watch vs phone) land in DIFFERENT buckets so the + * coarse writer's awake/in-bed blocks never blend into the granular + * writer's night. Rows without a device-type keep the bare source as their + * key — single-writer sources and legacy rows collapse exactly as before. + */ +function writerKeyOf(r: SleepStageRow): string { + const src = r.source ?? NO_SOURCE; + const dev = r.deviceType; + return dev ? `${src}${WRITER_KEY_SEP}${dev}` : src; +} + +/** The source part of a writer key (for ladder ranking + display). */ +function sourceOfWriterKey(key: string): string { + const i = key.indexOf(WRITER_KEY_SEP); + return i === -1 ? key : key.slice(0, i); +} + +/** + * Count of DISTINCT granular stages (CORE / DEEP / REM) in a row set — the + * stage-richness score of a writer's night. 3 = full hypnogram, 0 = coarse + * (bare ASLEEP / AWAKE / IN_BED only). + */ +function granularStageCount(rows: readonly SleepStageRow[]): number { + const seen = new Set(); + for (const r of rows) { + const stage = r.sleepStage; + if (stage != null && GRANULAR_ASLEEP_STAGES.has(stage)) seen.add(stage); + } + return seen.size; +} + +/** + * Pick the one canonical WRITER for a session. STAGE RICHNESS wins first: + * the writer carrying the most distinct granular stages (CORE / DEEP / REM) + * always beats a coarser one, so neither a parallel coarse export of the + * same night (e.g. Apple Health's AWAKE/ASLEEP blocks alongside WHOOP's + * per-stage rows) nor a second writer app behind the same source (the + * phone's in-bed detection next to the watch's stages) can mask the + * fullest hypnogram available for the night. Among equally rich writers + * the user's `sleep` source-priority ladder decides (lowest ladder index = + * highest priority); writers whose source is absent from the ladder fall + * back to "most asleep minutes", then a stable key tiebreak. Single-writer + * (or source-less) sessions resolve to that one bucket with no work. + */ +function pickSessionWriter( rows: SleepStageRow[], sleepLadder: readonly MeasurementSource[], ): string { - // Group rows by source, then sum each source's asleep minutes with the - // granular-over-bare rule so a source that writes BOTH a bare ASLEEP + // Group rows by writer, then sum each writer's asleep minutes with the + // granular-over-bare rule so a writer that stores BOTH a bare ASLEEP // aggregate and the granular breakdown is not over-weighted in the tiebreak. - const rowsBySource = new Map(); + const rowsByWriter = new Map(); for (const r of rows) { - const src = r.source ?? NO_SOURCE; - const list = rowsBySource.get(src) ?? []; + const key = writerKeyOf(r); + const list = rowsByWriter.get(key) ?? []; list.push(r); - rowsBySource.set(src, list); + rowsByWriter.set(key, list); } - const asleepBySource = new Map(); - for (const [src, srcRows] of rowsBySource) { - const minutes = asleepMinutesOf(srcRows); - if (minutes > 0) asleepBySource.set(src, minutes); + const asleepByWriter = new Map(); + for (const [key, writerRows] of rowsByWriter) { + const minutes = asleepMinutesOf(writerRows); + if (minutes > 0) asleepByWriter.set(key, minutes); } // No asleep minutes anywhere (IN_BED / AWAKE only) — fall back to the set - // of all present sources so the session still resolves to a single bucket. - let sources = - asleepBySource.size > 0 - ? [...asleepBySource.keys()] - : [...new Set(rows.map((r) => r.source ?? NO_SOURCE))]; - // Granularity gate: a source with a CORE/DEEP/REM partition always beats a - // coarse ASLEEP-only source — otherwise a parallel coarse export of the - // same night masks the wearable's hypnogram. Coarse sources stay only as - // the fallback when NO granular source covers the session. - const granularSources = sources.filter((src) => - sawGranularStage(rowsBySource.get(src) ?? []), - ); - if (granularSources.length > 0) sources = granularSources; - if (sources.length <= 1) return sources[0] ?? NO_SOURCE; + // of all present writers so the session still resolves to a single bucket. + let writers = + asleepByWriter.size > 0 + ? [...asleepByWriter.keys()] + : [...rowsByWriter.keys()]; + // Richness gate: keep only the writers tied for the MOST distinct granular + // stages. A 3-stage hypnogram beats a 1-stage partial beats a coarse + // ASLEEP-only export; coarse writers survive only when NO writer carries a + // granular stage for the session. + const richnessOf = new Map(); + let maxRichness = 0; + for (const key of writers) { + const score = granularStageCount(rowsByWriter.get(key) ?? []); + richnessOf.set(key, score); + if (score > maxRichness) maxRichness = score; + } + if (maxRichness > 0) { + writers = writers.filter((key) => richnessOf.get(key) === maxRichness); + } + if (writers.length <= 1) return writers[0] ?? NO_SOURCE; - const rankOf = (src: string): number => { - const i = sleepLadder.indexOf(src as MeasurementSource); + const rankOf = (key: string): number => { + const i = sleepLadder.indexOf(sourceOfWriterKey(key) as MeasurementSource); return i === -1 ? Number.MAX_SAFE_INTEGER : i; }; - return sources.sort((a, b) => { + return writers.sort((a, b) => { const ra = rankOf(a); const rb = rankOf(b); if (ra !== rb) return ra - rb; - // Neither (or both) on the ladder — most asleep minutes wins, then name. - const ma = asleepBySource.get(a) ?? 0; - const mb = asleepBySource.get(b) ?? 0; + // Neither (or both) on the ladder — most asleep minutes wins, then key. + const ma = asleepByWriter.get(a) ?? 0; + const mb = asleepByWriter.get(b) ?? 0; if (ma !== mb) return mb - ma; return a < b ? -1 : 1; })[0]; @@ -316,15 +384,15 @@ export function reconstructSleepNights( if (current.length > 0) sessions.push(current); // Two sessions can land on the same wake day (e.g. a genuine overnight plus - // a same-day nap); collect each session's canonical-source rows under one + // a same-day nap); collect each session's canonical-writer rows under one // wake-day key. The rows are grouped PER SESSION (not flattened) so the // granular-over-bare gate and the asleep total are decided per session — a // bare-only nap keeps its minutes even when the overnight session is // granular (the v1.11.5 merged-night nap under-count fix). const byNight = new Map(); for (const session of sessions) { - const canonical = pickSessionSource(session, sleepLadder); - const kept = session.filter((r) => (r.source ?? NO_SOURCE) === canonical); + const canonical = pickSessionWriter(session, sleepLadder); + const kept = session.filter((r) => writerKeyOf(r) === canonical); const pool = kept.length > 0 ? kept : session; // The wake day is the LATEST segment END in the session (Apple Health // attributes a session to the morning you wake up). @@ -526,8 +594,8 @@ export function reconstructSleepSessions( const sessions: SleepSession[] = []; for (const session of rawSessions) { - const canonical = pickSessionSource(session, sleepLadder); - const kept = session.filter((r) => (r.source ?? NO_SOURCE) === canonical); + const canonical = pickSessionWriter(session, sleepLadder); + const kept = session.filter((r) => writerKeyOf(r) === canonical); const pool = kept.length > 0 ? kept : session; // v1.11.5 — drop the redundant bare ASLEEP aggregate (and stage-less // rows) from the hypnogram segments AND the per-stage breakdown when the @@ -571,10 +639,13 @@ export function reconstructSleepSessions( } } const asleep = asleepMinutesOf(pool); + const canonicalSource = sourceOfWriterKey(canonical); sessions.push({ night: userDayKey(latest, tz), source: - canonical === NO_SOURCE ? null : (canonical as MeasurementSource), + canonicalSource === NO_SOURCE + ? null + : (canonicalSource as MeasurementSource), start: earliest, end: latest, asleepMinutes: asleep, diff --git a/src/lib/analytics/summaries-slice.ts b/src/lib/analytics/summaries-slice.ts index 1af2e97f8..db65866f7 100644 --- a/src/lib/analytics/summaries-slice.ts +++ b/src/lib/analytics/summaries-slice.ts @@ -316,7 +316,15 @@ async function withSleepNightTotals( measuredAt: { gte: since }, }, orderBy: { measuredAt: "asc" }, - select: { value: true, measuredAt: true, sleepStage: true, source: true }, + // `deviceType` feeds the writer-level collapse: two HealthKit apps + // behind one source (watch stages vs phone in-bed) must not blend. + select: { + value: true, + measuredAt: true, + sleepStage: true, + source: true, + deviceType: true, + }, })) as SleepStageRow[]; if (rows.length === 0) return slice; diff --git a/src/lib/export.ts b/src/lib/export.ts index da69e3fd6..26e7d85aa 100644 --- a/src/lib/export.ts +++ b/src/lib/export.ts @@ -95,6 +95,8 @@ interface ExportMeasurement { notes: string | null; glucoseContext?: string | null; sleepStage?: SleepStage | null; + /** Writer tag for the per-night sleep collapse (watch vs phone). */ + deviceType?: string | null; } /** @@ -158,6 +160,7 @@ export function formatMeasurementsForExport( measuredAt: m.measuredAt, sleepStage: m.sleepStage ?? null, source: m.source as SleepStageRow["source"], + deviceType: m.deviceType ?? null, })); const sessions = reconstructSleepSessions( stageRows, @@ -281,6 +284,8 @@ export function formatMoodEntriesForExport( mood: string; score: number; tags: string | null; + /** Free-text note — exported in full so the texts are readable offline. */ + note?: string | null; source: string; moodLoggedAt: Date; }>, @@ -291,6 +296,7 @@ export function formatMoodEntriesForExport( mood: e.mood, score: e.score, tags: e.tags ?? "", + note: e.note ?? "", source: e.source, loggedAt: formatTimestamp(e.moodLoggedAt, userTz), })); diff --git a/src/lib/whoop/sync.ts b/src/lib/whoop/sync.ts index 2223d247a..dd691f191 100644 --- a/src/lib/whoop/sync.ts +++ b/src/lib/whoop/sync.ts @@ -102,12 +102,17 @@ const TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1000; /** * Overlap window for the incremental sync, in ms. WHOOP re-scores recovery - * and sleep after the fact (the final recovery/sleep score can land hours - * after the night), so the recovery/sleep overlap is a full 24 h to make sure - * the re-scored record is re-fetched on the next tick. Workout/cycle settle - * fast — a smaller overlap suffices and keeps the page count down. + * and sleep after the fact, and a night can reach the WHOOP cloud DAYS late + * (phone offline, app unopened, score pending). The collection endpoints + * filter on the record's own time range — not on when it arrived — so any + * record surfacing after the cursor moved past it is missed FOREVER by a + * narrow overlap; the night's per-stage rows then never reach the DB and + * every sleep surface keeps showing the parallel coarse source for that + * night. Seven days re-fetches a handful of records per tick (the upserts + * are idempotent) and closes the gap for every realistic lag. Workout/cycle + * settle fast — a smaller overlap suffices and keeps the page count down. */ -export const WHOOP_RECOVERY_SLEEP_OVERLAP_MS = 24 * 60 * 60 * 1000; +export const WHOOP_RECOVERY_SLEEP_OVERLAP_MS = 7 * 24 * 60 * 60 * 1000; export const WHOOP_DEFAULT_OVERLAP_MS = 60 * 60 * 1000; // 1 h export interface WhoopTokenInfo { From 3d95ace21878f18ad3c7f1ca46569fae1f3775a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 11 Jun 2026 22:12:06 +0200 Subject: [PATCH 07/12] feat(ui): an always-there coach button and a tidied insights chrome MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A round coach button sits fixed bottom-right on every authenticated page, above the bottom nav and safe-area inset, with a small dot when a nudge is unread — replacing the bubble that only existed while a nudge was pending. The per-chart target-adjust link is gone (the page-level control remains), the duplicated customize entry on insights sub-pages collapses onto the tab strip, and the full-width show-all-values foot button becomes an icon beside the target control. The tab strip's scroll container keeps four pixels of paint room so pill borders and focus rings stop clipping at fractional zoom, and the mood tag breakdown bars adopt the muted opacity the other mood charts use. --- e2e/v1427-coach-launch.spec.ts | 51 ++++++---- messages/de.json | 2 +- messages/en.json | 2 +- messages/es.json | 2 +- messages/fr.json | 2 +- messages/it.json | 2 +- messages/pl.json | 2 +- src/app/insights/blood-pressure/page.tsx | 1 - src/app/insights/layout.tsx | 24 ++--- src/app/insights/weight/page.tsx | 1 - .../health-chart-target-link-removed.test.ts | 46 +++++++++ src/components/charts/health-chart.tsx | 29 +----- .../data-list/selection-action-bar.tsx | 4 + .../__tests__/insights-tab-strip.test.tsx | 19 ++++ .../__tests__/layout-coach-fab.test.tsx | 73 +++++++++++--- .../__tests__/sub-page-shell.test.tsx | 42 ++++++++ .../insights/insights-tab-strip.tsx | 11 ++- src/components/insights/layout-coach-fab.tsx | 52 ++++++---- .../__tests__/mood-tag-bars-muted.test.tsx | 61 ++++++++++++ .../mood/mood-structured-tag-breakdown.tsx | 5 +- .../insights/mood/mood-tag-breakdown.tsx | 5 +- src/components/insights/sub-page-shell.tsx | 98 +++++++++---------- .../__tests__/auth-shell-coach-hoist.test.ts | 21 ++-- src/components/layout/auth-shell.tsx | 19 +++- 24 files changed, 402 insertions(+), 172 deletions(-) create mode 100644 src/components/charts/__tests__/health-chart-target-link-removed.test.ts create mode 100644 src/components/insights/mood/__tests__/mood-tag-bars-muted.test.tsx diff --git a/e2e/v1427-coach-launch.spec.ts b/e2e/v1427-coach-launch.spec.ts index 7ad75be77..cabecdadb 100644 --- a/e2e/v1427-coach-launch.spec.ts +++ b/e2e/v1427-coach-launch.spec.ts @@ -5,20 +5,21 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup"; /** * Coach launch surfaces on `/insights/{slug}` sub-pages. * - * Contracts under test (v1.16.1): + * Contracts under test (v1.16.8): * - * 1. The floating bubble (`data-slot="coach-nudge-bubble"`) renders - * ONLY while an unread Coach-initiated nudge exists - * (`GET /api/insights/coach/nudge-status`). By default no bubble - * mounts on any viewport; the everyday entry point is the inline - * launch the sub-page shell mounts in the header - * (`data-slot="coach-launch-icon"`, visible across breakpoints). + * 1. The floating Coach FAB (`data-slot="coach-fab"`) is a permanent + * launcher on every authenticated page. An unread Coach-initiated + * nudge (`GET /api/insights/coach/nudge-status`) paints an unread + * dot (`data-slot="coach-fab-unread"`) on its corner; with nothing + * unread the FAB renders without the dot. The inline launch the + * sub-page shell mounts in the header + * (`data-slot="coach-launch-icon"`) stays the contextual entry. * * 2. Clicking the inline launch opens the Coach drawer — as a * bottom-sheet (`data-variant="bottom-sheet"`) on `=sm`. * - * 3. With an unread nudge the bubble mounts and clicking it + * 3. With an unread nudge the FAB carries the dot and clicking it * navigates to the full-page chat at `/insights/coach`. * * 4. The drawer's "Conversations" button no longer opens an in-panel @@ -124,7 +125,7 @@ test.describe("Coach launch surfaces on insights sub-pages", () => { }); for (const slug of ["blutdruck", "gewicht", "schlaf"] as const) { - test(`/insights/${slug} mounts the inline Coach launch and no bubble by default on Pixel 5`, async ({ + test(`/insights/${slug} mounts the inline Coach launch and the dot-free FAB on Pixel 5`, async ({ page, }, testInfo) => { test.skip( @@ -134,18 +135,20 @@ test.describe("Coach launch surfaces on insights sub-pages", () => { await page.goto(`/insights/${slug}`, { waitUntil: "domcontentloaded" }); - // The header launch icon is the everyday entry point — visible - // on mobile too since the layout bubble is nudge-gated. + // The header launch icon stays the contextual entry point. const icon = page.locator('[data-slot="coach-launch-icon"]').first(); await expect(icon).toBeVisible({ timeout: 10_000 }); - // No unread nudge → no floating bubble in the DOM. + // The FAB is a permanent launcher; with no unread nudge it + // renders without the unread dot. + const fab = page.locator('[data-slot="coach-fab"]'); + await expect(fab).toBeVisible({ timeout: 10_000 }); await expect( - page.locator('[data-slot="coach-nudge-bubble"]'), + page.locator('[data-slot="coach-fab-unread"]'), ).toHaveCount(0); }); - test(`/insights/${slug} mounts the inline Coach launch and no bubble by default on Desktop Chrome`, async ({ + test(`/insights/${slug} mounts the inline Coach launch and the dot-free FAB on Desktop Chrome`, async ({ page, }, testInfo) => { test.skip( @@ -158,8 +161,10 @@ test.describe("Coach launch surfaces on insights sub-pages", () => { const icon = page.locator('[data-slot="coach-launch-icon"]').first(); await expect(icon).toBeVisible({ timeout: 10_000 }); + const fab = page.locator('[data-slot="coach-fab"]'); + await expect(fab).toBeVisible({ timeout: 10_000 }); await expect( - page.locator('[data-slot="coach-nudge-bubble"]'), + page.locator('[data-slot="coach-fab-unread"]'), ).toHaveCount(0); }); } @@ -202,10 +207,10 @@ test.describe("Coach launch surfaces on insights sub-pages", () => { await expect(drawer).toHaveAttribute("data-variant", "side-sheet"); }); - test("an unread Coach nudge mounts the floating bubble and clicking it opens the full-page chat", async ({ + test("an unread Coach nudge paints the FAB dot and clicking the FAB opens the full-page chat", async ({ page, }) => { - // The bubble contract is viewport-independent — the test runs on + // The FAB contract is viewport-independent — the test runs on // both projects. Override the default nudge mock with an unread // one (later registration wins). await page.route("**/api/insights/coach/nudge-status*", (route) => @@ -221,9 +226,15 @@ test.describe("Coach launch surfaces on insights sub-pages", () => { await page.goto("/insights/blutdruck", { waitUntil: "domcontentloaded" }); - const bubble = page.locator('[data-slot="coach-nudge-bubble"]'); - await expect(bubble).toBeVisible({ timeout: 10_000 }); - await bubble.click(); + const fab = page.locator('[data-slot="coach-fab"]'); + await expect(fab).toBeVisible({ timeout: 10_000 }); + await expect(fab).toHaveAttribute("data-unread", "true", { + timeout: 10_000, + }); + await expect( + page.locator('[data-slot="coach-fab-unread"]'), + ).toBeVisible(); + await fab.click(); await page.waitForURL("**/insights/coach", { timeout: 10_000 }); await expect( diff --git a/messages/de.json b/messages/de.json index b35371b88..9850095b8 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1667,7 +1667,6 @@ "charts": { "loadingLabel": "Diagramm wird geladen…", "loadingSlowHint": "Auswertungen werden berechnet — das kann einen Moment dauern.", - "adjustTargets": "Zielwerte anpassen", "weight": "Gewicht", "bloodPressure": "Blutdruck", "pulse": "Puls", @@ -2036,6 +2035,7 @@ "thinking": "Denke nach…", "composerPlaceholder": "Frag mich etwas zu deinen Daten…", "nudgeBubbleLabel": "Neue Nachricht vom Coach — Chat öffnen", + "fabLabel": "Coach öffnen", "selfContextAdopt": { "offer": "Antwort in deine Selbstauskunft übernehmen?", "confirm": "Übernehmen", diff --git a/messages/en.json b/messages/en.json index af05ebee5..27f63a816 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1667,7 +1667,6 @@ "charts": { "loadingLabel": "Loading chart…", "loadingSlowHint": "Computing analytics — this can take a moment.", - "adjustTargets": "Adjust targets", "weight": "Weight", "bloodPressure": "Blood Pressure", "pulse": "Pulse", @@ -2036,6 +2035,7 @@ "thinking": "Thinking…", "composerPlaceholder": "Ask anything about your data…", "nudgeBubbleLabel": "New message from the Coach — open chat", + "fabLabel": "Open the Coach", "selfContextAdopt": { "offer": "Add this answer to your self-context?", "confirm": "Add", diff --git a/messages/es.json b/messages/es.json index 869f354f4..f34d8725c 100644 --- a/messages/es.json +++ b/messages/es.json @@ -1667,7 +1667,6 @@ "charts": { "loadingLabel": "Cargando la gráfica…", "loadingSlowHint": "Calculando los análisis — puede tardar un momento.", - "adjustTargets": "Ajustar objetivos", "weight": "Peso", "bloodPressure": "Tensión arterial", "pulse": "Pulso", @@ -2036,6 +2035,7 @@ "thinking": "Pensando…", "composerPlaceholder": "Pregúntame sobre tus datos…", "nudgeBubbleLabel": "Nuevo mensaje del coach — abrir el chat", + "fabLabel": "Abrir el coach", "selfContextAdopt": { "offer": "¿Añadir esta respuesta a tu información personal?", "confirm": "Añadir", diff --git a/messages/fr.json b/messages/fr.json index 8d6eda75c..2bd929dc7 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -1667,7 +1667,6 @@ "charts": { "loadingLabel": "Chargement du graphique…", "loadingSlowHint": "Calcul des analyses — cela peut prendre un instant.", - "adjustTargets": "Ajuster les valeurs cibles", "weight": "Poids", "bloodPressure": "Tension artérielle", "pulse": "Pouls", @@ -2036,6 +2035,7 @@ "thinking": "Réflexion en cours…", "composerPlaceholder": "Pose-moi une question sur tes données…", "nudgeBubbleLabel": "Nouveau message du coach — ouvrir le chat", + "fabLabel": "Ouvrir le coach", "selfContextAdopt": { "offer": "Ajouter cette réponse à vos informations personnelles ?", "confirm": "Ajouter", diff --git a/messages/it.json b/messages/it.json index 9162e2ef0..0425b7449 100644 --- a/messages/it.json +++ b/messages/it.json @@ -1667,7 +1667,6 @@ "charts": { "loadingLabel": "Caricamento del grafico…", "loadingSlowHint": "Calcolo delle analisi — può richiedere un momento.", - "adjustTargets": "Regola i valori target", "weight": "Peso", "bloodPressure": "Pressione arteriosa", "pulse": "Pulsazioni", @@ -2036,6 +2035,7 @@ "thinking": "Sto pensando…", "composerPlaceholder": "Chiedimi qualcosa sui tuoi dati…", "nudgeBubbleLabel": "Nuovo messaggio dal coach — apri la chat", + "fabLabel": "Apri il coach", "selfContextAdopt": { "offer": "Aggiungere questa risposta alle tue informazioni personali?", "confirm": "Aggiungi", diff --git a/messages/pl.json b/messages/pl.json index f1bdaca93..60672612c 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -1667,7 +1667,6 @@ "charts": { "loadingLabel": "Ładowanie wykresu…", "loadingSlowHint": "Obliczanie analiz — to może chwilę potrwać.", - "adjustTargets": "Dostosuj wartości docelowe", "weight": "Waga", "bloodPressure": "Ciśnienie tętnicze", "pulse": "Tętno", @@ -2036,6 +2035,7 @@ "thinking": "Myślę…", "composerPlaceholder": "Zapytaj o swoje dane…", "nudgeBubbleLabel": "Nowa wiadomość od trenera — otwórz czat", + "fabLabel": "Otwórz trenera", "selfContextAdopt": { "offer": "Dodać tę odpowiedź do Twoich informacji o sobie?", "confirm": "Dodaj", diff --git a/src/app/insights/blood-pressure/page.tsx b/src/app/insights/blood-pressure/page.tsx index f5f6d3b9a..7cb330f84 100644 --- a/src/app/insights/blood-pressure/page.tsx +++ b/src/app/insights/blood-pressure/page.tsx @@ -160,7 +160,6 @@ export default function InsightsBlutdruckPage() { unit="mmHg" yAxisUnit="mmHg" targetZones={bpTargetZones} - targetSettingsHref="/settings/thresholds" compareBaseline={compareBaseline} userTimezone={user?.timezone} onVisibleStats={onVisibleStats} diff --git a/src/app/insights/layout.tsx b/src/app/insights/layout.tsx index 5d7a83631..e1150ee07 100644 --- a/src/app/insights/layout.tsx +++ b/src/app/insights/layout.tsx @@ -1,7 +1,6 @@ import type { ReactNode } from "react"; import { InsightsLayoutShell } from "@/components/insights/insights-layout-shell"; -import { LayoutCoachFab } from "@/components/insights/layout-coach-fab"; /** * v1.4.25 W4 — shared layout for the routed `/insights` sub-pages. @@ -18,25 +17,16 @@ import { LayoutCoachFab } from "@/components/insights/layout-coach-fab"; * the mother `/insights/page.tsx` consumes the same hook for its hero * strip + suggested-prompt chips. * - * v1.4.28 R3c — the mobile FAB also lives here via `` - * (carved out of `` so each sub-page mount no - * longer duplicates the FAB into the a11y tree). The inline - * `` pill stays on the per-page action rows where - * copy + position matter. - * * v1.4.34 IW-B — `` + `` now * live on the global `` (`src/components/layout/auth-shell.tsx`) * so every authenticated route (dashboard included) can open the drawer - * from the same context. The FAB stays scoped to `/insights/**` only — - * the floating action would distract from the dashboard hero on - * surfaces where a contextual inline button already covers the same - * affordance. + * from the same context. + * + * v1.16.8 — the floating Coach launcher (`LayoutCoachFab`) moved to + * `` as well: it renders once for every authenticated route + * and hides itself on the Coach page, so this layout no longer mounts + * any Coach surface of its own. */ export default function InsightsLayout({ children }: { children: ReactNode }) { - return ( - <> - {children} - - - ); + return {children}; } diff --git a/src/app/insights/weight/page.tsx b/src/app/insights/weight/page.tsx index c1b946e72..b31c56ede 100644 --- a/src/app/insights/weight/page.tsx +++ b/src/app/insights/weight/page.tsx @@ -106,7 +106,6 @@ export default function InsightsGewichtPage() { colors={["#bd93f9"]} unit="kg" valueBands={weightBands} - targetSettingsHref="/settings/thresholds" compareBaseline={compareBaseline} userTimezone={user?.timezone} onVisibleStats={onVisibleStats} diff --git a/src/components/charts/__tests__/health-chart-target-link-removed.test.ts b/src/components/charts/__tests__/health-chart-target-link-removed.test.ts new file mode 100644 index 000000000..69b884356 --- /dev/null +++ b/src/components/charts/__tests__/health-chart-target-link-removed.test.ts @@ -0,0 +1,46 @@ +/** + * v1.16.8 — the in-header "adjust targets" link is gone from the chart + * card. The affordance duplicated the page-level target-adjust control + * in the insights sub-page header (``); the chart + * stays a read surface. Pinned at the source level (same convention as + * the auth-shell hoist contract) so the link doesn't quietly return on + * a future header rework. + */ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +import { describe, expect, it } from "vitest"; + +const HEALTH_CHART_PATH = resolve(__dirname, "..", "health-chart.tsx"); +const WEIGHT_PAGE_PATH = resolve( + __dirname, + "../../../app/insights/weight/page.tsx", +); +const BP_PAGE_PATH = resolve( + __dirname, + "../../../app/insights/blood-pressure/page.tsx", +); +const EN_BUNDLE_PATH = resolve(__dirname, "../../../../messages/en.json"); + +describe("v1.16.8 — chart-card target-adjust link removed", () => { + it("ships no targetSettingsHref prop or header link in the chart", () => { + const source = readFileSync(HEALTH_CHART_PATH, "utf8"); + expect(source).not.toContain("targetSettingsHref"); + expect(source).not.toContain("chart-target-settings-link"); + expect(source).not.toContain("charts.adjustTargets"); + }); + + it("weight and blood-pressure pages no longer pass the link href", () => { + for (const path of [WEIGHT_PAGE_PATH, BP_PAGE_PATH]) { + const source = readFileSync(path, "utf8"); + expect(source).not.toContain("targetSettingsHref"); + } + }); + + it("the orphaned locale key is gone", () => { + const bundle = JSON.parse(readFileSync(EN_BUNDLE_PATH, "utf8")) as { + charts: Record; + }; + expect(bundle.charts.adjustTargets).toBeUndefined(); + }); +}); diff --git a/src/components/charts/health-chart.tsx b/src/components/charts/health-chart.tsx index c8c6be1d7..8814bb555 100644 --- a/src/components/charts/health-chart.tsx +++ b/src/components/charts/health-chart.tsx @@ -18,8 +18,6 @@ import { ReferenceArea, ReferenceDot, } from "recharts"; -import { SlidersHorizontal } from "lucide-react"; -import Link from "next/link"; import { useState, useMemo, @@ -111,15 +109,6 @@ interface HealthChartProps { textColor?: string; lineOpacity?: number; }>; - /** - * Optional href for a small "adjust targets" link in the chart - * header. Callers whose charts paint a target band / zones (weight, - * blood pressure) pass `/settings/thresholds` so the band's origin — - * and the page that edits personal target ranges — is one click - * away. Omitted (every other mount) renders no link. Ignored in - * `mini` mode, which has no header. - */ - targetSettingsHref?: string; /** * v1.4.16 phase B5c — compact chart mode used by the Oura-style * rationale card. Drops the range tabs, the moving-average / trend @@ -537,7 +526,6 @@ export function HealthChart({ showYAxisUnit = true, valueBands, targetZones, - targetSettingsHref, mini = false, windowOverride, compareBaseline = "none", @@ -1471,19 +1459,10 @@ export function HealthChart({ )} )} - {/* Discoverability link from the painted target band to the - page that edits personal target ranges. Only mounts when - a caller passes `targetSettingsHref` (weight + BP). */} - {targetSettingsHref ? ( - -