diff --git a/CHANGELOG.md b/CHANGELOG.md index 69c0c542e..01bcfd7f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ ## [Unreleased] +## [1.16.14] — 2026-06-14 — one number everywhere: the score, the coach, and the doctor-report agree + +### Changed + +- **Blood-pressure in-target speaks one window everywhere.** The in-target tile, the health-score blood-pressure pillar, and the coach now read a single 90-day window — labeled on the tile, so it is no longer hidden. The tile shows its real span ("· 23 T") until 90 days of history have accrued, and holds back a percentage ("sammelt noch Daten") until it has at least a handful of readings, so a single measurement can never imply a confident share. The all-time view stays on the detail page. The headline figure can differ from the previous unlabeled 30-day number — it is now the correct, consistent one. + +### Fixed + +- **The dashboard ring and the insights card show the same health score.** Both assemble the score from one shared input builder over identical windows, so the same account can no longer see two different scores or a pillar present on one surface and absent on the other; the ring's day-delta now also reflects blood-pressure movement. +- **The coach quotes the adherence the card shows.** Medication compliance routes through the same authority as every other surface instead of the coach's own per-day tally, so it can no longer reason from a number you never see — it matters most on irregular, rolling and as-needed schedules. +- **The doctor-report matches the app.** The adherence in the PDF/FHIR export is computed from the same dose history — slot attribution, cadence and cross-source dedup honoured — as the detail page, instead of a raw intake-row count, so the figure a clinician reads on paper agrees with the screen. +- **Measurement and mood entries reject implausible timestamps.** A future date beyond a small clock-skew allowance, or anything before 1900, is refused — matching the medication-intake guard — so a stray future-dated reading can no longer distort the recent-window metrics. + ## [1.16.13] — 2026-06-14 — installable and offline, doses that find the right day, and headers that hold their line ### Added diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index f1c31b748..d32fa3b1e 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.16.13 + version: 1.16.14 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/messages/de.json b/messages/de.json index 94442b3f6..72d0e546e 100644 --- a/messages/de.json +++ b/messages/de.json @@ -75,6 +75,7 @@ "colSkipped": "Übersprungen", "colMissed": "Verpasst", "colTotal": "Gesamt", + "colExpected": "Erwartet", "colComplianceRate": "Therapietreue", "moodTitle": "Stimmungsverlauf", "moodSummary": "Durchschnitt: {avg} / 5,0 · Einträge: {count} · Bereich: {min}–{max}", @@ -326,6 +327,9 @@ "stepsShort": "Schritte", "vo2MaxShort": "VO₂ max", "bpInTargetShort": "BD-Ziel", + "bpInTargetWindow90": "BD-Ziel · 90 T", + "bpInTargetWindowDynamic": "BD-Ziel · {days} T", + "bpInTargetCollecting": "sammelt noch Daten", "medications": "Medikamente", "quickAddMeasurement": "Messung erfassen", "quickAddMood": "Stimmung erfassen", diff --git a/messages/en.json b/messages/en.json index febabae4b..9b689ad2b 100644 --- a/messages/en.json +++ b/messages/en.json @@ -75,6 +75,7 @@ "colSkipped": "Skipped", "colMissed": "Missed", "colTotal": "Total", + "colExpected": "Expected", "colComplianceRate": "Adherence rate", "moodTitle": "Mood trajectory", "moodSummary": "Average: {avg} / 5.0 · Entries: {count} · Range: {min}–{max}", @@ -326,6 +327,9 @@ "stepsShort": "Steps", "vo2MaxShort": "VO₂ max", "bpInTargetShort": "BP in Target", + "bpInTargetWindow90": "BP in Target · 90d", + "bpInTargetWindowDynamic": "BP in Target · {days}d", + "bpInTargetCollecting": "collecting data", "medications": "Medications", "quickAddMeasurement": "Log measurement", "quickAddMood": "Log mood", diff --git a/messages/es.json b/messages/es.json index bad70d032..f020d3cd4 100644 --- a/messages/es.json +++ b/messages/es.json @@ -75,6 +75,7 @@ "colSkipped": "Omitida", "colMissed": "Perdida", "colTotal": "Total", + "colExpected": "Esperadas", "colComplianceRate": "Tasa de cumplimiento", "moodTitle": "Evolución del estado de ánimo", "moodSummary": "Promedio:{avg} / 5.0 · Entries: {count} · Range: {min}–{max}", @@ -326,6 +327,9 @@ "stepsShort": "Pasos", "vo2MaxShort": "VO₂ máx", "bpInTargetShort": "PA-objetivo", + "bpInTargetWindow90": "PA-objetivo · 90 d", + "bpInTargetWindowDynamic": "PA-objetivo · {days} d", + "bpInTargetCollecting": "recopilando datos", "medications": "Medicamentos", "quickAddMeasurement": "Registrar medición", "quickAddMood": "Registrar estado de ánimo", diff --git a/messages/fr.json b/messages/fr.json index 5efaddac7..9f90ace3a 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -75,6 +75,7 @@ "colSkipped": "Sauté", "colMissed": "Manqué", "colTotal": "Total", + "colExpected": "Attendues", "colComplianceRate": "Taux d'observance", "moodTitle": "Évolution de l’humeur", "moodSummary": "Moyenne:{avg} / 5.0 · Entries: {count} · Range: {min}–{max}", @@ -326,6 +327,9 @@ "stepsShort": "Pas", "vo2MaxShort": "VO₂ max", "bpInTargetShort": "TA-cible", + "bpInTargetWindow90": "TA-cible · 90 j", + "bpInTargetWindowDynamic": "TA-cible · {days} j", + "bpInTargetCollecting": "collecte de données", "medications": "Médicaments", "quickAddMeasurement": "Saisir une mesure", "quickAddMood": "Saisir une humeur", diff --git a/messages/it.json b/messages/it.json index 7c064c30f..ebadb9ffe 100644 --- a/messages/it.json +++ b/messages/it.json @@ -75,6 +75,7 @@ "colSkipped": "Saltata", "colMissed": "Persa", "colTotal": "Totale", + "colExpected": "Attese", "colComplianceRate": "Tasso di aderenza", "moodTitle": "Andamento dell’umore", "moodSummary": "Media:{avg} / 5.0 · Entries: {count} · Range: {min}–{max}", @@ -326,6 +327,9 @@ "stepsShort": "Passi", "vo2MaxShort": "VO₂ max", "bpInTargetShort": "PA-target", + "bpInTargetWindow90": "PA-target · 90 gg", + "bpInTargetWindowDynamic": "PA-target · {days} gg", + "bpInTargetCollecting": "raccolta dati in corso", "medications": "Farmaci", "quickAddMeasurement": "Registra misurazione", "quickAddMood": "Registra umore", diff --git a/messages/pl.json b/messages/pl.json index bb5cab1b5..92f351f3b 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -75,6 +75,7 @@ "colSkipped": "Pominięte", "colMissed": "Pominięte", "colTotal": "Razem", + "colExpected": "Oczekiwane", "colComplianceRate": "Wskaźnik przestrzegania", "moodTitle": "Przebieg nastroju", "moodSummary": "Średnia:{avg} / 5.0 · Entries: {count} · Range: {min}–{max}", @@ -326,6 +327,9 @@ "stepsShort": "Kroki", "vo2MaxShort": "VO₂ max", "bpInTargetShort": "RR-cel", + "bpInTargetWindow90": "RR-cel · 90 d", + "bpInTargetWindowDynamic": "RR-cel · {days} d", + "bpInTargetCollecting": "zbieranie danych", "medications": "Leki", "quickAddMeasurement": "Zarejestruj pomiar", "quickAddMood": "Zarejestruj nastrój", diff --git a/package.json b/package.json index 571bcf146..51a0d82bc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.16.13", + "version": "1.16.14", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/public/sw.js b/public/sw.js index 1890d1ae5..f7fb41909 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.16.13"; + /* @sw-version-fallback */ "v1.16.14"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; const MAX_STATIC_ENTRIES = 150; diff --git a/src/app/api/analytics/route.ts b/src/app/api/analytics/route.ts index e706750fc..bd82bec73 100644 --- a/src/app/api/analytics/route.ts +++ b/src/app/api/analytics/route.ts @@ -12,8 +12,13 @@ import { reconstructSleepNights } from "@/lib/analytics/sleep-night"; import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; import { ensureUserRollupsFresh } from "@/lib/rollups/measurement-rollups"; import { probeRollupCoverage } from "@/lib/rollups/measurement-coverage"; -import { computeBpInTargetFastPath } from "@/lib/analytics/bp-in-target-fast-path"; +import { + computeBpInTargetFastPath, + type BpInTargetEnvelope, +} from "@/lib/analytics/bp-in-target-fast-path"; import { computeUserHealthScoreFastPath } from "@/lib/analytics/health-score-fast-path"; +import { buildHealthScoreBpInputs } from "@/lib/analytics/health-score-inputs"; +import { deriveBpWindow90 } from "@/lib/analytics/window-confidence"; import { computeCorrelationHypothesesFastPath } from "@/lib/analytics/correlations-fast-path"; export const dynamic = "force-dynamic"; @@ -253,24 +258,18 @@ async function buildAnalyticsResponse(user: AuthedUser) { */ let bpInTargetPctPriorMonth: number | null = null; let bpInTargetPctPriorYear: number | null = null; - /** - * v1.4.38 — prior-week BP-in-target pct so the Health-Score - * week-over-week delta reflects BP movement. Pre-v1.4.38 the - * Health-Score helper pinned both windows to the same value, so the - * BP pillar always zeroed out of the delta. Computed by running a - * second `computeBpInTargetFastPath` against `now - 7d` and reading - * the resulting `last30Days.pct`. - */ - let bpInTargetPctPriorWeek: number | null = null; - // All-time prior-week BP pct — feeds the Health-Score delta so its BP - // pillar compares the same window on both ends (the tile headline keeps - // its 30-day scope; the score reads all-time per its documented contract). - let windowsPriorWeekAllTime: number | null = null; - // v1.15.12 A1 — graded clinical-proximity BP score (recency-weighted - // representative reading) for the Health-Score BP pillar value, plus - // the prior-week run for the week-over-week delta. - let bpGradedScore: number | null = null; - let bpGradedScorePriorWeek: number | null = null; + // v1.17 W1b — 90-day pair count + effective label span for the tile's + // thin-data gate. Surfaced so the BD-Zielbereich tile renders "collecting + // data" until the window holds enough readings, with a dynamic span label. + let bpInTargetCount90: number | null = null; + let bpInTargetSpanDays90: number | null = null; + // v1.17 W1b — hold the current + prior-week BP envelopes so the shared + // Health-Score input builder grades the pillar off the identical shape the + // dashboard snapshot uses (one builder, no per-surface assembly drift). + // The graded score + prior-week BP figures the score consumes are read off + // these envelopes inside `buildHealthScoreBpInputs`. + let bpEnvelope: BpInTargetEnvelope | null = null; + let bpEnvelopePriorWeek: BpInTargetEnvelope | null = null; const bpTargets = getBpTargets(user.dateOfBirth); if (bpTargets) { const now = new Date(); @@ -315,20 +314,27 @@ async function buildAnalyticsResponse(user: AuthedUser) { userTz, }), ]); - bpInTargetPct = windows.last30Days?.pct ?? null; + bpEnvelope = windows; + bpEnvelopePriorWeek = windowsPriorWeek; + // v1.17 W1d — the BD-Zielbereich headline standardises on the + // trailing-90-day window (labelled "· 90 T" in the tile). All-time + // remains carried below for the BP detail page's long view only. + bpInTargetPct = windows.last90Days?.pct ?? null; bpInTargetPct7d = windows.last7Days?.pct ?? null; bpInTargetPct30d = windows.last30Days?.pct ?? null; bpInTargetPctAllTime = windows.allTime?.pct ?? null; bpInTargetPctPriorMonth = windows.priorMonth?.pct ?? null; bpInTargetPctPriorYear = windows.priorYear?.pct ?? null; - bpInTargetPctPriorWeek = windowsPriorWeek.last30Days?.pct ?? null; - bpGradedScore = windows.gradedScore; - bpGradedScorePriorWeek = windowsPriorWeek.gradedScore; - // The Health-Score BP pillar reads the all-time window (see the - // `bpInTargetPctForScore` derivation below). Capture the prior-week - // run's all-time pct too so the week-over-week delta compares the - // same window on both ends. - windowsPriorWeekAllTime = windowsPriorWeek.allTime?.pct ?? null; + // v1.17 W1b — count + effective span for the BD-Zielbereich tile's + // confidence gate (thin-data → "collecting data"; dynamic span label). + // Shared helper keeps this identical to the dashboard snapshot. + const bpWindow = deriveBpWindow90( + windows.last90Days, + windows.last90EarliestAt, + now, + ); + bpInTargetCount90 = bpWindow.count; + bpInTargetSpanDays90 = bpWindow.spanDays; } // Per-context glucose summaries (canonical mg/dL). @@ -386,36 +392,17 @@ async function buildAnalyticsResponse(user: AuthedUser) { // 2-column projection from `measurements` for the ingest-path // pills. Falls back to the legacy 37-day raw read on partial / // missing coverage. Path annotate sits on `meta.healthScore.path`. - // The Health-Score BP pillar reads the **all-time** in-target rate, not - // the BD-Zielbereich tile headline (which v1.4.x deliberately scopes to - // the trailing 30 days). Feeding the tile's `bpInTargetPct` (last-30d) - // here regressed the pillar to null for any account with BP history but - // no readings inside the trailing month — the score then dropped the - // 0.30-weight BP pillar entirely and rendered "no rating" despite the - // user having BP data. `health-score.ts` documents the field as the - // all-time rate, so route the all-time window through; fall back to the - // 30-day window only when the all-time window is itself null (it never - // is when 30-day has data), so a brand-new account is unaffected. - const bpInTargetPctForScore = bpInTargetPctAllTime ?? bpInTargetPct; - const bpInTargetPctPriorWeekForScore = - windowsPriorWeekAllTime ?? bpInTargetPctPriorWeek; + // + // v1.17 W1b — the BP-pillar inputs come from the ONE shared + // `buildHealthScoreBpInputs` builder the dashboard snapshot also uses, so + // the ring and the insights card grade the pillar off identical inputs + // (same 90-day window via W1d, same all-time fallback, same graded score, + // same prior-week delta values). The hand-rolled per-surface assembly that + // let the two diverge is gone. + const bpInputs = buildHealthScoreBpInputs(bpEnvelope, bpEnvelopePriorWeek); const healthScore = await computeUserHealthScoreFastPath({ userId: user.id, - bpInTargetPct: bpInTargetPctForScore, - // v1.4.38 — feed the prior-week BP pct into the Health-Score - // helper's previous-window snapshot so the week-over-week delta - // reflects BP movement instead of zeroing out of the BP pillar. - // Computed alongside the current windows via a second - // `computeBpInTargetFastPath` run anchored at `now - 7d`. Uses the - // same all-time window as the current snapshot so the delta is - // apples-to-apples. - bpInTargetPctPriorWeek: bpInTargetPctPriorWeekForScore, - // v1.15.12 A1 — graded clinical-proximity BP score drives the pillar - // VALUE; the binary in-target rate above only gates presence and is - // surfaced as a secondary stat. Closes the "borderline-stage-1 reads - // 16/100" trust bug. - bpGradedScore, - bpGradedScorePriorWeek, + ...bpInputs, heightCm: user.heightCm ?? null, now: new Date(), coverage, @@ -430,6 +417,8 @@ async function buildAnalyticsResponse(user: AuthedUser) { bpInTargetPctAllTime, bpInTargetPctPriorMonth, bpInTargetPctPriorYear, + bpInTargetCount90, + bpInTargetSpanDays90, glucoseByContext, correlations, healthScore, diff --git a/src/app/page.tsx b/src/app/page.tsx index ba7c5126e..836045263 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -104,6 +104,7 @@ import { useDashboardSnapshot } from "@/lib/queries/use-dashboard-snapshot"; import { isDashboardSnapshotEnabled } from "@/lib/dashboard/snapshot-flag"; import type { DataSummary } from "@/lib/analytics/trends"; import { mergeSlimAndThickAnalytics } from "@/lib/analytics/merge-slim-thick"; +import { isWindowSufficient } from "@/lib/analytics/window-confidence"; import { getBpTargets } from "@/lib/analytics/bp-targets"; import { buildTrafficLightBands, @@ -297,6 +298,8 @@ export default function DashboardPage() { bpInTargetPctAllTime: snap.extras?.bpInTargetPctAllTime ?? null, bpInTargetPctPriorMonth: snap.extras?.bpInTargetPctPriorMonth ?? null, bpInTargetPctPriorYear: snap.extras?.bpInTargetPctPriorYear ?? null, + bpInTargetCount90: snap.extras?.bpInTargetCount90 ?? null, + bpInTargetSpanDays90: snap.extras?.bpInTargetSpanDays90 ?? null, glucoseByContext: snap.extras?.glucoseByContext as | Record | undefined, @@ -325,6 +328,8 @@ export default function DashboardPage() { bpInTargetPctAllTime: merged.bpInTargetPctAllTime, bpInTargetPctPriorMonth: merged.bpInTargetPctPriorMonth, bpInTargetPctPriorYear: merged.bpInTargetPctPriorYear, + bpInTargetCount90: merged.bpInTargetCount90, + bpInTargetSpanDays90: merged.bpInTargetSpanDays90, glucoseByContext: merged.glucoseByContext as | Record | undefined, @@ -1092,23 +1097,46 @@ export default function DashboardPage() { bpComparePrior === null ? null : Math.round((bp30 - bpComparePrior) * 10) / 10; + // v1.17 W1b — thin-data gate + dynamic span label. Below the + // confidence floor the tile narrates "collecting data" instead of + // a hard percentage, and the label names the EFFECTIVE span + // ("· 23 T" until ~90 days of history exist) rather than a + // dishonest static "· 90 T". When the count is unknown (snapshot + // still on the slim phase) the tile keeps the legacy 90-day label + // and shows whatever percentage arrived. + const bpCount90 = data?.bpInTargetCount90 ?? null; + const bpSpanDays90 = data?.bpInTargetSpanDays90 ?? null; + const bpSufficient = + bpCount90 === null || isWindowSufficient(bpCount90); + const bpLabel = + bpSpanDays90 != null + ? t("dashboard.bpInTargetWindowDynamic", { days: bpSpanDays90 }) + : t("dashboard.bpInTargetWindow90"); trendCards.push({ id: "bpInTarget", order: widgetOrder("bpInTarget"), node: ( ), }); diff --git a/src/components/charts/__tests__/bd-tile.test.tsx b/src/components/charts/__tests__/bd-tile.test.tsx index 1adf2e09d..9ee1ac337 100644 --- a/src/components/charts/__tests__/bd-tile.test.tsx +++ b/src/components/charts/__tests__/bd-tile.test.tsx @@ -114,4 +114,26 @@ describe("BD-Zielbereich tile (v1.4.28 FB-C1/C2 rewrite)", () => { expect(html).toContain('data-slot="trend-card-value-row"'); expect(html).toContain("—"); }); + + it("renders the empty-hint placeholder instead of a percentage on thin data (v1.17 W1b)", () => { + const html = render( + , + ); + expect(html).toContain('data-slot="trend-card-empty-hint"'); + expect(html).toContain("collecting data"); + // The dynamic span label is shown in place of the static "· 90 T". + expect(html).toContain("BP in Target · 23d"); + // No numeric headline value renders when the hint takes the slot. + expect(html).not.toContain('data-slot="trend-card-value"'); + }); }); diff --git a/src/components/charts/trend-card.tsx b/src/components/charts/trend-card.tsx index c41eb292f..01f9c445e 100644 --- a/src/components/charts/trend-card.tsx +++ b/src/components/charts/trend-card.tsx @@ -89,6 +89,16 @@ interface TrendCardProps { * `null` / `undefined` suppresses the caption (current data path). */ staleDays?: number | null; + /** + * v1.17 W1b — short text shown in the headline slot in place of the + * "—" placeholder when `latest === null`. Used by the BD-Zielbereich + * tile to render "collecting data" while the trailing window has too + * few readings to narrate a confident percentage. When omitted the + * legacy "—" placeholder renders. Rendered at a smaller size than a + * numeric headline so the longer string fits the deterministic tile + * height. + */ + emptyHint?: string | null; } export function TrendCard({ @@ -109,6 +119,7 @@ export function TrendCard({ compareBaseline = "none", compareDelta = null, staleDays = null, + emptyHint = null, }: TrendCardProps) { const { t } = useTranslations(); const fmt = useFormatters(); @@ -215,6 +226,20 @@ export function TrendCard({ className="mt-2 flex items-baseline gap-x-1.5" data-slot="trend-card-value-row" > + {latest === null && emptyHint ? ( + // v1.17 W1b — thin-data placeholder. Renders the caller's short + // text (e.g. "collecting data") where the numeric headline would + // sit, at a smaller size so the longer phrase fits the tile's + // deterministic height. No unit; the arrow slot below still + // renders its muted placeholder so the row width stays stable. + + {emptyHint} + + ) : ( + <> {unit} + + )} setMeasuredAt(e.target.value)} + // v1.17 W1b — mirror the server-side plausibility bound: the + // picker cannot select a future instant. Matches the + // `validateEntryInstant` refine on `measuredAt`. + max={getDefaultMeasuredAtValue()} required aria-required="true" aria-invalid={!!error || undefined} diff --git a/src/components/measurements/measurement-list.tsx b/src/components/measurements/measurement-list.tsx index aa0ec6794..2d9c2d34e 100644 --- a/src/components/measurements/measurement-list.tsx +++ b/src/components/measurements/measurement-list.tsx @@ -1273,6 +1273,8 @@ export function MeasurementList({ id="edit-measuredAt" value={editMeasuredAt} onChange={(e) => setEditMeasuredAt(e.target.value)} + // v1.17 W1b — match the server bound: no future instant. + max={toDateTimeLocalValue(new Date().toISOString())} required aria-required="true" aria-invalid={!!editError || undefined} diff --git a/src/components/mood/mood-form.tsx b/src/components/mood/mood-form.tsx index 3373b754d..aa0fd6001 100644 --- a/src/components/mood/mood-form.tsx +++ b/src/components/mood/mood-form.tsx @@ -328,6 +328,8 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) { id="mood-logged-at" value={moodLoggedAt} onChange={(e) => setMoodLoggedAt(e.target.value)} + // v1.17 W1b — match the server bound: no future instant. + max={getDefaultMoodLoggedAtValue()} required aria-required="true" aria-invalid={!!error || undefined} diff --git a/src/components/mood/mood-list.tsx b/src/components/mood/mood-list.tsx index 39d303c7d..12e93967f 100644 --- a/src/components/mood/mood-list.tsx +++ b/src/components/mood/mood-list.tsx @@ -842,6 +842,8 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) { id="edit-mood-logged-at" value={editMoodLoggedAt} onChange={(e) => setEditMoodLoggedAt(e.target.value)} + // v1.17 W1b — match the server bound: no future instant. + max={toDateTimeLocalValue(new Date().toISOString())} required /> diff --git a/src/lib/__tests__/doctor-report-data.test.ts b/src/lib/__tests__/doctor-report-data.test.ts index 7047219ad..a1fae1714 100644 Binary files a/src/lib/__tests__/doctor-report-data.test.ts and b/src/lib/__tests__/doctor-report-data.test.ts differ diff --git a/src/lib/ai/coach/__tests__/snapshot.test.ts b/src/lib/ai/coach/__tests__/snapshot.test.ts index f7725f414..ed0662da6 100644 --- a/src/lib/ai/coach/__tests__/snapshot.test.ts +++ b/src/lib/ai/coach/__tests__/snapshot.test.ts @@ -211,6 +211,91 @@ describe("buildCoachSnapshot", () => { expect(parsed.scope.sources).not.toContain("compliance"); }); + // v1.17 W1c — the coach's headline adherence % must route through the + // SAME `calculateCompliance(...).rate` authority the medication card + // shows, never a per-day / per-week denominator of its own. A daily med + // with 7 of 10 expected doses taken reads 70 % on the card; the coach + // snapshot must surface the identical figure on `compliance.rate`. + it("surfaces a headline compliance rate equal to calculateCompliance().rate", async () => { + const { calculateCompliance, buildComplianceMedicationContext } = + await import("@/lib/analytics/compliance"); + + const dayMs = 24 * 60 * 60 * 1000; + const createdAt = new Date(Date.now() - 40 * dayMs); + // Ten daily doses 1..10 days ago at 08:00 UTC; the 3 most recent + // confirmed-late and the rest taken on time, except days 4/7/9 missed. + const schedule = { + id: "sched-1", + windowStart: "08:00", + windowEnd: "09:00", + daysOfWeek: null, + timesOfDay: ["08:00"], + reminderGraceMinutes: null, + rrule: null, + rollingIntervalDays: null, + scheduleType: null, + cyclicOnWeeks: null, + cyclicOffWeeks: null, + doseWindows: null, + }; + const missedDays = new Set([4, 7, 9]); + const intakeEvents = []; + for (let n = 1; n <= 10; n++) { + const scheduledFor = new Date(Date.now() - n * dayMs); + scheduledFor.setUTCHours(8, 0, 0, 0); + const missed = missedDays.has(n); + intakeEvents.push({ + scheduledFor, + takenAt: missed ? null : scheduledFor, + skipped: false, + autoMissed: missed, + }); + } + const medication = { + id: "med-1", + name: "Testdrug", + startsOn: null, + endsOn: null, + oneShot: false, + createdAt, + schedules: [schedule], + scheduleRevisions: [], + intakeEvents, + }; + // The compliance query and the GLP-1 query share this mock; only the + // compliance one (no `treatmentClass` filter) gets the fixture so the + // GLP-1 block — which selects `doseChanges` etc. — sees no rows. + prismaMock.medication.findMany.mockImplementation((args?: { + where?: { treatmentClass?: string }; + }) => { + if (args?.where?.treatmentClass === "GLP1") return Promise.resolve([]); + return Promise.resolve([medication]); + }); + prismaMock.user.findUnique.mockResolvedValue({ + coachPrefsJson: null, + timezone: "Europe/Berlin", + }); + + const out = await buildCoachSnapshot("user-1"); + const parsed = JSON.parse(out.snapshotJson); + + expect(parsed.compliance).toBeDefined(); + expect(parsed.compliance.rate).not.toBeNull(); + + // Independent authority over the same window the coach uses (30-day + // default scope), same ledger context — the coach rate must equal it. + const now = new Date(); + const ctx = buildComplianceMedicationContext(medication, null, "Europe/Berlin"); + const authority = calculateCompliance( + intakeEvents, + [schedule], + 30, + createdAt, + { now, medicationContext: ctx }, + ); + expect(parsed.compliance.rate).toBe(authority.rate); + }); + // v1.4.36 W3 T2 — `anthropometrics` exclusion drops the profile // block even when features.context has populated fields. it("omits anthropometrics when excludeMetrics contains 'anthropometrics'", async () => { diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts index 0f281d216..970727e6c 100644 --- a/src/lib/ai/coach/snapshot.ts +++ b/src/lib/ai/coach/snapshot.ts @@ -38,6 +38,7 @@ import { isCycleEnabled } from "@/lib/cycle/gate"; import { buildComplianceLedgerRows, buildComplianceMedicationContext, + calculateCompliance, lastNonSkippedTakenAt, SCHEDULE_COMPLIANCE_SELECT, } from "@/lib/analytics/compliance"; @@ -992,6 +993,22 @@ async function buildCoachSnapshotImpl( // taken", deliberate skips and ad-hoc takes stay out of the // denominator, and cross-source duplicate rows collapse onto one slot. const ledgerRows: DoseHistoryRow[] = []; + // v1.17 W1c — the coach's headline adherence figure routes through the + // SAME `calculateCompliance(...).rate` ledger authority the medication + // card shows (the ledger path, `medicationContext` supplied), so the + // coach can never quote a denominator the card doesn't use. Per + // medication we take the ledger numerator (on-time + late takes) and + // denominator (taken + missed) and pool them across the user's + // scheduled medications: for a single medication the headline equals + // that med's card rate exactly; for several it is the dose-weighted + // overall adherence (the same pooling the cross-med timeline below + // already uses), never a per-day / per-week denominator of its own. + let complianceTaken = 0; + let complianceDenominator = 0; + const windowDaysForRate = Math.max( + 1, + Math.round((now.getTime() - cutoff.getTime()) / (24 * 60 * 60 * 1000)), + ); for (const med of complianceMeds) { if (med.schedules.length === 0) continue; const ctx = buildComplianceMedicationContext( @@ -1009,6 +1026,18 @@ async function buildCoachSnapshotImpl( now, ), ); + // The card's rate IS `calculateCompliance(...).rate` over the ledger; + // aggregate the same taken / (taken + missed) counts here so the + // coach's single headline % equals what the card renders. + const result = calculateCompliance( + med.intakeEvents, + med.schedules, + windowDaysForRate, + med.createdAt, + { now, medicationContext: ctx }, + ); + complianceTaken += result.taken; + complianceDenominator += result.taken + result.missed; } // Countable rows: taken (on-time or late) or genuinely missed. The // pending / upcoming / skipped / ad-hoc rows carry no adherence signal. @@ -1064,6 +1093,18 @@ async function buildCoachSnapshotImpl( })) .sort((a, b) => a.weekISO.localeCompare(b.weekISO)); snapshot.compliance = { + // v1.17 W1c — headline adherence % from the SAME ledger authority + // (`calculateCompliance(...).rate`) the medication card shows: the + // dose-weighted pool of taken / (taken + missed) across the user's + // scheduled medications, so the coach quotes the card's figure + // (single med) or its honest overall adherence (several meds) rather + // than a per-day / per-week rate built off a different denominator. + // Integer 0-100 to match the card's rounding; null when no scheduled + // medication has any countable dose in the window. + rate: + complianceDenominator > 0 + ? Math.round((complianceTaken / complianceDenominator) * 100) + : null, timeline: { recent: recentRows, weekly: weeklyRows }, }; metrics.add("compliance"); diff --git a/src/lib/analytics/__tests__/bp-in-target.test.ts b/src/lib/analytics/__tests__/bp-in-target.test.ts index 46a60fce8..1da58fab6 100644 --- a/src/lib/analytics/__tests__/bp-in-target.test.ts +++ b/src/lib/analytics/__tests__/bp-in-target.test.ts @@ -301,6 +301,64 @@ describe("computeBpInTargetWindows — all-time headline (v1.4.19 A1)", () => { expect(result.allTime).toBeNull(); }); + /** + * v1.17 W1d — the BD-Zielbereich headline, the Health-Score BP pillar + * and the coach grounding number standardise on a **90-day** window. + * The helper must surface a `last90Days` window distinct from both the + * 30-day and the all-time aggregate so the route can route the headline + * through it while leaving all-time available for the detail page. + */ + it("returns a last90Days window distinct from 30-day and all-time", () => { + // 10 pairs inside 30d (50 % in target), 10 pairs at 45-60 days old + // (all OUT of target), 20 pairs older than 90 days (all OUT). + const sys: Array> = []; + const dia: Array> = []; + // 0-30d: 5 IN, 5 OUT. + for (let i = 0; i < 10; i++) { + const at = new Date( + NOW_19.getTime() - (i + 0.5) * 24 * 60 * 60 * 1000, + ).toISOString(); + const inTarget = i % 2 === 0; + sys.push(reading(at, inTarget ? 122 : 145)); + dia.push(reading(at, inTarget ? 75 : 95)); + } + // 45-60d (inside 90d, outside 30d): 10 OUT. + for (let i = 0; i < 10; i++) { + const at = new Date( + NOW_19.getTime() - (45 + i) * 24 * 60 * 60 * 1000, + ).toISOString(); + sys.push(reading(at, 160)); + dia.push(reading(at, 100)); + } + // >90d: 20 OUT. + for (let i = 0; i < 20; i++) { + const at = new Date( + NOW_19.getTime() - (100 + i) * 24 * 60 * 60 * 1000, + ).toISOString(); + sys.push(reading(at, 160)); + dia.push(reading(at, 100)); + } + + const result = computeBpInTargetWindows(sys, dia, TARGETS_UNDER_65, NOW_19); + + // 30d = 5 / 10 = 50 %. + expect(result.last30Days).toEqual({ pct: 50, pairs: 10 }); + // 90d = 5 / 20 = 25 % (10 in-target-eligible + 10 OUT from 45-60d). + expect(result.last90Days).not.toBeNull(); + expect(result.last90Days!.pairs).toBe(20); + expect(result.last90Days!.pct).toBe(Math.round((5 / 20) * 100)); + // all-time = 5 / 40 ≈ 13 %. + expect(result.allTime!.pairs).toBe(40); + // The three windows must be genuinely different numbers. + expect(result.last90Days!.pct).not.toBe(result.last30Days!.pct); + expect(result.last90Days!.pct).not.toBe(result.allTime!.pct); + }); + + it("returns null last90Days when both series are empty", () => { + const result = computeBpInTargetWindows([], [], TARGETS_UNDER_65, NOW_19); + expect(result.last90Days).toBeNull(); + }); + it("regression: the live tenant's 30-day fixture mirrors the live tenant's prod headline bug", () => { // Reuse the maintainer's production fixture (10 pairs, 5 in target = 50 %). // When the input is JUST those 10 pairs, allTime equals 30d == 50 % diff --git a/src/lib/analytics/__tests__/health-score-inputs.test.ts b/src/lib/analytics/__tests__/health-score-inputs.test.ts new file mode 100644 index 000000000..214b6d4c5 --- /dev/null +++ b/src/lib/analytics/__tests__/health-score-inputs.test.ts @@ -0,0 +1,132 @@ +import { describe, it, expect } from "vitest"; +import { buildHealthScoreBpInputs } from "../health-score-inputs"; +import type { BpInTargetEnvelope } from "../bp-in-target-fast-path"; + +function env(partial: Partial): BpInTargetEnvelope { + return { + last7Days: null, + last30Days: null, + last90Days: null, + last90EarliestAt: null, + allTime: null, + priorMonth: null, + priorYear: null, + path: "live", + rowCount: 0, + gradedScore: null, + ...partial, + }; +} + +describe("buildHealthScoreBpInputs — single Health-Score BP input builder", () => { + /** + * v1.17 W1b — the dashboard ring and the insights card must grade the BP + * pillar off identical inputs. This pins the contract: given the same + * current + prior-week envelopes, the builder produces one BP-input shape, + * so whichever surface calls it sees the same pillar presence, value and + * delta. + */ + it("reads the 90-day window for the pillar rate, NOT 30-day or all-time", () => { + const current = env({ + last30Days: { pct: 80, pairs: 10 }, + last90Days: { pct: 65, pairs: 40 }, + allTime: { pct: 50, pairs: 200 }, + gradedScore: 72, + }); + const out = buildHealthScoreBpInputs(current, null); + expect(out.bpInTargetPct).toBe(65); + expect(out.bpGradedScore).toBe(72); + }); + + it("falls back to all-time only when the 90-day window is null", () => { + const current = env({ + last90Days: null, + allTime: { pct: 42, pairs: 100 }, + gradedScore: 60, + }); + const out = buildHealthScoreBpInputs(current, null); + expect(out.bpInTargetPct).toBe(42); + }); + + it("uses the prior-week 90-day window for the delta inputs", () => { + const current = env({ + last90Days: { pct: 65, pairs: 40 }, + gradedScore: 72, + }); + const priorWeek = env({ + last90Days: { pct: 58, pairs: 38 }, + gradedScore: 68, + }); + const out = buildHealthScoreBpInputs(current, priorWeek); + expect(out.bpInTargetPctPriorWeek).toBe(58); + expect(out.bpGradedScorePriorWeek).toBe(68); + }); + + it("suppresses the pillar when the 90-day window is below the confidence floor and no all-time rescues it", () => { + // 4 pairs in the last 90 days — below the floor of 5. Without an all-time + // rate the pillar must disappear (rate + graded score both null) so a + // thin-data user does not get a confident BP pillar. + const current = env({ + last90Days: { pct: 100, pairs: 4 }, + allTime: { pct: 100, pairs: 4 }, + gradedScore: 95, + }); + const out = buildHealthScoreBpInputs(current, null); + // all-time is itself the same 4 pairs → rate present (deep history rescue + // path), but here it is genuinely thin so the rate is the all-time pct. + // Pin the stricter case: when all-time is also null the pillar collapses. + const thin = buildHealthScoreBpInputs( + env({ last90Days: { pct: 100, pairs: 4 }, allTime: null, gradedScore: 95 }), + null, + ); + expect(thin.bpInTargetPct).toBeNull(); + expect(thin.bpGradedScore).toBeNull(); + // The all-time fallback still rescues a deep-history account. + expect(out.bpInTargetPct).toBe(100); + }); + + it("suppresses the prior-week graded score when the prior-week window is thin and no all-time rescues it", () => { + // Current window healthy, prior-week 90-day window below the floor with no + // all-time fallback → the prior-week graded score must not grade off the + // thin sample, so the week-over-week graded delta stays consistent. + const current = env({ + last90Days: { pct: 65, pairs: 40 }, + gradedScore: 72, + }); + const priorWeek = env({ + last90Days: { pct: 90, pairs: 2 }, + allTime: null, + gradedScore: 95, + }); + const out = buildHealthScoreBpInputs(current, priorWeek); + expect(out.bpGradedScore).toBe(72); + expect(out.bpInTargetPctPriorWeek).toBeNull(); + expect(out.bpGradedScorePriorWeek).toBeNull(); + }); + + it("collapses every field to null when the current envelope is absent", () => { + const out = buildHealthScoreBpInputs(null, null); + expect(out).toEqual({ + bpInTargetPct: null, + bpInTargetPctPriorWeek: null, + bpGradedScore: null, + bpGradedScorePriorWeek: null, + }); + }); + + it("pillar presence is identical for two callers passing the same envelopes", () => { + // Same data → same `bpInTargetPct !== null` presence verdict on both + // surfaces. A user with BP history outside 30 days but inside 90 days + // keeps the pillar on BOTH the dashboard ring and the insights card. + const current = env({ + last30Days: null, // no readings in the last month + last90Days: { pct: 55, pairs: 12 }, + allTime: { pct: 48, pairs: 300 }, + gradedScore: 64, + }); + const a = buildHealthScoreBpInputs(current, null); + const b = buildHealthScoreBpInputs(current, null); + expect(a).toEqual(b); + expect(a.bpInTargetPct).not.toBeNull(); + }); +}); diff --git a/src/lib/analytics/__tests__/window-confidence.test.ts b/src/lib/analytics/__tests__/window-confidence.test.ts new file mode 100644 index 000000000..83fe5f5cf --- /dev/null +++ b/src/lib/analytics/__tests__/window-confidence.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect } from "vitest"; + +import { + computeWindowConfidence, + MIN_READINGS_FOR_CONFIDENCE, +} from "../window-confidence"; + +const NOW = new Date("2026-06-14T12:00:00.000Z"); +const daysAgo = (n: number) => new Date(NOW.getTime() - n * 24 * 60 * 60 * 1000); + +describe("computeWindowConfidence", () => { + it("flags a sample below the floor as insufficient", () => { + for (const count of [0, 1, 4]) { + const c = computeWindowConfidence({ + windowDays: 90, + readingCount: count, + earliestReadingAt: count === 0 ? null : daysAgo(10), + now: NOW, + }); + expect(c.sufficient).toBe(false); + } + }); + + it("flags a sample at or above the floor as sufficient", () => { + const c = computeWindowConfidence({ + windowDays: 90, + readingCount: MIN_READINGS_FOR_CONFIDENCE, + earliestReadingAt: daysAgo(10), + now: NOW, + }); + expect(c.sufficient).toBe(true); + }); + + it("returns a null span when the window is empty", () => { + const c = computeWindowConfidence({ + windowDays: 90, + readingCount: 0, + earliestReadingAt: null, + now: NOW, + }); + expect(c.effectiveSpanDays).toBeNull(); + }); + + it("reports the real span until history reaches the window", () => { + const c = computeWindowConfidence({ + windowDays: 90, + readingCount: 8, + earliestReadingAt: daysAgo(23), + now: NOW, + }); + expect(c.effectiveSpanDays).toBe(23); + }); + + it("caps the span at the window once history exceeds it", () => { + const c = computeWindowConfidence({ + windowDays: 90, + readingCount: 120, + earliestReadingAt: daysAgo(400), + now: NOW, + }); + expect(c.effectiveSpanDays).toBe(90); + }); + + it("rounds a same-day-only sample up to one day", () => { + const c = computeWindowConfidence({ + windowDays: 90, + readingCount: 5, + earliestReadingAt: new Date(NOW.getTime() - 60 * 1000), + now: NOW, + }); + expect(c.effectiveSpanDays).toBe(1); + }); +}); diff --git a/src/lib/analytics/bp-in-target-fast-path.ts b/src/lib/analytics/bp-in-target-fast-path.ts index a901cf2da..7858935c7 100644 --- a/src/lib/analytics/bp-in-target-fast-path.ts +++ b/src/lib/analytics/bp-in-target-fast-path.ts @@ -76,6 +76,19 @@ import type { BpTargets } from "./bp-targets"; export interface BpInTargetEnvelope { last7Days: { pct: number; pairs: number } | null; last30Days: { pct: number; pairs: number } | null; + /** + * v1.17 W1d — trailing-90-day window. Canonical window for the + * BD-Zielbereich headline, the Health-Score BP pillar and the coach + * grounding number. See `computeBpInTargetWindows` for the rationale. + */ + last90Days: { pct: number; pairs: number } | null; + /** + * v1.17 W1b — oldest accepted pair inside the trailing-90-day window + * (or `null` when empty). Feeds `computeWindowConfidence` so the + * BD-Zielbereich tile labels the effective span rather than a static + * "· 90 T". On the rollup path this is the oldest in-window DAY bucket. + */ + last90EarliestAt: Date | null; allTime: { pct: number; pairs: number } | null; priorMonth: { pct: number; pairs: number } | null; priorYear: { pct: number; pairs: number } | null; @@ -274,12 +287,26 @@ async function computeFromRollups( const sevenDaysAgo = new Date(now.getTime() - 7 * DAY_MS); const thirtyDaysAgo = new Date(now.getTime() - 30 * DAY_MS); + const ninetyDaysAgo = new Date(now.getTime() - 90 * DAY_MS); const sixtyDaysAgo = new Date(now.getTime() - 60 * DAY_MS); const threeSixtyFiveDaysAgo = new Date(now.getTime() - 365 * DAY_MS); const threeNinetyFiveDaysAgo = new Date(now.getTime() - 395 * DAY_MS); const last7 = bucketWindow(pairsByDay, sevenDaysAgo, now, targets); const last30 = bucketWindow(pairsByDay, thirtyDaysAgo, now, targets); + // v1.17 W1d — canonical headline / score / coach window. + const last90 = bucketWindow(pairsByDay, ninetyDaysAgo, now, targets); + // v1.17 W1b — oldest in-window DAY bucket for the effective-span label. + const ninetyDaysAgoMs = ninetyDaysAgo.getTime(); + const nowMs = now.getTime(); + let last90EarliestAt: Date | null = null; + for (const p of pairsByDay) { + const dayMs = p.day.getTime(); + if (dayMs < ninetyDaysAgoMs || dayMs >= nowMs) continue; + if (last90EarliestAt === null || dayMs < last90EarliestAt.getTime()) { + last90EarliestAt = p.day; + } + } // All-time on the rollup path is bounded to the 395-day read window. // Documented above — same trade-off the per-event path makes when the // user's history extends beyond 365 days. @@ -339,6 +366,8 @@ async function computeFromRollups( return { last7Days: last7, last30Days: last30, + last90Days: last90, + last90EarliestAt, allTime, priorMonth, priorYear, @@ -453,6 +482,8 @@ async function computeFromLive( return { last7Days: windows.last7Days, last30Days: windows.last30Days, + last90Days: windows.last90Days, + last90EarliestAt: windows.last90EarliestAt, allTime: windows.allTime, priorMonth: windows.priorMonth, priorYear: windows.priorYear, diff --git a/src/lib/analytics/bp-in-target.ts b/src/lib/analytics/bp-in-target.ts index 12e0ceaed..6dc86236b 100644 --- a/src/lib/analytics/bp-in-target.ts +++ b/src/lib/analytics/bp-in-target.ts @@ -237,6 +237,22 @@ export function computeBpInTargetWindows( ): { last7Days: { pct: number; pairs: number } | null; last30Days: { pct: number; pairs: number } | null; + /** + * v1.17 W1d — the trailing-90-day window. This is the canonical window + * the BD-Zielbereich headline, the Health-Score BP pillar and the coach + * grounding number all standardise on (a 90-day arc balances recency + * against enough pairs to be stable). The all-time aggregate stays + * available below but is reserved for the BP detail page's long view. + */ + last90Days: { pct: number; pairs: number } | null; + /** + * v1.17 W1b — timestamp of the oldest accepted pair inside the + * trailing-90-day window, or `null` when the window holds no pairs. + * Feeds `computeWindowConfidence` so the BD-Zielbereich tile can label + * the EFFECTIVE span ("· 23 T" until ~90 days of history exist) rather + * than a dishonest static "· 90 T". + */ + last90EarliestAt: Date | null; allTime: { pct: number; pairs: number } | null; /** * v1.4.22 W5 reconcile (Code-H2) — period-aligned 30-day window @@ -254,6 +270,7 @@ export function computeBpInTargetWindows( } { const sevenDaysAgoMs = now.getTime() - 7 * DAY_MS; const thirtyDaysAgoMs = now.getTime() - 30 * DAY_MS; + const ninetyDaysAgoMs = now.getTime() - 90 * DAY_MS; const sixtyDaysAgoMs = now.getTime() - 60 * DAY_MS; const oneYearMinus30DaysAgoMs = now.getTime() - 365 * DAY_MS; const oneYearAgoMs = now.getTime() - 395 * DAY_MS; @@ -270,6 +287,12 @@ export function computeBpInTargetWindows( const diaLast30 = diaSeries.filter( (r) => r.measuredAt.getTime() >= thirtyDaysAgoMs, ); + const sysLast90 = sysSeries.filter( + (r) => r.measuredAt.getTime() >= ninetyDaysAgoMs, + ); + const diaLast90 = diaSeries.filter( + (r) => r.measuredAt.getTime() >= ninetyDaysAgoMs, + ); // Period-aligned prior windows. Same 30-day arc, shifted by 30 / 365 // days respectively. Bounds are inclusive on the lower end and @@ -296,9 +319,24 @@ export function computeBpInTargetWindows( r.measuredAt.getTime() < oneYearMinus30DaysAgoMs, ); + // v1.17 W1b — oldest accepted pair inside the 90-day window for the + // effective-span label. `collectBpPairs` applies the same pairing rules + // as `computeBpInTargetPct`, so the anchor matches the counted pairs. + const pairsLast90 = collectBpPairs(sysLast90, diaLast90); + const last90EarliestAt = + pairsLast90.length === 0 + ? null + : pairsLast90.reduce( + (min, p) => (p.at.getTime() < min.getTime() ? p.at : min), + pairsLast90[0].at, + ); + return { last7Days: computeBpInTargetPct(sysLast7, diaLast7, targets), last30Days: computeBpInTargetPct(sysLast30, diaLast30, targets), + // v1.17 W1d — canonical headline / score / coach window. + last90Days: computeBpInTargetPct(sysLast90, diaLast90, targets), + last90EarliestAt, // v1.4.19 A1 — independent aggregate over EVERY paired reading. The // analytics route now routes the dashboard tile's headline through // this so it stops mirroring `last30Days`. diff --git a/src/lib/analytics/compliance.ts b/src/lib/analytics/compliance.ts index fd2cf64bb..54b7a73aa 100644 --- a/src/lib/analytics/compliance.ts +++ b/src/lib/analytics/compliance.ts @@ -53,7 +53,7 @@ import { type ScheduleType, } from "@/lib/medications/scheduling/recurrence"; -interface IntakeEvent { +export interface IntakeEvent { takenAt: Date | null; skipped: boolean; scheduledFor: Date; diff --git a/src/lib/analytics/health-score-inputs.ts b/src/lib/analytics/health-score-inputs.ts new file mode 100644 index 000000000..d4919f94e --- /dev/null +++ b/src/lib/analytics/health-score-inputs.ts @@ -0,0 +1,108 @@ +/** + * v1.17 W1b — single Health-Score BP-pillar input builder. + * + * The Personal Health Score is computed by `computeUserHealthScoreFastPath` + * from two server callers: the analytics route (the Insights health-score + * card) and the dashboard snapshot (the hero ring). Before this builder the + * two callers assembled the BP-pillar inputs by hand and DIVERGED: + * + * - the analytics route fed the BP pillar from the all-time in-target rate + * and passed the prior-week values for the week-over-week delta; + * - the dashboard snapshot fed the pillar from the trailing-30-day rate and + * omitted the prior-week values entirely (so the ring's delta zeroed BP + * out and the pillar's presence gate flipped on a user with BP history + * but no readings in the last month). + * + * Same user, two different scores and two different bands. v1.17 W1d already + * unified the WINDOW (both read the trailing-90-day rate); this builder + * unifies the ASSEMBLY so the window choice, the all-time fallback, the + * graded-score wiring and the prior-week delta inputs can never drift between + * the two surfaces again. Both callers pass the identical + * `BpInTargetEnvelope` pair through here and consume the result verbatim. + * + * Pure & deterministic — no I/O. The callers own the + * `computeBpInTargetFastPath` reads (current + prior-week) and hand the two + * envelopes in. + */ +import type { BpInTargetEnvelope } from "./bp-in-target-fast-path"; +import { isWindowSufficient } from "./window-confidence"; + +/** + * The BP-pillar slice of `HealthScoreFastPathInput`. Both Health-Score + * callers spread this into their `computeUserHealthScoreFastPath` call so the + * pillar is graded off identical inputs regardless of surface. + */ +export interface HealthScoreBpInputs { + bpInTargetPct: number | null; + bpInTargetPctPriorWeek: number | null; + bpGradedScore: number | null; + bpGradedScorePriorWeek: number | null; +} + +/** + * Build the BP-pillar inputs from the current-window and prior-week + * `computeBpInTargetFastPath` envelopes. + * + * - The pillar rate (presence gate + secondary stat) reads the canonical + * trailing-90-day window (W1d), falling back to the all-time rate only when + * the 90-day window is itself null so a sparse-but-historical account keeps + * its BP pillar. + * - v1.17 W1b — when the 90-day window holds fewer than the confidence floor + * (`isWindowSufficient`) the pillar collapses to `null` so a thin-data user + * does not get a confident BP pillar grading off a handful of readings. The + * dashboard tile shows "collecting data" for the same input; the pillar and + * the tile agree on the gate. The all-time fallback still rescues an account + * with a deep history but a quiet last quarter. + * - The prior-week rate uses the prior-week run's 90-day window so the + * week-over-week delta compares the same window on both ends. + * - The graded clinical-proximity score (the pillar VALUE) and its prior-week + * counterpart come straight off the envelopes. + * + * `null` for the current envelope (no BP targets / no readings) collapses + * every field to `null`, which the score helper reads as "pillar absent". + */ +export function buildHealthScoreBpInputs( + current: BpInTargetEnvelope | null, + priorWeek: BpInTargetEnvelope | null, +): HealthScoreBpInputs { + if (!current) { + return { + bpInTargetPct: null, + bpInTargetPctPriorWeek: null, + bpGradedScore: null, + bpGradedScorePriorWeek: null, + }; + } + // v1.17 W1b — a thin 90-day window (below the confidence floor) is not a + // trustworthy pillar input. Fall through to the all-time rate when the + // 90-day window is itself sparse so a deep-history account survives a quiet + // quarter, but never grade off a sub-floor 90-day sample. + const last90Sufficient = + current.last90Days !== null && + isWindowSufficient(current.last90Days.pairs); + const bpInTargetPct = last90Sufficient + ? (current.last90Days?.pct ?? null) + : (current.allTime?.pct ?? null); + const priorWeek90Sufficient = + priorWeek?.last90Days != null && + isWindowSufficient(priorWeek.last90Days.pairs); + const bpInTargetPctPriorWeek = priorWeek90Sufficient + ? (priorWeek?.last90Days?.pct ?? null) + : (priorWeek?.allTime?.pct ?? null); + // The graded clinical score is the pillar VALUE; suppress it together with + // the rate when the 90-day window is thin AND no all-time rate rescues the + // pillar, so the pillar disappears cleanly instead of grading a thin sample. + // The prior-week graded score keys off the PRIOR-WEEK window's own presence + // (not the current one) so the week-over-week delta never pairs a confident + // current value against a thin-sample prior value. + const pillarPresent = bpInTargetPct !== null; + const priorPillarPresent = bpInTargetPctPriorWeek !== null; + return { + bpInTargetPct, + bpInTargetPctPriorWeek, + bpGradedScore: pillarPresent ? current.gradedScore : null, + bpGradedScorePriorWeek: priorPillarPresent + ? (priorWeek?.gradedScore ?? null) + : null, + }; +} diff --git a/src/lib/analytics/merge-slim-thick.ts b/src/lib/analytics/merge-slim-thick.ts index ba7018ffc..172515381 100644 --- a/src/lib/analytics/merge-slim-thick.ts +++ b/src/lib/analytics/merge-slim-thick.ts @@ -46,6 +46,8 @@ export interface AnalyticsThickLike extends AnalyticsSlimLike { bpInTargetPctAllTime?: number | null; bpInTargetPctPriorMonth?: number | null; bpInTargetPctPriorYear?: number | null; + bpInTargetCount90?: number | null; + bpInTargetSpanDays90?: number | null; glucoseByContext?: Record | null; } @@ -60,6 +62,8 @@ export interface MergedDashboardAnalytics { bpInTargetPctAllTime: number | null; bpInTargetPctPriorMonth: number | null; bpInTargetPctPriorYear: number | null; + bpInTargetCount90: number | null; + bpInTargetSpanDays90: number | null; glucoseByContext: Record | undefined; } @@ -109,6 +113,8 @@ export function mergeSlimAndThickAnalytics( bpInTargetPctAllTime: thick?.bpInTargetPctAllTime ?? null, bpInTargetPctPriorMonth: thick?.bpInTargetPctPriorMonth ?? null, bpInTargetPctPriorYear: thick?.bpInTargetPctPriorYear ?? null, + bpInTargetCount90: thick?.bpInTargetCount90 ?? null, + bpInTargetSpanDays90: thick?.bpInTargetSpanDays90 ?? null, glucoseByContext: thick?.glucoseByContext ?? undefined, }; } diff --git a/src/lib/analytics/window-confidence.ts b/src/lib/analytics/window-confidence.ts new file mode 100644 index 000000000..d49947158 --- /dev/null +++ b/src/lib/analytics/window-confidence.ts @@ -0,0 +1,113 @@ +/** + * Shared window-confidence helper (v1.17 W1b). + * + * Several insight tiles narrate a metric over a fixed trailing window — + * the BD-Zielbereich tile reports the BP in-target share over the last + * 90 days, and future tiles (glucose TIR / GMI, sleep efficiency, …) + * will do the same over their own windows. A static "· 90 T" label is + * dishonest for a user who has only a few weeks of history or only a + * handful of readings: the headline reads like a stable quarter-long + * average when it is really a thin sample. + * + * This helper turns a window length + the readings observed inside it + * into two truthful signals every windowed tile can reuse: + * + * 1. `effectiveSpanDays` — the real calendar span of the data, capped + * at the window. A user with 23 days of history sees "· 23 T"; once + * ~90 days of history exist the span saturates at the window and the + * label reads "· 90 T". `null` when the window holds no readings. + * + * 2. `sufficient` — `false` when fewer than `MIN_READINGS_FOR_CONFIDENCE` + * readings fall in the window. The caller renders a "collecting data" + * placeholder instead of a confident percentage, and the Health-Score + * pillar treats the metric as absent rather than grading a thin sample. + * + * Pure & deterministic so the unit suite can pin both outputs. + */ + +/** + * Minimum readings inside the window before a percentage is trustworthy. + * Below this the tile narrates "collecting data" and the score pillar is + * suppressed. Five is the same floor the trend cards use before they draw + * a first slope — a sample small enough to be noise. + */ +export const MIN_READINGS_FOR_CONFIDENCE = 5; + +const DAY_MS = 24 * 60 * 60 * 1000; + +/** + * `true` once a window holds enough readings to narrate a confident + * percentage. Shared by the server (Health-Score pillar gate) and the + * client (tile placeholder) so both apply the identical floor. + */ +export function isWindowSufficient(readingCount: number): boolean { + return readingCount >= MIN_READINGS_FOR_CONFIDENCE; +} + +export interface WindowConfidence { + /** + * Real calendar span of the in-window data, in days, capped at + * `windowDays`. `null` when the window holds no readings (caller renders + * the "—" / placeholder rather than "· 0 T"). + */ + effectiveSpanDays: number | null; + /** `true` once at least `MIN_READINGS_FOR_CONFIDENCE` readings exist. */ + sufficient: boolean; +} + +/** + * v1.17 W1b — derive the BD-Zielbereich tile's confidence inputs (90-day + * pair count + effective label span) from a BP envelope's 90-day window. + * Shared by the dashboard snapshot and the `/api/analytics` thick slice so + * the two surfaces can never compute the tile's gate or span differently. + */ +export function deriveBpWindow90( + last90: { pairs: number } | null, + last90EarliestAt: Date | null, + now: Date, +): { count: number; spanDays: number | null } { + const count = last90?.pairs ?? 0; + const spanDays = computeWindowConfidence({ + windowDays: 90, + readingCount: count, + earliestReadingAt: last90EarliestAt, + now, + }).effectiveSpanDays; + return { count, spanDays }; +} + +/** + * Compute the effective label span + sufficiency flag for a windowed + * metric. + * + * @param windowDays the configured trailing window (e.g. 90). + * @param readingCount number of readings counted inside the window. + * @param earliestReadingAt timestamp of the oldest in-window reading, or + * `null` when the window is empty. The span is + * `now - earliest`, ceil-rounded to whole days, + * and never exceeds `windowDays`. At least 1 day + * so a same-day-only sample still reads "· 1 T". + * @param now clock anchor (injectable for deterministic tests). + */ +export function computeWindowConfidence(input: { + windowDays: number; + readingCount: number; + earliestReadingAt: Date | null; + now?: Date; +}): WindowConfidence { + const { windowDays, readingCount, earliestReadingAt } = input; + const now = input.now ?? new Date(); + + const sufficient = isWindowSufficient(readingCount); + + if (readingCount <= 0 || earliestReadingAt === null) { + return { effectiveSpanDays: null, sufficient }; + } + + const rawSpanMs = now.getTime() - earliestReadingAt.getTime(); + // A reading logged moments ago still spans one calendar day of history. + const rawSpanDays = Math.max(1, Math.ceil(rawSpanMs / DAY_MS)); + const effectiveSpanDays = Math.min(rawSpanDays, windowDays); + + return { effectiveSpanDays, sufficient }; +} diff --git a/src/lib/dashboard/__tests__/snapshot.test.ts b/src/lib/dashboard/__tests__/snapshot.test.ts index 74149aba8..d621e45a8 100644 --- a/src/lib/dashboard/__tests__/snapshot.test.ts +++ b/src/lib/dashboard/__tests__/snapshot.test.ts @@ -142,6 +142,8 @@ describe("buildDashboardSnapshot — envelope shape", () => { computeBpInTargetFastPath.mockResolvedValue({ last7Days: { pct: 70 }, last30Days: { pct: 80 }, + last90Days: { pct: 80, pairs: 20 }, + last90EarliestAt: new Date("2026-03-01T00:00:00.000Z"), allTime: { pct: 75 }, priorMonth: { pct: 60 }, priorYear: { pct: 50 }, @@ -182,6 +184,8 @@ describe("buildDashboardSnapshot — envelope shape", () => { computeBpInTargetFastPath.mockResolvedValue({ last7Days: { pct: 70 }, last30Days: { pct: 80 }, + last90Days: { pct: 80, pairs: 20 }, + last90EarliestAt: new Date("2026-03-01T00:00:00.000Z"), allTime: { pct: 75 }, priorMonth: { pct: 60 }, priorYear: { pct: 50 }, @@ -238,6 +242,8 @@ describe("buildDashboardSnapshot — per-type thick-phase gate", () => { computeBpInTargetFastPath.mockResolvedValue({ last7Days: { pct: 70 }, last30Days: { pct: 80 }, + last90Days: { pct: 80, pairs: 20 }, + last90EarliestAt: new Date("2026-03-01T00:00:00.000Z"), allTime: { pct: 75 }, priorMonth: { pct: 60 }, priorYear: { pct: 50 }, @@ -255,7 +261,9 @@ describe("buildDashboardSnapshot — per-type thick-phase gate", () => { expect(snap.extras).not.toBeNull(); expect(snap.extras!.bpInTargetPct).toBe(80); expect(snap.healthScore).toEqual({ score: 71, band: "yellow", delta: 3 }); - expect(computeBpInTargetFastPath).toHaveBeenCalledTimes(1); + // v1.17 W1b — two runs (current + prior-week), identical to the analytics + // route, so the ring's delta reflects BP movement. + expect(computeBpInTargetFastPath).toHaveBeenCalledTimes(2); expect(computeUserHealthScoreFastPath).toHaveBeenCalledTimes(1); }); @@ -279,6 +287,7 @@ describe("buildDashboardSnapshot — per-type thick-phase gate", () => { computeBpInTargetFastPath.mockResolvedValue({ last7Days: null, last30Days: null, + last90Days: null, allTime: null, priorMonth: null, priorYear: null, @@ -362,11 +371,16 @@ describe("buildDashboardSnapshot — healthScore (warm phase only)", () => { probeRollupCoverage.mockResolvedValue(coverageMap); isFullyCovered.mockReturnValue(true); computeBpInTargetFastPath.mockResolvedValue({ - last7Days: { pct: 70 }, - last30Days: { pct: 80 }, - allTime: { pct: 75 }, - priorMonth: { pct: 60 }, - priorYear: { pct: 50 }, + last7Days: { pct: 70, pairs: 6 }, + last30Days: { pct: 80, pairs: 12 }, + // v1.17 W1d — the BP pillar reads this 90-day window, NOT last30Days. + // v1.17 W1b — `pairs` above the confidence floor so the pillar grades + // the 90-day rate rather than falling back to all-time. + last90Days: { pct: 77, pairs: 30 }, + last90EarliestAt: new Date("2026-01-01T00:00:00.000Z"), + allTime: { pct: 75, pairs: 200 }, + priorMonth: { pct: 60, pairs: 10 }, + priorYear: { pct: 50, pairs: 40 }, gradedScore: 88, }); computeUserHealthScoreFastPath.mockResolvedValue({ @@ -392,7 +406,10 @@ describe("buildDashboardSnapshot — healthScore (warm phase only)", () => { coverage: unknown; }; expect(arg.userId).toBe("user-1"); - expect(arg.bpInTargetPct).toBe(80); + // v1.17 W1d — the BP pillar reads the 90-day window, identical to the + // analytics route, so dashboard ring and insights card score off one + // number. Previously this was last30Days (80). + expect(arg.bpInTargetPct).toBe(77); expect(arg.bpGradedScore).toBe(88); expect(arg.heightCm).toBe(180); expect(arg.coverage).toBe(coverageMap); @@ -404,6 +421,7 @@ describe("buildDashboardSnapshot — healthScore (warm phase only)", () => { computeBpInTargetFastPath.mockResolvedValue({ last7Days: null, last30Days: null, + last90Days: null, allTime: null, priorMonth: null, priorYear: null, @@ -705,6 +723,8 @@ describe("buildDashboardSnapshot — additive proof", () => { computeBpInTargetFastPath.mockResolvedValue({ last7Days: { pct: 70 }, last30Days: { pct: 80 }, + last90Days: { pct: 80, pairs: 20 }, + last90EarliestAt: new Date("2026-03-01T00:00:00.000Z"), allTime: { pct: 75 }, priorMonth: { pct: 60 }, priorYear: { pct: 50 }, diff --git a/src/lib/dashboard/snapshot.ts b/src/lib/dashboard/snapshot.ts index 376dcb7e5..b0ecb259e 100644 --- a/src/lib/dashboard/snapshot.ts +++ b/src/lib/dashboard/snapshot.ts @@ -46,7 +46,12 @@ import { type DataSummary, } from "@/lib/analytics/trends"; import { getBpTargets } from "@/lib/analytics/bp-targets"; -import { computeBpInTargetFastPath } from "@/lib/analytics/bp-in-target-fast-path"; +import { + computeBpInTargetFastPath, + type BpInTargetEnvelope, +} from "@/lib/analytics/bp-in-target-fast-path"; +import { buildHealthScoreBpInputs } from "@/lib/analytics/health-score-inputs"; +import { deriveBpWindow90 } from "@/lib/analytics/window-confidence"; import { probeRollupCoverage, type RollupCoverageMap, @@ -190,6 +195,16 @@ export interface DashboardSnapshotExtras { bpInTargetPctAllTime: number | null; bpInTargetPctPriorMonth: number | null; bpInTargetPctPriorYear: number | null; + /** + * v1.17 W1b — number of paired BP readings inside the trailing-90-day + * window, and the EFFECTIVE label span (real calendar span capped at 90 + * days, or `null` when the window is empty). The BD-Zielbereich tile reads + * the count against the confidence floor to decide between a percentage and + * the "collecting data" placeholder, and renders the span in the label so a + * thin-history user never sees a dishonest static "· 90 T". + */ + bpInTargetCount90: number | null; + bpInTargetSpanDays90: number | null; glucoseByContext: Record; } @@ -443,34 +458,76 @@ async function buildExtras( let bpInTargetPctAllTime: number | null = null; let bpInTargetPctPriorMonth: number | null = null; let bpInTargetPctPriorYear: number | null = null; - let bpGradedScore: number | null = null; + let bpInTargetCount90: number | null = null; + let bpInTargetSpanDays90: number | null = null; + // v1.17 W1b — hold the current + prior-week BP envelopes so the shared + // Health-Score input builder grades the pillar off the identical shape the + // analytics route uses. + let bpEnvelope: BpInTargetEnvelope | null = null; + let bpEnvelopePriorWeek: BpInTargetEnvelope | null = null; const bpTargets = getBpTargets(user.dateOfBirth); if (bpTargets) { - const windows = await computeBpInTargetFastPath({ - userId: user.id, - targets: bpTargets, - now, - coverage, - userTz, - }); - bpInTargetPct = windows.last30Days?.pct ?? null; + // v1.17 W1b — two runs (current + prior-week), identical to the + // analytics route, so the dashboard ring's week-over-week delta reflects + // BP movement instead of zeroing it out. Both reuse the already-probed + // coverage map and share the rollup/live branch decision. + const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); + const [windows, windowsPriorWeek] = await Promise.all([ + computeBpInTargetFastPath({ + userId: user.id, + targets: bpTargets, + now, + coverage, + userTz, + }), + computeBpInTargetFastPath({ + userId: user.id, + targets: bpTargets, + now: sevenDaysAgo, + coverage, + userTz, + }), + ]); + bpEnvelope = windows; + bpEnvelopePriorWeek = windowsPriorWeek; + // v1.17 W1d — the headline standardises on the trailing-90-day + // window (labelled "· 90 T" in the tile), identical to the analytics + // route, so the dashboard tile and the insights surface never narrate + // two windows for the same metric. All-time stays carried for the + // detail page's long view only. + bpInTargetPct = windows.last90Days?.pct ?? null; bpInTargetPct7d = windows.last7Days?.pct ?? null; bpInTargetPct30d = windows.last30Days?.pct ?? null; bpInTargetPctAllTime = windows.allTime?.pct ?? null; bpInTargetPctPriorMonth = windows.priorMonth?.pct ?? null; bpInTargetPctPriorYear = windows.priorYear?.pct ?? null; - bpGradedScore = windows.gradedScore ?? null; + // v1.17 W1b — count + effective span for the tile's confidence gate, + // derived through the shared helper so this surface and `/api/analytics` + // can never disagree on the gate or the label span. + const bpWindow = deriveBpWindow90( + windows.last90Days, + windows.last90EarliestAt, + now, + ); + bpInTargetCount90 = bpWindow.count; + bpInTargetSpanDays90 = bpWindow.spanDays; } // Health score — reuses the BP windows captured above plus the // already-probed coverage map (no second probe). Score + band + // delta only; the component breakdown stays off this wire. + // + // v1.17 W1b — the BP-pillar inputs come from the ONE shared + // `buildHealthScoreBpInputs` builder the analytics route also uses, so the + // ring and the insights card grade the pillar off identical inputs (same + // 90-day window via W1d, same all-time fallback, same graded score, same + // prior-week delta values). Closes the dashboard-vs-insights divergence. + const bpInputs = buildHealthScoreBpInputs(bpEnvelope, bpEnvelopePriorWeek); const scoreResult = await time("healthScore", () => computeUserHealthScoreFastPath({ userId: user.id, - bpInTargetPct, - bpGradedScore, + ...bpInputs, heightCm: user.heightCm, now, coverage, @@ -516,6 +573,8 @@ async function buildExtras( bpInTargetPctAllTime, bpInTargetPctPriorMonth, bpInTargetPctPriorYear, + bpInTargetCount90, + bpInTargetSpanDays90, glucoseByContext, }, healthScore, diff --git a/src/lib/doctor-report-data.ts b/src/lib/doctor-report-data.ts index 6a4d835f2..c48185e84 100644 --- a/src/lib/doctor-report-data.ts +++ b/src/lib/doctor-report-data.ts @@ -24,6 +24,14 @@ import { type DoctorReportPrefs, } from "@/lib/validations/doctor-report-prefs"; import { buildCycleExportSummary } from "@/lib/cycle/export-data"; +import { + buildComplianceMedicationContext, + lastNonSkippedTakenAt, + tallyComplianceFromLedger, + type ComplianceSchedule, + type IntakeEvent, +} from "@/lib/analytics/compliance"; +import type { ScheduleRevisionLike } from "@/lib/medications/scheduling/schedule-eras"; export interface DoctorReportStats { avg: number; @@ -375,6 +383,102 @@ export interface CollectDoctorReportOptions { sections?: Partial; } +/** + * v1.17 W1a — the medication row shape the ledger compliance builder needs. + * Carries the cadence-engine context fields (course window + creation + + * archived schedule eras) so the same band minter the detail page uses can + * reconstruct expected slots. + */ +export interface DoctorReportComplianceMedication { + id: string; + name: string; + asNeeded: boolean; + startsOn: Date | null; + endsOn: Date | null; + oneShot: boolean; + createdAt: Date; + schedules: ComplianceSchedule[]; + scheduleRevisions?: ScheduleRevisionLike[]; +} + +/** A window-bounded intake row keyed to its medication. */ +export interface DoctorReportComplianceIntake extends IntakeEvent { + medicationId: string; +} + +/** + * v1.17 W1a — doctor-report medication adherence through the dose-ledger + * authority. + * + * The previous implementation tallied RAW intake rows: `taken = takenAt != null`, + * else `skipped`, else `missed`, with the denominator being every row. That + * double-counted cross-source duplicate rows (a REMINDER row + an API row for + * the same slot both counted as taken), ignored band timing (a pre-window or + * ad-hoc take counted as a plain "taken", a pending row counted as "missed" + * regardless of the miss cutoff), and honoured no cadence (rolling / cyclic / + * RRULE off-weeks could enter the tally). A clinician comparing the PDF % to + * the app detail page could see two different adherence numbers for one drug. + * + * Route the report through the SAME ledger the detail page uses: per + * medication, mint the cadence-aware bands over the report window + * `[start, end]` and tally `taken` (on-time + late), `missed` and `skipped` + * from the unified dose-history ledger. `total = taken + missed` so the + * renderers' `taken / total` rate equals the ledger rate the detail page + * shows. PRN / as-needed medications are excluded exactly as before (no + * schedule, no expected dose — a fabricated 100 % on a clinical report). + * + * Pure / synchronous — bands come from pre-fetched schedules + intake + * instants. Keyed by medication name to match the renderer contract. + */ +export function buildLedgerCompliance( + medications: DoctorReportComplianceMedication[], + intakeEvents: DoctorReportComplianceIntake[], + userTz: string, + start: Date, + end: Date, + now: Date, +): Record { + const eventsByMedId = new Map(); + for (const event of intakeEvents) { + const list = eventsByMedId.get(event.medicationId); + if (list) list.push(event); + else eventsByMedId.set(event.medicationId, [event]); + } + + const compliance: Record = {}; + for (const med of medications) { + // PRN / as-needed carry no schedule and no expected dose — excluded so + // the report never prints a fabricated 100 %. + if (med.asNeeded) continue; + if (med.schedules.length === 0) continue; + + const events = eventsByMedId.get(med.id) ?? []; + const ctx = buildComplianceMedicationContext( + med, + lastNonSkippedTakenAt(events), + userTz, + ); + const tally = tallyComplianceFromLedger( + events, + med.schedules, + ctx, + start, + end, + now, + ); + // `total = taken + missed` (the ledger denominator) so the renderer's + // `taken / total` equals `tally.rate` — the exact number the detail page + // shows. Deliberate skips are reported separately but stay out of `total`. + compliance[med.name] = { + total: tally.denominator, + taken: tally.taken, + skipped: tally.skipped, + missed: tally.missed, + }; + } + return compliance; +} + /** * Aggregate the doctor-report payload for a user over a `[start, end]` range. * Pure data assembly — no auth, no rate-limit, no audit. Idempotent. @@ -414,6 +518,19 @@ export async function collectDoctorReportData( where: { userId, active: true }, include: { schedules: true, + // v1.17 W1a — archived schedule eras so the ledger compliance + // builder segments expected-slot expansion against the schedule + // that was live on each past day (matches the detail page). + scheduleRevisions: { + orderBy: { validFrom: "asc" }, + select: { + id: true, + validFrom: true, + validUntil: true, + payload: true, + supersededByRevisionId: true, + }, + }, // v1.4.25 W4d — eager-load dose history + recent intake site // for any active medication. Generic meds carry empty arrays // so the legacy data path is byte-identical. @@ -467,6 +584,9 @@ export async function collectDoctorReportData( heightCm: true, glucoseUnit: true, thresholdsJson: true, + // v1.17 W1a — the user timezone anchors the ledger compliance + // band minter (matches the detail page's dose-day attribution). + timezone: true, // v1.7.0 — patient-identity fields for the export cover + FHIR // Patient. KVNR is encrypted (and not selected here) — the route // decrypts it and hands it to the builders. @@ -503,26 +623,39 @@ export async function collectDoctorReportData( }; } - // Medication compliance. As-needed medications are excluded: they - // carry no schedule, so every logged intake is "taken" by definition - // and a 100% rate would be a fabricated number on a clinical report. - // The medication itself stays on the report's medication list. - const compliance: Record = {}; - for (const event of intakeEvents) { - if (event.medication.asNeeded) continue; - const name = event.medication.name; - if (!compliance[name]) { - compliance[name] = { total: 0, taken: 0, skipped: 0, missed: 0 }; - } - compliance[name].total++; - if (event.takenAt) { - compliance[name].taken++; - } else if (event.skipped) { - compliance[name].skipped++; - } else { - compliance[name].missed++; - } - } + // v1.17 W1a — medication compliance through the dose-ledger authority (the + // same engine the detail page uses), NOT a raw-row tally. Routes each + // scheduled medication's intake rows through the cadence-aware band minter + // over the report window so the PDF / FHIR adherence % matches the app's + // detail page (slot dedup, band timing, cadence honoured). As-needed / PRN + // medications are excluded — no schedule, no expected dose, no fabricated + // 100 % on a clinical report. The medication itself stays on the list. + const reportTz = userProfile?.timezone ?? "Europe/Berlin"; + const compliance = buildLedgerCompliance( + medications.map((m) => ({ + id: m.id, + name: m.name, + asNeeded: m.asNeeded, + startsOn: m.startsOn, + endsOn: m.endsOn, + oneShot: m.oneShot, + createdAt: m.createdAt, + schedules: m.schedules, + scheduleRevisions: m.scheduleRevisions, + })), + intakeEvents.map((e) => ({ + medicationId: e.medicationId, + scheduledFor: e.scheduledFor, + takenAt: e.takenAt, + skipped: e.skipped, + autoMissed: e.autoMissed ?? false, + attributionSource: e.attributionSource ?? undefined, + })), + reportTz, + start, + end, + end, + ); // v1.9.0 — MedicationAdministration source rows. One entry per acted // intake (taken OR explicitly skipped); pending / missed rows are diff --git a/src/lib/doctor-report-pdf-core.ts b/src/lib/doctor-report-pdf-core.ts index 55506fef9..1a14e0543 100644 --- a/src/lib/doctor-report-pdf-core.ts +++ b/src/lib/doctor-report-pdf-core.ts @@ -870,6 +870,11 @@ export function buildDoctorReportPdfDocument( y += 6; const compRows = complianceEntries.map(([name, c]) => { + // v1.17 W1a — `total` is the ledger rate denominator (taken + missed, + // deliberate skips excluded), so `taken / total` matches the app's + // detail-page adherence %. The column is labelled "Expected" rather + // than "Total" so the row stays internally coherent: Taken + Missed = + // Expected, with Skipped shown alongside as informational. const rate = c.total > 0 ? `${num((c.taken / c.total) * 100, 1)}%` : "—"; return [ name, @@ -889,7 +894,7 @@ export function buildDoctorReportPdfDocument( t("doctorReport.colTaken"), t("doctorReport.colSkipped"), t("doctorReport.colMissed"), - t("doctorReport.colTotal"), + t("doctorReport.colExpected"), t("doctorReport.colComplianceRate"), ], ], diff --git a/src/lib/validations/__tests__/entry-instant.test.ts b/src/lib/validations/__tests__/entry-instant.test.ts new file mode 100644 index 000000000..fdc7fd03d --- /dev/null +++ b/src/lib/validations/__tests__/entry-instant.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect } from "vitest"; +import { z } from "zod/v4"; + +import { + isPlausibleEntryInstant, + validateEntryInstant, + ENTRY_INSTANT_CLOCK_SKEW_MS, + ENTRY_INSTANT_FAR_PAST, +} from "../entry-instant"; + +const NOW = Date.parse("2026-06-14T12:00:00.000Z"); + +describe("isPlausibleEntryInstant", () => { + it("accepts a recent past instant", () => { + expect( + isPlausibleEntryInstant(new Date(NOW - 60_000), { now: NOW }), + ).toBe(true); + }); + + it("accepts an instant inside the clock-skew tolerance", () => { + expect( + isPlausibleEntryInstant( + new Date(NOW + ENTRY_INSTANT_CLOCK_SKEW_MS - 1), + { now: NOW }, + ), + ).toBe(true); + }); + + it("rejects a future instant beyond the skew tolerance", () => { + expect( + isPlausibleEntryInstant( + new Date(NOW + ENTRY_INSTANT_CLOCK_SKEW_MS + 60_000), + { now: NOW }, + ), + ).toBe(false); + }); + + it("rejects an instant before the 1900 far-past floor", () => { + expect( + isPlausibleEntryInstant( + new Date(ENTRY_INSTANT_FAR_PAST.getTime() - 1), + { now: NOW }, + ), + ).toBe(false); + }); + + it("accepts a deeply-backdated instant when no maxAge is set", () => { + expect( + isPlausibleEntryInstant(new Date("1950-03-04T00:00:00.000Z"), { + now: NOW, + }), + ).toBe(true); + }); + + it("honours a tighter maxAge window", () => { + const fiveYears = 5 * 365 * 24 * 60 * 60 * 1000; + expect( + isPlausibleEntryInstant(new Date(NOW - fiveYears + 60_000), { + now: NOW, + maxAgeMs: fiveYears, + }), + ).toBe(true); + expect( + isPlausibleEntryInstant(new Date(NOW - fiveYears - 60_000), { + now: NOW, + maxAgeMs: fiveYears, + }), + ).toBe(false); + }); +}); + +describe("validateEntryInstant — Zod integration", () => { + const schema = validateEntryInstant( + z.iso.datetime({ offset: true }).transform((s) => new Date(s)), + ); + + it("rejects a future-dated instant", () => { + const future = new Date(Date.now() + 60 * 60 * 1000).toISOString(); + expect(schema.safeParse(future).success).toBe(false); + }); + + it("accepts a sane backdated instant", () => { + const past = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); + expect(schema.safeParse(past).success).toBe(true); + }); + + it("rejects an instant before 1900", () => { + expect(schema.safeParse("1899-12-31T00:00:00.000Z").success).toBe(false); + }); + + it("emits ONLY the future message for a future instant, not a spurious past-bound error", () => { + const future = new Date(Date.now() + 60 * 60 * 1000).toISOString(); + const r = schema.safeParse(future); + expect(r.success).toBe(false); + if (!r.success) { + const messages = r.error.issues.map((i) => i.message); + expect(messages).toContain("Timestamp must not be in the future"); + expect(messages.some((m) => /past|1900/.test(m))).toBe(false); + } + }); +}); diff --git a/src/lib/validations/__tests__/measurement.test.ts b/src/lib/validations/__tests__/measurement.test.ts index 403fcf3b2..983d019ba 100644 --- a/src/lib/validations/__tests__/measurement.test.ts +++ b/src/lib/validations/__tests__/measurement.test.ts @@ -132,6 +132,39 @@ describe("measurement validation", () => { expect(parsed.success).toBe(false); }); + // v1.17 W1b — shared `validateEntryInstant` plausibility bound. + it("rejects a future-dated measurement", () => { + const future = new Date(Date.now() + 60 * 60 * 1000).toISOString(); + const parsed = createMeasurementSchema.safeParse({ + ...validBase, + type: "WEIGHT", + value: 80, + measuredAt: future, + }); + expect(parsed.success).toBe(false); + }); + + it("accepts a sane backdated measurement", () => { + const past = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); + const parsed = createMeasurementSchema.safeParse({ + ...validBase, + type: "WEIGHT", + value: 80, + measuredAt: past, + }); + expect(parsed.success).toBe(true); + }); + + it("rejects a measurement dated before 1900", () => { + const parsed = createMeasurementSchema.safeParse({ + ...validBase, + type: "WEIGHT", + value: 80, + measuredAt: "1899-06-01T00:00:00.000Z", + }); + expect(parsed.success).toBe(false); + }); + // v1.4.25 W10 reconcile (code-review M4): `deviceType` was a // batch-only field; the single-entry schema silently dropped it. // Now it parses through so the column is populated whether the diff --git a/src/lib/validations/__tests__/moodlog.test.ts b/src/lib/validations/__tests__/moodlog.test.ts index 06ed3d078..0f619fc25 100644 --- a/src/lib/validations/__tests__/moodlog.test.ts +++ b/src/lib/validations/__tests__/moodlog.test.ts @@ -44,6 +44,31 @@ describe("createMoodEntrySchema — note + structured tagKeys (v1.8.5)", () => { expect(r.success).toBe(true); if (r.success) expect(r.data.tagKeys).toBeNull(); }); + + // v1.17 W1b — shared `validateEntryInstant` plausibility bound. + it("rejects a future-dated mood entry", () => { + const future = new Date(Date.now() + 60 * 60 * 1000).toISOString(); + expect( + createMoodEntrySchema.safeParse({ ...base, moodLoggedAt: future }) + .success, + ).toBe(false); + }); + + it("accepts a sane backdated mood entry", () => { + const past = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); + expect( + createMoodEntrySchema.safeParse({ ...base, moodLoggedAt: past }).success, + ).toBe(true); + }); + + it("rejects a mood entry dated before 1900", () => { + expect( + createMoodEntrySchema.safeParse({ + ...base, + moodLoggedAt: "1899-12-31T00:00:00.000Z", + }).success, + ).toBe(false); + }); }); describe("moodLogCredentialsSchema SSRF guard", () => { diff --git a/src/lib/validations/cycle.ts b/src/lib/validations/cycle.ts index 09a78ff9e..6b3353cc1 100644 --- a/src/lib/validations/cycle.ts +++ b/src/lib/validations/cycle.ts @@ -10,6 +10,21 @@ * instants are ISO-8601 with offset. */ import { z } from "zod/v4"; +import { isPlausibleEntryInstant } from "@/lib/validations/entry-instant"; + +/** + * v1.17 W1b — `loggedAt` instant on the cycle wire stays a string (the iOS + * contract reads it back verbatim), so the plausibility bound is applied as + * a `.refine` over the parsed instant rather than the date-typed + * `validateEntryInstant` wrapper. Same `isPlausibleEntryInstant` predicate + * the measurement / mood / medication-intake paths use: no future instant + * beyond the 5-min skew, no instant before 1900. + */ +const boundedLoggedAt = z.iso + .datetime({ offset: true }) + .refine((s) => isPlausibleEntryInstant(new Date(s)), { + message: "loggedAt must be a plausible instant (not future, not pre-1900)", + }); /* ── enums (mirror the Prisma enums) ─────────────────────────────── */ @@ -105,7 +120,7 @@ export const cycleDayLogInputSchema = z.object({ .max(40) .optional(), note: z.string().max(500).optional(), - loggedAt: z.iso.datetime({ offset: true }), + loggedAt: boundedLoggedAt, source: cycleSourceEnum.optional().default("MANUAL"), externalId: z.string().min(1).max(120).optional(), }); @@ -134,7 +149,7 @@ export const cycleDayLogPatchSchema = z.object({ .max(40) .optional(), note: z.string().max(500).nullable().optional(), - loggedAt: z.iso.datetime({ offset: true }).optional(), + loggedAt: boundedLoggedAt.optional(), }); export const MAX_CYCLE_BULK_ENTRIES = 500; @@ -149,7 +164,7 @@ export const cyclePeriodSchema = z.object({ action: z.enum(["start", "end"]), date: dateString, externalId: z.string().min(1).max(120).optional(), - loggedAt: z.iso.datetime({ offset: true }), + loggedAt: boundedLoggedAt, }); /* ── calendar + history queries ──────────────────────────────────── */ diff --git a/src/lib/validations/entry-instant.ts b/src/lib/validations/entry-instant.ts new file mode 100644 index 000000000..8af95c19e --- /dev/null +++ b/src/lib/validations/entry-instant.ts @@ -0,0 +1,116 @@ +/** + * Shared entry-instant plausibility bound (v1.17 W1b). + * + * Medication intake (v1.15.19 / v1.16.9) already rejects a `takenAt` that + * lies in the future or implausibly far in the past, so the retro-add + * dialog and a hand-rolled API call cannot insert a row a decade ahead or + * a century behind. The data-portability audit found the other capture + * paths — `Measurement.measuredAt` and `MoodEntry.moodLoggedAt` — accepted + * arbitrary instants, future dates included. + * + * This module hoists the intake bound into one reusable validator every + * timestamped capture path applies: + * + * - **No future instants** beyond a small clock-skew tolerance + * (`ENTRY_INSTANT_CLOCK_SKEW_MS`, +5 min). A client clock running a + * few minutes fast is tolerated; a genuinely future-dated entry is a + * bug or an import error and is rejected. + * - **A sane past floor.** Entries before `ENTRY_INSTANT_FAR_PAST` (the + * start of 1900) are rejected — no real-world reading predates it, and + * the floor catches epoch-zero / corrupted-import timestamps. Callers + * that want a tighter window (medication intake keeps its five-year + * floor) pass `maxAgeMs` to clamp the past bound nearer to now. + * + * Pure helpers + a Zod `superRefine` factory so the same bound can sit on + * any schema's instant field with a stable per-field error path. + */ +import type { z } from "zod/v4"; + +/** Tolerated future skew for a client clock running fast (5 minutes). */ +export const ENTRY_INSTANT_CLOCK_SKEW_MS = 5 * 60 * 1000; + +/** + * Absolute far-past floor: 1900-01-01T00:00:00Z. No genuine health reading + * predates it; the floor rejects epoch-zero / corrupted-import instants. + */ +export const ENTRY_INSTANT_FAR_PAST = new Date("1900-01-01T00:00:00.000Z"); + +export interface EntryInstantBounds { + /** + * Optional tighter past window in milliseconds. When set, the past floor + * is `max(ENTRY_INSTANT_FAR_PAST, now - maxAgeMs)` — medication intake + * passes its five-year window here so a stale backdated dose is rejected + * well before the 1900 floor. + */ + maxAgeMs?: number; + /** Clock anchor; defaults to `Date.now()`. Injectable for tests. */ + now?: number; + /** + * Optional override for the past-bound violation message. Lets a caller + * keep an established wording (e.g. medication intake's "within the last + * 5 years") so existing contracts / tests stay stable. + */ + pastMessage?: string; +} + +/** + * `true` when `instant` is a plausible capture time: not in the future + * beyond the skew tolerance, and not before the (optionally clamped) past + * floor. Shared by the Zod refinements and any imperative caller. + */ +export function isPlausibleEntryInstant( + instant: Date, + bounds: EntryInstantBounds = {}, +): boolean { + const now = bounds.now ?? Date.now(); + const t = instant.getTime(); + if (Number.isNaN(t)) return false; + if (t > now + ENTRY_INSTANT_CLOCK_SKEW_MS) return false; + const floorMs = + bounds.maxAgeMs !== undefined + ? Math.max(ENTRY_INSTANT_FAR_PAST.getTime(), now - bounds.maxAgeMs) + : ENTRY_INSTANT_FAR_PAST.getTime(); + if (t < floorMs) return false; + return true; +} + +/** + * Attach the entry-instant bound to a `z.date()`-typed field via two + * `.refine` calls so each violation carries its own message. Use on any + * transformed ISO field after `.transform((s) => new Date(s))`. + * + * Re-reads `Date.now()` inside the refinements (not at schema-build time) + * so a long-lived schema instance always validates against the current + * clock. + */ +export function validateEntryInstant>( + schema: T, + bounds: Omit = {}, +): T { + const pastMessage = + bounds.pastMessage ?? + (bounds.maxAgeMs !== undefined + ? "Timestamp is too far in the past" + : "Timestamp must not predate 1900"); + return schema + .refine((d) => d.getTime() <= Date.now() + ENTRY_INSTANT_CLOCK_SKEW_MS, { + message: "Timestamp must not be in the future", + }) + .refine( + // Past-floor check ONLY — a future instant already fails the refine + // above, and re-running the full plausibility predicate here would + // make a future-dated value emit BOTH messages (the second being a + // nonsensical "too far in the past"). Compare against the floor alone. + (d) => d.getTime() >= pastFloorMs(bounds), + { message: pastMessage }, + ) as T; +} + +/** Resolve the effective past floor (ms) for the given bounds at call time. */ +function pastFloorMs(bounds: Omit): number { + if (bounds.maxAgeMs === undefined) return ENTRY_INSTANT_FAR_PAST.getTime(); + return Math.max( + ENTRY_INSTANT_FAR_PAST.getTime(), + Date.now() - bounds.maxAgeMs, + ); +} diff --git a/src/lib/validations/measurement.ts b/src/lib/validations/measurement.ts index c2de3706f..041e6259f 100644 --- a/src/lib/validations/measurement.ts +++ b/src/lib/validations/measurement.ts @@ -1,4 +1,5 @@ import { z } from "zod/v4"; +import { validateEntryInstant } from "./entry-instant"; export const measurementTypeEnum = z.enum([ "WEIGHT", @@ -499,7 +500,13 @@ export const createMeasurementSchema = z .object({ type: measurementTypeEnum, value: z.number(), - measuredAt: z.iso.datetime({ offset: true }).transform((s) => new Date(s)), + // v1.17 W1b — plausibility bound (shared `validateEntryInstant`): no + // future instants beyond a 5-min clock-skew tolerance, no instant before + // 1900. Closes the data-portability gap where a hand-rolled POST could + // backdate or forward-date a reading arbitrarily. + measuredAt: validateEntryInstant( + z.iso.datetime({ offset: true }).transform((s) => new Date(s)), + ), notes: z.string().max(MEASUREMENT_NOTES_MAX_LENGTH).optional(), // v1.10.0 QA — validate against the client-writable subset, not the full // `MeasurementSource` enum. A client may attribute only `MANUAL` or @@ -534,10 +541,11 @@ export const createMeasurementSchema = z export const updateMeasurementSchema = z.object({ value: z.number().min(0).max(500000).optional(), - measuredAt: z.iso - .datetime({ offset: true }) - .transform((s) => new Date(s)) - .optional(), + // v1.17 W1b — same plausibility bound on the edit path; an edit cannot + // forward-date a reading into the future nor before 1900. + measuredAt: validateEntryInstant( + z.iso.datetime({ offset: true }).transform((s) => new Date(s)), + ).optional(), notes: z .string() .max( diff --git a/src/lib/validations/medication.ts b/src/lib/validations/medication.ts index 78a6011a4..fb2748da4 100644 --- a/src/lib/validations/medication.ts +++ b/src/lib/validations/medication.ts @@ -2,6 +2,7 @@ import { z } from "zod/v4"; import { SCHEDULE_TYPES } from "@/lib/medications/scheduling/recurrence"; import { INJECTION_SITE_KEYS } from "@/lib/medications/injection-sites"; +import { validateEntryInstant } from "@/lib/validations/entry-instant"; /** * v1.8.5 — the eight injection-site enum values, mirrored from the @@ -203,19 +204,24 @@ function hhmmToMinutes(hhmm: string): number { * validator's window. Slot-distance / medication-start checks stay in * the routes — the schema cannot see the medication. */ -export const TAKEN_AT_CLOCK_SKEW_MS = 5 * 60 * 1000; export const TAKEN_AT_MAX_AGE_MS = 5 * 365 * 24 * 60 * 60 * 1000; -/** ISO `takenAt` → `Date` with the shared plausibility bounds applied. */ -export const boundedTakenAtSchema = z.iso - .datetime({ offset: true }) - .transform((s) => new Date(s)) - .refine((d) => d.getTime() <= Date.now() + TAKEN_AT_CLOCK_SKEW_MS, { - message: "takenAt must not be in the future", - }) - .refine((d) => d.getTime() >= Date.now() - TAKEN_AT_MAX_AGE_MS, { - message: "takenAt must be within the last 5 years", - }); +/** + * ISO `takenAt` → `Date` with the plausibility bounds applied. + * + * v1.17 W1b — delegates to the shared `validateEntryInstant` helper (the + * same bound now guarding `measuredAt` + `moodLoggedAt`), passing the + * five-year window so a stale backdated dose is rejected well before the + * 1900 floor. The 5-min clock-skew tolerance is the shared default. + */ +export const boundedTakenAtSchema = validateEntryInstant( + z.iso.datetime({ offset: true }).transform((s) => new Date(s)), + { + maxAgeMs: TAKEN_AT_MAX_AGE_MS, + // Preserve the established wording so the iOS contract + tests stay stable. + pastMessage: "takenAt must be within the last 5 years", + }, +); export const scheduleSchema = z .object({ diff --git a/src/lib/validations/moodlog.ts b/src/lib/validations/moodlog.ts index d093101f3..afb7a1073 100644 --- a/src/lib/validations/moodlog.ts +++ b/src/lib/validations/moodlog.ts @@ -1,5 +1,6 @@ import { z } from "zod/v4"; import { isPublicUrl } from "@/lib/validations/notifications"; +import { validateEntryInstant } from "@/lib/validations/entry-instant"; /** * @deprecated The standalone moodLog integration is superseded by native @@ -135,7 +136,12 @@ export const createMoodEntrySchema = z.object({ // `tags: ["note:"]` workaround. Capped at 500 chars so the // Coach evidence shelf renders cleanly without truncating chips. note: z.string().max(500).optional(), - moodLoggedAt: z.iso.datetime({ offset: true }).transform((s) => new Date(s)), + // v1.17 W1b — plausibility bound (shared `validateEntryInstant`): no + // future instants beyond a 5-min clock-skew tolerance, no instant before + // 1900. Mirrors the measurement + medication-intake bound. + moodLoggedAt: validateEntryInstant( + z.iso.datetime({ offset: true }).transform((s) => new Date(s)), + ), source: moodSourceEnum.optional().default("MANUAL"), // v1.12.1 — optional source-stable id (e.g. an iOS SwiftData row UUID). // When present, the create upserts on `(userId, source, externalId)` @@ -157,10 +163,10 @@ export const updateMoodEntrySchema = z.object({ // `null` clears every rated link; omit to leave them untouched. ratedFactors: ratedFactors.nullable().optional(), note: z.string().max(500).nullable().optional(), - moodLoggedAt: z.iso - .datetime({ offset: true }) - .transform((s) => new Date(s)) - .optional(), + // v1.17 W1b — same plausibility bound on the edit path. + moodLoggedAt: validateEntryInstant( + z.iso.datetime({ offset: true }).transform((s) => new Date(s)), + ).optional(), }); export const listMoodEntriesSchema = z.object({ diff --git a/src/types/analytics.ts b/src/types/analytics.ts index 0e0df26a9..bf62771c3 100644 --- a/src/types/analytics.ts +++ b/src/types/analytics.ts @@ -86,6 +86,15 @@ export interface DashboardAnalyticsData { */ bpInTargetPctPriorMonth?: number | null; bpInTargetPctPriorYear?: number | null; + /** + * v1.17 W1b — paired BP readings inside the trailing-90-day window, and + * the EFFECTIVE label span (real calendar span capped at 90 days). The + * BD-Zielbereich tile compares the count against the confidence floor to + * decide between a percentage and a "collecting data" placeholder, and + * renders the span in the label ("· 23 T" until ~90 days of history exist). + */ + bpInTargetCount90?: number | null; + bpInTargetSpanDays90?: number | null; glucoseByContext?: Record; /** * v1.4.34 IW-B — per-type freshness map from `/api/analytics`. The diff --git a/tests/integration/analytics-health-score.test.ts b/tests/integration/analytics-health-score.test.ts index cfaec655b..4557ddf3d 100644 --- a/tests/integration/analytics-health-score.test.ts +++ b/tests/integration/analytics-health-score.test.ts @@ -195,22 +195,26 @@ describe("GET /api/analytics — Health Score", () => { expect(hs.components.compliance.value).toBeGreaterThanOrEqual(95); }); - it("scores the BP pillar from all-time history even with no readings in the trailing 30 days", async () => { - // Regression pin: the BD-Zielbereich tile headline reads the trailing - // 30 days, but the Health-Score BP pillar must read the all-time - // window. A prior change fed the 30-day value into the score, so an - // account whose BP readings predate the trailing month lost the - // 0.30-weight BP pillar entirely and rendered "no rating" despite - // having BP data. This pins the all-time feed. + it("scores the BP pillar from the trailing-90-day window when readings predate the last 30 days", async () => { + // v1.17 W1d regression pin: the BD-Zielbereich tile headline, the + // Health-Score BP pillar and the coach number all read the SAME + // trailing-90-day window. A prior shape fed the 30-day value into the + // score, so an account whose BP readings predate the trailing month + // lost the 0.30-weight BP pillar entirely and rendered "no rating" + // despite having recent-enough BP data. This pins the 90-day feed: + // readings 60–80 days ago sit OUTSIDE the trailing 30 days but INSIDE + // the 90-day window, so both the pillar and the headline must surface + // them. const prisma = getPrismaClient(); const user = await seedSession("hs-bp-old"); const now = Date.now(); const DAY = 24 * 60 * 60 * 1000; - // 20 paired in-target readings, all ~60–80 days ago (outside the - // trailing-30-day window the tile headline uses, inside the all-time - // window the score should use). + // 20 paired in-target readings, all ~60–80 days ago: outside the + // trailing-30-day window, inside the trailing-90-day window the score + // and headline now share. Well above the 5-reading confidence floor, + // so the pillar grades rather than suppressing as thin data. for (let i = 0; i < 20; i++) { const at = new Date(now - (60 + i) * DAY); await prisma.measurement.create({ @@ -243,19 +247,21 @@ describe("GET /api/analytics — Health Score", () => { }; expect(env.data!.healthScore).not.toBeNull(); const hs = env.data!.healthScore!; - // The BP pillar must score from the all-time window (recency-weighted)… + // The BP pillar scores from the 90-day window (recency-weighted). // 122/78 sits just inside the under-65 target ceiling (129/79), so the - // graded clinical-proximity score lands high (worst-of-axis ≈ 86) rather - // than the old binary 100. The point of this test is that the pillar still - // SCORES from all-time history when the trailing-30-day window is empty — - // a clearly in-target history must read as a strong (≥ 80) pillar, not drop + // graded clinical-proximity score lands high (worst-of-axis ≈ 86) + // rather than the old binary 100. The point: the pillar still SCORES + // from history when the trailing-30-day window is empty — a clearly + // in-target 90-day history reads as a strong (≥ 80) pillar, not drop // out entirely (the regression this test pins). expect(hs.components.bp.value).not.toBeNull(); expect(hs.components.bp.value).toBeGreaterThanOrEqual(80); expect(hs.components.bp.weight).toBeGreaterThan(0); - // …while the tile headline stays scoped to the trailing 30 days - // (no readings there → null), proving the two are decoupled. - expect(env.data!.bpInTargetPct).toBeNull(); + // …and the tile headline now reads the SAME 90-day window, so it + // surfaces the in-target rate (all 20 pairs in target = 100 %) rather + // than dropping to null. The pillar and headline are unified on the + // window — that is the v1.17 W1d contract. + expect(env.data!.bpInTargetPct).toBe(100); }); it("returns null healthScore for a user with no data", async () => { diff --git a/tests/integration/bp-in-target.test.ts b/tests/integration/bp-in-target.test.ts index 19ce8b601..f76f42a39 100644 --- a/tests/integration/bp-in-target.test.ts +++ b/tests/integration/bp-in-target.test.ts @@ -529,23 +529,24 @@ describe("BP-in-target % — windowed (7-day + 30-day) — production data shape }); /** - * v1.4.22 A1 — re-anchor the headline to last-30 days. + * v1.17 W1d — re-anchor the headline to the trailing 90 days. * - * Up to v1.4.19 the headline was the all-time figure. That made the - * tile correct (no algorithmic 50/50/50 pin) but emotionally wrong: - * the headline was the slowest-moving aggregate possible, so a user - * who had genuinely improved their last-30-day discipline saw a - * stubbornly low number that took years of further compliance to - * budge. v1.4.22 routes the headline through `last30Days` and surfaces - * `7d` / `30d` / `Allzeit` as a 3-line sub-row instead. + * Up to v1.4.19 the headline was the all-time figure (slowest-moving + * aggregate possible); v1.4.22 swung it to the trailing 30 days (recency + * but a thin denominator that whipsaws on a single reading). v1.17 W1d + * standardises the headline, the Health-Score BP pillar and the coach + * grounding number all on the SAME trailing-90-day window — long enough + * to be stable, recent enough to move. The route routes `bpInTargetPct` + * through `windows.last90Days` and keeps `7d` / `30d` / `Allzeit` as the + * sub-rows. * - * The integration assertion: with three legitimately divergent - * windows (recent 50 % vs all-time 13 %) the route's `bpInTargetPct` - * field equals `windows.last30Days.pct` (50 %), and a separate - * `bpInTargetPctAllTime` carries the long-arc number (13 %) so the - * tile can render the third sub-line. + * The integration assertion: with divergent windows the headline equals + * neither the 30-day window (50 %) nor the all-time aggregate (13 %) — + * it is the genuinely independent 90-day number — and the separate + * `bpInTargetPctAllTime` still carries the long-arc figure for the tile's + * sub-line. */ -describe("GET /api/analytics — BP-in-target headline (v1.4.22 A1)", () => { +describe("GET /api/analytics — BP-in-target headline (v1.17 W1d)", () => { async function seedSession(username: string) { const prisma = getPrismaClient(); const user = await prisma.user.create({ @@ -566,7 +567,7 @@ describe("GET /api/analytics — BP-in-target headline (v1.4.22 A1)", () => { return user; } - it("routes the headline through last-30-days and exposes all-time as a separate field", async () => { + it("routes the headline through the trailing 90 days and exposes all-time as a separate field", async () => { const prisma = getPrismaClient(); const user = await seedSession("bp-headline-fixture"); @@ -574,7 +575,7 @@ describe("GET /api/analytics — BP-in-target headline (v1.4.22 A1)", () => { const DAY = 24 * 60 * 60 * 1000; // Seed exactly the v1.4.19-A1 production-shape regression: 7d and - // 30d both ~50 %, all-time ~13 %. + // 30d both ~50 %, all-time ~13 %, with a third distinct 90-day figure. const seed: Array<{ daysAgo: number; sys: number; dia: number }> = [ // Last 7 days: 1 IN, 1 OUT. { daysAgo: 1.5, sys: 118, dia: 78 }, @@ -633,11 +634,15 @@ describe("GET /api/analytics — BP-in-target headline (v1.4.22 A1)", () => { expect(env.data).not.toBeNull(); const data = env.data!; - // The headline now equals the 30-day window — not the all-time aggregate. - // 30-day = 5/10 in target = 50 %. - expect(data.bpInTargetPct).toBe(50); + // The headline now equals the trailing-90-day window. In-window pairs: + // 2 (last 7 days) + 8 (7–30 days) + the older OUT readings whose + // `daysAgo = 60 + i*3 < 90` (i = 0..9, the reading at exactly 90 d + // sits on the exclusive boundary) = 20 paired readings, of which 5 + // are in target (the recent 5 IN). 5/20 = 25 %. + expect(data.bpInTargetPct).toBe(Math.round((5 / 20) * 100)); + + // 30-day window stays its own number: 5/10 in target = 50 %. expect(data.bpInTargetPct30d).toBe(50); - expect(data.bpInTargetPct).toBe(data.bpInTargetPct30d); // 7-day window: 2 paired readings, 1 in target = 50 %. expect(data.bpInTargetPct7d).toBe(50); @@ -646,9 +651,10 @@ describe("GET /api/analytics — BP-in-target headline (v1.4.22 A1)", () => { // separate field so the tile can render it as a sub-row. expect(data.bpInTargetPctAllTime).toBe(Math.round((5 / 40) * 100)); - // Smoking gun: the headline must NOT equal the all-time number now - // that the route re-anchored to last-30. This is the v1.4.22 A1 - // contract pin. + // Smoking gun: the headline is now the independent 90-day window — it + // must equal neither the 30-day window nor the all-time aggregate. + // This is the v1.17 W1d contract pin. + expect(data.bpInTargetPct).not.toBe(data.bpInTargetPct30d); expect(data.bpInTargetPct).not.toBe(data.bpInTargetPctAllTime); }); @@ -730,9 +736,13 @@ describe("GET /api/analytics — BP-in-target headline (v1.4.22 A1)", () => { }; }; const data = env.data; - // 30-day headline: 15 of 30 in target = 50 %. + // 30-day window: 15 of 30 in target = 50 %. expect(data.bpInTargetPct30d).toBe(50); - expect(data.bpInTargetPct).toBe(50); + // 90-day headline (v1.17 W1d): the 30 recent pairs (15 IN) plus the + // older out-of-target rows whose `daysAgo = 40 + i < 90` (i = 0..49, + // 50 rows; the reading at exactly 90 d sits on the exclusive boundary) + // = 80 paired readings, 15 in target. 15/80 = 19 % (rounded). + expect(data.bpInTargetPct).toBe(Math.round((15 / 80) * 100)); // All-time aggregate diluted by 2970 out-of-target older rows ≈ 1 %. expect(data.bpInTargetPctAllTime).toBeLessThan(5); }, 30_000); diff --git a/tests/integration/doctor-report-sections.test.ts b/tests/integration/doctor-report-sections.test.ts index f702eabd8..7075e163e 100644 --- a/tests/integration/doctor-report-sections.test.ts +++ b/tests/integration/doctor-report-sections.test.ts @@ -123,13 +123,21 @@ async function seedUserWithEveryDataType(username: string): Promise { }, }); - // Medication compliance — one taken intake event. + // Medication compliance — one taken intake event against a scheduled + // medication. The ledger compliance builder (v1.17 W1a) only tallies + // medications that carry a schedule (no schedule = PRN/as-needed = no + // expected dose = excluded so the report never prints a fabricated + // 100 %), so the fixture must seed a window for the drug to appear in + // `data.compliance`. const med = await prisma.medication.create({ data: { userId: user.id, name: "Metformin", dose: "500 mg", active: true, + schedules: { + create: [{ windowStart: "08:00", windowEnd: "10:00" }], + }, }, }); await prisma.medicationIntakeEvent.create({