From 4a0ad1fc64b5a381675fa5df9ebc06d1ac9572b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 2 Jun 2026 16:19:59 +0200 Subject: [PATCH 01/30] fix(medications): restore equal-height cards and aligned dose rows The v1.8.6 compliance rework left the last/next-dose and compliance rows in a plain vertical stack with independently conditional rows. A card missing its last-dose line (never taken) or its streak flame (zero streak) rendered shorter, so the dose rows and action buttons drifted out of alignment across the two-column grid. Pin the layout on both card variants identically: the Card fills the stretched grid cell with h-full, the CardContent becomes a flex column, the next/last-dose pair sits in a reserved min-height slot so a missing line keeps its footprint, and the intake-actions block is pushed to the bottom with mt-auto for a shared action baseline. Generic and GLP-1 cards now align row-for-row regardless of which optional rows render. Co-Authored-By: Claude Opus 4.8 (1M context) (cherry picked from commit a7e541eeb5123abb63514f56d12b93b0023ce417) --- .../medications/glp1-medication-card.tsx | 28 ++-- .../medications/medication-card.tsx | 147 ++++++++++-------- 2 files changed, 99 insertions(+), 76 deletions(-) diff --git a/src/components/medications/glp1-medication-card.tsx b/src/components/medications/glp1-medication-card.tsx index 8a07ef2c9..50d6687c6 100644 --- a/src/components/medications/glp1-medication-card.tsx +++ b/src/components/medications/glp1-medication-card.tsx @@ -5,6 +5,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { Card, CardContent } from "@/components/ui/card"; +import { cn } from "@/lib/utils"; import { MedicationCardHeader } from "@/components/medications/MedicationCardHeader"; import { MedicationCardMenu } from "@/components/medications/medication-card-menu"; import { MedicationStateBadges } from "@/components/medications/card-parts/medication-state-badges"; @@ -394,7 +395,7 @@ export function Glp1MedicationCard({ const categoryLabel = getMedicationCategoryLabel(medication.category, t); return ( - + - + {/* Take-now / overdue / very-overdue pill, shared with the generic medication card. The GLP-1 card historically omitted this row, which made Mounjaro feel different from @@ -419,8 +420,12 @@ export function Glp1MedicationCard({ /> )} - {/* Injection state — last + next */} -
+ {/* Injection state — last + next. The reserved min-height keeps a + card whose last-injection line is absent the same vertical + footprint as one that renders both lines, so the compliance bars + and action row below align row-for-row across the grid — matching + the generic medication card's reserved last/next slot. */} +
{medication.lastTakenAt && (

{lastSite @@ -493,12 +498,17 @@ export function Glp1MedicationCard({ {/* Primary actions row — shared with the generic medication card. The GLP-1-specific side-effect quick-log lives in the header-actions overflow (kebab), not this row, so Mounjaro - and Ramipril share the canonical two-button primary row. */} + and Ramipril share the canonical two-button primary row. + Pinned to the bottom with `mt-auto` so the action baseline is + shared across every card in the grid row, identical to the + generic medication card. */} {medication.active && ( - +

+ +
)} diff --git a/src/components/medications/medication-card.tsx b/src/components/medications/medication-card.tsx index c4500a1ff..26a2c9a11 100644 --- a/src/components/medications/medication-card.tsx +++ b/src/components/medications/medication-card.tsx @@ -4,6 +4,7 @@ import { useState, useEffect, useReducer } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { Card, CardContent } from "@/components/ui/card"; +import { cn } from "@/lib/utils"; import { MedicationCardHeader } from "@/components/medications/MedicationCardHeader"; import { MedicationCardMenu } from "@/components/medications/medication-card-menu"; import { MedicationStateBadges } from "@/components/medications/card-parts/medication-state-badges"; @@ -299,7 +300,7 @@ export function MedicationCard({ ); return ( - + - + {/* Status, last & next intake info */} {currentWindowStatus.status && ( )} - {nextSchedule && - currentWindowStatus.status !== "in_window" && - (() => { - const s = nextSchedule; + {/* Next / last intake — wrapped in a fixed min-height slot so a + card missing its last-dose line keeps the same vertical + footprint as a sibling that renders both lines. Without the + reserved height the compliance bars and action row below would + start at a different Y card-to-card across the 2-col grid. */} +
+ {nextSchedule && + currentWindowStatus.status !== "in_window" && + (() => { + const s = nextSchedule; - // Format day label relative to today - let dayLabel = ""; - if (nextAt) { - const nextDate = toBerlinDate(new Date(nextAt)); - const todayStr = `${nowBerlin.getFullYear()}-${nowBerlin.getMonth()}-${nowBerlin.getDate()}`; - const nextStr = `${nextDate.getFullYear()}-${nextDate.getMonth()}-${nextDate.getDate()}`; - const tomorrow = new Date(nowBerlin); - tomorrow.setDate(tomorrow.getDate() + 1); - const tomorrowStr = `${tomorrow.getFullYear()}-${tomorrow.getMonth()}-${tomorrow.getDate()}`; + // Format day label relative to today + let dayLabel = ""; + if (nextAt) { + const nextDate = toBerlinDate(new Date(nextAt)); + const todayStr = `${nowBerlin.getFullYear()}-${nowBerlin.getMonth()}-${nowBerlin.getDate()}`; + const nextStr = `${nextDate.getFullYear()}-${nextDate.getMonth()}-${nextDate.getDate()}`; + const tomorrow = new Date(nowBerlin); + tomorrow.setDate(tomorrow.getDate() + 1); + const tomorrowStr = `${tomorrow.getFullYear()}-${tomorrow.getMonth()}-${tomorrow.getDate()}`; - const diffDays = Math.round( - (nextDate.getTime() - nowBerlin.getTime()) / - (24 * 60 * 60 * 1000), - ); + const diffDays = Math.round( + (nextDate.getTime() - nowBerlin.getTime()) / + (24 * 60 * 60 * 1000), + ); - if (nextStr === todayStr) { - dayLabel = t("medications.today"); - } else if (nextStr === tomorrowStr) { - dayLabel = t("medications.tomorrow"); - } else if (diffDays <= 5) { - const weekdayLabels = [ - t("medications.weekdaySunday"), - t("medications.weekdayMonday"), - t("medications.weekdayTuesday"), - t("medications.weekdayWednesday"), - t("medications.weekdayThursday"), - t("medications.weekdayFriday"), - t("medications.weekdaySaturday"), - ]; - dayLabel = weekdayLabels[nextDate.getDay()]; - } else { - dayLabel = fmt.dateWithWeekday(nextDate); + if (nextStr === todayStr) { + dayLabel = t("medications.today"); + } else if (nextStr === tomorrowStr) { + dayLabel = t("medications.tomorrow"); + } else if (diffDays <= 5) { + const weekdayLabels = [ + t("medications.weekdaySunday"), + t("medications.weekdayMonday"), + t("medications.weekdayTuesday"), + t("medications.weekdayWednesday"), + t("medications.weekdayThursday"), + t("medications.weekdayFriday"), + t("medications.weekdaySaturday"), + ]; + dayLabel = weekdayLabels[nextDate.getDay()]; + } else { + dayLabel = fmt.dateWithWeekday(nextDate); + } } - } - return ( -

- - {t("medications.nextIntake")} - {" "} - {dayLabel && `${dayLabel}, `} - {formatTimeWindowRange(s.windowStart, s.windowEnd, locale)} - {s.label && ( - ({s.label}) - )} - {s.dose && ( - - {" "} - — {s.dose} - - )} -

- ); - })()} + return ( +

+ + {t("medications.nextIntake")} + {" "} + {dayLabel && `${dayLabel}, `} + {formatTimeWindowRange(s.windowStart, s.windowEnd, locale)} + {s.label && ( + ({s.label}) + )} + {s.dose && ( + + {" "} + — {s.dose} + + )} +

+ ); + })()} - {medication.lastTakenAt && ( -

- {t("medications.lastIntake")}{" "} - {formatLastTakenAt(medication.lastTakenAt)} -

- )} + {medication.lastTakenAt && ( +

+ {t("medications.lastIntake")}{" "} + {formatLastTakenAt(medication.lastTakenAt)} +

+ )} +
{/* Compliance bars — always two rows. The server scales the two windows to the dosing cadence (7 / 30 days for dense meds, @@ -401,12 +409,17 @@ export function MedicationCard({ /> )} - {/* Quick actions — primary buttons of the medication card. */} + {/* Quick actions — primary buttons of the medication card. + Pinned to the bottom with `mt-auto` so the action baseline is + shared across every card in the grid row regardless of how many + optional rows above it rendered. */} {medication.active && ( - +
+ +
)}
From 2c6a9f8165fe3d58ea096184146b261aa8605c3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 2 Jun 2026 16:21:45 +0200 Subject: [PATCH 02/30] fix(insights): decouple the forced warm sections so a slow comprehensive step cannot starve the rest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The on-demand force-warm ran the comprehensive insight first and only ran the per-status (7) and generic-metric (~30) assessment passes when that step returned generated or cached. A slow or stalled comprehensive generation therefore aborted the entire warm — observed in production as a 102 s run ending with assessments_warmed: 0 — leaving every card cold and the user back on the lazy 30-60 s on-first-click path. Give the comprehensive step its own bounded budget and treat a timeout, error, or skip as non-fatal: the per-status and generic-metric passes now always run afterwards. Each per-card generator resolves its own provider chain and bounds its own provider call, so running them after a failed comprehensive costs a cheap chain-resolve when no provider is configured and is capped per card otherwise. The result reports each section's outcome independently, and a timed-out comprehensive is reported distinctly from a failed one so a partial warm is observable. Co-Authored-By: Claude Opus 4.8 (1M context) (cherry picked from commit f52f8e9c3b32590f3b085d082418ebdd3a923f1d) --- .../__tests__/insight-pregenerate.test.ts | 94 ++++++++++++++++++- src/lib/jobs/insight-pregenerate.ts | 84 +++++++++++++---- 2 files changed, 157 insertions(+), 21 deletions(-) diff --git a/src/lib/jobs/__tests__/insight-pregenerate.test.ts b/src/lib/jobs/__tests__/insight-pregenerate.test.ts index 572f28e50..83c478848 100644 --- a/src/lib/jobs/__tests__/insight-pregenerate.test.ts +++ b/src/lib/jobs/__tests__/insight-pregenerate.test.ts @@ -415,15 +415,22 @@ describe("forceWarmUser — on-demand single-user warm (v1.8.7.1)", () => { }); }); - it("does NOT warm the per-status or generic caches when the comprehensive pass skipped (no provider)", async () => { + 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). const statusGenerators = Array.from({ length: 7 }, () => vi.fn().mockResolvedValue({ hasProvider: false, cached: true }), ); - const warmGenericMetrics = vi.fn().mockResolvedValue(5); + const warmGenericMetrics = vi.fn().mockResolvedValue(0); const result = await forceWarmUser(prisma as never, "u1", "de", { generate, @@ -432,9 +439,10 @@ describe("forceWarmUser — on-demand single-user warm (v1.8.7.1)", () => { }); for (const g of statusGenerators) { - expect(g).not.toHaveBeenCalled(); + expect(g).toHaveBeenCalledTimes(1); + expect(g).toHaveBeenCalledWith("u1", { locale: "de", force: true }); } - expect(warmGenericMetrics).not.toHaveBeenCalled(); + expect(warmGenericMetrics).toHaveBeenCalledWith("u1", ["de"]); expect(result).toMatchObject({ comprehensive: "skipped", assessmentsWarmed: 0, @@ -442,6 +450,84 @@ describe("forceWarmUser — on-demand single-user warm (v1.8.7.1)", () => { }); }); + 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([]); + const generate = vi + .fn() + .mockResolvedValue({ status: "failed", reason: "all-providers-failed" }); + const statusGenerators = Array.from({ length: 7 }, () => + vi.fn().mockResolvedValue({ hasProvider: true, cached: false }), + ); + const warmGenericMetrics = vi.fn().mockResolvedValue(4); + + const result = await forceWarmUser(prisma as never, "u1", "de", { + generate, + statusGenerators, + warmGenericMetrics, + }); + + for (const g of statusGenerators) { + expect(g).toHaveBeenCalledTimes(1); + } + expect(warmGenericMetrics).toHaveBeenCalledWith("u1", ["de"]); + expect(result).toMatchObject({ + comprehensive: "failed", + 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`. + const { prisma } = makePrisma([]); + const generate = vi.fn().mockImplementation( + () => + new Promise((resolve) => { + // Never settles within the bounded budget; the fake timers below + // advance past it so the warm continues. + setTimeout( + () => resolve({ status: "generated", providerType: "x" }), + 120_000, + ); + }), + ); + const statusGenerators = Array.from({ length: 7 }, () => + vi.fn().mockResolvedValue({ hasProvider: true, cached: false }), + ); + const warmGenericMetrics = vi.fn().mockResolvedValue(3); + + vi.useFakeTimers(); + try { + const promise = forceWarmUser(prisma as never, "u1", "de", { + generate, + statusGenerators, + warmGenericMetrics, + }); + // Advance past the comprehensive budget so withTimeout fires. + await vi.advanceTimersByTimeAsync(60_000); + const result = await promise; + + for (const g of statusGenerators) { + expect(g).toHaveBeenCalledTimes(1); + } + expect(warmGenericMetrics).toHaveBeenCalledWith("u1", ["de"]); + expect(result).toMatchObject({ + comprehensive: "timeout", + assessmentsWarmed: 7, + metricAssessmentsWarmed: 3, + }); + } finally { + vi.useRealTimers(); + } + }); + it("short-circuits to a no-op when the briefing surface is disabled globally", async () => { getAssistantFlags.mockResolvedValue({ enabled: false, briefing: false }); const { prisma } = makePrisma([]); diff --git a/src/lib/jobs/insight-pregenerate.ts b/src/lib/jobs/insight-pregenerate.ts index 780dc3037..c189c4171 100644 --- a/src/lib/jobs/insight-pregenerate.ts +++ b/src/lib/jobs/insight-pregenerate.ts @@ -68,6 +68,7 @@ import { getMetricStatusMeta, type MetricStatusMetricId, } from "@/lib/insights/metric-status-registry"; +import { withTimeout } from "@/lib/insights/with-timeout"; import { INSIGHT_PREGENERATE_QUEUE, enqueueForceWarm, @@ -109,10 +110,28 @@ const PREGENERATE_BUDGET_WINDOW_MS = 20 * 60 * 60 * 1000; */ const WARM_PASS_CONCURRENCY = 3; +/** + * Bounded budget for the comprehensive step of a forced full warm. The + * comprehensive insight is the single heaviest generation (full feature + * extraction + a 1500-token completion) and a slow or stalled provider + * could pin the whole warm for a minute-plus, after which the entire job + * aborted with the per-status + generic-metric passes never run. Capping + * it lets the forced path abandon a slow comprehensive and still warm the + * cheaper per-status (7) + generic-metric (~30) assessments, so the user's + * cards are warm even when the briefing generation stalls. Set a hair + * below the queue's own retry horizon so a single tick stays bounded. + */ +const FORCE_WARM_COMPREHENSIVE_TIMEOUT_MS = 45_000; + /** Per-user result of one forced full warm. */ export interface ForceWarmResult { - /** Outcome of the comprehensive-insight generation. */ - comprehensive: GenerateOutcome["status"]; + /** + * Outcome of the comprehensive-insight generation. `"timeout"` is + * distinct from `"failed"`: the comprehensive step exceeded its own + * bounded budget (see `FORCE_WARM_COMPREHENSIVE_TIMEOUT_MS`) and was + * abandoned so the per-status + generic-metric passes could still run. + */ + comprehensive: GenerateOutcome["status"] | "timeout"; /** Count of specialised per-status assessments written warm. */ assessmentsWarmed: number; /** Count of generic per-HealthKit-metric assessments written warm. */ @@ -493,11 +512,20 @@ export async function runInsightPregenerate( * the queue `singletonKey`. * * Unlike the nightly cron (de+en), the forced path warms only the caller's - * active `locale` so a single-locale user pays half the provider cost. A - * `failed`/`skipped` comprehensive outcome short-circuits the per-status - * warm exactly as the cron does — re-running seven (then ~30) generators - * against an unhealthy or provider-less chain would only multiply the - * failure. + * active `locale` so a single-locale user pays half the provider cost. + * + * The three sections are decoupled: the comprehensive step runs under its + * own bounded budget (`FORCE_WARM_COMPREHENSIVE_TIMEOUT_MS`) and its + * failure / timeout / skip is non-fatal — the per-status (7) and + * generic-metric (~30) warm passes ALWAYS run afterwards. The earlier + * design short-circuited both passes on a non-`generated`/`cached` + * comprehensive, so a single slow briefing generation left every card cold + * and the user back on the lazy 30–60 s on-first-click path. The per-card + * generators each resolve their own provider chain and bound their own + * provider call, so a missing provider costs a cheap chain-resolve and a + * degraded one is capped per card — running them after a failed + * comprehensive can never multiply a stall. The result reports each + * section's outcome independently so a partial warm is observable. */ export async function forceWarmUser( prisma: PrismaClient, @@ -536,18 +564,40 @@ export async function forceWarmUser( const locales: ReadonlyArray<"de" | "en"> = [locale]; - const outcome = await generate(userId, { locale, force: true }); - result.comprehensive = outcome.status; - - if (outcome.status === "generated" || outcome.status === "cached") { - result.assessmentsWarmed = await warmPerStatusCaches( - userId, - locales, - statusGenerators, - ); - result.metricAssessmentsWarmed = await warmGenericMetrics(userId, locales); + // The comprehensive step gets its own bounded budget and its failure is + // non-fatal. A slow or stalled briefing generation must not short-circuit + // the per-status + generic-metric passes — those are the ~37 cards the + // 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. + const comprehensive = await withTimeout( + () => generate(userId, { locale, force: true }), + FORCE_WARM_COMPREHENSIVE_TIMEOUT_MS, + null, + ); + if (comprehensive.timedOut) { + result.comprehensive = "timeout"; + } else if (comprehensive.errored || comprehensive.value === null) { + result.comprehensive = "failed"; + } else { + result.comprehensive = comprehensive.value.status; } + // Always run the per-status + generic-metric warm passes, regardless of + // the comprehensive outcome. Each generator independently no-ops to its + // no-key fallback when no provider is configured (no LLM call) and serves + // its fallback without persisting on a per-card timeout, so running them + // after a failed comprehensive is safe and bounded by their own per-card + // budgets. The only outcome that has nothing to warm is a missing provider, + // and the generators detect that themselves at near-zero cost. + result.assessmentsWarmed = await warmPerStatusCaches( + userId, + locales, + statusGenerators, + ); + result.metricAssessmentsWarmed = await warmGenericMetrics(userId, locales); + annotate({ action: { name: "insights.pregenerate.force" }, meta: { From f1145c0e9163868dfa4bf302dc5d1f28b8f5f8cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 2 Jun 2026 16:21:53 +0200 Subject: [PATCH 03/30] fix(insights): debounce ingest-driven status invalidation to coalesce the regeneration storm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A constantly-syncing client drips measurement batches every few minutes. Every batch deleted the dirtied per-metric assessment cache rows and re-enqueued a regeneration. The per-(user,scope,locale) enqueue singleton is only 120 s, so it could not coalesce across batches that arrive minutes apart, and each delete dropped the row a fresh enqueue would otherwise have found warm — so a metric card regenerated several times an hour and its assessment felt regenerated on every visit. Before evicting and re-enqueuing, check whether the scope's cached assessment was already (re)generated within a 30 min debounce window. A scope that is fresh is left intact and not re-enqueued; a genuinely stale or missing scope still refreshes immediately, so correctness holds. A timeout stub never counts as fresh, so a scope that recently stalled still retries. One indexed read per user answers it for every candidate scope at once. Co-Authored-By: Claude Opus 4.8 (1M context) (cherry picked from commit 024baea664490ef0494add78c87492f01e61c6b9) --- .../invalidate-status-insights.test.ts | 71 ++++++- src/lib/insights/comprehensive-generate.ts | 197 ++++++++++++++---- 2 files changed, 222 insertions(+), 46 deletions(-) diff --git a/src/lib/insights/__tests__/invalidate-status-insights.test.ts b/src/lib/insights/__tests__/invalidate-status-insights.test.ts index c6c38c9e8..8ba5550e5 100644 --- a/src/lib/insights/__tests__/invalidate-status-insights.test.ts +++ b/src/lib/insights/__tests__/invalidate-status-insights.test.ts @@ -11,12 +11,16 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; const deleteMany = vi.fn(); +const auditFindMany = vi.fn(); const enqueueStatusGeneration = vi.fn(); const userFindUnique = vi.fn(); vi.mock("@/lib/db", () => ({ prisma: { - auditLog: { deleteMany: (...a: unknown[]) => deleteMany(...a) }, + auditLog: { + deleteMany: (...a: unknown[]) => deleteMany(...a), + findMany: (...a: unknown[]) => auditFindMany(...a), + }, user: { findUnique: (...a: unknown[]) => userFindUnique(...a) }, }, })); @@ -30,6 +34,9 @@ import { invalidateStatusInsightsForTypes } from "../comprehensive-generate"; beforeEach(() => { vi.clearAllMocks(); deleteMany.mockResolvedValue({ count: 0 }); + // Default: no recently-warmed cache rows, so every dirtied scope is stale + // and refreshes — the pre-debounce contract the bulk of these tests pin. + auditFindMany.mockResolvedValue([]); enqueueStatusGeneration.mockResolvedValue(undefined); userFindUnique.mockResolvedValue({ locale: "de" }); }); @@ -176,4 +183,66 @@ describe("invalidateStatusInsightsForTypes", () => { enqueuedScopes().some((s) => s.startsWith("metric:")), ).toBe(false); }); + + describe("ingest-invalidation debounce (v1.9.0)", () => { + /** Build a recent (within-window) real assessment cache row. */ + function freshRow(scope: string, locale = "de") { + return { + action: `insights.${scope}-status.${locale}`, + details: JSON.stringify({ text: "fresh assessment", model: "gpt" }), + }; + } + + 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. + 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 () => { + auditFindMany.mockResolvedValue([ + freshRow("weight"), + freshRow("bmi"), + freshRow("general"), + ]); + await invalidateStatusInsightsForTypes("u1", ["WEIGHT"]); + expect(deleteMany).not.toHaveBeenCalled(); + expect(enqueueStatusGeneration).not.toHaveBeenCalled(); + }); + + it("treats a recent timeout stub as not-fresh and still refreshes the scope", async () => { + // A stub carries no real assessment, so a scope that recently stalled + // must retry rather than be debounced into staying cold. + auditFindMany.mockResolvedValue([ + { + action: "insights.general-status.de", + details: JSON.stringify({ model: "timeout-stub", text: "stub" }), + }, + ]); + 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 () => { + userFindUnique.mockResolvedValue({ locale: "en" }); + await invalidateStatusInsightsForTypes("u1", ["WEIGHT"]); + const where = auditFindMany.mock.calls[0][0].where; + expect(where.userId).toBe("u1"); + // Only the en cache actions for the dirtied scopes are probed. + expect(new Set(where.action.in)).toEqual( + new Set([ + "insights.weight-status.en", + "insights.bmi-status.en", + "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()); + }); + }); }); diff --git a/src/lib/insights/comprehensive-generate.ts b/src/lib/insights/comprehensive-generate.ts index aaa54229b..7b6705532 100644 --- a/src/lib/insights/comprehensive-generate.ts +++ b/src/lib/insights/comprehensive-generate.ts @@ -47,15 +47,42 @@ import { runRawCompletionWithFallback, } from "@/lib/ai/provider-runner"; import { invalidateUserInsights } from "@/lib/cache/invalidate"; -import { enqueueStatusGeneration } from "@/lib/jobs/insight-status-generate-shared"; +import { + enqueueStatusGeneration, + type InsightStatusScope, +} from "@/lib/jobs/insight-status-generate-shared"; import { normalizeLocale } from "@/lib/insights/status-shared"; +import { isTimeoutStub } from "@/lib/insights/status-cache"; import { metricIdForMeasurementType, metricStatusScope, } from "@/lib/insights/metric-status-registry"; +import { annotate } from "@/lib/logging/context"; const CACHE_TTL_MS = 24 * 60 * 60 * 1000; +/** + * Debounce window for ingest-driven status invalidation. A scope whose + * cached assessment was (re)generated within this window is left intact on + * a fresh measurement ingest — neither evicted nor re-enqueued. + * + * A constantly-syncing client (Apple Health drips batches every few + * minutes) used to delete + re-enqueue every dirtied scope on every batch, + * and because each delete dropped the cache row the per-(user,scope,locale) + * enqueue singleton (120 s) could not coalesce across batches that arrive + * minutes apart — so STEPS / general / a metric card regenerated several + * 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. + * + * 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. + */ +const INGEST_INVALIDATE_DEBOUNCE_MS = 30 * 60 * 1000; + const MAX_DOWNGRADE_TOKENS: ReadonlyArray = [ "anthropometrics", "medications", @@ -221,52 +248,26 @@ export async function invalidateStatusInsightsForTypes( userId: string, types: Iterable, ): Promise { - const scopes = new Set(); - // v1.8.7.1 — a fresh measurement of a type that backs a generic - // HealthKit assessment card (the ~30 registry metrics) also dirties that - // card's `metric:` scope. Collect those alongside the seven - // specialised scopes so the generic tier is invalidated + re-warmed on - // ingest with the same debounced enqueue, rather than lagging until the - // nightly cron. Only registered, data-bearing types map here; the seven - // specialised metrics and any unregistered type resolve to null and are - // skipped, so the constant sync cannot fan out to unwanted scopes. - const metricScopes = new Set<`metric:${string}`>(); + // One ordered set of every scope the batch dirties. The seven specialised + // scopes are bare slugs (`weight`, `general`, …); the generic HealthKit + // cards (v1.8.7.1) carry a `metric:` prefix. Both share the cache-key + // shape `insights.-status.`, so a single set covers the + // eviction + the debounce filter + the enqueue uniformly. Only registered, + // data-bearing types contribute a generic scope; the seven specialised + // metrics and any unregistered type resolve to null and are skipped, so + // the constant sync cannot fan out to unwanted scopes. + const scopes = new Set(); for (const type of types) { for (const scope of statusScopesForMeasurementType(type)) { scopes.add(scope); } const metricId = metricIdForMeasurementType(type); if (metricId) { - metricScopes.add(metricStatusScope(metricId)); + scopes.add(metricStatusScope(metricId)); } } - if (scopes.size === 0 && metricScopes.size === 0) return; + if (scopes.size === 0) return; - await prisma.auditLog.deleteMany({ - where: { - userId, - OR: [ - ...Array.from(scopes, (scope) => ({ - action: { startsWith: `insights.${scope}-status.` }, - })), - ...Array.from(metricScopes, (scope) => ({ - action: { startsWith: `insights.${scope}-status.` }, - })), - ], - }, - }); - - // 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), - // so the constant Apple-Health sync cannot fan out a storm of jobs — a - // burst of samples collapses into one regenerate per scope. - // The stale-while-revalidate read keeps the previous assessment visible - // until the fresh one lands, so the user never waits on a card. - // // 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 @@ -277,16 +278,122 @@ export async function invalidateStatusInsightsForTypes( select: { locale: true }, }); 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. + const freshScopes = await findRecentlyWarmedScopes(userId, locale, scopes); + const staleScopes = Array.from(scopes).filter( + (scope) => !freshScopes.has(scope), + ); + if (staleScopes.length === 0) { + annotate({ + action: { name: "insights.status.invalidate.debounced" }, + meta: { skipped: scopes.size, refreshed: 0 }, + }); + 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: `insights.${scope}-status.` }, + })), + }, + }); + + // 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. + for (const scope of staleScopes) { + void enqueueStatusGeneration({ userId, metric: scope, locale }); + } + + annotate({ + action: { name: "insights.status.invalidate.debounced" }, + meta: { skipped: freshScopes.size, refreshed: staleScopes.length }, + }); +} + +/** + * v1.9.0 — return the subset of `scopes` whose cached assessment for + * `locale` was generated within `INGEST_INVALIDATE_DEBOUNCE_MS` and is a + * real (non-stub) assessment. Those scopes are skipped by the ingest + * invalidator so a fresh assessment survives the sync drip. + * + * One indexed read per user (a single `findMany` over this user's recent + * status-cache rows, newest-first) answers it for every candidate scope at + * once — cheaper than a per-scope probe and bounded by `take`. A timeout + * stub never counts as fresh (it carries no real assessment), so a scope + * that recently stalled still gets a retry enqueued. + */ +async function findRecentlyWarmedScopes( + userId: string, + locale: "de" | "en", + scopes: ReadonlySet, +): Promise> { + const cutoff = new Date(Date.now() - INGEST_INVALIDATE_DEBOUNCE_MS); + // Match exactly the cache actions for the candidate scopes in this locale. + const candidateActions = Array.from( + scopes, + (scope) => `insights.${scope}-status.${locale}`, + ); + const actionToScope = new Map(); for (const scope of scopes) { - void enqueueStatusGeneration({ userId, metric: scope, locale }); + actionToScope.set(`insights.${scope}-status.${locale}`, scope); } - // v1.8.7.1 — re-warm the matching generic metric scopes the same way, so - // a freshly-synced HealthKit metric's assessment card refreshes in the - // background instead of lagging until the nightly warm pass. Same - // single-locale, singleton-debounced enqueue as the seven scopes above. - for (const scope of metricScopes) { - void enqueueStatusGeneration({ userId, metric: scope, locale }); + + const rows = await prisma.auditLog.findMany({ + where: { + userId, + action: { in: candidateActions }, + createdAt: { gte: cutoff }, + }, + orderBy: { createdAt: "desc" }, + select: { action: true, details: true }, + }); + + const fresh = new Set(); + for (const row of rows) { + const scope = actionToScope.get(row.action); + if (!scope || fresh.has(scope)) continue; + if (!row.details) continue; + try { + const parsed = JSON.parse(row.details) as { + model?: string; + timeout?: boolean; + text?: string; + }; + // A stub is not a real assessment — let a stalled scope retry. + if (isTimeoutStub(parsed)) continue; + if (typeof parsed.text !== "string" || parsed.text.trim().length === 0) { + continue; + } + fresh.add(scope); + } catch { + // Malformed payload — treat as not-fresh so the scope refreshes. + continue; + } } + return fresh; } interface GenerateOptions { From 03925ddece00b220087adc56313d4f1b54833429 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 2 Jun 2026 16:43:41 +0200 Subject: [PATCH 04/30] feat(insights): time-range pills with period-over-period deltas on metric pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a 7d / 30d / 90d / 1y segmented control to every HealthKit metric page, between the stat strip and the chart, with a sentiment-coloured "vs prior period" delta caption beside it. New route GET /api/analytics/range?type=&range=<7d|30d|90d|1y> returns the current-window aggregate, the previous comparable window, and the composed delta. Single metric type by construction, so the read is one rollup-tier call covering the trailing 2N days sliced into the two halves — no per-type fan-out, no Prisma-pool burst. Reuses the WMY readers (readBestGranularityRollups + aggregateWmyBuckets) and the two-window pattern the year-ago baseline already uses; count/min/max/mean/sum compose across buckets, SD/slope/r2 stay out (not composable). The cached summaries slice is untouched so the hottest dashboard cache key never fragments. The delta caption reuses the dashboard's metric-aware up-good/up-bad colour logic — extracted from trend-card into a shared trend-sentiment module — with the metric's good direction sourced from the metric-status registry (target-band metrics render neutral, matching deviation framing). When the prior window has no data the caption shows "no prior-period data" rather than a misleading 0%. The range choice persists across metrics via a localStorage seam and defaults to 30d so nothing regresses. Cache keyed per (type, range) through the query-key factory. Co-Authored-By: Claude Opus 4.8 (1M context) (cherry picked from commit eac87a9f8b144f4d8e7a8b950528a19d5dcf8726) --- docs/api/openapi.yaml | 182 ++++++++++++++++++ messages/de.json | 16 ++ messages/en.json | 16 ++ messages/es.json | 16 ++ messages/fr.json | 16 ++ messages/it.json | 16 ++ messages/pl.json | 16 ++ src/app/api/analytics/range/route.ts | 72 +++++++ src/components/charts/trend-card.tsx | 75 ++------ .../healthkit-metric-page-status.test.tsx | 7 + .../insights/healthkit-metric-page.tsx | 50 ++++- .../insights/metric-range-delta.tsx | 86 +++++++++ src/components/insights/time-range-pills.tsx | 64 ++++++ src/hooks/use-analytics-range.ts | 56 ++++++ src/hooks/use-insights-layout-prefs.ts | 55 ++++++ .../analytics/__tests__/range-delta.test.ts | 120 ++++++++++++ src/lib/analytics/range-delta.ts | 180 +++++++++++++++++ src/lib/insights/trend-sentiment.ts | 66 +++++++ src/lib/openapi/registry.ts | 1 + src/lib/openapi/routes.ts | 96 +++++++++ src/lib/query-keys.ts | 10 + 21 files changed, 1151 insertions(+), 65 deletions(-) create mode 100644 src/app/api/analytics/range/route.ts create mode 100644 src/components/insights/metric-range-delta.tsx create mode 100644 src/components/insights/time-range-pills.tsx create mode 100644 src/hooks/use-analytics-range.ts create mode 100644 src/lib/analytics/__tests__/range-delta.test.ts create mode 100644 src/lib/analytics/range-delta.ts create mode 100644 src/lib/insights/trend-sentiment.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 2e4cf533e..6395d7cab 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -30,6 +30,7 @@ tags: - name: Measurements - name: Medications - name: Mood + - name: Analytics - name: Insights - name: Dashboard - name: Notifications @@ -1954,6 +1955,86 @@ paths: "401": *a1 "422": *a2 "429": *a3 + /api/analytics/range: + get: + tags: + - Analytics + summary: Single-metric period-over-period range delta + description: v1.9.0 — returns the current-window aggregate, the previous comparable window, and the composed delta for + ONE metric type over a `7d` / `30d` / `90d` / `1y` range. Single-type by construction (the metric page is + single-metric), so the read is one rollup-tier call covering the trailing 2N days sliced into the two halves — + no per-type fan-out. Additive route; the `/api/analytics` envelope is unchanged. Auth via cookie or Bearer. + parameters: + - in: query + name: type + schema: + type: string + enum: + - WEIGHT + - BLOOD_PRESSURE_SYS + - BLOOD_PRESSURE_DIA + - PULSE + - BODY_FAT + - SLEEP_DURATION + - ACTIVITY_STEPS + - BLOOD_GLUCOSE + - TOTAL_BODY_WATER + - BONE_MASS + - OXYGEN_SATURATION + - HEART_RATE_VARIABILITY + - RESTING_HEART_RATE + - ACTIVE_ENERGY_BURNED + - FLIGHTS_CLIMBED + - WALKING_RUNNING_DISTANCE + - VO2_MAX + - BODY_TEMPERATURE + - FAT_FREE_MASS + - FAT_MASS + - MUSCLE_MASS + - SKIN_TEMPERATURE + - PULSE_WAVE_VELOCITY + - VASCULAR_AGE + - VISCERAL_FAT + - AUDIO_EXPOSURE_ENV + - AUDIO_EXPOSURE_HEADPHONE + - TIME_IN_DAYLIGHT + - WALKING_STEADINESS + - AUDIO_EXPOSURE_EVENT + - RESPIRATORY_RATE + - BODY_MASS_INDEX + - LEAN_BODY_MASS + - WALKING_HEART_RATE_AVERAGE + - WALKING_ASYMMETRY + - WALKING_DOUBLE_SUPPORT + - WALKING_STEP_LENGTH + - WALKING_SPEED + description: "The measurement type to read (single metric — no fan-out). Closed enum: an unknown type 422s." + required: true + description: "The measurement type to read (single metric — no fan-out). Closed enum: an unknown type 422s." + - in: query + name: range + schema: + type: string + enum: + - 7d + - 30d + - 90d + - 1y + description: "Trailing window: `7d` / `30d` / `90d` / `1y`. The previous comparable window is the equally-sized span + immediately before it." + required: true + description: "Trailing window: `7d` / `30d` / `90d` / `1y`. The previous comparable window is the equally-sized span + immediately before it." + responses: + "200": + description: Current + previous window aggregates and the delta. + content: + application/json: + schema: + $ref: "#/components/schemas/AnalyticsRangeResponseEnvelope" + "401": *a1 + "422": *a2 + "429": *a3 /api/insights/metric-status: get: tags: @@ -6334,6 +6415,107 @@ components: additionalProperties: false description: Acknowledgement that a full assessment warm was enqueued for the calling user. The generation runs out of band; the text lands in the read-only status routes. + AnalyticsRangeResponseEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/AnalyticsRangeResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + AnalyticsRangeResponse: + type: object + properties: + range: + type: string + enum: + - 7d + - 30d + - 90d + - 1y + description: The range that was read (echoes the request). + windowDays: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Trailing-window length in days for the chosen range. + granularity: + type: string + description: Rollup granularity the read resolved against (`DAY` / `WEEK` / `MONTH` / `YEAR`, or `none` on a coverage + miss). + current: + description: Aggregate over the current window `[now-N, now)`. + $ref: "#/components/schemas/AnalyticsWindowAggregate" + previous: + description: Aggregate over the previous comparable window `[now-2N, now-N)`. + $ref: "#/components/schemas/AnalyticsWindowAggregate" + delta: + anyOf: + - type: number + - type: "null" + description: "`current.mean - previous.mean`; null when either window has no data (never a misleading 0)." + deltaPct: + anyOf: + - type: number + - type: "null" + description: "`delta / previous.mean` as a fraction (0.03 = +3 %); null when the prior window has no / zero mean (no + divide-by-zero). The client shows 'no prior-period data' in that case." + required: + - range + - windowDays + - granularity + - current + - previous + - delta + - deltaPct + additionalProperties: false + description: Single-metric period-over-period aggregate. Reads the current and previous comparable windows from the WMY + rollup tier and composes a count-weighted-mean delta. `count/min/max/mean/sum` are linearly composable across + buckets; SD/slope/r² are intentionally excluded (not composable). + AnalyticsWindowAggregate: + type: object + properties: + count: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Reading count composed across buckets. + min: + anyOf: + - type: number + - type: "null" + description: Window minimum; null when empty. + max: + anyOf: + - type: number + - type: "null" + description: Window maximum; null when empty. + mean: + anyOf: + - type: number + - type: "null" + description: Count-weighted mean across buckets; null when empty. + sum: + anyOf: + - type: number + - type: "null" + description: Cumulative total for cumulative metrics (steps, energy, distance); null when no bucket carries a sum. + required: + - count + - min + - max + - mean + - sum + additionalProperties: false MetricStatusResponseEnvelope: type: object properties: diff --git a/messages/de.json b/messages/de.json index b8f1a6c9f..0b4fce63a 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1601,6 +1601,22 @@ "confidenceLow": "Geringe Konfidenz", "pendingLabel": "Trend-Kommentar wird generiert" }, + "range": { + "label": "Zeitraum", + "noPriorData": "keine Daten im Vorzeitraum", + "option": { + "7d": "7T", + "30d": "30T", + "90d": "90T", + "1y": "1J" + }, + "priorLabel": { + "7d": "ggü. vorherigen 7T", + "30d": "ggü. vorherigen 30T", + "90d": "ggü. vorherigen 90T", + "1y": "ggü. Vorjahr" + } + }, "correlationRow": { "title": "Zusammenhänge", "subtitle": "Vorab definierte Hypothesen, gegated auf n ≥ 14 und p < 0,05", diff --git a/messages/en.json b/messages/en.json index ca23e5853..2d8dd0c2f 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1601,6 +1601,22 @@ "confidenceLow": "Low confidence", "pendingLabel": "Generating trend commentary" }, + "range": { + "label": "Time range", + "noPriorData": "no prior-period data", + "option": { + "7d": "7d", + "30d": "30d", + "90d": "90d", + "1y": "1y" + }, + "priorLabel": { + "7d": "vs prior 7d", + "30d": "vs prior 30d", + "90d": "vs prior 90d", + "1y": "vs prior year" + } + }, "correlationRow": { "title": "Relationships", "subtitle": "Pre-defined hypotheses, gated on n ≥ 14 and p < 0.05", diff --git a/messages/es.json b/messages/es.json index b34fa799b..81e7c7f83 100644 --- a/messages/es.json +++ b/messages/es.json @@ -1601,6 +1601,22 @@ "confidenceLow": "Confianza baja", "pendingLabel": "Generando comentario de tendencia" }, + "range": { + "label": "Período", + "noPriorData": "sin datos del período anterior", + "option": { + "7d": "7d", + "30d": "30d", + "90d": "90d", + "1y": "1a" + }, + "priorLabel": { + "7d": "vs 7d anteriores", + "30d": "vs 30d anteriores", + "90d": "vs 90d anteriores", + "1y": "vs año anterior" + } + }, "correlationRow": { "title": "Correlaciones", "subtitle": "Hipótesis predefinidas, con umbral n ≥ 14 y p < 0,05", diff --git a/messages/fr.json b/messages/fr.json index cf4ceb691..69ba52e45 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -1601,6 +1601,22 @@ "confidenceLow": "Confiance faible", "pendingLabel": "Commentaire de tendance en cours de génération" }, + "range": { + "label": "Période", + "noPriorData": "aucune donnée sur la période précédente", + "option": { + "7d": "7j", + "30d": "30j", + "90d": "90j", + "1y": "1an" + }, + "priorLabel": { + "7d": "vs 7j précédents", + "30d": "vs 30j précédents", + "90d": "vs 90j précédents", + "1y": "vs année précédente" + } + }, "correlationRow": { "title": "Corrélations", "subtitle": "Hypothèses prédéfinies, validées si n ≥ 14 et p < 0,05", diff --git a/messages/it.json b/messages/it.json index 175434875..360007928 100644 --- a/messages/it.json +++ b/messages/it.json @@ -1601,6 +1601,22 @@ "confidenceLow": "Confidenza bassa", "pendingLabel": "Commento di trend in generazione" }, + "range": { + "label": "Periodo", + "noPriorData": "nessun dato nel periodo precedente", + "option": { + "7d": "7g", + "30d": "30g", + "90d": "90g", + "1y": "1a" + }, + "priorLabel": { + "7d": "vs 7g precedenti", + "30d": "vs 30g precedenti", + "90d": "vs 90g precedenti", + "1y": "vs anno precedente" + } + }, "correlationRow": { "title": "Correlazioni", "subtitle": "Ipotesi predefinite, vincolate a n ≥ 14 e p < 0,05", diff --git a/messages/pl.json b/messages/pl.json index 22b4b7bc5..2e37e134e 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -1601,6 +1601,22 @@ "confidenceLow": "Niska pewność", "pendingLabel": "Komentarz trendu jest generowany" }, + "range": { + "label": "Okres", + "noPriorData": "brak danych z poprzedniego okresu", + "option": { + "7d": "7d", + "30d": "30d", + "90d": "90d", + "1y": "1r" + }, + "priorLabel": { + "7d": "vs poprzednie 7d", + "30d": "vs poprzednie 30d", + "90d": "vs poprzednie 90d", + "1y": "vs poprzedni rok" + } + }, "correlationRow": { "title": "Korelacje", "subtitle": "Hipotezy zdefiniowane wcześniej, z progiem n ≥ 14 i p < 0,05", diff --git a/src/app/api/analytics/range/route.ts b/src/app/api/analytics/range/route.ts new file mode 100644 index 000000000..bebb0a292 --- /dev/null +++ b/src/app/api/analytics/range/route.ts @@ -0,0 +1,72 @@ +/** + * v1.9.0 — single-metric period-over-period range read. + * + * `GET /api/analytics/range?type=&range=7d|30d|90d|1y` + * returns the current-window aggregate, the previous comparable window, and + * the composed delta. Single metric type by construction — the Insights + * metric page is single-metric — so the read is one + * `readBestGranularityRollups` covering the trailing `2N` days, sliced into + * the two halves. No per-type fan-out, no Prisma-pool burst (the opposite of + * the deleted 15-way live walk). + * + * The cached `/api/analytics?slice=summaries` slice is deliberately left + * untouched: adding a range dimension to its single shared cache key would + * fragment the hottest cache on the dashboard. This dedicated route carries + * its own per-(type, range) cache slot on the client (`queryKeys.analyticsRange`). + * + * iOS-safe: additive new route, no change to the `/api/analytics` envelope. + */ +import { NextRequest } from "next/server"; +import { z } from "zod/v4"; + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiSuccess, returnAllZodIssues } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { measurementTypeEnum } from "@/lib/validations/measurement"; +import { + ANALYTICS_RANGES, + computeRangeDelta, + type AnalyticsRange, +} from "@/lib/analytics/range-delta"; +import type { MeasurementType } from "@/generated/prisma/client"; + +export const dynamic = "force-dynamic"; + +const rangeQuerySchema = z.object({ + type: measurementTypeEnum, + range: z.enum(ANALYTICS_RANGES), +}); + +export const GET = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + + const parsed = rangeQuerySchema.safeParse({ + type: request.nextUrl.searchParams.get("type"), + range: request.nextUrl.searchParams.get("range"), + }); + if (!parsed.success) { + annotate({ + action: { name: "analytics.range.invalid-params" }, + meta: { issue_count: parsed.error.issues.length }, + }); + return returnAllZodIssues(parsed.error, 422); + } + + const type = parsed.data.type as MeasurementType; + const range = parsed.data.range as AnalyticsRange; + + const result = await computeRangeDelta(user.id, type, range); + + annotate({ + action: { name: "analytics.range.read" }, + meta: { + type, + range, + granularity: result.granularity, + current_count: result.current.count, + previous_count: result.previous.count, + }, + }); + + return apiSuccess(result); +}); diff --git a/src/components/charts/trend-card.tsx b/src/components/charts/trend-card.tsx index cfc73b02a..8addfbfb6 100644 --- a/src/components/charts/trend-card.tsx +++ b/src/components/charts/trend-card.tsx @@ -11,6 +11,17 @@ import { } from "@/components/ui/tooltip"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; import type { ComparisonBaseline } from "@/lib/dashboard-layout"; +import { + getTrendSentiment, + sentimentColorClass, + type TrendDirectionSentiment, + type TrendSentimentDirection, +} from "@/lib/insights/trend-sentiment"; + +// v1.9.0 — the metric-aware sentiment mapping moved to a shared module so the +// range-delta caption paints the same colour for the same signal. Re-exported +// here so existing `@/components/charts/trend-card` importers keep the type. +export type { TrendDirectionSentiment, TrendSentimentDirection }; interface SecondaryMetric { /** Sub-value latest reading (e.g. diastolic when latest is systolic). */ @@ -19,70 +30,6 @@ interface SecondaryMetric { avg30: number | null; } -/** - * Maps a metric's "up means" direction to colour sentiment for the small - * trend arrow on each tile. v1.4.6 P4 stripped the colour entirely after - * the original up=red / down=green mapping was wrong for half the metrics - * (mood up = good, BP up = bad, pulse up = neutral). v1.5 phase-5 restores - * the colour but per-metric: - * - * - `up-good` — higher value is better (mood, sleep hours, steps). - * ↑ green, ↓ orange. - * - `up-bad` — higher value is worse (BP, weight, body fat). - * ↑ orange, ↓ green. - * - `neutral` — direction doesn't carry a value judgement (pulse, - * "BP in target %" — those have their own range - * colouring on the avg7/avg30 numbers already). - * - * Strictly affects the ↑/↓/→ arrow next to the latest reading. Chart - * lines, axes, and the avg7/avg30 colour classes are untouched. - */ -export type TrendDirectionSentiment = "up-good" | "up-bad" | "neutral"; - -/** - * v1.4.33 F5 — single sentiment helper so the headline arrow, the 7-day - * delta value, and the comparison-overlay caption all paint the same - * colour for the same signal. Before this helper each consumer ran its - * own branch — the arrow read `slope30.direction`, the delta value read - * `trend7Delta > 0`, and the comparison caption read `compareDelta > 0`. - * When the 30-day regression and the 7-day delta disagreed (weight down - * over a month, up over a week) the tile painted a green arrow next to - * an orange value, which read as "two metrics in one tile". - * - * `change` is a signed magnitude of the movement under inspection (the - * 7-day delta in units, or the slope-projected-over-7-days for tiles - * with only `slope30`). `sentiment` is the metric's improvement - * direction. - * - * - `'positive'` — the change moves the metric toward its goal - * (e.g. weight on `up-bad` going down, mood on `up-good` going up). - * Renders green. - * - `'negative'` — the change moves away from the goal. Renders orange. - * - `'neutral'` — either the sentiment is neutral (pulse, BP-in-target), - * or the change is below the noise floor (|change| < 0.05). Renders - * muted. - */ -export type TrendSentimentDirection = "positive" | "negative" | "neutral"; - -function getTrendSentiment( - change: number | null | undefined, - sentiment: TrendDirectionSentiment, -): TrendSentimentDirection { - if (change == null || Math.abs(change) < 0.05) return "neutral"; - if (sentiment === "neutral") return "neutral"; - const isUp = change > 0; - const isGood = - (sentiment === "up-good" && isUp) || - (sentiment === "up-bad" && !isUp); - return isGood ? "positive" : "negative"; -} - -function sentimentColorClass(direction: TrendSentimentDirection): string { - if (direction === "positive") return "text-dracula-green"; - if (direction === "negative") return "text-dracula-orange"; - return "text-muted-foreground"; -} - interface TrendCardProps { label: string; latest: number | null; diff --git a/src/components/insights/__tests__/healthkit-metric-page-status.test.tsx b/src/components/insights/__tests__/healthkit-metric-page-status.test.tsx index d03212622..5a398339f 100644 --- a/src/components/insights/__tests__/healthkit-metric-page-status.test.tsx +++ b/src/components/insights/__tests__/healthkit-metric-page-status.test.tsx @@ -33,6 +33,13 @@ vi.mock("@/hooks/use-auth", () => ({ vi.mock("@/hooks/use-insights-layout-prefs", () => ({ useInsightsLayoutPrefs: () => ({ layout: null, compareBaseline: false }), + useInsightsRangePref: () => ({ range: "30d", setRange: () => {} }), +})); + +// v1.9.0 — the page reads the period-over-period range delta; mock the hook +// so the test stays deterministic and never touches `fetch`. +vi.mock("@/hooks/use-analytics-range", () => ({ + useAnalyticsRange: () => ({ data: undefined, isLoading: false }), })); const analyticsMock = vi.fn(); diff --git a/src/components/insights/healthkit-metric-page.tsx b/src/components/insights/healthkit-metric-page.tsx index dcbe22f2f..2fa7b6ff8 100644 --- a/src/components/insights/healthkit-metric-page.tsx +++ b/src/components/insights/healthkit-metric-page.tsx @@ -7,11 +7,21 @@ import { Sparkles } from "lucide-react"; import { useAuth } from "@/hooks/use-auth"; import { useInsightsAnalytics } from "@/hooks/use-insights-analytics"; -import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs"; +import { + useInsightsLayoutPrefs, + useInsightsRangePref, +} from "@/hooks/use-insights-layout-prefs"; +import { useAnalyticsRange } from "@/hooks/use-analytics-range"; import { useTranslations } from "@/lib/i18n/context"; import type { InsightMetric } from "@/lib/insights/metric-availability"; +import { + getMetricStatusMeta, + metricIdForMeasurementType, +} from "@/lib/insights/metric-status-registry"; import type { MetricStatusMetricId } from "@/lib/insights/metric-status-registry"; +import { sentimentFromMetricDirection } from "@/lib/insights/trend-sentiment"; import type { ChartOverlayKey } from "@/lib/dashboard-layout"; +import type { MeasurementType } from "@/generated/prisma/client"; import { Button } from "@/components/ui/button"; import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic"; import { MetricStatusCard } from "@/components/insights/metric-status-card"; @@ -19,6 +29,8 @@ import { MetricEmptyState } from "@/components/insights/metric-empty-state"; import { MetricStatStrip } from "@/components/insights/metric-stat-strip"; import { MeasurementDiversityNudge } from "@/components/insights/measurement-diversity-nudge"; import { MetricTargetSummary } from "@/components/insights/metric-target-summary"; +import { TimeRangePills } from "@/components/insights/time-range-pills"; +import { MetricRangeDelta } from "@/components/insights/metric-range-delta"; import { SubPageShell } from "@/components/insights/sub-page-shell"; /** @@ -139,9 +151,30 @@ export function HealthKitMetricPage({ const { user, isAuthenticated } = useAuth(); const { t } = useTranslations(); const { compareBaseline } = useInsightsLayoutPrefs(isAuthenticated); + const { range, setRange } = useInsightsRangePref(); const { data: analytics, isEmpty } = useInsightsAnalytics(insightMetric); + // v1.9.0 — period-over-period range read. Single-metric, additive route; + // gated off the empty-data branch so a brand-new metric never fires it. + const { data: rangeData, isLoading: isRangeLoading } = useAnalyticsRange( + measurementType, + range, + !isEmpty, + ); + + // The delta's sentiment colour follows the metric's "good direction" from + // the metric-status registry (higher-better → up-good, lower-better → + // up-bad, target-band → neutral). Every HealthKit metric page is backed by + // a registry entry; a metric absent from the registry falls back to neutral. + const registryMeta = (() => { + const id = metricIdForMeasurementType(measurementType as MeasurementType); + return id ? getMetricStatusMeta(id) : null; + })(); + const directionSentiment = registryMeta + ? sentimentFromMetricDirection(registryMeta.direction) + : "neutral"; + const rawSummary = analytics?.summaries?.[measurementType] ?? null; // The stat strip renders display-unit values. The summary holds stored // values, so when the page renders a scaled unit (e.g. WALKING_SPEED @@ -207,6 +240,21 @@ export function HealthKitMetricPage({ coachLaunch showAllValuesType={measurementType} > + {/* v1.9.0 — time-range pills + period-over-period delta, between the + stat strip and the chart. The selected range persists across metrics + via `useInsightsRangePref`. */} +
+ + +
diff --git a/src/components/insights/mood/mood-narrative-feed.tsx b/src/components/insights/mood/mood-narrative-feed.tsx index a528cd69f..ce2f2444c 100644 --- a/src/components/insights/mood/mood-narrative-feed.tsx +++ b/src/components/insights/mood/mood-narrative-feed.tsx @@ -4,6 +4,7 @@ import { ArrowDownRight, ArrowUpRight, CalendarDays, + Clock, Flame, Tag, Target, @@ -42,6 +43,7 @@ const ICONS: Record> = { "tag-drop": ArrowDownRight, "in-target": Target, streak: Flame, + "time-of-day": Clock, }; function pickIcon(item: MoodNarrativeItem): ComponentType<{ @@ -76,6 +78,7 @@ export function MoodNarrativeFeed({ items }: { items: MoodNarrativeItem[] }) { const vars: Record = { ...item.vars }; if (vars.weekdayKey) vars.weekday = t(vars.weekdayKey); if (vars.tagKey) vars.tag = t(vars.tagKey); + if (vars.bucketKey) vars.bucket = t(vars.bucketKey); return (
  • = { + verySteady: "insights.mood.stability.bandVerySteady", + steady: "insights.mood.stability.bandSteady", + variable: "insights.mood.stability.bandVariable", + veryVariable: "insights.mood.stability.bandVeryVariable", +}; + +export function MoodStabilityTile({ + stability, +}: { + stability: MoodStabilityData | null; +}) { + const { t } = useTranslations(); + + if (stability == null) return null; + + return ( + + +
    + +
    +
    +

    + {t("insights.mood.stability.value", { + score: String(stability.score), + })} +

    +

    + {t(BAND_KEY[stability.band])} +

    +
    +
    +
    + ); +} diff --git a/src/components/insights/mood/mood-time-of-day-chart.tsx b/src/components/insights/mood/mood-time-of-day-chart.tsx new file mode 100644 index 000000000..7478ead1f --- /dev/null +++ b/src/components/insights/mood/mood-time-of-day-chart.tsx @@ -0,0 +1,149 @@ +"use client"; + +import { + Bar, + BarChart, + Cell, + ReferenceLine, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; + +import { useTranslations } from "@/lib/i18n/context"; + +/** + * v1.9.0 — average mood per part of day. + * + * Four bars (morning / afternoon / evening / night) over the average + * mood logged in each part of the user's local day. The buckets are + * computed server-side in each entry's own timezone and gated on a + * spread floor (`reliable`); the parent only mounts this chart when the + * pattern is trustworthy, so a once-a-day logger never sees a misleading + * single bar dressed up as a daypart preference. Empty buckets render as + * a muted zero-height bar so the four-column rhythm stays intact. + */ + +export type MoodTimeOfDayBucket = + | "morning" + | "afternoon" + | "evening" + | "night"; + +export interface MoodTimeOfDayRow { + bucket: MoodTimeOfDayBucket; + avgScore: number | null; + count: number; +} + +export interface MoodTimeOfDayPattern { + buckets: MoodTimeOfDayRow[]; + reliable: boolean; + best: MoodTimeOfDayBucket | null; + worst: MoodTimeOfDayBucket | null; +} + +const LABEL_KEYS: Record = { + morning: "insights.mood.timeOfDay.morning", + afternoon: "insights.mood.timeOfDay.afternoon", + evening: "insights.mood.timeOfDay.evening", + night: "insights.mood.timeOfDay.night", +}; + +function colorForScore(score: number): string { + if (score < 2) return "var(--dracula-red)"; + if (score < 3) return "var(--dracula-orange)"; + if (score < 3.5) return "var(--dracula-yellow)"; + return "var(--dracula-green)"; +} + +export function MoodTimeOfDayChart({ + pattern, +}: { + pattern: MoodTimeOfDayPattern; +}) { + const { t } = useTranslations(); + + const data = pattern.buckets.map((row) => ({ + bucket: row.bucket, + label: t(LABEL_KEYS[row.bucket]), + value: row.avgScore ?? 0, + avgScore: row.avgScore, + count: row.count, + })); + + const bestLabel = pattern.best ? t(LABEL_KEYS[pattern.best]) : null; + + return ( +
    +
    + + + + + + { + const payload = item?.payload as + | { avgScore: number | null; count: number } + | undefined; + if (!payload || payload.avgScore == null) { + return [t("insights.mood.weekdayNoData"), ""]; + } + return [ + `${payload.avgScore.toFixed(1)} (${payload.count})`, + t("insights.mood.timeOfDay.title"), + ]; + }} + /> + + {data.map((row) => ( + + ))} + + + +
    + {bestLabel && ( +

    + {t("insights.mood.timeOfDay.bestCaption", { bucket: bestLabel })} +

    + )} +
    + ); +} From 555138110c8bdcdad2317d5d5bfe817285e60dde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 2 Jun 2026 16:31:57 +0200 Subject: [PATCH 08/30] feat(medications): give the external API its own settings group MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The advanced-settings sheet crammed the external ingest endpoint into the Data group next to CSV import/export — two unrelated audiences sharing one frame. Pull `ApiTokensRow` into a dedicated "Externe API" group so the developer-facing surface reads as a distinct, lowest-frequency block. New group order: Reminders -> Lifecycle -> Data -> Externe API -> Danger. The new group reuses the existing `SettingsGroup` bordered-card chrome, so it stays visually consistent with its siblings. Adds the `externalApi` group label across all six locales and updates the composition test to pin the five-block order plus the API row's new home. Co-Authored-By: Claude Opus 4.8 (1M context) (cherry picked from commit f9f3a1277b5aad1710815511ddf7f6b210a652b2) --- messages/de.json | 3 +- messages/en.json | 3 +- messages/es.json | 3 +- messages/fr.json | 3 +- messages/it.json | 3 +- messages/pl.json | 3 +- .../advanced-settings-sheet.test.tsx | 34 ++++++++++++++----- .../medications/advanced-settings-sheet.tsx | 32 +++++++++++++---- 8 files changed, 62 insertions(+), 22 deletions(-) diff --git a/messages/de.json b/messages/de.json index f958e2f11..0f7157efd 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1278,7 +1278,8 @@ "group": { "data": "Daten", "reminders": "Erinnerungen", - "lifecycle": "Lebenszyklus" + "lifecycle": "Lebenszyklus", + "externalApi": "Externe API" }, "dataPortability": { "import": { diff --git a/messages/en.json b/messages/en.json index c445fc30d..a178a67c6 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1278,7 +1278,8 @@ "group": { "data": "Data", "reminders": "Reminders", - "lifecycle": "Lifecycle" + "lifecycle": "Lifecycle", + "externalApi": "External API" }, "dataPortability": { "import": { diff --git a/messages/es.json b/messages/es.json index f1e7155ef..d52467ffa 100644 --- a/messages/es.json +++ b/messages/es.json @@ -1278,7 +1278,8 @@ "group": { "data": "Datos", "reminders": "Recordatorios", - "lifecycle": "Ciclo de vida" + "lifecycle": "Ciclo de vida", + "externalApi": "API externa" }, "dataPortability": { "import": { diff --git a/messages/fr.json b/messages/fr.json index 20ff9269a..41e99c492 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -1278,7 +1278,8 @@ "group": { "data": "Données", "reminders": "Rappels", - "lifecycle": "Cycle de vie" + "lifecycle": "Cycle de vie", + "externalApi": "API externe" }, "dataPortability": { "import": { diff --git a/messages/it.json b/messages/it.json index fb830e1ae..bbae9bf67 100644 --- a/messages/it.json +++ b/messages/it.json @@ -1278,7 +1278,8 @@ "group": { "data": "Dati", "reminders": "Promemoria", - "lifecycle": "Ciclo di vita" + "lifecycle": "Ciclo di vita", + "externalApi": "API esterna" }, "dataPortability": { "import": { diff --git a/messages/pl.json b/messages/pl.json index e198df4ae..47ef01bbd 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -1278,7 +1278,8 @@ "group": { "data": "Dane", "reminders": "Przypomnienia", - "lifecycle": "Cykl życia" + "lifecycle": "Cykl życia", + "externalApi": "Zewnętrzne API" }, "dataPortability": { "import": { diff --git a/src/components/medications/__tests__/advanced-settings-sheet.test.tsx b/src/components/medications/__tests__/advanced-settings-sheet.test.tsx index 60931d50b..08d0355ba 100644 --- a/src/components/medications/__tests__/advanced-settings-sheet.test.tsx +++ b/src/components/medications/__tests__/advanced-settings-sheet.test.tsx @@ -1,13 +1,14 @@ /** * v1.7.0 — AdvancedSettingsSheet composition contract. * - * The sheet hosts four blocks — Reminders → Lifecycle → Data → Danger - * zone — composed from bare section bodies. `` - * wraps Radix portals that `renderToStaticMarkup` does not materialise, - * so we mock it to a passthrough that renders children only when `open` - * and echoes `contentWidth`. The bodies are mocked to ordered markers. - * The test pins: - * - the four blocks render in the documented order; + * The sheet hosts five blocks — Reminders → Lifecycle → Data → + * Externe API → Danger zone — composed from bare section bodies. + * `` wraps Radix portals that `renderToStaticMarkup` + * does not materialise, so we mock it to a passthrough that renders + * children only when `open` and echoes `contentWidth`. The bodies are + * mocked to ordered markers. The test pins: + * - the five blocks render in the documented order; + * - the external-API row lives in its own group, not in Data; * - the sheet opens at the `2xl` width token; * - `onRequestPhaseSheet` threads through to the phases row; * - `onOpenImport` wires the import button (co-located with export); @@ -107,7 +108,7 @@ function baseProps( } describe("AdvancedSettingsSheet (v1.7.0)", () => { - it("renders the four blocks in Reminders → Lifecycle → Data → Danger order", () => { + it("renders the five blocks in Reminders → Lifecycle → Data → Externe API → Danger order", () => { const html = renderToStaticMarkup( {} })} @@ -116,11 +117,26 @@ describe("AdvancedSettingsSheet (v1.7.0)", () => { const reminders = html.indexOf("advanced-group-reminders"); const lifecycle = html.indexOf("advanced-group-lifecycle"); const data = html.indexOf("advanced-group-data"); + const externalApi = html.indexOf("advanced-group-external-api"); const danger = html.indexOf("mock-danger"); expect(reminders).toBeGreaterThan(-1); expect(lifecycle).toBeGreaterThan(reminders); expect(data).toBeGreaterThan(lifecycle); - expect(danger).toBeGreaterThan(data); + expect(externalApi).toBeGreaterThan(data); + expect(danger).toBeGreaterThan(externalApi); + }); + + it("homes the external-API row in its own group, not in the Data group", () => { + const html = renderToStaticMarkup( + , + ); + // The API row marker must sit after the external-API group opens, + // not inside the Data group it used to share. + const dataGroup = html.indexOf("advanced-group-data"); + const externalApiGroup = html.indexOf("advanced-group-external-api"); + const apiRow = html.indexOf("mock-api-tokens"); + expect(apiRow).toBeGreaterThan(externalApiGroup); + expect(externalApiGroup).toBeGreaterThan(dataGroup); }); it("opens at the 2xl width token", () => { diff --git a/src/components/medications/advanced-settings-sheet.tsx b/src/components/medications/advanced-settings-sheet.tsx index 7d8974b20..171346be1 100644 --- a/src/components/medications/advanced-settings-sheet.tsx +++ b/src/components/medications/advanced-settings-sheet.tsx @@ -6,15 +6,21 @@ * grid. * v1.8.6 — rebuilt as a single column. The two-column data|reminders * grid read as overloaded; the sheet now narrows to `2xl` (~672px) and - * stacks four bordered section cards in most-used-first order: - * Reminders → Lifecycle → Data → Danger. One spacing rule throughout — - * `space-y-4` between cards, `divide-y` + `py-3` rows inside each card - * (the `` card owns the dividers). + * stacks bordered section cards in most-used-first order. One spacing + * rule throughout — `space-y-4` between cards, `divide-y` + `py-3` rows + * inside each card (the `` card owns the dividers). + * + * v1.9.0 — the Data group split. It had carried both operator data + * portability and the developer-facing external API in one frame; the + * API endpoint now sits in its own `Externe API` group so the two + * audiences no longer share a card. Order: Reminders → Lifecycle → + * Data → Externe API → Danger. * * - Reminders: notifications switch + reminder window (grace). * - Lifecycle: pause/resume + end course + phases. - * - Data: intake Import + medications CSV Export (co-located) + - * external-API endpoint / token. + * - Data: intake Import + medications CSV Export (co-located). + * - Externe API: the per-medication ingest endpoint + token, with the + * request examples collapsed by default. * - Danger zone: purge intake history + delete medication, set off by * its own destructive-tinted card so an irreversible action never * sits next to a routine toggle. @@ -142,7 +148,9 @@ export function AdvancedSettingsSheet({ )} - {/* DATA — import + export + external API */} + {/* DATA — import + export. The external API lives in its own + group below; this group stays scoped to operator data + portability so the two audiences don't share one frame. */} + + + {/* EXTERNAL API — the per-medication ingest endpoint + token. + Pulled out of the Data group into its own frame so the + developer/integration surface reads as a distinct, lowest- + frequency block right before the danger zone. */} +
    Date: Tue, 2 Jun 2026 16:32:05 +0200 Subject: [PATCH 09/30] feat(medications): collapse the API request examples and quiet the URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The external-API row was the tallest thing in the advanced-settings sheet: a full-width endpoint URL block plus an always-expanded 4-9 line request snippet, on screen for every user whether or not they touch the API. Collapse the multi-language request examples behind a chevron disclosure, closed by default — the language picker and the live-interpolated bearer token now only render once the user opts in, keeping the token-shaped payload out of the default-visible DOM. De-emphasise the raw endpoint URL to a muted, single-line truncated ``: the copy button already carries the full value, so the long absolute URL was redundant noise. Co-Authored-By: Claude Opus 4.8 (1M context) (cherry picked from commit 6310ed49f4523e3210b26c05f2c934748967af88) --- .../medications/sections/api-tokens-row.tsx | 111 ++++++++++++------ 1 file changed, 73 insertions(+), 38 deletions(-) diff --git a/src/components/medications/sections/api-tokens-row.tsx b/src/components/medications/sections/api-tokens-row.tsx index 9d1e9417d..414884374 100644 --- a/src/components/medications/sections/api-tokens-row.tsx +++ b/src/components/medications/sections/api-tokens-row.tsx @@ -19,7 +19,7 @@ import { useEffect, useState } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; -import { Copy, KeyRound } from "lucide-react"; +import { ChevronDown, ChevronUp, Copy, KeyRound } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; @@ -58,6 +58,11 @@ export function ApiTokensRow({ const [mintedToken, setMintedToken] = useState(null); const [busy, setBusy] = useState(false); const [exampleType, setExampleType] = useState("curl"); + // The request examples are the tallest, lowest-frequency content in + // the sheet — collapsed by default so the verbose `
    ` snippets stay
    +  // out of the default viewport (and the live-interpolated bearer token
    +  // stays out of the default-visible DOM until the user opts in).
    +  const [examplesOpen, setExamplesOpen] = useState(false);
     
       // v1.5.5 F-1 H-2 — single source of truth for the per-medication
       // api-endpoint key. The earlier inline `useMemo` minted the same
    @@ -198,8 +203,12 @@ export function ApiTokensRow({
           

    {t("medications.detail.api.caption", { name: medicationName })}

    + {/* The raw endpoint URL is redundant with the copy button below, so + it is rendered muted + single-line-truncated rather than as a + full-width block — the long absolute URL was pure visual noise. */} POST {endpoint} @@ -259,43 +268,69 @@ export function ApiTokensRow({ {/* v1.6.0 — multi-language request snippets (restored from the v1.5.4 endpoint dialog). The minted token is interpolated - live; until then the snippet carries a `YOUR_TOKEN` stand-in. */} -
    -
    -
    ); From 0a8c6e307cbd36746a63cf9123a70262d79f9186 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 2 Jun 2026 16:34:04 +0200 Subject: [PATCH 10/30] feat(medications): add optional ATC + RxNorm codes to the medication row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add two nullable, additive columns to `Medication` (`atc_code`, `rxnorm_code`) so a user or clinician can assert a drug-classification code that the health-record FHIR export emits on the medication concept. Codes are taken verbatim, never machine-guessed: a wrong code is a clinical-safety problem, an absent one is merely a (conformant) text-only concept. - Migration 0103 — additive, nullable, no backfill. Pre-existing rows read NULL, which the exporter already treats as "no code". - Validate on write — ATC `^[A-Z]\d{2}[A-Z]{2}\d{2}$`, RxCUI `^\d+$`; 422 on a malformed value. Wired field-by-field through the create + update routes (no mass assignment); `null` clears a code on update. - Surface both codes in the medication advanced-settings sheet's Data group with a self-saving input, and echo them on the GET/POST/PUT response shape. OpenAPI regenerated. Co-Authored-By: Claude Opus 4.8 (1M context) (cherry picked from commit 2e0051d42826fa3f043c32e78a34dab7c8050165) --- docs/api/openapi.yaml | 77 +++++++++++++ messages/de.json | 8 ++ messages/en.json | 8 ++ messages/es.json | 8 ++ messages/fr.json | 8 ++ messages/it.json | 8 ++ messages/pl.json | 8 ++ .../migration.sql | 38 +++++++ prisma/schema.prisma | 12 ++ src/app/api/medications/[id]/route.ts | 6 + src/app/api/medications/route.ts | 6 + src/app/medications/page.tsx | 6 + .../advanced-settings-sheet.test.tsx | 1 + .../medications/advanced-settings-sheet.tsx | 13 +++ .../medications/sections/settings-section.tsx | 105 ++++++++++++++++++ src/lib/openapi/routes.ts | 12 ++ .../__tests__/medication-drug-codes.test.ts | 104 +++++++++++++++++ src/lib/validations/medication.ts | 48 ++++++++ 18 files changed, 476 insertions(+) create mode 100644 prisma/migrations/0103_v190_medication_drug_codes/migration.sql create mode 100644 src/lib/validations/__tests__/medication-drug-codes.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 3b14061c0..b784f0af7 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -2382,6 +2382,22 @@ components: description: iOS 26 AlarmKit critical-reminder opt-in. Default false. Critical alarms bypass the device mute switch / Focus; the server stores the preference only and hangs no server-side behaviour off it. type: boolean + atcCode: + description: Optional WHO ATC classification code (active-substance class, 7 chars, e.g. `A10BX10`). + User/clinician-asserted; never machine-guessed. Emitted on the FHIR `medicationCodeableConcept` under + `http://www.whocc.no/atc`. NULL clears it; absent leaves it untouched. + anyOf: + - type: string + pattern: ^[A-Z]\d{2}[A-Z]{2}\d{2}$ + - type: "null" + rxNormCode: + description: Optional RxNorm RxCUI (numeric, US identifier, e.g. `2601723`). Secondary coding emitted under + `http://www.nlm.nih.gov/research/umls/rxnorm` alongside any ATC code, never instead of the free-text name. + NULL clears it; absent leaves it untouched. + anyOf: + - type: string + pattern: ^\d+$ + - type: "null" startsOn: description: Date the medication course begins (ISO `YYYY-MM-DD`). Anchors RRULE BYDAY / BYMONTHDAY patterns and the rolling-interval countdown's first window. NULL = active from creation (the legacy implicit behaviour). @@ -2584,6 +2600,22 @@ components: description: iOS 26 AlarmKit critical-reminder opt-in. Critical alarms bypass the device mute switch / Focus; the server stores the preference only. type: boolean + atcCode: + description: Optional WHO ATC classification code (active-substance class, 7 chars, e.g. `A10BX10`). + User/clinician-asserted; never machine-guessed. Emitted on the FHIR `medicationCodeableConcept` under + `http://www.whocc.no/atc`. NULL clears it; absent leaves it untouched. + anyOf: + - type: string + pattern: ^[A-Z]\d{2}[A-Z]{2}\d{2}$ + - type: "null" + rxNormCode: + description: Optional RxNorm RxCUI (numeric, US identifier, e.g. `2601723`). Secondary coding emitted under + `http://www.nlm.nih.gov/research/umls/rxnorm` alongside any ATC code, never instead of the free-text name. + NULL clears it; absent leaves it untouched. + anyOf: + - type: string + pattern: ^\d+$ + - type: "null" startsOn: description: Date the medication course begins (ISO `YYYY-MM-DD`). Anchors RRULE BYDAY / BYMONTHDAY patterns and the rolling-interval countdown's first window. NULL = active from creation (the legacy implicit behaviour). @@ -4189,6 +4221,19 @@ components: type: boolean description: v1.7.0 iOS 26 AlarmKit critical-reminder opt-in. Default false. Critical alarms bypass the device mute switch / Focus; the server stores the preference only. + atcCode: + anyOf: + - type: string + - type: "null" + description: v1.9.0 optional WHO ATC classification code (active-substance class, e.g. `A10BX10`). + User/clinician-asserted; never machine-guessed. Emitted on the FHIR `medicationCodeableConcept` under + `http://www.whocc.no/atc`. NULL = no code captured. + rxNormCode: + anyOf: + - type: string + - type: "null" + description: v1.9.0 optional RxNorm RxCUI (numeric, US identifier, e.g. `2601723`). Secondary FHIR coding under + `http://www.nlm.nih.gov/research/umls/rxnorm`, alongside any ATC code. NULL = no code captured. pausedAt: anyOf: - type: string @@ -4267,6 +4312,8 @@ components: - notificationsEnabled - liveActivityEnabled - criticalAlarmEnabled + - atcCode + - rxNormCode - pausedAt - snoozedUntil - nextDueAt @@ -4349,6 +4396,19 @@ components: type: boolean description: v1.7.0 iOS 26 AlarmKit critical-reminder opt-in. Default false. Critical alarms bypass the device mute switch / Focus; the server stores the preference only. + atcCode: + anyOf: + - type: string + - type: "null" + description: v1.9.0 optional WHO ATC classification code (active-substance class, e.g. `A10BX10`). + User/clinician-asserted; never machine-guessed. Emitted on the FHIR `medicationCodeableConcept` under + `http://www.whocc.no/atc`. NULL = no code captured. + rxNormCode: + anyOf: + - type: string + - type: "null" + description: v1.9.0 optional RxNorm RxCUI (numeric, US identifier, e.g. `2601723`). Secondary FHIR coding under + `http://www.nlm.nih.gov/research/umls/rxnorm`, alongside any ATC code. NULL = no code captured. pausedAt: anyOf: - type: string @@ -4414,6 +4474,8 @@ components: - notificationsEnabled - liveActivityEnabled - criticalAlarmEnabled + - atcCode + - rxNormCode - pausedAt - snoozedUntil - nextDueAt @@ -6803,6 +6865,19 @@ components: type: boolean description: v1.7.0 iOS 26 AlarmKit critical-reminder opt-in. Default false. Critical alarms bypass the device mute switch / Focus; the server stores the preference only. + atcCode: + anyOf: + - type: string + - type: "null" + description: v1.9.0 optional WHO ATC classification code (active-substance class, e.g. `A10BX10`). + User/clinician-asserted; never machine-guessed. Emitted on the FHIR `medicationCodeableConcept` under + `http://www.whocc.no/atc`. NULL = no code captured. + rxNormCode: + anyOf: + - type: string + - type: "null" + description: v1.9.0 optional RxNorm RxCUI (numeric, US identifier, e.g. `2601723`). Secondary FHIR coding under + `http://www.nlm.nih.gov/research/umls/rxnorm`, alongside any ATC code. NULL = no code captured. pausedAt: anyOf: - type: string @@ -6866,6 +6941,8 @@ components: - notificationsEnabled - liveActivityEnabled - criticalAlarmEnabled + - atcCode + - rxNormCode - pausedAt - snoozedUntil - nextDueAt diff --git a/messages/de.json b/messages/de.json index 0f7157efd..13b90e269 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1214,6 +1214,14 @@ "unit": "Minuten", "saved": "Erinnerungsfenster gespeichert", "failed": "Erinnerungsfenster konnte nicht gespeichert werden." + }, + "codes": { + "label": "Klinische Codes (ATC / RxNorm)", + "note": "Optional. Werden beim FHIR-Export der Gesundheitsakte verwendet. Codes werden genau wie eingegeben übernommen — nie geraten.", + "atcLabel": "ATC", + "rxNormLabel": "RxNorm", + "saved": "Klinische Codes gespeichert", + "failed": "Klinische Codes konnten nicht gespeichert werden." } }, "zone": { diff --git a/messages/en.json b/messages/en.json index a178a67c6..fbb92ebb0 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1214,6 +1214,14 @@ "unit": "minutes", "saved": "Reminder window saved", "failed": "Could not save the reminder window." + }, + "codes": { + "label": "Clinical codes (ATC / RxNorm)", + "note": "Optional. Used on the FHIR health-record export. Codes are taken exactly as entered — never guessed.", + "atcLabel": "ATC", + "rxNormLabel": "RxNorm", + "saved": "Clinical codes saved", + "failed": "Could not save the clinical codes." } }, "zone": { diff --git a/messages/es.json b/messages/es.json index d52467ffa..975cad5c0 100644 --- a/messages/es.json +++ b/messages/es.json @@ -1214,6 +1214,14 @@ "unit": "minutes", "saved": "Reminder window saved", "failed": "Could not save the reminder window." + }, + "codes": { + "label": "Códigos clínicos (ATC / RxNorm)", + "note": "Opcional. Se usan en la exportación FHIR del historial de salud. Los códigos se toman tal cual se introducen — nunca se adivinan.", + "atcLabel": "ATC", + "rxNormLabel": "RxNorm", + "saved": "Códigos clínicos guardados", + "failed": "No se pudieron guardar los códigos clínicos." } }, "zone": { diff --git a/messages/fr.json b/messages/fr.json index 41e99c492..03d5475b5 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -1214,6 +1214,14 @@ "unit": "minutes", "saved": "Reminder window saved", "failed": "Could not save the reminder window." + }, + "codes": { + "label": "Codes cliniques (ATC / RxNorm)", + "note": "Facultatif. Utilisés lors de l'export FHIR du dossier de santé. Les codes sont repris tels qu'ils sont saisis — jamais devinés.", + "atcLabel": "ATC", + "rxNormLabel": "RxNorm", + "saved": "Codes cliniques enregistrés", + "failed": "Impossible d'enregistrer les codes cliniques." } }, "zone": { diff --git a/messages/it.json b/messages/it.json index bbae9bf67..4b0aa045f 100644 --- a/messages/it.json +++ b/messages/it.json @@ -1214,6 +1214,14 @@ "unit": "minutes", "saved": "Reminder window saved", "failed": "Could not save the reminder window." + }, + "codes": { + "label": "Codici clinici (ATC / RxNorm)", + "note": "Facoltativo. Usati nell'esportazione FHIR della cartella sanitaria. I codici vengono presi esattamente come inseriti — mai indovinati.", + "atcLabel": "ATC", + "rxNormLabel": "RxNorm", + "saved": "Codici clinici salvati", + "failed": "Impossibile salvare i codici clinici." } }, "zone": { diff --git a/messages/pl.json b/messages/pl.json index 47ef01bbd..66fa2289a 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -1214,6 +1214,14 @@ "unit": "minutes", "saved": "Reminder window saved", "failed": "Could not save the reminder window." + }, + "codes": { + "label": "Kody kliniczne (ATC / RxNorm)", + "note": "Opcjonalne. Używane w eksporcie FHIR dokumentacji zdrowotnej. Kody są zapisywane dokładnie tak, jak wprowadzone — nigdy nie są zgadywane.", + "atcLabel": "ATC", + "rxNormLabel": "RxNorm", + "saved": "Kody kliniczne zapisane", + "failed": "Nie udało się zapisać kodów klinicznych." } }, "zone": { diff --git a/prisma/migrations/0103_v190_medication_drug_codes/migration.sql b/prisma/migrations/0103_v190_medication_drug_codes/migration.sql new file mode 100644 index 000000000..67c46d0cf --- /dev/null +++ b/prisma/migrations/0103_v190_medication_drug_codes/migration.sql @@ -0,0 +1,38 @@ +-- v1.9.0 — optional drug-classification codes on the medication row. +-- +-- Two additive, nullable, NULL-defaulted columns on `medications`: +-- +-- * `atc_code` — the WHO ATC active-substance class (e.g. "A10BX10"). +-- Emitted on the FHIR `MedicationStatement` / +-- `MedicationAdministration` `medicationCodeableConcept` +-- under the system URI `http://www.whocc.no/atc`. +-- * `rxnorm_code` — the RxNorm RxCUI (US identifier). Secondary coding +-- under `http://www.nlm.nih.gov/research/umls/rxnorm`. +-- +-- Both are user/clinician-asserted, never machine-guessed: a wrong code +-- is a clinical-safety problem, an absent code is merely a (conformant) +-- text-only concept. The application validates the format on write +-- (ATC `^[A-Z]\d{2}[A-Z]{2}\d{2}$`, RxCUI `^\d+$`) and 422s on a +-- malformed value; the DB column is a plain nullable TEXT field. +-- +-- No backfill — pre-v1.9.0 rows read NULL, which the exporter treats +-- identically to an absent value (the medicationCodeableConcept stays +-- text-only, the pre-v1.9.0 shape). +-- +-- Idempotent guards (`IF NOT EXISTS`) make reruns safe. Forward-only. +-- +-- Reversibility: +-- ALTER TABLE "medications" DROP COLUMN IF EXISTS "atc_code"; +-- ALTER TABLE "medications" DROP COLUMN IF EXISTS "rxnorm_code"; +-- A roll-back loses the captured codes; the FHIR export falls back to the +-- text-only medicationCodeableConcept and the application reads tolerate a +-- missing column / NULL value. +-- +-- No index — reads are per-user at request time (`WHERE user_id = $1`), +-- matching the other display-identity columns on the same table. + +ALTER TABLE "medications" + ADD COLUMN IF NOT EXISTS "atc_code" TEXT; + +ALTER TABLE "medications" + ADD COLUMN IF NOT EXISTS "rxnorm_code" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 971c85626..2881102b3 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -957,6 +957,18 @@ model Medication { /// stores the preference only — it does not itself authorise anything /// server-side. criticalAlarmEnabled Boolean @default(false) @map("critical_alarm_enabled") + /// v1.9.0 — optional ATC classification code (active-substance class, + /// e.g. `A10BX10`). User/clinician-asserted; never machine-guessed on + /// the export path. NULL = no code (the FHIR exporter emits a text-only + /// `medicationCodeableConcept`, the pre-v1.9.0 behaviour). The WHO URI + /// `http://www.whocc.no/atc` is the emitted system. Validated on write + /// against `^[A-Z]\d{2}[A-Z]{2}\d{2}$`. + atcCode String? @map("atc_code") + /// v1.9.0 — optional RxNorm RxCUI (US; secondary coding). Same NULL + /// semantics as `atcCode`. Validated on write against `^\d+$`. Emitted + /// under `http://www.nlm.nih.gov/research/umls/rxnorm` alongside any + /// ATC code, never instead of the free-text `.text` anchor. + rxNormCode String? @map("rxnorm_code") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") diff --git a/src/app/api/medications/[id]/route.ts b/src/app/api/medications/[id]/route.ts index c1abfc1bf..ae5837a94 100644 --- a/src/app/api/medications/[id]/route.ts +++ b/src/app/api/medications/[id]/route.ts @@ -136,6 +136,8 @@ export const PUT = apiHandler( notificationsEnabled, liveActivityEnabled, criticalAlarmEnabled, + atcCode, + rxNormCode, schedules, startsOn, endsOn, @@ -234,6 +236,10 @@ export const PUT = apiHandler( // v1.7.0 — iOS reminder flags, field-by-field. ...(liveActivityEnabled !== undefined && { liveActivityEnabled }), ...(criticalAlarmEnabled !== undefined && { criticalAlarmEnabled }), + // v1.9.0 — optional drug-classification codes (ATC / RxNorm). + // Field-by-field; `null` is a valid explicit value (clears the code). + ...(atcCode !== undefined && { atcCode }), + ...(rxNormCode !== undefined && { rxNormCode }), // v1.5 scheduling primitives — pass-through when supplied. // `startsOn` / `endsOn` are `Date | null | undefined` (the // schema lets the user clear them explicitly with null). diff --git a/src/app/api/medications/route.ts b/src/app/api/medications/route.ts index 8ab1cf5e6..844535a7b 100644 --- a/src/app/api/medications/route.ts +++ b/src/app/api/medications/route.ts @@ -160,6 +160,8 @@ export const POST = apiHandler(async (request: NextRequest) => { notificationsEnabled, liveActivityEnabled, criticalAlarmEnabled, + atcCode, + rxNormCode, schedules, startsOn, endsOn, @@ -228,6 +230,10 @@ export const POST = apiHandler(async (request: NextRequest) => { // v1.7.0 — iOS reminder flags; Prisma defaults both to false. ...(liveActivityEnabled !== undefined && { liveActivityEnabled }), ...(criticalAlarmEnabled !== undefined && { criticalAlarmEnabled }), + // v1.9.0 — optional drug-classification codes (ATC / RxNorm). + // Validated for format by Zod; stored verbatim when present. + ...(atcCode !== undefined && { atcCode }), + ...(rxNormCode !== undefined && { rxNormCode }), // v1.5 — wizard's reminders toggle now ships through the create // payload (was orphaned in the initial diff). Prisma defaults to // true when omitted, matching the legacy form's behaviour. diff --git a/src/app/medications/page.tsx b/src/app/medications/page.tsx index 10d81def0..f6bdb4177 100644 --- a/src/app/medications/page.tsx +++ b/src/app/medications/page.tsx @@ -64,6 +64,10 @@ interface Medication { endsOn?: string | null; /** v1.5 — single-administration medication. */ oneShot?: boolean; + /** v1.9.0 — optional WHO ATC classification code for the FHIR export. */ + atcCode?: string | null; + /** v1.9.0 — optional RxNorm RxCUI (secondary FHIR coding). */ + rxNormCode?: string | null; schedules: Schedule[]; } @@ -357,6 +361,8 @@ export default function MedicationsPage() { advancedMed.schedules[0]?.reminderGraceMinutes ?? null } intakeCount={advancedIntakeCount ?? 0} + atcCode={advancedMed.atcCode} + rxNormCode={advancedMed.rxNormCode} /> )}
    diff --git a/src/components/medications/__tests__/advanced-settings-sheet.test.tsx b/src/components/medications/__tests__/advanced-settings-sheet.test.tsx index 08d0355ba..7611cbb7b 100644 --- a/src/components/medications/__tests__/advanced-settings-sheet.test.tsx +++ b/src/components/medications/__tests__/advanced-settings-sheet.test.tsx @@ -54,6 +54,7 @@ vi.mock("@/components/medications/sections/notifications-section", () => ({ vi.mock("@/components/medications/sections/settings-section", () => ({ GraceRow: () =>
    , + DrugCodingRow: () =>
    , PhasesRow: ({ onRequestPhaseSheet, }: { diff --git a/src/components/medications/advanced-settings-sheet.tsx b/src/components/medications/advanced-settings-sheet.tsx index 171346be1..232570b1e 100644 --- a/src/components/medications/advanced-settings-sheet.tsx +++ b/src/components/medications/advanced-settings-sheet.tsx @@ -41,6 +41,7 @@ import { ApiTokensRow } from "@/components/medications/sections/api-tokens-row"; import { DataPortabilityRow } from "@/components/medications/sections/data-portability-row"; import { NotificationsBody } from "@/components/medications/sections/notifications-section"; import { + DrugCodingRow, GraceRow, PhasesRow, } from "@/components/medications/sections/settings-section"; @@ -62,6 +63,9 @@ export interface AdvancedSettingsSheetProps { notificationsEnabled: boolean; reminderGraceMinutes: number | null; intakeCount: number; + /** v1.9.0 — optional drug-classification codes for the FHIR export. */ + atcCode?: string | null; + rxNormCode?: string | null; /** * Sibling-swap the phase sheet: the parent closes this sheet and * opens `` so the two never stack (G-1 §5). @@ -86,6 +90,8 @@ export function AdvancedSettingsSheet({ notificationsEnabled, reminderGraceMinutes, intakeCount, + atcCode, + rxNormCode, onRequestPhaseSheet, onOpenImport, }: AdvancedSettingsSheetProps) { @@ -177,6 +183,13 @@ export function AdvancedSettingsSheet({ medicationName={medicationName} />
    +
    + +
    {/* DANGER ZONE — purge + delete. Set off by the destructive- diff --git a/src/components/medications/sections/settings-section.tsx b/src/components/medications/sections/settings-section.tsx index 70036478c..a1da81bab 100644 --- a/src/components/medications/sections/settings-section.tsx +++ b/src/components/medications/sections/settings-section.tsx @@ -131,6 +131,111 @@ export function GraceRow({ ); } +/** + * v1.9.0 — optional drug-classification codes (ATC / RxNorm) row. + * Self-saves via `PUT /api/medications/[id]` with the two top-level + * `atcCode` / `rxNormCode` fields. The codes are user/clinician-asserted + * and surface on the FHIR health-record export's + * `medicationCodeableConcept` (ATC primary, RxNorm secondary); the app + * never machine-guesses a code. An empty input clears the stored code. + */ +export function DrugCodingRow({ + medicationId, + atcCode, + rxNormCode, +}: { + medicationId: string; + atcCode?: string | null; + rxNormCode?: string | null; +}) { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + const [atcValue, setAtcValue] = useState(atcCode ?? ""); + const [rxValue, setRxValue] = useState(rxNormCode ?? ""); + const [busy, setBusy] = useState(false); + + async function save() { + if (busy) return; + setBusy(true); + try { + // Empty input clears the column (null); a non-empty value is sent + // verbatim for the server to validate (422 on a malformed code). + const res = await fetch(`/api/medications/${medicationId}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + atcCode: atcValue.trim() === "" ? null : atcValue.trim(), + rxNormCode: rxValue.trim() === "" ? null : rxValue.trim(), + }), + }); + if (!res.ok) { + toast.error(t("medications.detail.settings.codes.failed")); + return; + } + await invalidateKeys(queryClient, medicationDependentKeys); + toast.success(t("medications.detail.settings.codes.saved")); + } catch { + toast.error(t("medications.detail.settings.codes.failed")); + } finally { + setBusy(false); + } + } + + return ( +
    + +

    + {t("medications.detail.settings.codes.note")} +

    +
    +
    + + setAtcValue(e.target.value.toUpperCase())} + placeholder="A10BX10" + className="w-32" + /> +
    +
    + + setRxValue(e.target.value)} + placeholder="2601723" + className="w-32" + /> +
    + +
    +
    + ); +} + /** * v1.7.0 — phases / course-window row. GLP-1 only; the button mounts * once a course window is set, otherwise an italic hint. When diff --git a/src/lib/openapi/routes.ts b/src/lib/openapi/routes.ts index 6c433d7c2..b93fded35 100644 --- a/src/lib/openapi/routes.ts +++ b/src/lib/openapi/routes.ts @@ -945,6 +945,18 @@ const medicationResource = z .describe( "v1.7.0 iOS 26 AlarmKit critical-reminder opt-in. Default false. Critical alarms bypass the device mute switch / Focus; the server stores the preference only.", ), + atcCode: z + .string() + .nullable() + .describe( + "v1.9.0 optional WHO ATC classification code (active-substance class, e.g. `A10BX10`). User/clinician-asserted; never machine-guessed. Emitted on the FHIR `medicationCodeableConcept` under `http://www.whocc.no/atc`. NULL = no code captured.", + ), + rxNormCode: z + .string() + .nullable() + .describe( + "v1.9.0 optional RxNorm RxCUI (numeric, US identifier, e.g. `2601723`). Secondary FHIR coding under `http://www.nlm.nih.gov/research/umls/rxnorm`, alongside any ATC code. NULL = no code captured.", + ), pausedAt: z.iso.datetime({ offset: true }).nullable(), snoozedUntil: z.iso.datetime({ offset: true }).nullable(), nextDueAt: z.iso diff --git a/src/lib/validations/__tests__/medication-drug-codes.test.ts b/src/lib/validations/__tests__/medication-drug-codes.test.ts new file mode 100644 index 000000000..a1c1c0756 --- /dev/null +++ b/src/lib/validations/__tests__/medication-drug-codes.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect } from "vitest"; + +import { + createMedicationSchema, + updateMedicationSchema, +} from "@/lib/validations/medication"; + +/** + * v1.9.0 — validation coverage for the optional drug-classification + * codes (`atcCode` / `rxNormCode`) on the medication create + update + * bodies. ATC format `^[A-Z]\d{2}[A-Z]{2}\d{2}$`, RxCUI `^\d+$`. + */ + +const baseCreate = { + name: "Mounjaro", + dose: "10mg", + schedules: [{ windowStart: "08:00", windowEnd: "09:00" }], +}; + +describe("medication drug-code validation", () => { + describe("createMedicationSchema", () => { + it("accepts a well-formed ATC + RxNorm code", () => { + const parsed = createMedicationSchema.safeParse({ + ...baseCreate, + atcCode: "A10BX10", + rxNormCode: "2601723", + }); + expect(parsed.success).toBe(true); + if (parsed.success) { + expect(parsed.data.atcCode).toBe("A10BX10"); + expect(parsed.data.rxNormCode).toBe("2601723"); + } + }); + + it("accepts a medication with no codes (the pre-v1.9.0 shape)", () => { + const parsed = createMedicationSchema.safeParse(baseCreate); + expect(parsed.success).toBe(true); + if (parsed.success) { + expect(parsed.data.atcCode).toBeUndefined(); + expect(parsed.data.rxNormCode).toBeUndefined(); + } + }); + + it("rejects a malformed ATC code", () => { + // Too short (anatomical-group prefix, not a leaf substance class). + expect( + createMedicationSchema.safeParse({ ...baseCreate, atcCode: "A10B" }) + .success, + ).toBe(false); + // Lowercase. + expect( + createMedicationSchema.safeParse({ ...baseCreate, atcCode: "a10bx10" }) + .success, + ).toBe(false); + // Wrong digit/letter layout. + expect( + createMedicationSchema.safeParse({ ...baseCreate, atcCode: "AA0BX10" }) + .success, + ).toBe(false); + }); + + it("rejects a non-numeric RxNorm code", () => { + expect( + createMedicationSchema.safeParse({ + ...baseCreate, + rxNormCode: "rx-260", + }).success, + ).toBe(false); + expect( + createMedicationSchema.safeParse({ ...baseCreate, rxNormCode: "" }) + .success, + ).toBe(false); + }); + }); + + describe("updateMedicationSchema", () => { + it("accepts null to clear a code", () => { + const parsed = updateMedicationSchema.safeParse({ + atcCode: null, + rxNormCode: null, + }); + expect(parsed.success).toBe(true); + if (parsed.success) { + expect(parsed.data.atcCode).toBeNull(); + expect(parsed.data.rxNormCode).toBeNull(); + } + }); + + it("accepts a well-formed code on update", () => { + const parsed = updateMedicationSchema.safeParse({ atcCode: "C09AA05" }); + expect(parsed.success).toBe(true); + if (parsed.success) expect(parsed.data.atcCode).toBe("C09AA05"); + }); + + it("rejects a malformed code on update", () => { + expect( + updateMedicationSchema.safeParse({ atcCode: "not-a-code" }).success, + ).toBe(false); + expect( + updateMedicationSchema.safeParse({ rxNormCode: "12.3" }).success, + ).toBe(false); + }); + }); +}); diff --git a/src/lib/validations/medication.ts b/src/lib/validations/medication.ts index a15e8f958..478838bcd 100644 --- a/src/lib/validations/medication.ts +++ b/src/lib/validations/medication.ts @@ -16,6 +16,46 @@ export type InjectionSiteValue = (typeof INJECTION_SITE_VALUES)[number]; export const injectionSiteEnum = z.enum(INJECTION_SITE_VALUES); const timeRegex = /^([01]\d|2[0-3]):([0-5]\d)$/; + +/** + * v1.9.0 — drug-classification code formats. + * + * `ATC_CODE_REGEX` — the 7-character WHO ATC code (one letter, two + * digits, two letters, two digits, e.g. `A10BX10`). This is the + * full leaf-level substance class; the shorter anatomical-group + * prefixes (`A`, `A10`, `A10B`) are deliberately NOT accepted — the + * exporter emits a substance-class coding, not a group. + * + * `RXCUI_REGEX` — the RxNorm RxCUI is a bare positive integer string + * (e.g. `2601723`). + * + * Both fields are user/clinician-asserted and never machine-guessed; + * a malformed value is rejected with 422 rather than silently stored. + */ +const ATC_CODE_REGEX = /^[A-Z]\d{2}[A-Z]{2}\d{2}$/; +const RXCUI_REGEX = /^\d+$/; + +/** + * v1.9.0 — reusable nullable/optional code-field validators shared by + * the create + update medication schemas. `null` clears the column; + * `undefined` leaves it untouched on update. + */ +const atcCodeField = z + .string() + .regex(ATC_CODE_REGEX, "Invalid ATC code (expected e.g. A10BX10)") + .nullable() + .optional() + .describe( + "Optional WHO ATC classification code (active-substance class, 7 chars, e.g. `A10BX10`). User/clinician-asserted; never machine-guessed. Emitted on the FHIR `medicationCodeableConcept` under `http://www.whocc.no/atc`. NULL clears it; absent leaves it untouched.", + ); +const rxNormCodeField = z + .string() + .regex(RXCUI_REGEX, "Invalid RxNorm code (expected a numeric RxCUI)") + .nullable() + .optional() + .describe( + "Optional RxNorm RxCUI (numeric, US identifier, e.g. `2601723`). Secondary coding emitted under `http://www.nlm.nih.gov/research/umls/rxnorm` alongside any ATC code, never instead of the free-text name. NULL clears it; absent leaves it untouched.", + ); /** * Clinical-category values stored in the `medication_categories` side- * table (TEXT column). v1.5.4 adds `DIABETES` and `ANTIBIOTIC` so the @@ -344,6 +384,10 @@ export const createMedicationSchema = z .describe( "iOS 26 AlarmKit critical-reminder opt-in. Default false. Critical alarms bypass the device mute switch / Focus; the server stores the preference only and hangs no server-side behaviour off it.", ), + /** v1.9.0 — optional WHO ATC classification code. */ + atcCode: atcCodeField, + /** v1.9.0 — optional RxNorm RxCUI (secondary, US). */ + rxNormCode: rxNormCodeField, ...courseWindowFields, schedules: z.array(scheduleSchema).min(1, "Mindestens ein Zeitfenster"), }) @@ -405,6 +449,10 @@ export const updateMedicationSchema = z .describe( "iOS 26 AlarmKit critical-reminder opt-in. Critical alarms bypass the device mute switch / Focus; the server stores the preference only.", ), + /** v1.9.0 — optional WHO ATC classification code. */ + atcCode: atcCodeField, + /** v1.9.0 — optional RxNorm RxCUI (secondary, US). */ + rxNormCode: rxNormCodeField, ...courseWindowFields, schedules: z.array(scheduleSchema).optional(), /** From 2fcd2e75f5647d7a59ffb21e5809beaa8dd28746 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 2 Jun 2026 16:37:23 +0200 Subject: [PATCH 11/30] feat(fhir): emit ATC + RxNorm codings on the medication concept MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The health-record export's `MedicationStatement.medicationCodeableConcept` gains additive `coding[]` entries when the medication carries a stored code: ATC primary (`http://www.whocc.no/atc`), RxNorm secondary (`http://www.nlm.nih.gov/research/umls/rxnorm`). The free-text name stays the `.text` anchor and the concept collapses to exactly the prior text-only shape when no code is present — the export never machine-guesses a code, only emits what the user stored. The aggregator now carries `atcCode` / `rxNormCode` on `medications[]` and assembles the acted-intake source rows (`medicationAdministrations`) the administration builder consumes, so the FHIR export keeps describing the same numbers as the PDF by construction. Co-Authored-By: Claude Opus 4.8 (1M context) (cherry picked from commit 740c86b3a830efa7e76d69b29ae376addeee6811) --- src/lib/doctor-report-data.ts | 128 +++++++++++++++++++- src/lib/fhir/__tests__/build-bundle.test.ts | 77 ++++++++++++ src/lib/fhir/build-bundle.ts | 39 +++++- 3 files changed, 242 insertions(+), 2 deletions(-) diff --git a/src/lib/doctor-report-data.ts b/src/lib/doctor-report-data.ts index a40206ade..7924a0a08 100644 --- a/src/lib/doctor-report-data.ts +++ b/src/lib/doctor-report-data.ts @@ -88,12 +88,47 @@ export interface DoctorReportData { medications: Array<{ name: string; dose: string; + // v1.9.0 — optional user/clinician-asserted drug-classification + // codes. Carried here so the FHIR exporter can emit a coded + // `medicationCodeableConcept` (ATC primary, RxNorm secondary). NULL + // when no code was captured — the exporter then emits the pre-v1.9.0 + // text-only concept. Optional in the type so pre-v1.9.0 fixtures + // still typecheck. + atcCode?: string | null; + rxNormCode?: string | null; schedules: Array<{ windowStart: string; windowEnd: string; label: string | null; }>; }>; + /** + * v1.9.0 — acted medication-intake events over the report window, fed + * to the FHIR `MedicationAdministration` builder. One entry per intake + * row the user actually actioned (taken OR explicitly skipped); + * pending / missed / soft-deleted rows are excluded upstream so the + * exporter never asserts an administration that did not happen. The + * `dose` is the medication's structured dose-in-effect at + * `effectiveAt` (from `MedicationDoseChange`) when one exists, else + * NULL. Optional so pre-v1.9.0 fixtures still typecheck. + */ + medicationAdministrations?: Array<{ + medicationName: string; + /** ISO timestamp: `takenAt` for a taken dose, `scheduledFor` for a skip. */ + effectiveAt: string; + status: "completed" | "not-done"; + /** Free-text dose label (the medication's `dose`), mirrors the statement. */ + doseText: string | null; + /** Structured dose-in-effect at `effectiveAt`, when a dose-change history exists. */ + dose: { value: number; unit: string } | null; + /** Injection site, when the row recorded one. */ + injectionSite: string | null; + /** ATC / RxNorm codes, mirrored from the medication for the self-describing concept. */ + atcCode: string | null; + rxNormCode: string | null; + /** Delivery form — ORAL / INJECTION / OTHER — for the route mapping. */ + deliveryForm: string | null; + }>; mood: DoctorReportMood | null; /** * v1.4.25 W4d — GLP-1 therapy section. Populated when the user has at @@ -310,7 +345,21 @@ export async function collectDoctorReportData( prisma.medicationIntakeEvent.findMany({ // v1.7.0 sync — exclude tombstoned rows from the doctor report. where: { userId, deletedAt: null, scheduledFor: { gte: start, lte: end } }, - include: { medication: { select: { name: true } } }, + include: { + // v1.9.0 — carry the medication identity + codes + delivery + // form so the FHIR MedicationAdministration builder can emit a + // self-describing `medicationCodeableConcept` and a route. + medication: { + select: { + id: true, + name: true, + dose: true, + atcCode: true, + rxNormCode: true, + deliveryForm: true, + }, + }, + }, orderBy: { scheduledFor: "asc" }, }), // Mood data: zero DB read when the user opted out. This is the @@ -386,6 +435,79 @@ export async function collectDoctorReportData( } } + // v1.9.0 — MedicationAdministration source rows. One entry per acted + // intake (taken OR explicitly skipped); pending / missed rows are + // dropped so the FHIR export never asserts an administration that did + // not happen, and tombstoned rows are already excluded by the query + // `deletedAt: null` predicate. The structured `dose` is resolved from + // the medication's `MedicationDoseChange` history — the latest change + // effective at or before the administration instant — when one exists. + // + // Dose-change history is loaded on the `medications` array (active + // meds only); a per-medicationId index lets the resolver run in O(1) + // lookups + a short linear scan over the (typically small) change list. + const doseChangesByMedId = new Map< + string, + Array<{ effectiveFrom: Date; doseValue: number; doseUnit: string }> + >(); + for (const m of medications) { + doseChangesByMedId.set( + m.id, + m.doseChanges.map((dc) => ({ + effectiveFrom: dc.effectiveFrom, + doseValue: dc.doseValue, + doseUnit: dc.doseUnit, + })), + ); + } + const resolveDoseInEffect = ( + medicationId: string, + at: Date, + ): { value: number; unit: string } | null => { + const changes = doseChangesByMedId.get(medicationId); + if (!changes || changes.length === 0) return null; + // `doseChanges` are loaded ordered by `effectiveFrom asc`; take the + // last one whose effectiveFrom is <= the administration instant. + let inEffect: { value: number; unit: string } | null = null; + for (const c of changes) { + if (c.effectiveFrom.getTime() <= at.getTime()) { + inEffect = { value: c.doseValue, unit: c.doseUnit }; + } else { + break; + } + } + return inEffect; + }; + + const medicationAdministrations: NonNullable< + DoctorReportData["medicationAdministrations"] + > = []; + for (const event of intakeEvents) { + // Only acted rows: a taken dose (completed) or an explicit skip + // (not-done). A scheduled-but-unconfirmed ("missed") slot is not an + // administration event and is omitted entirely. + const isTaken = event.takenAt !== null; + if (!isTaken && !event.skipped) continue; + const effectiveAt = isTaken + ? (event.takenAt as Date) + : event.scheduledFor; + medicationAdministrations.push({ + medicationName: event.medication.name, + effectiveAt: effectiveAt.toISOString(), + status: isTaken ? "completed" : "not-done", + doseText: event.medication.dose || null, + // Structured dose only meaningful for a taken dose with a + // dose-change history; a skip records no dose consumed. + dose: isTaken + ? resolveDoseInEffect(event.medication.id, effectiveAt) + : null, + injectionSite: event.injectionSite ?? null, + atcCode: event.medication.atcCode ?? null, + rxNormCode: event.medication.rxNormCode ?? null, + deliveryForm: event.medication.deliveryForm ?? null, + }); + } + // Mood summary. const moodScores = moodEntries.map((e) => e.score); const mood: DoctorReportMood | null = @@ -593,12 +715,16 @@ export async function collectDoctorReportData( medications: medications.map((m) => ({ name: m.name, dose: m.dose, + // v1.9.0 — drug-classification codes for the coded FHIR concept. + atcCode: m.atcCode, + rxNormCode: m.rxNormCode, schedules: m.schedules.map((s) => ({ windowStart: s.windowStart, windowEnd: s.windowEnd, label: s.label, })), })), + medicationAdministrations, mood, glp1, }; diff --git a/src/lib/fhir/__tests__/build-bundle.test.ts b/src/lib/fhir/__tests__/build-bundle.test.ts index 3fb55e4a1..7767a5f5e 100644 --- a/src/lib/fhir/__tests__/build-bundle.test.ts +++ b/src/lib/fhir/__tests__/build-bundle.test.ts @@ -336,6 +336,83 @@ describe("buildFhirDocumentBundle", () => { expect(meds[0].dosage?.[0].text).toBe("5mg"); }); + it("keeps a text-only medicationCodeableConcept when no codes are stored", () => { + const bundle = buildFhirDocumentBundle( + makeData(), + { insuranceNumber: null }, + FIXED_NOW, + ); + const stmt = bundle.entry + .map((e) => e.resource) + .find( + (r): r is FhirMedicationStatement => + r.resourceType === "MedicationStatement", + ); + // Pre-v1.9.0 shape: text anchor, no coding[]. + expect(stmt?.medicationCodeableConcept.text).toBe("Example Drug"); + expect(stmt?.medicationCodeableConcept.coding).toBeUndefined(); + }); + + it("emits ATC (primary) + RxNorm (secondary) codings when both are stored", () => { + const bundle = buildFhirDocumentBundle( + makeData({ + medications: [ + { + name: "Mounjaro", + dose: "10mg", + atcCode: "A10BX10", + rxNormCode: "2601723", + schedules: [], + }, + ], + }), + { insuranceNumber: null }, + FIXED_NOW, + ); + const stmt = bundle.entry + .map((e) => e.resource) + .find( + (r): r is FhirMedicationStatement => + r.resourceType === "MedicationStatement", + ); + const coding = stmt?.medicationCodeableConcept.coding; + expect(stmt?.medicationCodeableConcept.text).toBe("Mounjaro"); + expect(coding).toHaveLength(2); + // ATC is primary (first), with the name as display. + expect(coding?.[0]).toEqual({ + system: "http://www.whocc.no/atc", + code: "A10BX10", + display: "Mounjaro", + }); + // RxNorm is secondary, no display. + expect(coding?.[1]).toEqual({ + system: "http://www.nlm.nih.gov/research/umls/rxnorm", + code: "2601723", + }); + }); + + it("emits only the ATC coding when RxNorm is absent", () => { + const bundle = buildFhirDocumentBundle( + makeData({ + medications: [ + { name: "Ramipril", dose: "5mg", atcCode: "C09AA05", schedules: [] }, + ], + }), + { insuranceNumber: null }, + FIXED_NOW, + ); + const stmt = bundle.entry + .map((e) => e.resource) + .find( + (r): r is FhirMedicationStatement => + r.resourceType === "MedicationStatement", + ); + const coding = stmt?.medicationCodeableConcept.coding; + expect(coding).toHaveLength(1); + expect(coding?.[0].system).toBe("http://www.whocc.no/atc"); + expect(coding?.[0].code).toBe("C09AA05"); + }); + it("omits the mood Observation when mood is null (privacy default)", () => { const bundle = buildFhirDocumentBundle( makeData({ mood: null }), diff --git a/src/lib/fhir/build-bundle.ts b/src/lib/fhir/build-bundle.ts index 34a85eb26..785da312f 100644 --- a/src/lib/fhir/build-bundle.ts +++ b/src/lib/fhir/build-bundle.ts @@ -60,6 +60,36 @@ const KVNR_SYSTEM = "http://fhir.de/sid/gkv/kvid-10"; /** German insurer institution-number (IKNR) identifier namespace. */ const IKNR_SYSTEM = "http://fhir.de/sid/arge-ik/iknr"; +/** + * v1.9.0 — drug-coding system URIs. ATC is the portable WHO default + * (the iOS export emits the identical URI); RxNorm is the secondary US + * coding. Both are additive `coding[]` entries on the same concept; the + * free-text `.text` (the user's medication name) stays the anchor. + */ +const ATC_SYSTEM = "http://www.whocc.no/atc"; +const RXNORM_SYSTEM = "http://www.nlm.nih.gov/research/umls/rxnorm"; + +/** + * Build a `medicationCodeableConcept` from a medication's free-text name + * plus its optional user-asserted codes. ATC is emitted first (primary), + * RxNorm second (secondary); both are omitted when NULL, collapsing to + * exactly the pre-v1.9.0 `{ text }` shape. Never machine-guesses a code. + */ +function medicationConcept( + name: string, + atcCode: string | null | undefined, + rxNormCode: string | null | undefined, +): FhirCodeableConcept { + const coding: NonNullable = []; + if (atcCode) { + coding.push({ system: ATC_SYSTEM, code: atcCode, display: name }); + } + if (rxNormCode) { + coding.push({ system: RXNORM_SYSTEM, code: rxNormCode }); + } + return coding.length > 0 ? { coding, text: name } : { text: name }; +} + /** Escape the five XML-significant characters for the xhtml narrative. */ function escapeXml(value: string): string { return value @@ -389,7 +419,14 @@ export function buildFhirDocumentBundle( resourceType: "MedicationStatement", id, status: "active", - medicationCodeableConcept: { text: med.name }, + // v1.9.0 — additive ATC (primary) / RxNorm (secondary) codings when + // the user stored them; falls back to the text-only concept when + // neither code is present (the pre-v1.9.0 shape). + medicationCodeableConcept: medicationConcept( + med.name, + med.atcCode, + med.rxNormCode, + ), subject: patientRef, }; if (med.dose) stmt.dosage = [{ text: med.dose }]; From ca8781867b0f40a7d71ea37047d946ca179771ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 2 Jun 2026 16:40:41 +0200 Subject: [PATCH 12/30] feat(fhir): add a MedicationAdministration per acted intake MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Emit one `MedicationAdministration` per intake the user actually actioned — `completed` for a taken dose (`effectiveDateTime = takenAt`), `not-done` for an explicit skip (`effectiveDateTime = scheduledFor`). Pending / missed slots and soft-deleted tombstones are never exported, so the record only asserts administrations that happened. Each administration reuses the statement's ATC/RxNorm + text concept, so it is self-describing without a cross-resource reference. A `dosage` is emitted only when a structured dose Quantity is available (resolved from the dose-change history in effect at the administration instant) — a dosage carrying only `.text` would violate the R4 dose-or-rate invariant. Route + injection site ride along as text-only concepts. The administrations are referenced from the existing Medications section, so the document's two-section graph is preserved. `FhirMedicationAdministration` is new; `FhirDosage` gains `dose`, `route`, and `site`. The source rows come from the same aggregator the PDF consumes, keeping FHIR and PDF aligned by construction. Co-Authored-By: Claude Opus 4.8 (1M context) (cherry picked from commit dd22512f56b7b6dfa42386bbc88d1ced1d71255d) --- src/lib/fhir/__tests__/build-bundle.test.ts | 149 ++++++++++++++++++ src/lib/fhir/build-bundle.ts | 111 ++++++++++++- src/lib/fhir/types.ts | 33 ++++ .../medication-administrations.test.ts | 134 ++++++++++++++++ 4 files changed, 424 insertions(+), 3 deletions(-) create mode 100644 tests/integration/medication-administrations.test.ts diff --git a/src/lib/fhir/__tests__/build-bundle.test.ts b/src/lib/fhir/__tests__/build-bundle.test.ts index 7767a5f5e..62c840260 100644 --- a/src/lib/fhir/__tests__/build-bundle.test.ts +++ b/src/lib/fhir/__tests__/build-bundle.test.ts @@ -4,11 +4,23 @@ import type { DoctorReportData } from "@/lib/doctor-report-data"; import type { FhirObservation, FhirMedicationStatement, + FhirMedicationAdministration, FhirPatient, FhirCoverage, FhirDiagnosticReport, } from "../types"; +function administrationsOf( + bundle: ReturnType, +): FhirMedicationAdministration[] { + return bundle.entry + .map((e) => e.resource) + .filter( + (r): r is FhirMedicationAdministration => + r.resourceType === "MedicationAdministration", + ); +} + function coverageOf( bundle: ReturnType, ): FhirCoverage | undefined { @@ -413,6 +425,143 @@ describe("buildFhirDocumentBundle", () => { expect(coding?.[0].code).toBe("C09AA05"); }); + it("maps a taken intake to a completed MedicationAdministration with a structured dose", () => { + const bundle = buildFhirDocumentBundle( + makeData({ + medicationAdministrations: [ + { + medicationName: "Mounjaro", + effectiveAt: "2026-04-30T08:12:00.000Z", + status: "completed", + doseText: "10mg", + dose: { value: 10, unit: "mg" }, + injectionSite: "abdomen-left", + atcCode: "A10BX10", + rxNormCode: "2601723", + deliveryForm: "INJECTION", + }, + ], + }), + { insuranceNumber: null }, + FIXED_NOW, + ); + const admins = administrationsOf(bundle); + expect(admins).toHaveLength(1); + const a = admins[0]; + expect(a.status).toBe("completed"); + expect(a.effectiveDateTime).toBe("2026-04-30T08:12:00.000Z"); + // Self-describing concept reuses the ATC/RxNorm coding + text anchor. + expect(a.medicationCodeableConcept.text).toBe("Mounjaro"); + expect(a.medicationCodeableConcept.coding?.[0].code).toBe("A10BX10"); + // Structured dose satisfies the R4 dose-or-rate invariant. + expect(a.dosage?.dose).toEqual({ + value: 10, + unit: "mg", + system: "http://unitsofmeasure.org", + code: "mg", + }); + expect(a.dosage?.text).toBe("10mg"); + expect(a.dosage?.route?.text).toBe("Injection"); + expect(a.dosage?.site?.text).toBe("abdomen-left"); + }); + + it("maps a skipped intake to a not-done administration with no dosage", () => { + const bundle = buildFhirDocumentBundle( + makeData({ + medicationAdministrations: [ + { + medicationName: "Ramipril", + effectiveAt: "2026-04-29T08:00:00.000Z", + status: "not-done", + doseText: "5mg", + dose: null, + injectionSite: null, + atcCode: null, + rxNormCode: null, + deliveryForm: "ORAL", + }, + ], + }), + { insuranceNumber: null }, + FIXED_NOW, + ); + const admins = administrationsOf(bundle); + expect(admins).toHaveLength(1); + expect(admins[0].status).toBe("not-done"); + expect(admins[0].effectiveDateTime).toBe("2026-04-29T08:00:00.000Z"); + // No structured dose → no dosage block (R4 dose-or-rate invariant). + expect(admins[0].dosage).toBeUndefined(); + // Text-only concept when the medication has no codes. + expect(admins[0].medicationCodeableConcept.coding).toBeUndefined(); + expect(admins[0].medicationCodeableConcept.text).toBe("Ramipril"); + }); + + it("omits the dosage when a taken dose has no structured dose Quantity", () => { + const bundle = buildFhirDocumentBundle( + makeData({ + medicationAdministrations: [ + { + medicationName: "Vitamin D3", + effectiveAt: "2026-04-30T20:00:00.000Z", + status: "completed", + doseText: "1000 IU", + dose: null, + injectionSite: null, + atcCode: null, + rxNormCode: null, + deliveryForm: "ORAL", + }, + ], + }), + { insuranceNumber: null }, + FIXED_NOW, + ); + const a = administrationsOf(bundle)[0]; + expect(a.status).toBe("completed"); + // A dosage with only `.text` would violate the dose-or-rate + // invariant; the builder omits the dosage entirely instead. + expect(a.dosage).toBeUndefined(); + }); + + it("emits no MedicationAdministration when there are no acted intakes", () => { + const bundle = buildFhirDocumentBundle( + makeData({ medicationAdministrations: [] }), + { insuranceNumber: null }, + FIXED_NOW, + ); + expect(administrationsOf(bundle)).toHaveLength(0); + }); + + it("references the administrations from the Medications composition section", () => { + const bundle = buildFhirDocumentBundle( + makeData({ + medicationAdministrations: [ + { + medicationName: "Mounjaro", + effectiveAt: "2026-04-30T08:12:00.000Z", + status: "completed", + doseText: "10mg", + dose: { value: 10, unit: "mg" }, + injectionSite: null, + atcCode: null, + rxNormCode: null, + deliveryForm: "INJECTION", + }, + ], + }), + { insuranceNumber: null }, + FIXED_NOW, + ); + const composition = bundle.entry[0].resource; + expect(composition.resourceType).toBe("Composition"); + const medSection = + composition.resourceType === "Composition" + ? composition.section?.find((s) => s.title === "Medications") + : undefined; + const refs = medSection?.entry?.map((e) => e.reference) ?? []; + expect(refs).toContain("MedicationAdministration/medadmin-1"); + }); + it("omits the mood Observation when mood is null (privacy default)", () => { const bundle = buildFhirDocumentBundle( makeData({ mood: null }), diff --git a/src/lib/fhir/build-bundle.ts b/src/lib/fhir/build-bundle.ts index 785da312f..7227ba4d5 100644 --- a/src/lib/fhir/build-bundle.ts +++ b/src/lib/fhir/build-bundle.ts @@ -39,6 +39,8 @@ import type { FhirCodeableConcept, FhirObservation, FhirMedicationStatement, + FhirMedicationAdministration, + FhirDosage, FhirPatient, FhirCoverage, FhirOrganization, @@ -90,6 +92,54 @@ function medicationConcept( return coding.length > 0 ? { coding, text: name } : { text: name }; } +/** + * v1.9.0 — UCUM codes for the dose units HealthLog stores. The display + * `unit` is always the user's original string; the UCUM `code` is set + * only for an unambiguous mapping so a consumer that resolves UCUM never + * sees a guessed code. An unmapped unit drops `code` (and `system`) and + * keeps just the human-readable `unit` — conformant, just not coded. + */ +const UCUM_DOSE_CODES: Record = { + mg: "mg", + g: "g", + mcg: "ug", + µg: "ug", + ug: "ug", + ml: "mL", + mL: "mL", +}; + +function doseQuantity(value: number, unit: string): { + value: number; + unit: string; + system?: string; + code?: string; +} { + const ucum = UCUM_DOSE_CODES[unit]; + return ucum + ? { value, unit, system: UCUM_SYSTEM, code: ucum } + : { value, unit }; +} + +/** + * v1.9.0 — text-only route of administration derived from the + * medication's delivery form. Text-only (no SNOMED licence concern); the + * design doc upgrades to coded routes jointly with iOS later. Returns + * `undefined` for an unknown / absent form so no empty route is emitted. + */ +function routeConcept( + deliveryForm: string | null, +): FhirCodeableConcept | undefined { + switch (deliveryForm) { + case "ORAL": + return { text: "Oral" }; + case "INJECTION": + return { text: "Injection" }; + default: + return undefined; + } +} + /** Escape the five XML-significant characters for the xhtml narrative. */ function escapeXml(value: string): string { return value @@ -434,11 +484,57 @@ export function buildFhirDocumentBundle( medicationRefs.push({ reference: `MedicationStatement/${id}` }); } + // --- MedicationAdministration per acted intake ------------------------- + // One resource per intake the user actually actioned: `completed` + // (taken) or `not-done` (explicitly skipped). Pending / missed slots and + // soft-deleted tombstones are excluded upstream by the aggregator. The + // concept reuses the same ATC/RxNorm coding as the statement so each + // administration is self-describing without resolving a reference (no + // `partOf` / `request` coupling — see the v1.9.0 design doc). A `dosage` + // is emitted ONLY when a structured `dose` Quantity is available; a + // dosage with only `.text` would violate the R4 dose-or-rate invariant. + const administrationRefs: FhirReference[] = []; + let adminSeq = 0; + for (const admin of data.medicationAdministrations ?? []) { + adminSeq += 1; + const id = `medadmin-${adminSeq}`; + const resource: FhirMedicationAdministration = { + resourceType: "MedicationAdministration", + id, + status: admin.status, + medicationCodeableConcept: medicationConcept( + admin.medicationName, + admin.atcCode, + admin.rxNormCode, + ), + subject: patientRef, + effectiveDateTime: admin.effectiveAt, + }; + + // Dosage: only when a structured dose Quantity exists. Carry the + // free-text dose + route + site alongside it. Text-only route/site + // (no SNOMED licence concern); the design doc upgrades to coded + // routes jointly with iOS later. + if (admin.dose) { + const dosage: FhirDosage = { + dose: doseQuantity(admin.dose.value, admin.dose.unit), + }; + if (admin.doseText) dosage.text = admin.doseText; + const route = routeConcept(admin.deliveryForm); + if (route) dosage.route = route; + if (admin.injectionSite) dosage.site = { text: admin.injectionSite }; + resource.dosage = dosage; + } + + entries.push({ fullUrl: `urn:uuid:${id}`, resource }); + administrationRefs.push({ reference: `MedicationAdministration/${id}` }); + } + // --- Composition (leading "cover" resource) ---------------------------- const narrativeText = [ `Health record for ${escapeXml(displayName ?? "patient")}.`, `Reporting period ${data.period.start.slice(0, 10)} to ${data.period.end.slice(0, 10)}.`, - `${observationRefs.length} observation(s), ${medicationRefs.length} medication(s).`, + `${observationRefs.length} observation(s), ${medicationRefs.length} medication(s), ${administrationRefs.length} administration(s).`, ].join(" "); const composition: FhirComposition = { @@ -467,8 +563,17 @@ export function buildFhirDocumentBundle( text: { status: "generated", div: `
    ${escapeXml(narrativeText)}
    ` }, entry: [patientRef, ...observationRefs], }, - ...(medicationRefs.length > 0 - ? [{ title: "Medications", entry: medicationRefs }] + // Medications section carries both the active-medication + // statements and the per-dose administration records (v1.9.0), so + // the iOS two-section graph ("Vital signs" + "Medications") is + // preserved without adding a third section. + ...(medicationRefs.length > 0 || administrationRefs.length > 0 + ? [ + { + title: "Medications", + entry: [...medicationRefs, ...administrationRefs], + }, + ] : []), ], }; diff --git a/src/lib/fhir/types.ts b/src/lib/fhir/types.ts index 7b7484f66..55a375235 100644 --- a/src/lib/fhir/types.ts +++ b/src/lib/fhir/types.ts @@ -79,6 +79,17 @@ export interface FhirObservation { export interface FhirDosage { text?: string; + /** + * v1.9.0 — structured dose `Quantity`. On a `MedicationAdministration` + * a `dosage` SHALL carry at least one of `dose` or `rate` (R4 + * invariant), so the builder only emits a `dosage` block when a `dose` + * is available; a text-only dose stays on the `MedicationStatement`. + */ + dose?: FhirQuantity; + /** v1.9.0 — route of administration. Text-only by default (no SNOMED licence concern). */ + route?: FhirCodeableConcept; + /** v1.9.0 — administration body-site (e.g. injection site). Text-only by default. */ + site?: FhirCodeableConcept; } export interface FhirPeriod { @@ -96,6 +107,27 @@ export interface FhirMedicationStatement { dosage?: FhirDosage[]; } +export interface FhirMedicationAdministration { + resourceType: "MedicationAdministration"; + id: string; + /** + * v1.9.0 — only `completed` (a taken dose) or `not-done` (an explicit + * skip) are emitted; the exporter never asserts an administration for a + * pending / missed slot or a soft-deleted tombstone. + */ + status: "completed" | "not-done"; + /** Self-describing concept — the same ATC/RxNorm + text the statement carries. */ + medicationCodeableConcept: FhirCodeableConcept; + subject: FhirReference; + /** + * `takenAt` for a `completed` administration, `scheduledFor` (the + * intended instant) for a `not-done` skip. + */ + effectiveDateTime: string; + /** Single dosage (R4 `0..1`). Present only when a structured `dose` exists. */ + dosage?: FhirDosage; +} + export interface FhirDiagnosticReport { resourceType: "DiagnosticReport"; id: string; @@ -152,6 +184,7 @@ export type FhirResource = | FhirCoverage | FhirObservation | FhirMedicationStatement + | FhirMedicationAdministration | FhirDiagnosticReport; export interface FhirBundleEntry { diff --git a/tests/integration/medication-administrations.test.ts b/tests/integration/medication-administrations.test.ts new file mode 100644 index 000000000..f1b7ea5f7 --- /dev/null +++ b/tests/integration/medication-administrations.test.ts @@ -0,0 +1,134 @@ +/** + * v1.9.0 — `medicationAdministrations` aggregation + drug-code carry. + * + * Asserts the source rows the FHIR `MedicationAdministration` builder + * consumes are assembled correctly from `MedicationIntakeEvent`: + * - a taken row → `completed` with `effectiveAt = takenAt`, + * - an explicit skip → `not-done` with `effectiveAt = scheduledFor`, + * - a pending / missed slot (no `takenAt`, not skipped) → omitted, + * - a soft-deleted tombstone → omitted, + * - the structured dose-in-effect resolved from `MedicationDoseChange`, + * - the medication's `atcCode` / `rxNormCode` carried onto both the + * `medications[]` concept and each administration row. + */ +import { beforeEach, describe, expect, it } from "vitest"; + +process.env.ENCRYPTION_KEY ??= + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +import { collectDoctorReportData } from "@/lib/doctor-report-data"; + +import { getPrismaClient, truncateAllTables } from "./setup"; + +const RANGE = { + start: new Date("2026-01-01T00:00:00.000Z"), + end: new Date("2026-06-01T00:00:00.000Z"), + days: 152, +}; + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); +}); + +describe("medicationAdministrations aggregation (v1.9.0)", () => { + it("maps acted intakes, omits missed + tombstoned, resolves dose-in-effect", async () => { + const prisma = getPrismaClient(); + const user = await prisma.user.create({ + data: { username: "admin-agg", email: "admin-agg@example.test" }, + }); + + const med = await prisma.medication.create({ + data: { + userId: user.id, + name: "Mounjaro", + dose: "10mg", + active: true, + treatmentClass: "GLP1", + deliveryForm: "INJECTION", + atcCode: "A10BX10", + rxNormCode: "2601723", + }, + }); + + // Dose-change history: 5mg from Feb, stepped to 10mg from Apr. + await prisma.medicationDoseChange.createMany({ + data: [ + { + medicationId: med.id, + effectiveFrom: new Date("2026-02-01T00:00:00.000Z"), + doseValue: 5, + doseUnit: "mg", + }, + { + medicationId: med.id, + effectiveFrom: new Date("2026-04-01T00:00:00.000Z"), + doseValue: 10, + doseUnit: "mg", + }, + ], + }); + + // A taken dose in March (dose-in-effect = 5mg) with a site. + await prisma.medicationIntakeEvent.create({ + data: { + userId: user.id, + medicationId: med.id, + scheduledFor: new Date("2026-03-15T08:00:00.000Z"), + takenAt: new Date("2026-03-15T08:12:00.000Z"), + injectionSite: "ABDOMEN_LEFT", + }, + }); + // A skipped dose in April. + await prisma.medicationIntakeEvent.create({ + data: { + userId: user.id, + medicationId: med.id, + scheduledFor: new Date("2026-04-15T08:00:00.000Z"), + skipped: true, + }, + }); + // A missed slot (no takenAt, not skipped) — must be omitted. + await prisma.medicationIntakeEvent.create({ + data: { + userId: user.id, + medicationId: med.id, + scheduledFor: new Date("2026-05-01T08:00:00.000Z"), + }, + }); + // A soft-deleted (tombstoned) taken dose — must be omitted. + await prisma.medicationIntakeEvent.create({ + data: { + userId: user.id, + medicationId: med.id, + scheduledFor: new Date("2026-05-10T08:00:00.000Z"), + takenAt: new Date("2026-05-10T08:05:00.000Z"), + deletedAt: new Date("2026-05-11T00:00:00.000Z"), + }, + }); + + const data = await collectDoctorReportData(user.id, RANGE); + + // Drug codes carried onto the medication concept source. + expect(data.medications[0].atcCode).toBe("A10BX10"); + expect(data.medications[0].rxNormCode).toBe("2601723"); + + const admins = data.medicationAdministrations ?? []; + // Only the taken + skipped rows survive. + expect(admins).toHaveLength(2); + + const taken = admins.find((a) => a.status === "completed"); + expect(taken).toBeDefined(); + expect(taken?.effectiveAt).toBe("2026-03-15T08:12:00.000Z"); + expect(taken?.injectionSite).toBe("ABDOMEN_LEFT"); + expect(taken?.deliveryForm).toBe("INJECTION"); + expect(taken?.atcCode).toBe("A10BX10"); + // Dose-in-effect at 2026-03-15 is the 5mg step (not yet 10mg). + expect(taken?.dose).toEqual({ value: 5, unit: "mg" }); + + const skipped = admins.find((a) => a.status === "not-done"); + expect(skipped).toBeDefined(); + // A skip uses scheduledFor and records no consumed dose. + expect(skipped?.effectiveAt).toBe("2026-04-15T08:00:00.000Z"); + expect(skipped?.dose).toBeNull(); + }); +}); From 7fa2866154d03fb0aef8b469caa067100c892337 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 2 Jun 2026 16:42:39 +0200 Subject: [PATCH 13/30] feat(fhir): emit Coverage on a bare KVNR + add Composition.text Align the server's `Coverage` emission with the iOS exporter: a Coverage is now emitted whenever ANY payor signal is present, including a bare KVNR with no insurer name or IKNR. The KVNR-only case carries the `subscriberId` with no contained payor Organization; the insurer-known case is unchanged. A bundle with no payor signal at all still omits Coverage entirely. Also add a top-level `Composition.text` narrative (reusing the same escaped summary the section narrative carries) so a strict US-Core-style validator sees the document-level narrative it expects. Co-Authored-By: Claude Opus 4.8 (1M context) (cherry picked from commit 22c5ed7fbe02993307c3f04cd0498d199c79cf43) --- src/lib/fhir/__tests__/build-bundle.test.ts | 46 ++++++++++++++++++-- src/lib/fhir/build-bundle.ts | 48 +++++++++++++-------- src/lib/fhir/types.ts | 6 +++ 3 files changed, 78 insertions(+), 22 deletions(-) diff --git a/src/lib/fhir/__tests__/build-bundle.test.ts b/src/lib/fhir/__tests__/build-bundle.test.ts index 62c840260..c3aa8a1b7 100644 --- a/src/lib/fhir/__tests__/build-bundle.test.ts +++ b/src/lib/fhir/__tests__/build-bundle.test.ts @@ -241,7 +241,7 @@ describe("buildFhirDocumentBundle", () => { expect(coverage?.contained?.[0].identifier?.[0].value).toBe("101234567"); }); - it("omits the Coverage entirely when neither insurer name nor IKNR is present", () => { + it("omits the Coverage entirely when there is no payor signal at all", () => { const bundle = buildFhirDocumentBundle( makeData({ patient: { @@ -253,17 +253,57 @@ describe("buildFhirDocumentBundle", () => { // neither insurerName nor insurerIkNumber }, }), - { insuranceNumber: "A123456780" }, + // ...and no KVNR. + { insuranceNumber: null }, FIXED_NOW, ); expect(coverageOf(bundle)).toBeUndefined(); - // KVNR still rides on Patient.identifier. + }); + + it("emits a KVNR-only Coverage (no insurer) to align with the iOS exporter", () => { + const bundle = buildFhirDocumentBundle( + makeData({ + patient: { + username: "sample-user", + dateOfBirth: null, + gender: null, + heightCm: null, + fullName: "Sample Patient", + // neither insurerName nor insurerIkNumber — just a bare KVNR. + }, + }), + { insuranceNumber: "A123456780" }, + FIXED_NOW, + ); + const coverage = coverageOf(bundle); + expect(coverage).toBeDefined(); + expect(coverage?.status).toBe("active"); + expect(coverage?.beneficiary.reference).toBe("Patient/patient-1"); + expect(coverage?.subscriberId).toBe("A123456780"); + // No insurer known → no contained payor Organization, no payor[]. + expect(coverage?.contained).toBeUndefined(); + expect(coverage?.payor).toBeUndefined(); + // KVNR still rides on Patient.identifier too. const patient = bundle.entry.find( (e) => e.resource.resourceType === "Patient", )?.resource as FhirPatient; expect(patient.identifier?.[0].value).toBe("A123456780"); }); + it("emits a top-level Composition.text narrative", () => { + const bundle = buildFhirDocumentBundle( + makeData(), + { insuranceNumber: null }, + FIXED_NOW, + ); + const composition = bundle.entry[0].resource; + expect(composition.resourceType).toBe("Composition"); + if (composition.resourceType === "Composition") { + expect(composition.text?.status).toBe("generated"); + expect(composition.text?.div).toContain("Health record for"); + } + }); + it("maps weight to LOINC 29463-7 / UCUM kg with the latest reading", () => { const bundle = buildFhirDocumentBundle( makeData(), diff --git a/src/lib/fhir/build-bundle.ts b/src/lib/fhir/build-bundle.ts index 7227ba4d5..2578c6912 100644 --- a/src/lib/fhir/build-bundle.ts +++ b/src/lib/fhir/build-bundle.ts @@ -226,33 +226,36 @@ export function buildFhirDocumentBundle( entries.push({ fullUrl: `urn:uuid:${patientId}`, resource: patient }); // --- Coverage (insurer; sits right after the Patient) ------------------ - // Emitted whenever ANY payor info is present (insurer name and/or IKNR). - // The payor is a CONTAINED Organization referenced by a local `#`-ref. - // The KVNR stays on `Patient.identifier`; here it doubles as the - // Coverage `subscriberId` (the member id) when present. When neither an - // insurer name nor an IKNR is known, omit Coverage entirely rather than - // emit an empty payor. + // v1.9.0 — emitted whenever ANY payor signal is present: an insurer + // name, an IKNR, OR a bare KVNR. The KVNR-only case aligns the server + // with the iOS exporter (which emits a Coverage on a bare member id); + // it carries the `subscriberId` with no contained payor Organization. + // When an insurer name and/or IKNR is known, the payor is a CONTAINED + // Organization referenced by a local `#`-ref. The KVNR stays on + // `Patient.identifier` and doubles as the `subscriberId` (member id). const insurerName = data.patient.insurerName ?? null; const insurerIkNumber = data.patient.insurerIkNumber ?? null; - if (insurerName || insurerIkNumber) { - const orgId = "insurer-org-1"; - const payorOrg: FhirOrganization = { - resourceType: "Organization", - id: orgId, - }; - if (insurerIkNumber) { - payorOrg.identifier = [{ system: IKNR_SYSTEM, value: insurerIkNumber }]; - } - if (insurerName) payorOrg.name = insurerName; - + const hasPayorOrg = Boolean(insurerName || insurerIkNumber); + if (hasPayorOrg || identity.insuranceNumber) { const coverage: FhirCoverage = { resourceType: "Coverage", id: "coverage-1", status: "active", - contained: [payorOrg], beneficiary: patientRef, - payor: [{ reference: `#${orgId}` }], }; + if (hasPayorOrg) { + const orgId = "insurer-org-1"; + const payorOrg: FhirOrganization = { + resourceType: "Organization", + id: orgId, + }; + if (insurerIkNumber) { + payorOrg.identifier = [{ system: IKNR_SYSTEM, value: insurerIkNumber }]; + } + if (insurerName) payorOrg.name = insurerName; + coverage.contained = [payorOrg]; + coverage.payor = [{ reference: `#${orgId}` }]; + } if (identity.insuranceNumber) { coverage.subscriberId = identity.insuranceNumber; } @@ -555,6 +558,13 @@ export function buildFhirDocumentBundle( date: now.toISOString(), author: [{ reference: "Device/healthlog", display: "HealthLog" }], title: "Health Record", + // v1.9.0 — top-level document narrative (Coverage nit 2). Reuses the + // same escaped plain-text summary the Vital-signs section carries, so + // a strict US-Core-style validator sees a `Composition.text`. + text: { + status: "generated", + div: `
    ${escapeXml(narrativeText)}
    `, + }, section: [ { // Vital-signs section carries the narrative + every Observation ref, diff --git a/src/lib/fhir/types.ts b/src/lib/fhir/types.ts index 55a375235..649e6f54f 100644 --- a/src/lib/fhir/types.ts +++ b/src/lib/fhir/types.ts @@ -175,6 +175,12 @@ export interface FhirComposition { date: string; author: FhirReference[]; title: string; + /** + * v1.9.0 — top-level document narrative. Section narrative is already + * present; a strict US-Core-style validator additionally expects a + * `Composition.text`. Reuses the same escaped plain-text summary. + */ + text?: FhirNarrative; section?: FhirCompositionSection[]; } From 18cde65af7a6bfddf020f62ca572a4b4f321c8c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 2 Jun 2026 16:52:11 +0200 Subject: [PATCH 14/30] docs(openapi): register the six specialised insight status routes The generic metric-status route set the OpenAPI pattern for the assessment cards; retrofit the six specialised routes onto it. Add registry entries for /api/insights/{blood-pressure,pulse,weight,bmi,mood,medication-compliance}-status mirroring the metric-status envelope (hasProvider/text/cached/updatedAt plus the preparing + revalidating flags). Medication-compliance carries its richer summary + per-medication note array as its own schema. Regenerate the YAML. Co-Authored-By: Claude Opus 4.8 (1M context) (cherry picked from commit 91887a13b30e8978e91f615604163331bdc54950) --- docs/api/openapi.yaml | 366 ++++++++++++++++++++++++++++++++++++++ src/lib/openapi/routes.ts | 264 +++++++++++++++++++++++++++ 2 files changed, 630 insertions(+) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index b784f0af7..428463849 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -2035,6 +2035,174 @@ paths: "401": *a1 "422": *a2 "429": *a3 + /api/insights/blood-pressure-status: + get: + tags: + - Insights + summary: Blood-pressure assessment + description: "Data-driven plain-language assessment of the user's recent blood-pressure readings. Read-only: a cache + miss warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate). Auth via + cookie or Bearer." + parameters: + - in: query + name: locale + schema: + description: Optional UI-locale override; defaults to the session locale. + type: string + enum: + - de + - en + description: Optional UI-locale override; defaults to the session locale. + responses: + "200": + description: Assessment envelope (fresh, cached, or preparing). + content: + application/json: + schema: + $ref: "#/components/schemas/BloodPressureStatusResponseEnvelope" + "401": *a1 + "422": *a2 + "429": *a3 + /api/insights/pulse-status: + get: + tags: + - Insights + summary: Pulse assessment + description: "Data-driven plain-language assessment of the user's recent resting-pulse readings. Read-only: a cache miss + warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate). Auth via cookie + or Bearer." + parameters: + - in: query + name: locale + schema: + description: Optional UI-locale override; defaults to the session locale. + type: string + enum: + - de + - en + description: Optional UI-locale override; defaults to the session locale. + responses: + "200": + description: Assessment envelope (fresh, cached, or preparing). + content: + application/json: + schema: + $ref: "#/components/schemas/PulseStatusResponseEnvelope" + "401": *a1 + "422": *a2 + "429": *a3 + /api/insights/weight-status: + get: + tags: + - Insights + summary: Weight assessment + description: "Data-driven plain-language assessment of the user's recent weight trend. Read-only: a cache miss warms a + generation out of band and serves the last-good text meanwhile (stale-while-revalidate). Auth via cookie or + Bearer." + parameters: + - in: query + name: locale + schema: + description: Optional UI-locale override; defaults to the session locale. + type: string + enum: + - de + - en + description: Optional UI-locale override; defaults to the session locale. + responses: + "200": + description: Assessment envelope (fresh, cached, or preparing). + content: + application/json: + schema: + $ref: "#/components/schemas/WeightStatusResponseEnvelope" + "401": *a1 + "422": *a2 + "429": *a3 + /api/insights/bmi-status: + get: + tags: + - Insights + summary: BMI assessment + description: "Data-driven plain-language assessment of the user's body-mass index. Read-only: a cache miss warms a + generation out of band and serves the last-good text meanwhile (stale-while-revalidate). Auth via cookie or + Bearer." + parameters: + - in: query + name: locale + schema: + description: Optional UI-locale override; defaults to the session locale. + type: string + enum: + - de + - en + description: Optional UI-locale override; defaults to the session locale. + responses: + "200": + description: Assessment envelope (fresh, cached, or preparing). + content: + application/json: + schema: + $ref: "#/components/schemas/BmiStatusResponseEnvelope" + "401": *a1 + "422": *a2 + "429": *a3 + /api/insights/mood-status: + get: + tags: + - Insights + summary: Mood assessment + description: "Data-driven plain-language assessment of the user's recent mood entries. Read-only: a cache miss warms a + generation out of band and serves the last-good text meanwhile (stale-while-revalidate). Auth via cookie or + Bearer." + parameters: + - in: query + name: locale + schema: + description: Optional UI-locale override; defaults to the session locale. + type: string + enum: + - de + - en + description: Optional UI-locale override; defaults to the session locale. + responses: + "200": + description: Assessment envelope (fresh, cached, or preparing). + content: + application/json: + schema: + $ref: "#/components/schemas/MoodStatusResponseEnvelope" + "401": *a1 + "422": *a2 + "429": *a3 + /api/insights/medication-compliance-status: + get: + tags: + - Insights + summary: Medication-compliance assessment + description: "Data-driven plain-language assessment of the user's medication compliance — an overall `summary` plus a + per-medication note array. Read-only: a cache miss warms a generation out of band and serves the last-good + envelope meanwhile (stale-while-revalidate). Auth via cookie or Bearer." + parameters: + - in: query + name: locale + schema: + description: Optional UI-locale override; defaults to the session locale. + type: string + enum: + - de + - en + description: Optional UI-locale override; defaults to the session locale. + responses: + "200": + description: Compliance assessment envelope (fresh, cached, or preparing). + content: + application/json: + schema: + $ref: "#/components/schemas/MedicationComplianceStatusResponseEnvelope" + "401": *a1 + "422": *a2 + "429": *a3 /api/insights/metric-status: get: tags: @@ -6578,6 +6746,204 @@ components: - mean - sum additionalProperties: false + BloodPressureStatusResponseEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/InsightStatusResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + InsightStatusResponse: + type: object + properties: + hasProvider: + type: boolean + description: False when the user has no usable AI provider — `text` then carries the generic no-key guidance. + text: + anyOf: + - type: string + - type: "null" + description: The assessment narrative (plain text, rendered as React text children). Null while a first generation is + preparing. + cached: + type: boolean + description: True when `text` is served from cache (incl. last-good). + updatedAt: + anyOf: + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - type: "null" + description: When the served assessment was generated; null when none. + preparing: + description: True when a first assessment is being generated out of band and no prior text exists yet — the client polls + until it lands. + type: boolean + revalidating: + description: True when `text` is served from last-good cache (stale-while-revalidate) while a fresh generation is in + flight. The client keeps polling on `preparing || revalidating` (bounded) so the open card upgrades to the + warmed assessment without a remount. + type: boolean + required: + - hasProvider + - text + - cached + - updatedAt + additionalProperties: false + description: "Specialised per-metric assessment envelope (blood-pressure, pulse, weight, bmi, mood). Identical shape to + the generic metric-status card so the `InsightStatusCard` consumes it unchanged. Read-only + + stale-while-revalidate: a cache miss warms a generation out of band and serves the last-good text meanwhile." + PulseStatusResponseEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/InsightStatusResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + WeightStatusResponseEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/InsightStatusResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + BmiStatusResponseEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/InsightStatusResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + MoodStatusResponseEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/InsightStatusResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + MedicationComplianceStatusResponseEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/MedicationComplianceStatusResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + MedicationComplianceStatusResponse: + type: object + properties: + hasProvider: + type: boolean + description: False when the user has no usable AI provider — `summary` then carries the generic no-key guidance. + summary: + anyOf: + - type: string + - type: "null" + description: The overall compliance narrative (plain text). Null while a first generation is preparing. + medications: + type: array + items: + $ref: "#/components/schemas/MedicationComplianceStatusItem" + description: Per-medication compliance notes. Empty while preparing or when no medication qualifies. + cached: + type: boolean + description: True when the envelope is served from cache (incl. last-good). + updatedAt: + anyOf: + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - type: "null" + description: When the served assessment was generated; null when none. + preparing: + description: True when a first assessment is being generated out of band and no prior summary exists yet — the client + polls until it lands. + type: boolean + revalidating: + description: True when the envelope is served from last-good cache (stale-while-revalidate) while a fresh generation is + in flight. The client keeps polling on `preparing || revalidating` (bounded). + type: boolean + required: + - hasProvider + - summary + - medications + - cached + - updatedAt + additionalProperties: false + description: Medication-compliance assessment envelope. Unlike the other six specialised cards it carries a `summary` + plus a per-medication `text` array rather than a single `text` field. Read-only + stale-while-revalidate. + MedicationComplianceStatusItem: + type: object + properties: + medicationId: + type: string + description: The medication this note belongs to. + text: + type: string + description: Per-medication compliance note (plain text). + required: + - medicationId + - text + additionalProperties: false MetricStatusResponseEnvelope: type: object properties: diff --git a/src/lib/openapi/routes.ts b/src/lib/openapi/routes.ts index b93fded35..53217ae53 100644 --- a/src/lib/openapi/routes.ts +++ b/src/lib/openapi/routes.ts @@ -1175,6 +1175,119 @@ const metricStatusResponse = z "Generic per-metric assessment envelope. Identical shape to the seven specialised `*-status` cards so the `InsightStatusCard` consumes it unchanged. Read-only + stale-while-revalidate: a cache miss warms a generation out of band and serves the last-good text meanwhile.", }); +// The seven specialised `*-status` routes accept an optional locale +// override (the metric is fixed by the route path, unlike the generic +// metric-status route which carries it as a query field). +const insightStatusQuery = z + .object({ + locale: z + .enum(["de", "en"]) + .optional() + .describe("Optional UI-locale override; defaults to the session locale."), + }) + .meta({ id: "InsightStatusQuery" }); + +// Shared response shape for the five text-bearing specialised status +// routes (blood-pressure, pulse, weight, bmi, mood). Same envelope as +// the generic metric-status card minus the `insufficient` flag, which is +// metric-status-only. Read-only + stale-while-revalidate. +const insightStatusResponse = z + .object({ + hasProvider: z + .boolean() + .describe( + "False when the user has no usable AI provider — `text` then carries the generic no-key guidance.", + ), + text: z + .string() + .nullable() + .describe( + "The assessment narrative (plain text, rendered as React text children). Null while a first generation is preparing.", + ), + cached: z + .boolean() + .describe("True when `text` is served from cache (incl. last-good)."), + updatedAt: z.iso + .datetime({ offset: true }) + .nullable() + .describe("When the served assessment was generated; null when none."), + preparing: z + .boolean() + .optional() + .describe( + "True when a first assessment is being generated out of band and no prior text exists yet — the client polls until it lands.", + ), + revalidating: z + .boolean() + .optional() + .describe( + "True when `text` is served from last-good cache (stale-while-revalidate) while a fresh generation is in flight. The client keeps polling on `preparing || revalidating` (bounded) so the open card upgrades to the warmed assessment without a remount.", + ), + }) + .meta({ + id: "InsightStatusResponse", + description: + "Specialised per-metric assessment envelope (blood-pressure, pulse, weight, bmi, mood). Identical shape to the generic metric-status card so the `InsightStatusCard` consumes it unchanged. Read-only + stale-while-revalidate: a cache miss warms a generation out of band and serves the last-good text meanwhile.", + }); + +// The medication-compliance route carries a richer envelope than the +// other six: a `summary` narrative plus a per-medication `text` array, +// instead of a single `text` field. +const medicationComplianceStatusResponse = z + .object({ + hasProvider: z + .boolean() + .describe( + "False when the user has no usable AI provider — `summary` then carries the generic no-key guidance.", + ), + summary: z + .string() + .nullable() + .describe( + "The overall compliance narrative (plain text). Null while a first generation is preparing.", + ), + medications: z + .array( + z + .object({ + medicationId: z + .string() + .describe("The medication this note belongs to."), + text: z + .string() + .describe("Per-medication compliance note (plain text)."), + }) + .meta({ id: "MedicationComplianceStatusItem" }), + ) + .describe( + "Per-medication compliance notes. Empty while preparing or when no medication qualifies.", + ), + cached: z + .boolean() + .describe("True when the envelope is served from cache (incl. last-good)."), + updatedAt: z.iso + .datetime({ offset: true }) + .nullable() + .describe("When the served assessment was generated; null when none."), + preparing: z + .boolean() + .optional() + .describe( + "True when a first assessment is being generated out of band and no prior summary exists yet — the client polls until it lands.", + ), + revalidating: z + .boolean() + .optional() + .describe( + "True when the envelope is served from last-good cache (stale-while-revalidate) while a fresh generation is in flight. The client keeps polling on `preparing || revalidating` (bounded).", + ), + }) + .meta({ + id: "MedicationComplianceStatusResponse", + description: + "Medication-compliance assessment envelope. Unlike the other six specialised cards it carries a `summary` plus a per-medication `text` array rather than a single `text` field. Read-only + stale-while-revalidate.", + }); + const analyticsRangeQuery = z .object({ type: measurementTypeEnum.describe( @@ -2575,6 +2688,157 @@ export const openApiPaths: NonNullable = { }, }, }, + "/api/insights/blood-pressure-status": { + get: { + tags: ["Insights"], + summary: "Blood-pressure assessment", + description: + "Data-driven plain-language assessment of the user's recent blood-pressure readings. Read-only: a cache miss warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate). Auth via cookie or Bearer.", + requestParams: { + query: insightStatusQuery, + }, + responses: { + "200": { + description: "Assessment envelope (fresh, cached, or preparing).", + content: { + "application/json": { + schema: dataEnvelope( + insightStatusResponse, + "BloodPressureStatusResponseEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/insights/pulse-status": { + get: { + tags: ["Insights"], + summary: "Pulse assessment", + description: + "Data-driven plain-language assessment of the user's recent resting-pulse readings. Read-only: a cache miss warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate). Auth via cookie or Bearer.", + requestParams: { + query: insightStatusQuery, + }, + responses: { + "200": { + description: "Assessment envelope (fresh, cached, or preparing).", + content: { + "application/json": { + schema: dataEnvelope( + insightStatusResponse, + "PulseStatusResponseEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/insights/weight-status": { + get: { + tags: ["Insights"], + summary: "Weight assessment", + description: + "Data-driven plain-language assessment of the user's recent weight trend. Read-only: a cache miss warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate). Auth via cookie or Bearer.", + requestParams: { + query: insightStatusQuery, + }, + responses: { + "200": { + description: "Assessment envelope (fresh, cached, or preparing).", + content: { + "application/json": { + schema: dataEnvelope( + insightStatusResponse, + "WeightStatusResponseEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/insights/bmi-status": { + get: { + tags: ["Insights"], + summary: "BMI assessment", + description: + "Data-driven plain-language assessment of the user's body-mass index. Read-only: a cache miss warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate). Auth via cookie or Bearer.", + requestParams: { + query: insightStatusQuery, + }, + responses: { + "200": { + description: "Assessment envelope (fresh, cached, or preparing).", + content: { + "application/json": { + schema: dataEnvelope( + insightStatusResponse, + "BmiStatusResponseEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/insights/mood-status": { + get: { + tags: ["Insights"], + summary: "Mood assessment", + description: + "Data-driven plain-language assessment of the user's recent mood entries. Read-only: a cache miss warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate). Auth via cookie or Bearer.", + requestParams: { + query: insightStatusQuery, + }, + responses: { + "200": { + description: "Assessment envelope (fresh, cached, or preparing).", + content: { + "application/json": { + schema: dataEnvelope( + insightStatusResponse, + "MoodStatusResponseEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/insights/medication-compliance-status": { + get: { + tags: ["Insights"], + summary: "Medication-compliance assessment", + description: + "Data-driven plain-language assessment of the user's medication compliance — an overall `summary` plus a per-medication note array. Read-only: a cache miss warms a generation out of band and serves the last-good envelope meanwhile (stale-while-revalidate). Auth via cookie or Bearer.", + requestParams: { + query: insightStatusQuery, + }, + responses: { + "200": { + description: + "Compliance assessment envelope (fresh, cached, or preparing).", + content: { + "application/json": { + schema: dataEnvelope( + medicationComplianceStatusResponse, + "MedicationComplianceStatusResponseEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, "/api/insights/metric-status": { get: { tags: ["Insights"], From 4feac6b7e4218581a6471f46bc99438934d3f802 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 2 Jun 2026 16:53:54 +0200 Subject: [PATCH 15/30] fix(insights): gate the assessment warm on insightStatus, not coach MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /api/insights/pregenerate warms the assessment cards — the comprehensive insight plus the specialised status cards — all of which the read-only status GETs gate on the insightStatus surface. It was gating on coach, so a user with assessments enabled but the Coach disabled got a 403 and could not warm. Move the gate to insightStatus to match the surface it actually serves, add it to the route-gate inventory allowlist, and cover the coach-disabled-still-warms path with a route test. Co-Authored-By: Claude Opus 4.8 (1M context) (cherry picked from commit ec041db6e2c81b95a429625bb765f1f41ebce40b) --- .../coach-route-gate-inventory.test.ts | 4 + .../pregenerate/__tests__/route.test.ts | 119 ++++++++++++++++++ src/app/api/insights/pregenerate/route.ts | 7 +- 3 files changed, 127 insertions(+), 3 deletions(-) create mode 100644 src/app/api/insights/pregenerate/__tests__/route.test.ts diff --git a/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts b/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts index f64973294..8b4b61ac7 100644 --- a/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts +++ b/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts @@ -44,6 +44,10 @@ const NON_COACH_GATED_ROUTES: ReadonlyArray = [ // same `insightStatus` sub-flag as the seven specialised status routes. "src/app/api/insights/metric-status/route.ts", "src/app/api/insights/mood-status/route.ts", + // v1.9.0 — on-demand full assessment warm. Warms the same assessment + // cards the status routes serve, so it gates on `insightStatus`, not + // `coach`: a user with assessments enabled but Coach disabled can warm. + "src/app/api/insights/pregenerate/route.ts", "src/app/api/insights/pulse-status/route.ts", "src/app/api/insights/weight-status/route.ts", ]; diff --git a/src/app/api/insights/pregenerate/__tests__/route.test.ts b/src/app/api/insights/pregenerate/__tests__/route.test.ts new file mode 100644 index 000000000..9b9cdb332 --- /dev/null +++ b/src/app/api/insights/pregenerate/__tests__/route.test.ts @@ -0,0 +1,119 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + appSettings: { findUnique: vi.fn().mockResolvedValue(null) }, + }, +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); + +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); + +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: () => {}, + })), +})); + +// Always allow the anti-spam bucket so the surface gate is what the test +// exercises. +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn(async () => ({ allowed: true })), +})); + +// Stub the worker enqueue so the route test stays isolated from pg-boss. +vi.mock("@/lib/jobs/insight-pregenerate-shared", () => ({ + enqueueForceWarm: vi.fn(async () => undefined), +})); + +import { POST } from "../route"; +import { getSession } from "@/lib/auth/session"; +import { prisma } from "@/lib/db"; +import { enqueueForceWarm } from "@/lib/jobs/insight-pregenerate-shared"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "marc", role: "USER" as const, locale: "en" }, +}; + +const callPost = POST as unknown as (req: NextRequest) => Promise; +function makeReq(): NextRequest { + return new NextRequest( + new URL("http://localhost/api/insights/pregenerate"), + { method: "POST" }, + ); +} + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never); +}); + +describe("POST /api/insights/pregenerate", () => { + it("enqueues a warm when insightStatus is enabled", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const res = await callPost(makeReq()); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { queued: boolean } | null; + error: string | null; + }; + expect(body.error).toBeNull(); + expect(body.data?.queued).toBe(true); + expect(enqueueForceWarm).toHaveBeenCalledWith( + expect.objectContaining({ userId: "user-1" }), + ); + }); + + it("gates on insightStatus, not coach — a coach-disabled user can still warm", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.appSettings.findUnique).mockResolvedValueOnce({ + assistantEnabled: true, + assistantCoachEnabled: false, + assistantBriefingEnabled: true, + assistantInsightStatusEnabled: true, + assistantCorrelationsEnabled: true, + assistantHealthScoreExplainerEnabled: true, + } as never); + const res = await callPost(makeReq()); + expect(res.status).toBe(200); + expect(enqueueForceWarm).toHaveBeenCalledTimes(1); + }); + + it("403s + errorCode when insightStatus is disabled", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.appSettings.findUnique).mockResolvedValueOnce({ + assistantEnabled: true, + assistantCoachEnabled: true, + assistantBriefingEnabled: true, + assistantInsightStatusEnabled: false, + assistantCorrelationsEnabled: true, + assistantHealthScoreExplainerEnabled: true, + } as never); + const res = await callPost(makeReq()); + expect(res.status).toBe(403); + const body = (await res.json()) as { meta?: { errorCode?: string } }; + expect(body.meta?.errorCode).toBe("assistant.disabled.insightStatus"); + expect(enqueueForceWarm).not.toHaveBeenCalled(); + }); + + it("401s when unauthenticated", async () => { + vi.mocked(getSession).mockResolvedValue(null); + const res = await callPost(makeReq()); + expect(res.status).toBe(401); + expect(enqueueForceWarm).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/insights/pregenerate/route.ts b/src/app/api/insights/pregenerate/route.ts index a18936cda..ec5e41380 100644 --- a/src/app/api/insights/pregenerate/route.ts +++ b/src/app/api/insights/pregenerate/route.ts @@ -38,9 +38,10 @@ const WARM_WINDOW_MS = 3 * 60 * 1000; export const POST = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); - // Same surface gate as the read-only status routes — when the operator - // disables the assistant surface there is nothing to warm. - await requireAssistantSurface("coach"); + // Gate on the same surface as the read-only status routes: this warms + // the assessment cards (the `insightStatus` surface), not the Coach. + // A user with assessments enabled but Coach disabled can still warm. + await requireAssistantSurface("insightStatus"); const userId = user.id; // Short per-user bucket so the bypassed nightly budget can't be abused From 8c316ee3b0ca6a958fc1628f79f79e1450252c6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 2 Jun 2026 16:55:56 +0200 Subject: [PATCH 16/30] ops: add a post-deploy version assertion script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scripts/assert-deploy.ts polls /api/version on prod + demo and asserts the served data.version matches an expected value, printing each target's buildSha and exiting non-zero on a mismatch or after a bounded retry loop. This catches the BuildKit layer-cache pitfall where the deploy reports clean but /api/version still answers the prior release. Run via `pnpm dlx tsx scripts/assert-deploy.ts ` — not bundled into the standalone image. Document it in the deploy runbook. Co-Authored-By: Claude Opus 4.8 (1M context) (cherry picked from commit a86a9151ef670846c4b33a1c068da40324393308) --- docs/ops/deploy.md | 17 ++++ scripts/assert-deploy.ts | 169 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 186 insertions(+) create mode 100644 scripts/assert-deploy.ts diff --git a/docs/ops/deploy.md b/docs/ops/deploy.md index f5bfb0082..e22a1d7f7 100644 --- a/docs/ops/deploy.md +++ b/docs/ops/deploy.md @@ -59,3 +59,20 @@ roll back: dev --create-only --name vX_Y_Z_rollback_`), edit the SQL, commit, and re-deploy. Never `migrate resolve --rolled-back` against production unless you've taken a fresh `pg_dump` first. + +## Verify the served version + +The queued-deploy status is **not** the source of truth — the version +`/api/version` actually answers is. A BuildKit layer-cache hit can +re-ship the prior image even when Coolify reports a clean deploy, so +always confirm the served build after a deploy: + +```sh +# Asserts prod + demo serve 1.9.0, prints each target's buildSha, +# exits non-zero on a mismatch or timeout after a bounded retry loop. +pnpm dlx tsx scripts/assert-deploy.ts 1.9.0 +``` + +Pass `--only=prod` / `--only=demo` to check a single target, or +`--attempts=N --interval=ms` to widen the retry window while a slow +pull settles. diff --git a/scripts/assert-deploy.ts b/scripts/assert-deploy.ts new file mode 100644 index 000000000..ea1e677d7 --- /dev/null +++ b/scripts/assert-deploy.ts @@ -0,0 +1,169 @@ +#!/usr/bin/env tsx +/** + * Post-deploy version assertion. + * + * Polls `/api/version` on production + demo and asserts the running + * build's `data.version` matches an expected value, printing each + * target's `buildSha`. Exits non-zero on a mismatch or after the retry + * window elapses — so a CI step or a manual deploy can fail loudly when + * the `:latest` pull served the prior image (the recurring BuildKit + * layer-cache pitfall where Coolify reports "deployed" while + * `/api/version` still answers the old number). + * + * Run it after every deploy — the queued-deploy status is not the source + * of truth, the served version is: + * + * pnpm dlx tsx scripts/assert-deploy.ts + * + * Examples: + * + * # assert both prod + demo serve 1.9.0 + * pnpm dlx tsx scripts/assert-deploy.ts 1.9.0 + * + * # assert prod only + * pnpm dlx tsx scripts/assert-deploy.ts 1.9.0 --only=prod + * + * # widen the retry window (default 24 attempts × 5s = 2 min) + * pnpm dlx tsx scripts/assert-deploy.ts 1.9.0 --attempts=60 --interval=5000 + * + * Pure script — strips the leading `v` from the expected version so both + * `v1.9.0` and `1.9.0` work. No app-library import, no runtime wiring; + * the standalone image never ships this file. + */ + +interface Target { + name: string; + url: string; +} + +const TARGETS: Record = { + prod: { name: "prod", url: "https://healthlog.bombeck.io/api/version" }, + demo: { name: "demo", url: "https://demo.healthlog.dev/api/version" }, +}; + +interface VersionPayload { + version?: string; + buildSha?: string | null; + builtAt?: string | null; +} + +function parseArgs(argv: string[]): { + expected: string; + attempts: number; + intervalMs: number; + only: string | null; +} { + const positional: string[] = []; + let attempts = 24; + let intervalMs = 5_000; + let only: string | null = null; + + for (const arg of argv) { + if (arg.startsWith("--attempts=")) { + attempts = Number.parseInt(arg.slice("--attempts=".length), 10); + } else if (arg.startsWith("--interval=")) { + intervalMs = Number.parseInt(arg.slice("--interval=".length), 10); + } else if (arg.startsWith("--only=")) { + only = arg.slice("--only=".length); + } else { + positional.push(arg); + } + } + + const expected = (positional[0] ?? "").trim().replace(/^v/, ""); + if (!expected) { + console.error( + "usage: pnpm dlx tsx scripts/assert-deploy.ts [--only=prod|demo] [--attempts=N] [--interval=ms]", + ); + process.exit(2); + } + if (!Number.isFinite(attempts) || attempts < 1) attempts = 24; + if (!Number.isFinite(intervalMs) || intervalMs < 0) intervalMs = 5_000; + + return { expected, attempts, intervalMs, only }; +} + +const sleep = (ms: number): Promise => + new Promise((res) => setTimeout(res, ms)); + +/** One probe. Returns the parsed payload or null on any transport / shape error. */ +async function probe(url: string): Promise { + try { + const res = await fetch(url, { + redirect: "manual", + signal: AbortSignal.timeout(10_000), + headers: { accept: "application/json" }, + }); + if (!res.ok) return null; + const body = (await res.json()) as { data?: VersionPayload } | null; + return body?.data ?? null; + } catch { + return null; + } +} + +/** + * Poll one target until its served version matches `expected` or the + * retry window elapses. Resolves true on a match, false otherwise. + */ +async function assertTarget( + target: Target, + expected: string, + attempts: number, + intervalMs: number, +): Promise { + for (let attempt = 1; attempt <= attempts; attempt += 1) { + const payload = await probe(target.url); + const version = payload?.version?.trim() ?? null; + if (version === expected) { + console.log( + `[${target.name}] OK version=${version} buildSha=${payload?.buildSha ?? "—"}`, + ); + return true; + } + const seen = version ?? "unreachable"; + console.log( + `[${target.name}] attempt ${attempt}/${attempts}: expected ${expected}, got ${seen}`, + ); + if (attempt < attempts) await sleep(intervalMs); + } + console.error( + `[${target.name}] FAIL: ${target.url} did not report ${expected} within ${attempts} attempts`, + ); + return false; +} + +async function main(): Promise { + const { expected, attempts, intervalMs, only } = parseArgs( + process.argv.slice(2), + ); + + let selected: Target[]; + if (only) { + const picked = TARGETS[only]; + if (!picked) { + console.error(`unknown --only target: ${only} (use prod or demo)`); + process.exit(2); + } + selected = [picked]; + } else { + selected = Object.values(TARGETS); + } + + console.log( + `Asserting version ${expected} on: ${selected.map((t) => t.name).join(", ")}`, + ); + + // Probe targets in parallel; each runs its own bounded retry loop. + const results = await Promise.all( + selected.map((t) => assertTarget(t, expected, attempts, intervalMs)), + ); + + if (results.every((ok) => ok)) { + console.log("All targets serve the expected version."); + process.exit(0); + } + process.exit(1); +} + +void main(); From d88038563538c6e7a3a5561cd6979d1b07bc5fe3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 2 Jun 2026 17:00:10 +0200 Subject: [PATCH 17/30] refactor(targets): retire the standalone Targets page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The standalone /targets (Zielwerte) page is retired. Target editing moved inline onto the Insights metric pages in v1.8.6 — each metric's reference panel mounts the editor and writes the same PUT /api/user/thresholds, reading the same /api/insights/targets payload. The page carried a deprecation notice and had already dropped out of the navigation, so removing it is a UI-only change: no data model, API route, or inline editor moves. Remove the page and its tests, the components reachable only from it (target card, per-card coach button, summary header, target prompt / scope helpers, coach-handoff hook), the now-dead /zielwerte→/targets proxy redirect, and the orphaned targets.* i18n keys across all six locales. Repoint the handful of code comments that pointed readers at the page to the inline Insights panel, and prune the two coach gate-inventory allowlists plus the queryKey factory guard root that named the removed files. The reference panel keeps the range bar, consistency strip, status pill, edit sheet, and source-link helpers — those are the live editing surface now. Co-Authored-By: Claude Opus 4.8 (1M context) (cherry picked from commit 7f842c5620087127e980fe22a56133135b7c0531) --- messages/de.json | 39 -- messages/en.json | 39 -- messages/es.json | 39 -- messages/fr.json | 39 -- messages/it.json | 39 -- messages/pl.json | 39 -- .../__tests__/targets-coach-mount.test.tsx | 175 ------ src/app/__tests__/targets-i18n.test.tsx | 204 ------- src/app/__tests__/targets-responsive.test.tsx | 148 ----- src/app/__tests__/targets-spacing.test.tsx | 111 ---- src/app/page.tsx | 10 +- src/app/targets/__tests__/page.test.tsx | 93 --- src/app/targets/page.tsx | 313 ---------- src/components/charts/trend-card.tsx | 4 +- .../insights/metric-target-summary.tsx | 41 +- src/components/layout/bottom-nav.tsx | 7 +- .../targets/__tests__/target-card.test.tsx | 227 ------- .../__tests__/target-coach-button.test.tsx | 70 --- .../__tests__/targets-summary-header.test.tsx | 63 -- src/components/targets/range-bar.tsx | 4 - src/components/targets/target-card.tsx | 578 ------------------ .../targets/target-coach-button.tsx | 94 --- src/components/targets/target-edit-sheet.tsx | 26 +- .../targets/targets-summary-header.tsx | 91 --- src/hooks/use-coach-handoff.ts | 52 -- src/lib/__tests__/query-keys.test.ts | 1 - .../ai/coach/__tests__/target-prompts.test.ts | 129 ---- src/lib/ai/coach/target-prompts.ts | 117 ---- src/lib/ai/coach/target-scope.ts | 48 -- .../__tests__/coach-cascade.test.tsx | 20 +- .../__tests__/coach-user-disable.test.tsx | 68 +-- src/lib/targets/source-link.ts | 8 +- src/proxy.ts | 1 - 33 files changed, 51 insertions(+), 2886 deletions(-) delete mode 100644 src/app/__tests__/targets-coach-mount.test.tsx delete mode 100644 src/app/__tests__/targets-i18n.test.tsx delete mode 100644 src/app/__tests__/targets-responsive.test.tsx delete mode 100644 src/app/__tests__/targets-spacing.test.tsx delete mode 100644 src/app/targets/__tests__/page.test.tsx delete mode 100644 src/app/targets/page.tsx delete mode 100644 src/components/targets/__tests__/target-card.test.tsx delete mode 100644 src/components/targets/__tests__/target-coach-button.test.tsx delete mode 100644 src/components/targets/__tests__/targets-summary-header.test.tsx delete mode 100644 src/components/targets/target-card.tsx delete mode 100644 src/components/targets/target-coach-button.tsx delete mode 100644 src/components/targets/targets-summary-header.tsx delete mode 100644 src/hooks/use-coach-handoff.ts delete mode 100644 src/lib/ai/coach/__tests__/target-prompts.test.ts delete mode 100644 src/lib/ai/coach/target-prompts.ts delete mode 100644 src/lib/ai/coach/target-scope.ts diff --git a/messages/de.json b/messages/de.json index 13b90e269..b6b6f1165 100644 --- a/messages/de.json +++ b/messages/de.json @@ -4039,12 +4039,6 @@ } }, "targets": { - "title": "Zielwerte", - "introText": "Deine aktuellen Werte im Vergleich zu empfohlenen Zielbereichen. Quellen der Leitlinien sind auf jeder Karte verlinkt.", - "deprecation": { - "title": "Diese Seite wird eingestellt", - "body": "Zielbereiche werden jetzt direkt in den Insights bearbeitet, im Referenzbereich jeder Metrik. Diese Seite bleibt vorerst erreichbar und wird in einem künftigen Release entfernt." - }, "bmi": "BMI", "average30d": "30-Tage-Schnitt:", "consistency": { @@ -4066,23 +4060,11 @@ "sixDaysAgo": "vor 6 Tagen" } }, - "card": { - "lastMet": "Zuletzt im Ziel: {when}", - "streak": "{count} Tage in Folge" - }, "relativeDay": { "today": "heute", "yesterday": "gestern", "daysAgo": "vor {count} Tagen" }, - "summary": { - "weekTitle": "{met} von {total} Zielwerten diese Woche erreicht", - "weekTitleAll": "Alle {total} Zielwerte diese Woche erreicht", - "streakHighlight": "{count} Tage in Folge: {metric}" - }, - "coach": { - "cta": "Coach fragen" - }, "edit": { "openLabel": "Zielbereich für {metric} bearbeiten", "title": "Zielbereich bearbeiten — {metric}", @@ -4114,31 +4096,10 @@ "aboveTarget": "{delta} {unit} über Zielbereich", "belowTarget": "{delta} {unit} unter Zielbereich", "diastolic": "Diastolisch", - "noMeasurementYet": "Noch keine Messungen", - "noMeasurementData": "Keine Messdaten verfügbar. Füge Messwerte hinzu, um deine Ziele zu sehen.", - "profileIncomplete": "Profil unvollständig", - "profileIncompleteHeight": "Trage deine Körpergröße im Profil ein, um BMI-Ziele zu aktivieren.", - "profileIncompleteAge": "Trage dein Geburtsdatum im Profil ein, um altersspezifische Ziele zu aktivieren.", - "profileIncompleteHeightAge": "Trage Körpergröße und Geburtsdatum im Profil ein, um personalisierte Ziele zu sehen.", - "compliancePerMedication": "Einnahmetreue pro Medikament", "glucoseFasting": "Glukose — nüchtern", "glucosePostprandial": "Glukose — postprandial", "glucoseRandom": "Glukose — beliebig", "glucoseBedtime": "Glukose — vor dem Schlafen", - "loadError": "Zielwerte konnten nicht geladen werden", - "label": { - "WEIGHT": "Gewicht", - "BLOOD_PRESSURE": "Blutdruck", - "BLOOD_PRESSURE_IN_TARGET": "Blutdruck im Zielbereich", - "PULSE": "Ruhepuls", - "SLEEP_DURATION": "Schlafdauer", - "BMI": "BMI", - "BODY_FAT": "Körperfett", - "ACTIVITY_STEPS": "Schritte/Tag", - "MEDICATION_COMPLIANCE": "Einnahmetreue", - "MOOD_SCORE": "Stimmung", - "MOOD_STABILITY": "Stimmungsstabilität" - }, "status": { "underweight": "Untergewicht", "normal": "Normal", diff --git a/messages/en.json b/messages/en.json index fbb92ebb0..608022edb 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4039,12 +4039,6 @@ } }, "targets": { - "title": "Targets", - "introText": "Your current values compared to recommended target ranges. Guideline sources are linked on each card.", - "deprecation": { - "title": "This page is being retired", - "body": "Target ranges are now edited directly from the Insights pages, on each metric's reference panel. This page stays available for now and will be removed in a future release." - }, "bmi": "BMI", "average30d": "30-day average:", "consistency": { @@ -4066,23 +4060,11 @@ "sixDaysAgo": "6 days ago" } }, - "card": { - "lastMet": "Last met goal: {when}", - "streak": "{count}-day streak" - }, "relativeDay": { "today": "today", "yesterday": "yesterday", "daysAgo": "{count} days ago" }, - "summary": { - "weekTitle": "{met} of {total} targets met this week", - "weekTitleAll": "All {total} targets met this week", - "streakHighlight": "{count}-day streak: {metric}" - }, - "coach": { - "cta": "Ask Coach" - }, "edit": { "openLabel": "Edit target range for {metric}", "title": "Edit target range — {metric}", @@ -4114,31 +4096,10 @@ "aboveTarget": "{delta} {unit} above target", "belowTarget": "{delta} {unit} below target", "diastolic": "Diastolic", - "noMeasurementYet": "No measurements yet", - "noMeasurementData": "No measurement data available. Add some measurements to see your targets.", - "profileIncomplete": "Profile incomplete", - "profileIncompleteHeight": "Add your height in profile settings to enable BMI targets.", - "profileIncompleteAge": "Add your date of birth in profile settings for age-specific targets.", - "profileIncompleteHeightAge": "Add your height and date of birth in profile settings for personalized targets.", - "compliancePerMedication": "Compliance per medication", "glucoseFasting": "Glucose — fasting", "glucosePostprandial": "Glucose — post-meal", "glucoseRandom": "Glucose — random", "glucoseBedtime": "Glucose — bedtime", - "loadError": "Failed to load targets", - "label": { - "WEIGHT": "Weight", - "BLOOD_PRESSURE": "Blood pressure", - "BLOOD_PRESSURE_IN_TARGET": "Blood pressure on target", - "PULSE": "Resting pulse", - "SLEEP_DURATION": "Sleep duration", - "BMI": "BMI", - "BODY_FAT": "Body fat", - "ACTIVITY_STEPS": "Steps/day", - "MEDICATION_COMPLIANCE": "Medication compliance", - "MOOD_SCORE": "Mood", - "MOOD_STABILITY": "Mood stability" - }, "status": { "underweight": "Underweight", "normal": "Normal", diff --git a/messages/es.json b/messages/es.json index 975cad5c0..a9fbf5d91 100644 --- a/messages/es.json +++ b/messages/es.json @@ -4039,12 +4039,6 @@ } }, "targets": { - "title": "Valores objetivo", - "introText": "Tus valores actuales frente a los rangos objetivo recomendados. Las fuentes de las guías clínicas están enlazadas en cada tarjeta.", - "deprecation": { - "title": "Esta página se va a retirar", - "body": "Los rangos objetivo ahora se editan directamente desde las páginas de Insights, en el panel de referencia de cada métrica. Esta página sigue disponible por ahora y se eliminará en una versión futura." - }, "bmi": "IMC", "average30d": "Media 30 días:", "consistency": { @@ -4066,23 +4060,11 @@ "sixDaysAgo": "hace 6 días" } }, - "card": { - "lastMet": "Última vez en objetivo: {when}", - "streak": "{count} días seguidos" - }, "relativeDay": { "today": "Hoy", "yesterday": "Ayer", "daysAgo": "hace {count} días" }, - "summary": { - "weekTitle": "{met} de {total} objetivos cumplidos esta semana", - "weekTitleAll": "Todo{total} targets met this week", - "streakHighlight": "{count} días seguidos: {metric}" - }, - "coach": { - "cta": "Preguntar al coach" - }, "edit": { "openLabel": "Editar el rango objetivo de {metric}", "title": "Editar rango objetivo — {metric}", @@ -4114,31 +4096,10 @@ "aboveTarget": "{delta} {unit} por encima del rango", "belowTarget": "{delta} {unit} por debajo del rango", "diastolic": "Diastólica", - "noMeasurementYet": "Aún no hay mediciones", - "noMeasurementData": "No hay datos de mediciones. Añade mediciones para ver tus objetivos.", - "profileIncomplete": "Perfil incompleto", - "profileIncompleteHeight": "Indica tu altura en el perfil para activar los objetivos de IMC.", - "profileIncompleteAge": "Indica tu fecha de nacimiento en el perfil para activar los objetivos por edad.", - "profileIncompleteHeightAge": "Indica altura y fecha de nacimiento en el perfil para ver objetivos personalizados.", - "compliancePerMedication": "Cumplimiento por medicamento", "glucoseFasting": "Glucemia — en ayunas", "glucosePostprandial": "Glucemia — posprandial", "glucoseRandom": "Glucemia — cualquiera", "glucoseBedtime": "Glucemia — antes de dormir", - "loadError": "No se han podido cargar los valores objetivo", - "label": { - "WEIGHT": "Peso", - "BLOOD_PRESSURE": "Presión arterial", - "BLOOD_PRESSURE_IN_TARGET": "Tensión arterial en rango", - "PULSE": "Pulso en reposo", - "SLEEP_DURATION": "Duración del sueño", - "BMI": "IMC", - "BODY_FAT": "Grasa corporal", - "ACTIVITY_STEPS": "Pasos/día", - "MEDICATION_COMPLIANCE": "Cumplimiento", - "MOOD_SCORE": "Estado de ánimo", - "MOOD_STABILITY": "Estabilidad del ánimo" - }, "status": { "underweight": "Peso insuficiente", "normal": "Normal", diff --git a/messages/fr.json b/messages/fr.json index 03d5475b5..13e652ee3 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -4039,12 +4039,6 @@ } }, "targets": { - "title": "Valeurs cibles", - "introText": "Tes valeurs actuelles face aux plages cibles recommandées. Les sources des recommandations sont liées sur chaque carte.", - "deprecation": { - "title": "Cette page va être retirée", - "body": "Les plages cibles se modifient désormais directement depuis les pages Insights, sur le panneau de référence de chaque métrique. Cette page reste accessible pour l'instant et sera supprimée dans une prochaine version." - }, "bmi": "IMC", "average30d": "Moy. 30 jours :", "consistency": { @@ -4066,23 +4060,11 @@ "sixDaysAgo": "il y a 6 jours" } }, - "card": { - "lastMet": "Dernier objectif atteint : {when}", - "streak": "{count} jours d’affilée" - }, "relativeDay": { "today": "Aujourd'hui", "yesterday": "Hier", "daysAgo": "il y a {count} jours" }, - "summary": { - "weekTitle": "{met} cibles sur {total} atteintes cette semaine", - "weekTitleAll": "Tout{total} targets met this week", - "streakHighlight": "{count} jours d’affilée : {metric}" - }, - "coach": { - "cta": "Demander au coach" - }, "edit": { "openLabel": "Modifier la plage cible de {metric}", "title": "Modifier la plage cible — {metric}", @@ -4114,31 +4096,10 @@ "aboveTarget": "{delta} {unit} au-dessus de la plage", "belowTarget": "{delta} {unit} en dessous de la plage", "diastolic": "Diastolique", - "noMeasurementYet": "Pas encore de mesures", - "noMeasurementData": "Pas de données de mesure. Ajoute des mesures pour voir tes objectifs.", - "profileIncomplete": "Profil incomplet", - "profileIncompleteHeight": "Renseigne ta taille dans le profil pour activer les objectifs IMC.", - "profileIncompleteAge": "Renseigne ta date de naissance dans le profil pour activer les objectifs liés à l’âge.", - "profileIncompleteHeightAge": "Renseigne taille et date de naissance dans le profil pour voir des objectifs personnalisés.", - "compliancePerMedication": "Observance par médicament", "glucoseFasting": "Glycémie — à jeun", "glucosePostprandial": "Glycémie — postprandiale", "glucoseRandom": "Glycémie — indifférente", "glucoseBedtime": "Glycémie — avant le coucher", - "loadError": "Impossible de charger les valeurs cibles", - "label": { - "WEIGHT": "Poids", - "BLOOD_PRESSURE": "Tension artérielle", - "BLOOD_PRESSURE_IN_TARGET": "Tension dans la cible", - "PULSE": "Pouls au repos", - "SLEEP_DURATION": "Durée du sommeil", - "BMI": "IMC", - "BODY_FAT": "Masse grasse", - "ACTIVITY_STEPS": "Pas/jour", - "MEDICATION_COMPLIANCE": "Observance", - "MOOD_SCORE": "Humeur", - "MOOD_STABILITY": "Stabilité de l’humeur" - }, "status": { "underweight": "Insuffisance pondérale", "normal": "Normal", diff --git a/messages/it.json b/messages/it.json index 4b0aa045f..f6f4aa736 100644 --- a/messages/it.json +++ b/messages/it.json @@ -4039,12 +4039,6 @@ } }, "targets": { - "title": "Valori obiettivo", - "introText": "I tuoi valori attuali rispetto agli intervalli obiettivo consigliati. Le fonti delle linee guida sono collegate su ogni card.", - "deprecation": { - "title": "Questa pagina verrà rimossa", - "body": "Gli intervalli obiettivo ora si modificano direttamente dalle pagine Insights, nel pannello di riferimento di ogni metrica. Questa pagina resta disponibile per ora e verrà rimossa in una versione futura." - }, "bmi": "IMC", "average30d": "Media 30 giorni:", "consistency": { @@ -4066,23 +4060,11 @@ "sixDaysAgo": "6 giorni fa" } }, - "card": { - "lastMet": "Ultima volta in obiettivo: {when}", - "streak": "{count} giorni di fila" - }, "relativeDay": { "today": "Oggi", "yesterday": "Ieri", "daysAgo": "{count} giorni fa" }, - "summary": { - "weekTitle": "{met} di {total} obiettivi raggiunti questa settimana", - "weekTitleAll": "Tutto{total} targets met this week", - "streakHighlight": "{count} giorni di fila: {metric}" - }, - "coach": { - "cta": "Chiedi al coach" - }, "edit": { "openLabel": "Modifica il range obiettivo per {metric}", "title": "Modifica range obiettivo — {metric}", @@ -4114,31 +4096,10 @@ "aboveTarget": "{delta} {unit} sopra il range", "belowTarget": "{delta} {unit} sotto il range", "diastolic": "Diastolica", - "noMeasurementYet": "Ancora nessuna misurazione", - "noMeasurementData": "Nessun dato di misurazione. Aggiungi misurazioni per vedere gli obiettivi.", - "profileIncomplete": "Profilo incompleto", - "profileIncompleteHeight": "Inserisci la tua altezza nel profilo per attivare gli obiettivi BMI.", - "profileIncompleteAge": "Inserisci la tua data di nascita nel profilo per attivare gli obiettivi legati all’età.", - "profileIncompleteHeightAge": "Inserisci altezza e data di nascita nel profilo per vedere obiettivi personalizzati.", - "compliancePerMedication": "Aderenza per farmaco", "glucoseFasting": "Glicemia — a digiuno", "glucosePostprandial": "Glicemia — postprandiale", "glucoseRandom": "Glicemia — qualsiasi", "glucoseBedtime": "Glicemia — prima di coricarsi", - "loadError": "Impossibile caricare i valori obiettivo", - "label": { - "WEIGHT": "Peso", - "BLOOD_PRESSURE": "Pressione arteriosa", - "BLOOD_PRESSURE_IN_TARGET": "Pressione nel range", - "PULSE": "Pulsazioni a riposo", - "SLEEP_DURATION": "Durata del sonno", - "BMI": "IMC", - "BODY_FAT": "Massa grassa", - "ACTIVITY_STEPS": "Passi/giorno", - "MEDICATION_COMPLIANCE": "Aderenza", - "MOOD_SCORE": "Umore", - "MOOD_STABILITY": "Stabilità umore" - }, "status": { "underweight": "Sottopeso", "normal": "Normal", diff --git a/messages/pl.json b/messages/pl.json index 66fa2289a..d1970c15f 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -4039,12 +4039,6 @@ } }, "targets": { - "title": "Wartości docelowe", - "introText": "Twoje aktualne wartości na tle zalecanych zakresów docelowych. Źródła wytycznych podlinkowane na każdej karcie.", - "deprecation": { - "title": "Ta strona zostanie wycofana", - "body": "Zakresy docelowe edytuje się teraz bezpośrednio na stronach Insights, w panelu referencyjnym każdej metryki. Ta strona pozostaje na razie dostępna i zostanie usunięta w przyszłej wersji." - }, "bmi": "BMI", "average30d": "Średnia 30-dniowa:", "consistency": { @@ -4066,23 +4060,11 @@ "sixDaysAgo": "6 dni temu" } }, - "card": { - "lastMet": "Ostatnio w celu: {when}", - "streak": "{count} dni z rzędu" - }, "relativeDay": { "today": "Dzisiaj", "yesterday": "Wczoraj", "daysAgo": "{count} dni temu" }, - "summary": { - "weekTitle": "{met} z {total} celów osiągniętych w tym tygodniu", - "weekTitleAll": "Wszystko{total} targets met this week", - "streakHighlight": "{count} dni z rzędu: {metric}" - }, - "coach": { - "cta": "Zapytaj coacha" - }, "edit": { "openLabel": "Edytuj zakres docelowy dla {metric}", "title": "Edytuj zakres docelowy — {metric}", @@ -4114,31 +4096,10 @@ "aboveTarget": "{delta} {unit} powyżej zakresu", "belowTarget": "{delta} {unit} poniżej zakresu", "diastolic": "Rozkurczowe", - "noMeasurementYet": "Brak pomiarów", - "noMeasurementData": "Brak danych pomiarowych. Dodaj pomiary, aby zobaczyć cele.", - "profileIncomplete": "Profil niekompletny", - "profileIncompleteHeight": "Wpisz swój wzrost w profilu, aby aktywować cele BMI.", - "profileIncompleteAge": "Wpisz datę urodzenia w profilu, aby aktywować cele zależne od wieku.", - "profileIncompleteHeightAge": "Wpisz wzrost i datę urodzenia w profilu, aby zobaczyć spersonalizowane cele.", - "compliancePerMedication": "Przestrzeganie według leku", "glucoseFasting": "Glikemia — na czczo", "glucosePostprandial": "Glikemia — po posiłku", "glucoseRandom": "Glikemia — dowolna", "glucoseBedtime": "Glikemia — przed snem", - "loadError": "Nie udało się załadować wartości docelowych", - "label": { - "WEIGHT": "Waga", - "BLOOD_PRESSURE": "Ciśnienie krwi", - "BLOOD_PRESSURE_IN_TARGET": "Ciśnienie w zakresie", - "PULSE": "Tętno spoczynkowe", - "SLEEP_DURATION": "Czas snu", - "BMI": "BMI", - "BODY_FAT": "Tkanka tłuszczowa", - "ACTIVITY_STEPS": "Kroki/dzień", - "MEDICATION_COMPLIANCE": "Przestrzeganie", - "MOOD_SCORE": "Nastrój", - "MOOD_STABILITY": "Stabilność nastroju" - }, "status": { "underweight": "Niedowaga", "normal": "Normal", diff --git a/src/app/__tests__/targets-coach-mount.test.tsx b/src/app/__tests__/targets-coach-mount.test.tsx deleted file mode 100644 index 5700b1695..000000000 --- a/src/app/__tests__/targets-coach-mount.test.tsx +++ /dev/null @@ -1,175 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { renderToStaticMarkup } from "react-dom/server"; - -/** - * v1.4.25 W3e — Coach drawer mount + per-card CTA gate. - * - * Three scenarios: - * 1. AI provider configured → per-card "Ask Coach" CTAs render - * alongside each card; the drawer is mounted at the page level. - * 2. AI provider NOT configured → CTAs are completely absent (no - * broken-button state). - * 3. v1.4.37 W5 — operator's global Coach flag off → both the CTAs - * and the page-level drawer mount vanish. The flag default is - * all-on for fresh installs and on any fetch error, so the path - * stays open by default (mocked via `useFeatureFlags` below). - */ - -vi.mock("next/navigation", () => ({ - useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), - usePathname: () => "/targets", -})); - -let coachFlag = true; -vi.mock("@/hooks/use-feature-flags", async () => { - const actual = await vi.importActual< - typeof import("@/hooks/use-feature-flags") - >("@/hooks/use-feature-flags"); - return { - ...actual, - useFeatureFlags: () => ({ - ...actual.DEFAULT_ASSISTANT_FLAGS, - coach: coachFlag, - }), - }; -}); - -const sampleData = { - targets: [ - { - type: "WEIGHT", - label: "Weight", - current: 78, - average30: 78, - trend: "stable", - unit: "kg", - range: { min: 60, max: 80 }, - classification: { category: "Normal", color: "#50fa7b" }, - source: "WHO BMI", - daysInRange7d: 5, - daysLogged7d: 7, - lastMetGoalAt: null, - streakDays: 0, - insufficientData: false, - consistency7d: ["in", "in", "in", "in", "in", "in", "in"], - }, - ], - pageSummary: { - targetsMetThisWeek: 1, - totalTargets: 1, - streakHighlight: null, - }, - bpDiastolic: { current: null, average30: null, range: null }, - profile: { heightCm: 180, age: 35, gender: "MALE", glucoseUnit: null }, -}; - -let providerChainResponse: { activeProvider: string | null } | null = null; - -vi.mock("@tanstack/react-query", () => { - const noopClient = { - invalidateQueries: vi.fn(), - setQueryData: vi.fn(), - getQueryData: vi.fn(), - refetchQueries: vi.fn(), - }; - return { - useQuery: ({ queryKey }: { queryKey: unknown[] }) => { - if (Array.isArray(queryKey) && queryKey[1] === "provider-chain") { - return { - data: providerChainResponse, - isLoading: false, - isError: false, - refetch: vi.fn(), - }; - } - return { - data: sampleData, - isLoading: false, - isError: false, - refetch: vi.fn(), - }; - }, - useQueryClient: () => noopClient, - useMutation: () => ({ - mutate: vi.fn(), - mutateAsync: vi.fn(), - isPending: false, - reset: vi.fn(), - }), - }; -}); - -vi.mock("@/hooks/use-auth", () => ({ - useAuth: () => ({ - user: { - id: "u1", - username: "marc", - email: "marc@example.com", - role: "USER", - }, - isAuthenticated: true, - isLoading: false, - refetch: vi.fn(), - }), -})); - -import { I18nProvider } from "@/lib/i18n/context"; -import TargetsPage from "../targets/page"; - -function render() { - return renderToStaticMarkup( - - - , - ); -} - -describe("/targets page — Coach drawer mount + CTA gate", () => { - beforeEach(() => { - coachFlag = true; - }); - - it("renders the per-card Coach CTA when an AI provider is configured", () => { - providerChainResponse = { activeProvider: "openai" }; - const html = render(); - expect(html).toContain('data-slot="target-coach-cta"'); - expect(html).toContain("Ask Coach"); - }); - - it("hides every per-card Coach CTA when no AI provider is configured", () => { - providerChainResponse = { activeProvider: null }; - const html = render(); - expect(html).not.toContain('data-slot="target-coach-cta"'); - }); - - it("renders nothing different when chainStatus query is still loading", () => { - providerChainResponse = null; - const html = render(); - // Same as the "no provider" branch — until we know the provider is - // configured we don't render a CTA whose click would 404. - expect(html).not.toContain('data-slot="target-coach-cta"'); - }); - - // v1.4.37 W5 — Marc directive: when the operator turns off the - // global Coach flag, every Coach affordance on /targets must vanish. - // That is BOTH the per-card CTAs (currently gated through `aiEnabled`) - // AND the page-level mount — so the SSE machinery, - // Sheet portal and source-chip thread never load. - it("hides every per-card Coach CTA when the global Coach flag is off", () => { - coachFlag = false; - providerChainResponse = { activeProvider: "openai" }; - const html = render(); - expect(html).not.toContain('data-slot="target-coach-cta"'); - }); - - it("does not mount the CoachDrawer when the global Coach flag is off", () => { - coachFlag = false; - providerChainResponse = { activeProvider: "openai" }; - const html = render(); - // The drawer renders a Sheet that the body markup exposes via a - // role + the i18n key it carries. We grep on the localised - // composer label that the drawer always paints. - expect(html).not.toContain('data-slot="coach-drawer"'); - expect(html).not.toContain('data-slot="coach-drawer-composer"'); - }); -}); diff --git a/src/app/__tests__/targets-i18n.test.tsx b/src/app/__tests__/targets-i18n.test.tsx deleted file mode 100644 index f24de103c..000000000 --- a/src/app/__tests__/targets-i18n.test.tsx +++ /dev/null @@ -1,204 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { renderToStaticMarkup } from "react-dom/server"; - -/** - * v1.4.19 phase A7 — the maintainer reported the `/targets` (Zielwerte) page - * still surfaces English status labels even when the rest of the UI - * renders in German: "Low / On Target / Stable / Moderate" stayed in - * the source language. The card titles ("Weight", "Blood pressure", - * "Body fat" …) had the same issue — server returns English - * strings, the page renders them verbatim. - * - * Translate every classification category and card label on the - * client. The server keeps emitting stable English keys (so external - * consumers / logs don't churn); the page resolves them to i18n - * strings via `useTranslations()`. - */ - -vi.mock("next/navigation", () => ({ - useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), - usePathname: () => "/targets", -})); - -const sampleData = { - targets: [ - { - type: "WEIGHT", - label: "Weight", - current: 88.2, - average30: 88.6, - trend: "stable", - unit: "kg", - range: { min: 60, max: 80 }, - classification: { category: "Normal", color: "#50fa7b" }, - source: "WHO BMI", - }, - { - type: "BLOOD_PRESSURE_IN_TARGET", - label: "Blood pressure on target", - current: 65, - average30: 65, - trend: null, - unit: "%", - range: { min: 70, max: 100 }, - classification: { category: "Moderate", color: "#f1fa8c" }, - source: "ESH 2023", - }, - { - type: "PULSE", - label: "Resting pulse", - current: 72, - average30: 72, - trend: "stable", - unit: "bpm", - range: { min: 60, max: 100 }, - classification: { category: "On target", color: "#50fa7b" }, - source: "AHA", - }, - { - type: "MOOD_STABILITY", - label: "Mood stability", - current: 0.6, - average30: 0.6, - trend: null, - unit: "σ", - range: { min: 0, max: 0.5 }, - classification: { category: "Stable", color: "#f1fa8c" }, - source: "moodLog", - }, - { - type: "MEDICATION_COMPLIANCE", - label: "Medication compliance", - current: 60, - average30: 60, - trend: null, - unit: "%", - range: { min: 90, max: 100 }, - classification: { category: "Low", color: "#ff5555" }, - source: "7-day", - }, - ], - bpDiastolic: { current: null, average30: null, range: null }, - profile: { heightCm: 180, age: 35, gender: "MALE", glucoseUnit: null }, -}; - -vi.mock("@tanstack/react-query", () => { - const noopClient = { - invalidateQueries: vi.fn(), - setQueryData: vi.fn(), - getQueryData: vi.fn(), - refetchQueries: vi.fn(), - }; - return { - useQuery: ({ queryKey }: { queryKey: unknown[] }) => { - // v1.4.25 W3e — the targets page now also queries - // ["insights", "provider-chain"] to gate the per-card Coach - // CTA. Return null for that query (no AI provider configured) - // so the CTA hides and the existing test cases keep asserting - // the unchanged visual surface. - if (Array.isArray(queryKey) && queryKey[1] === "provider-chain") { - return { - data: null, - isLoading: false, - isError: false, - refetch: vi.fn(), - }; - } - return { - data: sampleData, - isLoading: false, - isError: false, - refetch: vi.fn(), - }; - }, - useQueryClient: () => noopClient, - useMutation: () => ({ - mutate: vi.fn(), - mutateAsync: vi.fn(), - isPending: false, - reset: vi.fn(), - }), - }; -}); - -vi.mock("@/hooks/use-auth", () => ({ - useAuth: () => ({ - user: { - id: "u1", - username: "marc", - email: "marc@example.com", - role: "USER", - }, - isAuthenticated: true, - isLoading: false, - refetch: vi.fn(), - }), -})); - -// v1.4.37 W5 — `/targets/page.tsx` now reads `useFeatureFlags` to gate -// the Coach drawer + per-card CTAs. The hook resolves QueryClientContext -// from `@tanstack/react-query`, and this test mocks the library -// without exposing that context. Mock the hook directly with the -// all-on default so the i18n assertions below run against the visible -// Coach surfaces. -vi.mock("@/hooks/use-feature-flags", async () => { - const actual = await vi.importActual< - typeof import("@/hooks/use-feature-flags") - >("@/hooks/use-feature-flags"); - return { - ...actual, - useFeatureFlags: () => actual.DEFAULT_ASSISTANT_FLAGS, - }; -}); - -import { I18nProvider } from "@/lib/i18n/context"; -import TargetsPage from "../targets/page"; - -function render(locale: "en" | "de" = "en") { - return renderToStaticMarkup( - - - , - ); -} - -describe("/targets page — i18n status labels and titles", () => { - it("translates card titles to German when the locale is `de`", () => { - const html = render("de"); - expect(html).toContain("Gewicht"); // Weight - expect(html).toContain("Ruhepuls"); // Resting pulse - expect(html).toContain("Stimmungsstabilität"); // Mood stability - expect(html).toContain("Einnahmetreue"); // Medication compliance - // Server-emitted English labels must not leak. - expect(html).not.toContain("Resting pulse"); - expect(html).not.toContain("Mood stability"); - expect(html).not.toContain("Medication compliance"); - }); - - it("translates every status badge category to German", () => { - const html = render("de"); - // the maintainer explicitly named these in his bug report. - expect(html).not.toMatch(/>LowOn targetStableModerate { - const html = render("en"); - expect(html).toContain("Normal"); - expect(html).toContain("Moderate"); - expect(html).toContain("On target"); - expect(html).toContain("Stable"); - expect(html).toContain("Low"); - }); -}); diff --git a/src/app/__tests__/targets-responsive.test.tsx b/src/app/__tests__/targets-responsive.test.tsx deleted file mode 100644 index 6d016d1bb..000000000 --- a/src/app/__tests__/targets-responsive.test.tsx +++ /dev/null @@ -1,148 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { renderToStaticMarkup } from "react-dom/server"; - -/** - * v1.4.25 W3e — mobile-first responsive layout audit. - * - * Marc directive: "mobile layout must be designed alongside desktop, - * not as an afterthought". This test pins the Tailwind responsive - * utilities so a future refactor cannot quietly collapse the grid - * back to the v1.4.22 `sm:grid-cols-2` and remove the lg three-col - * row. - * - * The three breakpoints we care about: - * • default (<640px): one column, full-width cards - * • sm (640-1023px): two columns - * • lg (1024px+): three columns, slightly wider gutters - */ - -vi.mock("next/navigation", () => ({ - useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), - usePathname: () => "/targets", -})); - -const sampleData = { - targets: [ - { - type: "WEIGHT", - label: "Weight", - current: 78, - average30: 78, - trend: "stable", - unit: "kg", - range: { min: 60, max: 80 }, - classification: { category: "Normal", color: "#50fa7b" }, - source: "WHO BMI", - daysInRange7d: 5, - daysLogged7d: 7, - lastMetGoalAt: null, - streakDays: 0, - insufficientData: false, - consistency7d: ["in", "in", "in", "in", "in", "in", "in"], - }, - ], - pageSummary: { - targetsMetThisWeek: 1, - totalTargets: 1, - streakHighlight: null, - }, - bpDiastolic: { current: null, average30: null, range: null }, - profile: { heightCm: 180, age: 35, gender: "MALE", glucoseUnit: null }, -}; - -vi.mock("@tanstack/react-query", () => { - const noopClient = { - invalidateQueries: vi.fn(), - setQueryData: vi.fn(), - getQueryData: vi.fn(), - refetchQueries: vi.fn(), - }; - return { - useQuery: ({ queryKey }: { queryKey: unknown[] }) => { - if (Array.isArray(queryKey) && queryKey[1] === "provider-chain") { - return { - data: { activeProvider: "openai" }, - isLoading: false, - isError: false, - refetch: vi.fn(), - }; - } - return { - data: sampleData, - isLoading: false, - isError: false, - refetch: vi.fn(), - }; - }, - useQueryClient: () => noopClient, - useMutation: () => ({ - mutate: vi.fn(), - mutateAsync: vi.fn(), - isPending: false, - reset: vi.fn(), - }), - }; -}); - -vi.mock("@/hooks/use-auth", () => ({ - useAuth: () => ({ - user: { - id: "u1", - username: "marc", - email: "marc@example.com", - role: "USER", - }, - isAuthenticated: true, - isLoading: false, - refetch: vi.fn(), - }), -})); - -// v1.4.37 W5 — `/targets/page.tsx` now reads `useFeatureFlags` to gate -// the Coach drawer + per-card CTAs. The hook resolves QueryClientContext -// from `@tanstack/react-query`, and this test mocks the library -// without exposing that context. Mock the hook directly so the page -// runs with the all-on default (CTAs + drawer mount visible) the -// responsive assertions below pin. -vi.mock("@/hooks/use-feature-flags", async () => { - const actual = await vi.importActual< - typeof import("@/hooks/use-feature-flags") - >("@/hooks/use-feature-flags"); - return { - ...actual, - useFeatureFlags: () => actual.DEFAULT_ASSISTANT_FLAGS, - }; -}); - -import { I18nProvider } from "@/lib/i18n/context"; -import TargetsPage from "../targets/page"; - -function render(locale: "en" | "de" = "en") { - return renderToStaticMarkup( - - - , - ); -} - -describe("/targets page — mobile-first responsive grid", () => { - it("uses one column by default, two at sm, three at lg", () => { - const html = render(); - expect(html).toMatch(/grid-cols-1/); - expect(html).toMatch(/sm:grid-cols-2/); - expect(html).toMatch(/lg:grid-cols-3/); - }); - - it("uses gap-4 at mobile/sm, gap-6 at lg", () => { - const html = render(); - expect(html).toMatch(/\bgap-4\b/); - expect(html).toMatch(/lg:gap-6/); - }); - - it("renders the Coach CTA as an icon-only affordance on every target card", () => { - const html = render(); - expect(html).toContain('data-slot="target-coach-cta"'); - expect(html).toContain('aria-label="Ask Coach"'); - expect(html).toContain("size-10"); - }); -}); diff --git a/src/app/__tests__/targets-spacing.test.tsx b/src/app/__tests__/targets-spacing.test.tsx deleted file mode 100644 index e0f097e0e..000000000 --- a/src/app/__tests__/targets-spacing.test.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { renderToStaticMarkup } from "react-dom/server"; - -/** - * v1.4.19 phase A7 — `/targets` (Zielwerte) had `space-y-8` between - * the page header (h1 + intro) and the first card grid. At Pixel-5 - * the maintainer reported "relativ viel Platz" wasted between the overview - * and the first values. Tighten the rhythm to `space-y-6` (24px) - * which matches the rest of the admin / settings pages. - */ - -vi.mock("next/navigation", () => ({ - useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), - usePathname: () => "/targets", -})); - -vi.mock("@tanstack/react-query", () => { - const noopClient = { - invalidateQueries: vi.fn(), - setQueryData: vi.fn(), - getQueryData: vi.fn(), - refetchQueries: vi.fn(), - }; - return { - useQuery: ({ queryKey }: { queryKey: unknown[] }) => { - if (Array.isArray(queryKey) && queryKey[1] === "provider-chain") { - return { - data: null, - isLoading: false, - isError: false, - refetch: vi.fn(), - }; - } - return { - data: { - targets: [], - bpDiastolic: { current: null, average30: null, range: null }, - profile: { - heightCm: null, - age: null, - gender: null, - glucoseUnit: null, - }, - }, - isLoading: false, - isError: false, - refetch: vi.fn(), - }; - }, - useQueryClient: () => noopClient, - useMutation: () => ({ - mutate: vi.fn(), - mutateAsync: vi.fn(), - isPending: false, - reset: vi.fn(), - }), - }; -}); - -vi.mock("@/hooks/use-auth", () => ({ - useAuth: () => ({ - user: { - id: "u1", - username: "marc", - email: "marc@example.com", - role: "USER", - }, - isAuthenticated: true, - isLoading: false, - refetch: vi.fn(), - }), -})); - -// v1.4.37 W5 — `/targets/page.tsx` now reads `useFeatureFlags` to gate -// the Coach drawer + per-card CTAs. The hook resolves QueryClientContext -// from `@tanstack/react-query`, and this test mocks the library -// without exposing that context. Mock the hook directly with the -// all-on default so the spacing assertions below run against the -// fully-painted page shape. -vi.mock("@/hooks/use-feature-flags", async () => { - const actual = await vi.importActual< - typeof import("@/hooks/use-feature-flags") - >("@/hooks/use-feature-flags"); - return { - ...actual, - useFeatureFlags: () => actual.DEFAULT_ASSISTANT_FLAGS, - }; -}); - -import { I18nProvider } from "@/lib/i18n/context"; -import TargetsPage from "../targets/page"; - -function render(locale: "en" | "de" = "en") { - return renderToStaticMarkup( - - - , - ); -} - -describe("/targets page — vertical spacing", () => { - it("uses space-y-6 (not space-y-8) between header and card grid", () => { - const html = render(); - // Outer wrapper rhythm. `space-y-8` would leave ~32px of - // unnecessary breathing room between the intro paragraph and the - // first card; `space-y-6` (24px) brings it in line with the - // admin / settings pages. - expect(html).toMatch(/]*\bspace-y-6\b/); - expect(html).not.toMatch(/]*\bspace-y-8\b/); - }); -}); diff --git a/src/app/page.tsx b/src/app/page.tsx index 6da5b170f..f9f989b65 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -466,7 +466,8 @@ export default function DashboardPage() { * misleading zero. The corrected gate requires at least one window * (7d, 30d, all-time) to report a non-zero share. When every * window is zero, the tile is hidden — the user sees the BP charts - * + the `/targets` Blutdruck card for the deeper analysis instead. + * + the Insights blood-pressure target panel for the deeper analysis + * instead. * The audit's F4 reproduction sat on 540 BP samples with all sub- * windows reading 0 % because the seed data straddled the target * ceiling; the tile keeps its place once even one sub-window @@ -1096,9 +1097,10 @@ export default function DashboardPage() { // small fractional float that the TrendCard's date-shaped // formatter pipeline rendered as "1.1." — the regression the // maintainer flagged in the post-v1.4.27 walk-through. The - // all-time aggregate moves to the `/targets` BP card which - // already shows the same number with more context; the - // dashboard tile no longer needs to carry it. `avgAllTime` + // all-time aggregate moves to the Insights blood-pressure + // target panel which already shows the same number with more + // context; the dashboard tile no longer needs to carry it. + // `avgAllTime` // also retires from the TrendCard API (this was its only // consumer). const bp7 = data?.bpInTargetPct7d ?? null; diff --git a/src/app/targets/__tests__/page.test.tsx b/src/app/targets/__tests__/page.test.tsx deleted file mode 100644 index 1fcbe607e..000000000 --- a/src/app/targets/__tests__/page.test.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import { describe, it, expect, vi } from "vitest"; -import { renderToStaticMarkup } from "react-dom/server"; - -import { I18nProvider } from "@/lib/i18n/context"; - -/** - * v1.8.6 — `/targets` deprecation banner. - * - * The Targets (Zielwerte) page is deprecated: target-range editing moved - * inline into the Insights category pages. The page stays routable but - * carries a top banner telling the user where editing lives now and that - * the page will be removed in a future release. This SSR test pins the - * banner copy + structure. - * - * The page leans on `useAuth`, two `useQuery` reads, `useFeatureFlags` - * and `useCoachHandoff`; all are stubbed so the render reaches the - * banner without standing up an App-Router runtime or a QueryClient. - */ - -vi.mock("@/hooks/use-auth", () => ({ - useAuth: () => ({ - isAuthenticated: true, - user: { id: "u1", disableCoach: false }, - }), -})); - -// The page issues two `useQuery` reads (targets payload + provider -// chain). Drive both off the same stub: the first call resolves the -// targets payload, the rest return a null provider chain. -let queryCall = 0; -const TARGETS_PAYLOAD = { - targets: [], - bpDiastolic: { current: null, average30: null, range: null }, - profile: { heightCm: 175, age: 40, gender: "male", glucoseUnit: "mg/dL" }, -}; -vi.mock("@tanstack/react-query", () => ({ - useQuery: () => { - queryCall += 1; - if (queryCall === 1) { - return { data: TARGETS_PAYLOAD, isLoading: false }; - } - return { data: null, isLoading: false }; - }, -})); - -// Turn Coach off so the page never mounts the SSE drawer machinery. -vi.mock("@/hooks/use-feature-flags", () => ({ - useFeatureFlags: () => ({ - enabled: false, - coach: false, - briefing: false, - insightStatus: false, - correlations: false, - healthScoreExplainer: false, - }), -})); - -vi.mock("@/hooks/use-coach-handoff", () => ({ - useCoachHandoff: () => ({ - coachOpen: false, - setCoachOpen: vi.fn(), - coachPrefill: null, - coachScope: null, - askCoach: vi.fn(), - }), -})); - -const { default: TargetsPage } = await import("../page"); - -function render(locale: "en" | "de" = "en") { - queryCall = 0; - return renderToStaticMarkup( - - - , - ); -} - -describe(" deprecation banner", () => { - it("renders the deprecation banner with title + body", () => { - const html = render(); - expect(html).toContain('data-slot="targets-deprecation"'); - expect(html).toContain("This page is being retired"); - expect(html).toContain("Insights"); - // Reuses the warning-card visual pattern. - expect(html).toContain("border-warning"); - }); - - it("localises the banner copy", () => { - const html = render("de"); - expect(html).toContain("Diese Seite wird eingestellt"); - }); -}); diff --git a/src/app/targets/page.tsx b/src/app/targets/page.tsx deleted file mode 100644 index 20f8a80c2..000000000 --- a/src/app/targets/page.tsx +++ /dev/null @@ -1,313 +0,0 @@ -"use client"; - -// DEPRECATED since v1.8.6. Target-range editing moved inline into the -// Insights category pages (the per-metric reference panel now mounts the -// same ), so this standalone page no longer appears in -// the navigation. It stays routable behind the `/zielwerte`→`/targets` -// proxy redirect and is a remove candidate after ~10 releases. -// -// What stays in place because Insights consumes the same API: -// • GET /api/insights/targets — the read powering this page is the -// same payload the Insights panel reads (`queryKeys.insightsTargets()`). -// • PUT/DELETE /api/user/thresholds — the threshold override write the -// inline calls. -// • src/components/targets/* — TargetCard, TargetEditSheet, RangeBar, -// ConsistencyStrip, TargetStatusPill, source-link helper are all -// reused by the inline Insights panel. -// Removing the page later is a UI-only change; no data or API moves. - -import { useMemo } from "react"; -import { useQuery } from "@tanstack/react-query"; -import { AlertCircle, Loader2 } from "lucide-react"; - -import { useAuth } from "@/hooks/use-auth"; -import { useTranslations } from "@/lib/i18n/context"; -import { queryKeys } from "@/lib/query-keys"; -import { Card, CardContent } from "@/components/ui/card"; -import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose"; -import { - TargetCard, - type TargetCardData, -} from "@/components/targets/target-card"; -import { TargetsSummaryHeader } from "@/components/targets/targets-summary-header"; -import { getTargetSourceLink } from "@/lib/targets/source-link"; -import { CoachDrawer } from "@/components/insights/coach-panel/coach-drawer"; -import { useCoachHandoff } from "@/hooks/use-coach-handoff"; -import { useFeatureFlags } from "@/hooks/use-feature-flags"; - -type TargetData = TargetCardData; - -interface BpDiastolic { - current: number | null; - average30: number | null; - range: { min: number; max: number } | null; -} - -interface TargetPageSummary { - targetsMetThisWeek: number; - totalTargets: number; - streakHighlight: { metric: string; days: number } | null; -} - -interface TargetsResponse { - targets: TargetData[]; - pageSummary?: TargetPageSummary; - bpDiastolic: BpDiastolic; - profile: { - heightCm: number | null; - age: number | null; - gender: string | null; - glucoseUnit?: string | null; - }; -} - -/** - * v1.4.25 W3e — provider-chain status used to gate the per-card Coach - * CTA. Returns true when at least one provider is configured AND - * enabled. The Settings → AI surface owns the same query (via - * `queryKeys.insightsProviderChain()`) so the cache is shared. - */ -interface ProviderChainStatus { - activeProvider: string | null; - cachedActiveProvider: string | null; - configuredChain: Array<{ - providerType: string; - enabled: boolean; - available: boolean; - }>; -} - -const GLUCOSE_TYPES = new Set([ - "BLOOD_GLUCOSE_FASTING", - "BLOOD_GLUCOSE_POSTPRANDIAL", - "BLOOD_GLUCOSE_RANDOM", - "BLOOD_GLUCOSE_BEDTIME", -]); - -/** - * Fixed card order per Marc directive — explicitly NOT status-sorted. - * Marc wants a stable visual hierarchy so the user's eye lands on the - * same metric in the same place every visit. Cards whose type is not - * in this list (glucose contexts, future metrics) sort after the - * core six in their server-emitted order. - */ -const FIXED_TARGET_ORDER: Record = { - BLOOD_PRESSURE: 0, - BLOOD_PRESSURE_IN_TARGET: 1, - WEIGHT: 2, - PULSE: 3, - BMI: 4, - MOOD_SCORE: 5, - MOOD_STABILITY: 6, - MEDICATION_COMPLIANCE: 7, - SLEEP_DURATION: 8, - BODY_FAT: 9, - ACTIVITY_STEPS: 10, -}; - -function sortKey(target: TargetData): number { - return FIXED_TARGET_ORDER[target.type] ?? 100; -} - -export default function TargetsPage() { - const { t } = useTranslations(); - const { isAuthenticated, user } = useAuth(); - - const { data, isLoading } = useQuery({ - queryKey: queryKeys.insightsTargets(), - queryFn: async () => { - const res = await fetch("/api/insights/targets"); - if (!res.ok) throw new Error(t("targets.loadError")); - const json = await res.json(); - return json.data as TargetsResponse; - }, - enabled: isAuthenticated, - }); - - // v1.4.25 W3e — share the cache with Settings → AI section. When the - // user has no configured provider, `activeProvider` is null and the - // per-card Coach CTA hides entirely (no broken-button state). - const { data: chainStatus } = useQuery({ - queryKey: queryKeys.insightsProviderChain(), - queryFn: async () => { - const res = await fetch("/api/insights/provider-chain"); - if (!res.ok) return null; - const json = await res.json(); - return json.data as ProviderChainStatus; - }, - enabled: isAuthenticated, - }); - // v1.4.37 W5 — operator's global Coach flag layered on top of the - // per-user provider chain. Either gate disables every Coach - // affordance on this page: the per-card CTAs (via `aiEnabled` - // threaded into ) and the page-level mount - // below. The flag default is all-on for fresh installs and on any - // fetch error, so the path stays open by default. - const flags = useFeatureFlags(); - // v1.4.47 W3 — per-user "Hide Coach" toggle is a peer gate to the - // operator's master flag. When either is off, the per-card CTAs + - // the page-level mount below both go dark. - const coachEnabled = flags.coach && !user?.disableCoach; - const aiEnabled = coachEnabled && chainStatus?.activeProvider != null; - - // v1.4.25 W3e — Coach drawer state owned by the page. The per-card - // CTA + summary header both feed the same drawer so the user only - // ever sees one drawer instance. - const { coachOpen, setCoachOpen, coachPrefill, coachScope, askCoach } = - useCoachHandoff(); - - const visibleTargets = useMemo(() => { - if (!data) return [] as TargetData[]; - const displayGlucoseUnit = resolveGlucoseUnit( - data.profile.glucoseUnit ?? null, - ); - return data.targets - .filter((target) => target.current != null) - .map((target) => { - if (!GLUCOSE_TYPES.has(target.type)) return target; - // Server label is an i18n key for glucose; resolve here. - const label = t(target.label); - // Convert mg/dL canonical values to the user's display unit. - const convert = (v: number | null) => - v == null ? null : convertGlucose(v, displayGlucoseUnit); - return { - ...target, - label, - unit: displayGlucoseUnit, - current: convert(target.current), - average30: convert(target.average30), - range: target.range - ? { - min: convertGlucose(target.range.min, displayGlucoseUnit), - max: convertGlucose(target.range.max, displayGlucoseUnit), - } - : null, - }; - }) - .sort((a, b) => sortKey(a) - sortKey(b)); - }, [data, t]); - - if (isLoading) { - return ( -
    - -
    - ); - } - - if (!data) { - return ( -
    - {t("common.noData")} -
    - ); - } - - const profileIncomplete = !data.profile.heightCm || !data.profile.age; - - return ( - // v1.4.19 phase A7 — the maintainer reported "relativ viel Platz" wasted - // between the overview header and the first values on `/targets`. - // Tighten the rhythm from `space-y-8` (32 px) to `space-y-6` - // (24 px) — that matches the admin / settings pages. -
    -
    -

    {t("targets.title")}

    -

    {t("targets.introText")}

    -
    - - {/* v1.8.6 — single warning banner. Target editing moved inline into - the Insights category pages; this page stays reachable but will - be removed in a future release. - - Design Low fix: the deprecation notice and the profile-incomplete - hint used to stack as two near-identical warning cards. They are - folded into one card now — the deprecation message stays the - headline, and the profile-incomplete detail (height / age missing) - renders as a secondary line beneath it only when relevant, so the - page never shows two warning banners. */} - - - -
    -
    -

    - {t("targets.deprecation.title")} -

    -

    - {t("targets.deprecation.body")} -

    -
    - {profileIncomplete && ( -
    -

    - {t("targets.profileIncomplete")} -

    -

    - {!data.profile.heightCm && !data.profile.age - ? t("targets.profileIncompleteHeightAge") - : !data.profile.heightCm - ? t("targets.profileIncompleteHeight") - : t("targets.profileIncompleteAge")} -

    -
    - )} -
    -
    -
    - - {/* v1.4.25 W3e — page-level summary line. Renders nothing when - the API hasn't shipped pageSummary yet (older clients during - rollout / mocked test fixtures that pre-date this addition). */} - {data.pageSummary && ( - - )} - - {/* v1.4.25 W3e — responsive grid. Mobile (default): single - column. sm (640px+): two columns. lg (1024px+): three columns, - matching the dashboard / insights rhythm. Cards reflow - internally too — see . */} - {visibleTargets.length > 0 ? ( -
    - {visibleTargets.map((target) => ( - - ))} -
    - ) : ( -
    - {t("targets.noMeasurementData")} -
    - )} - - {/* v1.4.25 W3e — Coach drawer mounted at the page level. The - drawer is fully-controlled; per-card CTAs flip `coachOpen` - and seed `coachPrefill` via the `askCoach()` hook callback. - v1.4.37 W5 — short-circuit when the operator turned the - global Coach flag off so the SSE machinery + Sheet portal - never mount. The per-card CTAs already hide via `aiEnabled` - above, so the drawer can never be triggered with the flag - off; the gate is defence-in-depth. */} - {coachEnabled && ( - - )} -
    - ); -} diff --git a/src/components/charts/trend-card.tsx b/src/components/charts/trend-card.tsx index 8addfbfb6..e69183791 100644 --- a/src/components/charts/trend-card.tsx +++ b/src/components/charts/trend-card.tsx @@ -423,8 +423,8 @@ export function TrendCard({ {/* v1.4.28 FB-C2 — the all-time third sub-value retired alongside the `avgAllTime*` props. The BD-Zielbereich tile was the only consumer; the same number stays - visible on the `/targets` Blutdruck card with full - context. Every dashboard tile now ships the same two + visible on the Insights blood-pressure target panel with + full context. Every dashboard tile now ships the same two sub-rows. */}
    diff --git a/src/components/insights/metric-target-summary.tsx b/src/components/insights/metric-target-summary.tsx index b875518c1..d46cc012e 100644 --- a/src/components/insights/metric-target-summary.tsx +++ b/src/components/insights/metric-target-summary.tsx @@ -18,18 +18,16 @@ import { getTargetSourceLink } from "@/lib/targets/source-link"; * v1.8.0 → v1.8.5 — surface the per-metric target reference panel on each * insights category page, directly beneath the chart / assessment. * - * The data is the same `/api/insights/targets` payload that powers the - * `/targets` editing surface — this component reads the shared - * `queryKeys.insightsTargets()` cache, so on a session that has already - * visited `/targets` the read is free, and on a cold sub-page it warms a - * cache the `/targets` page then reuses. We never recompute the ranges - * here: the route is the single source of truth for the age-based ESH - * blood-pressure band, the WHO weight / BMI band, the Karvonen pulse - * band, the AASM sleep band, the ADA / DDG glucose bands, and the - * medication / mood targets. + * The data is the `/api/insights/targets` payload, read through the + * shared `queryKeys.insightsTargets()` cache so warm sessions pay + * nothing and a cold sub-page warms the cache its siblings reuse. We + * never recompute the ranges here: the route is the single source of + * truth for the age-based ESH blood-pressure band, the WHO weight / BMI + * band, the Karvonen pulse band, the AASM sleep band, the ADA / DDG + * glucose bands, and the medication / mood targets. * - * v1.8.5 W5 — the panel grew from a one-line range card into a compact - * reference panel that ports the context the `/targets` card carries: + * The panel is a compact reference panel carrying the full target + * context: * * • Row 1: target range string + `` + source link * (the "what is the band / what is it based on" answer). @@ -38,13 +36,13 @@ import { getTargetSourceLink } from "@/lib/targets/source-link"; * • Row 3: in-target share % + the 30-day average. * • Row 4: `` — the last seven days at a glance. * • Footer: an "Adjust target range" button that opens the - * `` inline (v1.8.6 — the `/targets` page is - * deprecated, so the editor mounts here rather than routing). + * `` inline — this panel is the home of + * target editing. * * Each insights slug maps to one target `type`, except blood glucose * which maps to up to four per-context cards (fasting / postprandial / * random / bedtime) — those carry mg/dL canonical values that we convert - * to the user's display unit exactly like the `/targets` page does. + * to the user's display unit. */ interface TargetRange { @@ -146,8 +144,8 @@ export function MetricTargetSummary({ slug }: MetricTargetSummaryProps) { if ((!targetType && !isGlucose) || !data) return null; // Glucose: one panel per logged context, converting the canonical - // mg/dL values to the user's display unit exactly like the /targets - // page. The route's per-context labels are i18n keys, so resolve them. + // mg/dL values to the user's display unit. The route's per-context + // labels are i18n keys, so resolve them. if (isGlucose) { const displayUnit = resolveGlucoseUnit(data.profile?.glucoseUnit ?? null); const convert = (v: number | null) => @@ -222,11 +220,10 @@ function TargetReferencePanel({ }: TargetReferencePanelProps) { const { t } = useTranslations(); - // v1.8.6 — the `/targets` page is deprecated; the per-metric editor - // moved here. The button below opens the same self-contained - // `` the targets card mounts, seeded with this - // metric's type / unit / range (and the diastolic range for BP). The - // sheet writes `PUT /api/user/thresholds` and invalidates the + // The per-metric editor lives here. The button below opens a + // self-contained ``, seeded with this metric's + // type / unit / range (and the diastolic range for BP). The sheet + // writes `PUT /api/user/thresholds` and invalidates the // `insightsTargets()` cache on save, so the panel repaints in place. const [editOpen, setEditOpen] = useState(false); @@ -261,7 +258,7 @@ function TargetReferencePanel({ // In-target share: fraction of logged days in the green band over the // last 30. Suppressed when the route flagged insufficient data so the - // sub-page mirrors the `/targets` strip exactly. + // sub-page suppresses the share line. const showShare = !target.insufficientData && target.daysLogged30d > 0; const sharePct = showShare ? Math.round((target.daysInRange30d / target.daysLogged30d) * 100) diff --git a/src/components/layout/bottom-nav.tsx b/src/components/layout/bottom-nav.tsx index 5b7039745..a76c63dfc 100644 --- a/src/components/layout/bottom-nav.tsx +++ b/src/components/layout/bottom-nav.tsx @@ -39,10 +39,9 @@ interface NavLink { // Order is preserved relative to the v1.4.15 bottom-nav so muscle // memory keeps working for the four core actions. // -// v1.8.6 — the Targets (Zielwerte) page is deprecated: target editing -// moved inline into Insights, so its overflow entry is gone. The page -// stays routable (the `/zielwerte`→`/targets` proxy redirect remains) -// but no longer appears in the menu. +// v1.9.0 — the standalone Targets (Zielwerte) page is retired. Target +// editing lives inline on the Insights metric pages (the per-metric +// reference panel mounts the target editor), so there is no nav entry. const PRIMARY: ReadonlyArray = [ { href: "/", tKey: "nav.dashboard", icon: Home }, { href: "/measurements", tKey: "nav.measurements", icon: Activity }, diff --git a/src/components/targets/__tests__/target-card.test.tsx b/src/components/targets/__tests__/target-card.test.tsx deleted file mode 100644 index f6db1ecac..000000000 --- a/src/components/targets/__tests__/target-card.test.tsx +++ /dev/null @@ -1,227 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { renderToStaticMarkup } from "react-dom/server"; - -import { I18nProvider } from "@/lib/i18n/context"; -import { TargetCard, type TargetCardData } from "../target-card"; -import { DEFAULT_ASSISTANT_FLAGS } from "@/hooks/use-feature-flags"; - -// v1.4.37 W4a item 9 — when the operator disables the Coach feature -// flag globally, the per-card CTA must disappear. The hook reads from -// QueryClientContext but falls back to the all-on default in SSR; -// the flag-off path needs an explicit mock so the test can drive the -// negative case without a QueryClientProvider. -vi.mock("@/hooks/use-feature-flags", async () => { - const actual = await vi.importActual< - typeof import("@/hooks/use-feature-flags") - >("@/hooks/use-feature-flags"); - return { - ...actual, - useFeatureFlags: vi.fn(() => actual.DEFAULT_ASSISTANT_FLAGS), - }; -}); - -const { useFeatureFlags } = await import("@/hooks/use-feature-flags"); - -/** - * v1.4.25 W3e — `` composition. Three load-bearing cases: - * - * 1. Full-data card — headline + status pill + range bar + - * consistency strip + Coach CTA + source link all visible. - * 2. Insufficient-data card — strip + recency line + streak chip - * all suppressed; the explicit "not enough data yet" inline - * message renders instead. - * 3. AI gate — Coach CTA suppressed when `aiEnabled` is false. - */ - -function render(props: Parameters[0]) { - return renderToStaticMarkup( - - - , - ); -} - -const fullDataTarget: TargetCardData = { - type: "WEIGHT", - label: "Weight", - current: 78.2, - average30: 79.1, - trend: "down", - unit: "kg", - range: { min: 60, max: 80 }, - classification: { category: "Normal", color: "#50fa7b" }, - source: "WHO BMI", - daysInRange7d: 5, - daysLogged7d: 7, - lastMetGoalAt: "2026-05-10", - streakDays: 4, - insufficientData: false, - consistency7d: ["in", "in", "near", "in", "out", "in", "in"], -}; - -const sparseTarget: TargetCardData = { - type: "PULSE", - label: "Resting pulse", - current: 72, - average30: 72, - trend: null, - unit: "bpm", - range: { min: 60, max: 100 }, - classification: { category: "On target", color: "#50fa7b" }, - source: "AHA", - daysInRange7d: 0, - daysLogged7d: 0, - lastMetGoalAt: null, - streakDays: 0, - insufficientData: true, - consistency7d: [null, null, null, null, null, null, null], -}; - -describe("", () => { - it("renders the full layout when the target has rich data", () => { - const html = render({ - target: fullDataTarget, - aiEnabled: true, - onAskCoach: vi.fn(), - }); - expect(html).toContain('data-slot="target-card"'); - expect(html).toContain('data-slot="target-status-pill"'); - expect(html).toContain('data-slot="target-range-bar"'); - expect(html).toContain('data-slot="consistency-strip"'); - expect(html).toContain('data-slot="target-coach-cta"'); - expect(html).toContain('data-slot="target-streak"'); - // Streak chip surfaces the count from i18n - expect(html).toContain("4-day streak"); - // Headline number (78.2 → 78.2) + the kg unit - expect(html).toContain("78.2"); - }); - - it("hides the consistency strip + recency + streak when insufficient data", () => { - const html = render({ - target: sparseTarget, - aiEnabled: true, - onAskCoach: vi.fn(), - }); - expect(html).not.toContain('data-slot="consistency-strip"'); - expect(html).not.toContain('data-slot="target-last-met"'); - expect(html).not.toContain('data-slot="target-streak"'); - // The explicit "not enough data yet" message replaces the strip - expect(html).toContain('data-slot="target-insufficient-data"'); - }); - - it("suppresses the Coach CTA when aiEnabled is false", () => { - const html = render({ - target: fullDataTarget, - aiEnabled: false, - onAskCoach: vi.fn(), - }); - expect(html).not.toContain('data-slot="target-coach-cta"'); - }); - - // v1.4.37 W4a item 9 — operator-level feature flag. - it("suppresses the Coach CTA when the global Coach flag is OFF", () => { - // Swap the mock for this case only — flag-off should hide the - // CTA regardless of the user-level aiEnabled state. Restore the - // all-on default so the rest of the suite doesn't see a leaked - // mock state. - vi.mocked(useFeatureFlags).mockReturnValueOnce({ - ...DEFAULT_ASSISTANT_FLAGS, - coach: false, - }); - const html = render({ - target: fullDataTarget, - aiEnabled: true, - onAskCoach: vi.fn(), - }); - expect(html).not.toContain('data-slot="target-coach-cta"'); - }); - - it("renders the Coach CTA when both aiEnabled AND the global Coach flag are ON", () => { - // Positive control: the default-mock returns the all-on flag set, - // so the CTA should mount when aiEnabled is also true. - const html = render({ - target: fullDataTarget, - aiEnabled: true, - onAskCoach: vi.fn(), - }); - expect(html).toContain('data-slot="target-coach-cta"'); - }); - - it("renders MOOD_STABILITY headline as a verbal label, not the σ value", () => { - const moodStability: TargetCardData = { - ...fullDataTarget, - type: "MOOD_STABILITY", - label: "Mood stability", - current: 0.6, - unit: "σ", - range: { min: 0, max: 0.5 }, - classification: { category: "Stable", color: "#f1fa8c" }, - }; - const html = render({ - target: moodStability, - aiEnabled: false, - onAskCoach: vi.fn(), - }); - // The big-number slot should carry the EN verbal label, not "0.6". - expect(html).toContain('data-mood-stability="stable"'); - expect(html).toContain("stable"); - expect(html).not.toMatch(/>0\.6 { - it("renders the edit cog with an aria-label for the rich-data card", () => { - const html = render({ - target: fullDataTarget, - aiEnabled: true, - onAskCoach: vi.fn(), - }); - expect(html).toContain('data-slot="target-edit-cog"'); - expect(html).toContain('data-target-type="WEIGHT"'); - expect(html).toContain("Edit target range for Weight"); - }); - - it("STILL renders the cog when the target is in insufficient-data mode", () => { - // Consistency rule: target-config UI must always be reachable, - // even when the consistency strip is hidden. - const html = render({ - target: sparseTarget, - aiEnabled: true, - onAskCoach: vi.fn(), - }); - expect(html).toContain('data-slot="target-edit-cog"'); - expect(html).toContain('data-target-type="PULSE"'); - }); - - it("renders the cog when the AI provider is disabled (gate is independent)", () => { - // The Coach CTA depends on aiEnabled; the edit cog does not. - const html = render({ - target: fullDataTarget, - aiEnabled: false, - onAskCoach: vi.fn(), - }); - expect(html).toContain('data-slot="target-edit-cog"'); - expect(html).not.toContain('data-slot="target-coach-cta"'); - }); - - it("renders the cog button with adequate touch target (min-h-11 + min-w-11)", () => { - // Mobile-first: tap target ≥ 44 px so the cog isn't an - // accessibility hazard on smaller phones. - const html = render({ - target: fullDataTarget, - aiEnabled: true, - onAskCoach: vi.fn(), - }); - expect(html).toMatch( - /data-slot="target-edit-cog"[^>]*class="[^"]*min-h-11[^"]*"/, - ); - expect(html).toMatch( - /data-slot="target-edit-cog"[^>]*class="[^"]*min-w-11[^"]*"/, - ); - }); - }); -}); diff --git a/src/components/targets/__tests__/target-coach-button.test.tsx b/src/components/targets/__tests__/target-coach-button.test.tsx deleted file mode 100644 index 752811b1b..000000000 --- a/src/components/targets/__tests__/target-coach-button.test.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { renderToStaticMarkup } from "react-dom/server"; - -import { I18nProvider } from "@/lib/i18n/context"; -import { TargetCoachButton } from "../target-coach-button"; - -/** - * v1.4.25 W3e — gate test for the per-card Coach CTA. The button - * MUST disappear when `aiEnabled` is false (the maintainer's rule: - * no broken-button state for users with no provider configured). - */ - -function render(props: Parameters[0]) { - return renderToStaticMarkup( - - - , - ); -} - -describe("", () => { - const baseProps = { - prefill: "How am I doing on weight?", - sources: ["weight"] as const, - onAskCoach: vi.fn(), - }; - - it("renders the button when aiEnabled is true", () => { - const html = render({ ...baseProps, aiEnabled: true }); - expect(html).toContain('data-slot="target-coach-cta"'); - // v1.4.28 FB-L1 — the affordance is now icon-only; the label - // survives as an aria-label + title so screen readers + tooltips - // still surface "Ask Coach" / "Coach fragen". - expect(html).toContain('aria-label="Ask Coach"'); - expect(html).toContain('title="Ask Coach"'); - // No visible text label — only the Sparkles glyph. - expect(html).not.toMatch(/>Ask Coach { - // The icon variant alone ships `size-10` (40 px); the per-card - // Coach button rides `min-h-11 min-w-11` on top so the hit - // surface matches the medication-history and Coach drawer - // buttons. Pinning the class so a future refactor can't drop - // back to the 40 px shadcn default. - const html = render({ ...baseProps, aiEnabled: true }); - const trigger = html.match( - /]*data-slot="target-coach-cta"[^>]*>/, - ); - expect(trigger).not.toBeNull(); - expect(trigger?.[0]).toContain("min-h-11"); - expect(trigger?.[0]).toContain("min-w-11"); - }); - - it("renders NOTHING when aiEnabled is false (no broken-button state)", () => { - const html = render({ ...baseProps, aiEnabled: false }); - expect(html).toBe(""); - }); - - it("does not call onAskCoach during SSR", () => { - const onAskCoach = vi.fn(); - render({ ...baseProps, onAskCoach, aiEnabled: true }); - // Server-side render does not fire click handlers. - expect(onAskCoach).not.toHaveBeenCalled(); - }); -}); diff --git a/src/components/targets/__tests__/targets-summary-header.test.tsx b/src/components/targets/__tests__/targets-summary-header.test.tsx deleted file mode 100644 index 5449d80b8..000000000 --- a/src/components/targets/__tests__/targets-summary-header.test.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { renderToStaticMarkup } from "react-dom/server"; - -import { I18nProvider } from "@/lib/i18n/context"; -import { TargetsSummaryHeader } from "../targets-summary-header"; - -/** - * v1.4.25 W3e — page-level summary line. Three load-bearing cases: - * 1. Partial: "4 of 6 targets met this week" - * 2. Full: "All 6 targets met this week" + check mark - * 3. With streak highlight chip - */ - -function render(props: Parameters[0]) { - return renderToStaticMarkup( - - - , - ); -} - -describe("", () => { - it("renders the partial-count headline when met < total", () => { - const html = render({ - targetsMetThisWeek: 4, - totalTargets: 6, - streakHighlight: null, - }); - expect(html).toContain('data-slot="targets-summary-title"'); - expect(html).toContain("4 of 6 targets met this week"); - // Check mark should NOT render in the partial state. - expect(html).not.toContain("lucide-check"); - }); - - it("renders the all-met headline + check mark when every target is met", () => { - const html = render({ - targetsMetThisWeek: 6, - totalTargets: 6, - streakHighlight: null, - }); - expect(html).toContain("All 6 targets met this week"); - expect(html).toContain("text-[var(--dracula-green)]"); - }); - - it("renders the streak highlight chip when present", () => { - const html = render({ - targetsMetThisWeek: 4, - totalTargets: 6, - streakHighlight: { metric: "BLOOD_PRESSURE", days: 5 }, - }); - expect(html).toContain('data-slot="targets-summary-streak"'); - expect(html).toContain("5-day streak: Blood pressure"); - }); - - it("renders nothing when there are no targets at all", () => { - const html = render({ - targetsMetThisWeek: 0, - totalTargets: 0, - streakHighlight: null, - }); - expect(html).toBe(""); - }); -}); diff --git a/src/components/targets/range-bar.tsx b/src/components/targets/range-bar.tsx index 2adc4f62b..89537bace 100644 --- a/src/components/targets/range-bar.tsx +++ b/src/components/targets/range-bar.tsx @@ -9,10 +9,6 @@ import { import { useTranslations } from "@/lib/i18n/context"; /** - * v1.4.25 W3e — extracted from `src/app/targets/page.tsx` (the page - * was 790 lines; the redesign breaks it into focused primitives so - * each component carries its own contract + test suite). - * * A horizontal range bar with green/yellow/red zones showing where * the current value falls relative to a target range. The marker dot * carries a tooltip with the current value + the target range + a diff --git a/src/components/targets/target-card.tsx b/src/components/targets/target-card.tsx deleted file mode 100644 index 24c3192bd..000000000 --- a/src/components/targets/target-card.tsx +++ /dev/null @@ -1,578 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { - AlertCircle, - Activity, - Droplet, - ExternalLink, - Heart, - Minus, - Moon, - Percent, - Scale, - Settings2, - Smile, - TrendingDown, - TrendingUp, -} from "lucide-react"; -import type { LucideIcon } from "lucide-react"; - -import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardHeader } from "@/components/ui/card"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/ui/tooltip"; -import { cn } from "@/lib/utils"; -import { useTranslations } from "@/lib/i18n/context"; -import { useFeatureFlags } from "@/hooks/use-feature-flags"; -import { moodStabilityLabel } from "@/lib/targets/mood-stability-label"; -import { ConsistencyStrip } from "./consistency-strip"; -import { RangeBar } from "./range-bar"; -import { - TargetStatusPill, - statusGroupForCategory, - translateStatus, -} from "./target-status-pill"; -import { TargetCoachButton } from "./target-coach-button"; -import { TargetEditSheet } from "./target-edit-sheet"; -import { buildTargetPrompt } from "@/lib/ai/coach/target-prompts"; -import { coachScopeForTarget } from "@/lib/ai/coach/target-scope"; -import type { CoachScope } from "@/lib/ai/coach/types"; - -/** - * v1.4.25 W3e — `` extracted from `src/app/targets/page.tsx` - * with a Marc-directive redesign. Visual rationale: - * - * • Headline row pairs the metric label with a verbal status pill — - * Q1 ("am I on track right now?") answered before the user's eye - * moves below the title. - * • Big value with the unit small + muted; the inline trend chevron - * sits next to the value, not in a separate row. - * • Range bar carries the precise placement; the bar is the - * "spatial" answer to Q2 (delta to goal). - * • Consistency strip beneath the bar answers Q7 ("how consistent - * am I?"). Hidden when `insufficientData` is true so cold-start - * accounts don't see a dead-grey row. - * • Recency caption ("last met goal: 2 days ago") + streak chip - * when ≥ 3 days. Both are conditional on real data — no empty - * placeholders. - * • Footer row: per-card Coach CTA on the left (only when the user - * has an AI provider configured), guideline-source link on the - * right. - * - * Per Marc's "muss nicht so sein wie die KI Sache": the card stays - * quiet. No gradients, no chips with emoji, no animation. Editorial - * spacing rhythm; one decisive number and one decisive status pill. - */ - -const TYPE_ICONS: Record = { - WEIGHT: Scale, - BLOOD_PRESSURE: Heart, - BLOOD_PRESSURE_IN_TARGET: Heart, - PULSE: Activity, - SLEEP_DURATION: Moon, - BODY_FAT: Percent, - BMI: Scale, - MOOD_SCORE: Smile, - MOOD_STABILITY: Smile, - BLOOD_GLUCOSE_FASTING: Droplet, - BLOOD_GLUCOSE_POSTPRANDIAL: Droplet, - BLOOD_GLUCOSE_RANDOM: Droplet, - BLOOD_GLUCOSE_BEDTIME: Droplet, -}; - -const TYPE_COLORS: Record = { - WEIGHT: "text-dracula-purple", - BLOOD_PRESSURE: "text-dracula-pink", - BLOOD_PRESSURE_IN_TARGET: "text-dracula-pink", - PULSE: "text-dracula-green", - SLEEP_DURATION: "text-dracula-cyan", - BODY_FAT: "text-dracula-orange", - BMI: "text-dracula-yellow", - MOOD_SCORE: "text-dracula-lavender", - MOOD_STABILITY: "text-dracula-lavender", - BLOOD_GLUCOSE_FASTING: "text-dracula-red", - BLOOD_GLUCOSE_POSTPRANDIAL: "text-dracula-red", - BLOOD_GLUCOSE_RANDOM: "text-dracula-red", - BLOOD_GLUCOSE_BEDTIME: "text-dracula-red", -}; - -interface TargetClassification { - category: string; - color: string; -} - -interface BpDiastolic { - current: number | null; - average30: number | null; - range: { min: number; max: number } | null; -} - -export interface TargetCardData { - type: string; - label: string; - current: number | null; - average30: number | null; - trend: "up" | "down" | "stable" | null; - unit: string; - range: { min: number; max: number } | null; - classification: TargetClassification | null; - source: string; - daysInRange7d?: number; - daysLogged7d?: number; - lastMetGoalAt?: string | null; - streakDays?: number; - insufficientData?: boolean; - consistency7d?: ReadonlyArray<"in" | "near" | "out" | null>; - details?: { - medications?: Array<{ - name: string; - compliance7: number; - compliance30: number; - }>; - }; -} - -export interface TargetCardProps { - target: TargetCardData; - bpDiastolic?: BpDiastolic; - /** - * AI gate. When false, the per-card Coach CTA is suppressed entirely - * (no greyed-out button, no tooltip placeholder). The parent reads - * `/api/insights/provider-chain` and threads this flag. - */ - aiEnabled: boolean; - /** - * Drawer handoff. Receives the pre-formatted prefill + the - * narrowed scope (single source for 1:1 metrics, derived source - * for BMI / mood stability, all sources for the rest). - */ - onAskCoach: (payload: { prefill: string; scope: CoachScope }) => void; - /** - * Optional source URL for the guideline link. Threaded from the - * page rather than computed in the card so the resolution stays - * declarative + testable. - */ - sourceLink?: string | null; -} - -function TrendIcon({ trend }: { trend: "up" | "down" | "stable" | null }) { - if (trend === "up") { - return ( -