From 114db6bbcaf4a65e930bc961eee1c5e9048ed8fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 13:02:17 +0200 Subject: [PATCH 01/12] fix(blood-pressure): unify in-target window on 90 days across headline, score, and tile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "BD im Zielbereich" share was narrated on three different windows on the same screen: the dashboard headline read the trailing 30 days, the dashboard Health-Score BP pillar read all-time, and the analytics route's pillar read all-time while its headline read 30 days. A user could see one in-target rate on the tile and a pillar graded off a different window directly above it. Standardise the headline, the Health-Score BP pillar input, and the week-over-week delta on a single trailing-90-day window everywhere — a span wide enough to stay stable yet recent enough to track current control. `computeBpInTargetWindows` and the rollup fast-path now both surface a `last90Days` window; the analytics route and the dashboard snapshot route the headline and the pillar through it, and both feed the pillar from the identical value so the dashboard ring and the insights card can never disagree. Label the window in the tile ("BD-Ziel · 90 T") so the scope is visible; the 30-day figure stays as the short-horizon caption and the all-time aggregate stays carried for the detail view. Visible-number change is expected: the headline now reflects the quarter rather than the month. --- messages/de.json | 1 + messages/en.json | 1 + messages/es.json | 1 + messages/fr.json | 1 + messages/it.json | 1 + messages/pl.json | 1 + src/app/api/analytics/route.ts | 36 ++++++++---- src/app/page.tsx | 6 +- .../analytics/__tests__/bp-in-target.test.ts | 58 +++++++++++++++++++ src/lib/analytics/bp-in-target-fast-path.ts | 11 ++++ src/lib/analytics/bp-in-target.ts | 17 ++++++ src/lib/dashboard/__tests__/snapshot.test.ts | 13 ++++- src/lib/dashboard/snapshot.ts | 19 +++++- 13 files changed, 150 insertions(+), 16 deletions(-) diff --git a/messages/de.json b/messages/de.json index 94442b3f6..019dcdcde 100644 --- a/messages/de.json +++ b/messages/de.json @@ -326,6 +326,7 @@ "stepsShort": "Schritte", "vo2MaxShort": "VO₂ max", "bpInTargetShort": "BD-Ziel", + "bpInTargetWindow90": "BD-Ziel · 90 T", "medications": "Medikamente", "quickAddMeasurement": "Messung erfassen", "quickAddMood": "Stimmung erfassen", diff --git a/messages/en.json b/messages/en.json index febabae4b..cf984502e 100644 --- a/messages/en.json +++ b/messages/en.json @@ -326,6 +326,7 @@ "stepsShort": "Steps", "vo2MaxShort": "VO₂ max", "bpInTargetShort": "BP in Target", + "bpInTargetWindow90": "BP in Target · 90d", "medications": "Medications", "quickAddMeasurement": "Log measurement", "quickAddMood": "Log mood", diff --git a/messages/es.json b/messages/es.json index bad70d032..7d69659f0 100644 --- a/messages/es.json +++ b/messages/es.json @@ -326,6 +326,7 @@ "stepsShort": "Pasos", "vo2MaxShort": "VO₂ máx", "bpInTargetShort": "PA-objetivo", + "bpInTargetWindow90": "PA-objetivo · 90 d", "medications": "Medicamentos", "quickAddMeasurement": "Registrar medición", "quickAddMood": "Registrar estado de ánimo", diff --git a/messages/fr.json b/messages/fr.json index 5efaddac7..7b2c9bd70 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -326,6 +326,7 @@ "stepsShort": "Pas", "vo2MaxShort": "VO₂ max", "bpInTargetShort": "TA-cible", + "bpInTargetWindow90": "TA-cible · 90 j", "medications": "Médicaments", "quickAddMeasurement": "Saisir une mesure", "quickAddMood": "Saisir une humeur", diff --git a/messages/it.json b/messages/it.json index 7c064c30f..91f9469e4 100644 --- a/messages/it.json +++ b/messages/it.json @@ -326,6 +326,7 @@ "stepsShort": "Passi", "vo2MaxShort": "VO₂ max", "bpInTargetShort": "PA-target", + "bpInTargetWindow90": "PA-target · 90 gg", "medications": "Farmaci", "quickAddMeasurement": "Registra misurazione", "quickAddMood": "Registra umore", diff --git a/messages/pl.json b/messages/pl.json index bb5cab1b5..f1f976d49 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -326,6 +326,7 @@ "stepsShort": "Kroki", "vo2MaxShort": "VO₂ max", "bpInTargetShort": "RR-cel", + "bpInTargetWindow90": "RR-cel · 90 d", "medications": "Leki", "quickAddMeasurement": "Zarejestruj pomiar", "quickAddMood": "Zarejestruj nastrój", diff --git a/src/app/api/analytics/route.ts b/src/app/api/analytics/route.ts index e706750fc..84df0a8ea 100644 --- a/src/app/api/analytics/route.ts +++ b/src/app/api/analytics/route.ts @@ -242,6 +242,8 @@ async function buildAnalyticsResponse(user: AuthedUser) { let bpInTargetPct: number | null = null; let bpInTargetPct7d: number | null = null; let bpInTargetPct30d: number | null = null; + // v1.17 W1d — the canonical 90-day window for the headline / score / coach. + let bpInTargetPct90d: number | null = null; let bpInTargetPctAllTime: number | null = null; /** * v1.4.22 W5 reconcile (Code-H2) — period-aligned prior-window @@ -262,10 +264,11 @@ async function buildAnalyticsResponse(user: AuthedUser) { * 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.17 W1d — prior-week 90-day BP pct. Feeds the Health-Score delta so + // its BP pillar compares the same 90-day window on both ends, identical + // to the headline scope. (Pre-v1.17 the score read all-time; W1d + // unifies headline + score + coach on 90 days.) + let windowsPriorWeekForScore: 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. @@ -315,20 +318,24 @@ async function buildAnalyticsResponse(user: AuthedUser) { userTz, }), ]); - bpInTargetPct = windows.last30Days?.pct ?? null; + // 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; + bpInTargetPct90d = windows.last90Days?.pct ?? null; bpInTargetPctAllTime = windows.allTime?.pct ?? null; bpInTargetPctPriorMonth = windows.priorMonth?.pct ?? null; bpInTargetPctPriorYear = windows.priorYear?.pct ?? null; - bpInTargetPctPriorWeek = windowsPriorWeek.last30Days?.pct ?? null; + bpInTargetPctPriorWeek = windowsPriorWeek.last90Days?.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 + // v1.17 W1d — the Health-Score BP pillar reads the same 90-day window + // as the headline (see `bpInTargetPctForScore` below). Capture the + // prior-week run's 90-day pct so the week-over-week delta compares the // same window on both ends. - windowsPriorWeekAllTime = windowsPriorWeek.allTime?.pct ?? null; + windowsPriorWeekForScore = windowsPriorWeek.last90Days?.pct ?? null; } // Per-context glucose summaries (canonical mg/dL). @@ -396,9 +403,14 @@ async function buildAnalyticsResponse(user: AuthedUser) { // 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; + // v1.17 W1d — the Health-Score BP pillar reads the SAME 90-day window as + // the BD-Zielbereich tile headline, so the dashboard ring and the + // insights card grade the BP pillar off one number. Fall back to the + // all-time rate only when the 90-day window is itself null so a + // sparse-but-historical account still surfaces a BP pillar. + const bpInTargetPctForScore = bpInTargetPct90d ?? bpInTargetPctAllTime; const bpInTargetPctPriorWeekForScore = - windowsPriorWeekAllTime ?? bpInTargetPctPriorWeek; + windowsPriorWeekForScore ?? bpInTargetPctPriorWeek; const healthScore = await computeUserHealthScoreFastPath({ userId: user.id, bpInTargetPct: bpInTargetPctForScore, diff --git a/src/app/page.tsx b/src/app/page.tsx index ba7c5126e..8eded4b07 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1098,7 +1098,11 @@ export default function DashboardPage() { node: ( { 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/bp-in-target-fast-path.ts b/src/lib/analytics/bp-in-target-fast-path.ts index a901cf2da..3d43eab47 100644 --- a/src/lib/analytics/bp-in-target-fast-path.ts +++ b/src/lib/analytics/bp-in-target-fast-path.ts @@ -76,6 +76,12 @@ 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; allTime: { pct: number; pairs: number } | null; priorMonth: { pct: number; pairs: number } | null; priorYear: { pct: number; pairs: number } | null; @@ -274,12 +280,15 @@ 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); // 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 +348,7 @@ async function computeFromRollups( return { last7Days: last7, last30Days: last30, + last90Days: last90, allTime, priorMonth, priorYear, @@ -453,6 +463,7 @@ async function computeFromLive( return { last7Days: windows.last7Days, last30Days: windows.last30Days, + last90Days: windows.last90Days, 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..8ddaed1e0 100644 --- a/src/lib/analytics/bp-in-target.ts +++ b/src/lib/analytics/bp-in-target.ts @@ -237,6 +237,14 @@ 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; allTime: { pct: number; pairs: number } | null; /** * v1.4.22 W5 reconcile (Code-H2) — period-aligned 30-day window @@ -254,6 +262,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 +279,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 @@ -299,6 +314,8 @@ export function computeBpInTargetWindows( return { last7Days: computeBpInTargetPct(sysLast7, diaLast7, targets), last30Days: computeBpInTargetPct(sysLast30, diaLast30, targets), + // v1.17 W1d — canonical headline / score / coach window. + last90Days: computeBpInTargetPct(sysLast90, diaLast90, targets), // 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/dashboard/__tests__/snapshot.test.ts b/src/lib/dashboard/__tests__/snapshot.test.ts index 74149aba8..564a6ee0f 100644 --- a/src/lib/dashboard/__tests__/snapshot.test.ts +++ b/src/lib/dashboard/__tests__/snapshot.test.ts @@ -142,6 +142,7 @@ describe("buildDashboardSnapshot — envelope shape", () => { computeBpInTargetFastPath.mockResolvedValue({ last7Days: { pct: 70 }, last30Days: { pct: 80 }, + last90Days: { pct: 80 }, allTime: { pct: 75 }, priorMonth: { pct: 60 }, priorYear: { pct: 50 }, @@ -182,6 +183,7 @@ describe("buildDashboardSnapshot — envelope shape", () => { computeBpInTargetFastPath.mockResolvedValue({ last7Days: { pct: 70 }, last30Days: { pct: 80 }, + last90Days: { pct: 80 }, allTime: { pct: 75 }, priorMonth: { pct: 60 }, priorYear: { pct: 50 }, @@ -238,6 +240,7 @@ describe("buildDashboardSnapshot — per-type thick-phase gate", () => { computeBpInTargetFastPath.mockResolvedValue({ last7Days: { pct: 70 }, last30Days: { pct: 80 }, + last90Days: { pct: 80 }, allTime: { pct: 75 }, priorMonth: { pct: 60 }, priorYear: { pct: 50 }, @@ -279,6 +282,7 @@ describe("buildDashboardSnapshot — per-type thick-phase gate", () => { computeBpInTargetFastPath.mockResolvedValue({ last7Days: null, last30Days: null, + last90Days: null, allTime: null, priorMonth: null, priorYear: null, @@ -364,6 +368,8 @@ describe("buildDashboardSnapshot — healthScore (warm phase only)", () => { computeBpInTargetFastPath.mockResolvedValue({ last7Days: { pct: 70 }, last30Days: { pct: 80 }, + // v1.17 W1d — the BP pillar reads this 90-day window, NOT last30Days. + last90Days: { pct: 77 }, allTime: { pct: 75 }, priorMonth: { pct: 60 }, priorYear: { pct: 50 }, @@ -392,7 +398,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 +413,7 @@ describe("buildDashboardSnapshot — healthScore (warm phase only)", () => { computeBpInTargetFastPath.mockResolvedValue({ last7Days: null, last30Days: null, + last90Days: null, allTime: null, priorMonth: null, priorYear: null, @@ -705,6 +715,7 @@ describe("buildDashboardSnapshot — additive proof", () => { computeBpInTargetFastPath.mockResolvedValue({ last7Days: { pct: 70 }, last30Days: { pct: 80 }, + last90Days: { pct: 80 }, 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..3650de8c0 100644 --- a/src/lib/dashboard/snapshot.ts +++ b/src/lib/dashboard/snapshot.ts @@ -440,6 +440,8 @@ async function buildExtras( let bpInTargetPct: number | null = null; let bpInTargetPct7d: number | null = null; let bpInTargetPct30d: number | null = null; + // v1.17 W1d — canonical 90-day headline / score window. + let bpInTargetPct90d: number | null = null; let bpInTargetPctAllTime: number | null = null; let bpInTargetPctPriorMonth: number | null = null; let bpInTargetPctPriorYear: number | null = null; @@ -454,9 +456,15 @@ async function buildExtras( coverage, userTz, }); - bpInTargetPct = windows.last30Days?.pct ?? null; + // 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; + bpInTargetPct90d = windows.last90Days?.pct ?? null; bpInTargetPctAllTime = windows.allTime?.pct ?? null; bpInTargetPctPriorMonth = windows.priorMonth?.pct ?? null; bpInTargetPctPriorYear = windows.priorYear?.pct ?? null; @@ -466,10 +474,17 @@ async function buildExtras( // 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/W1d — the BP pillar reads the SAME 90-day window as the + // analytics route, so the dashboard ring and the insights card compute + // the score from identical inputs (closing the dashboard-vs-insights + // score divergence). Fall back to all-time only when the 90-day window + // is null so a sparse-but-historical account keeps its BP pillar. + const bpInTargetPctForScore = bpInTargetPct90d ?? bpInTargetPctAllTime; const scoreResult = await time("healthScore", () => computeUserHealthScoreFastPath({ userId: user.id, - bpInTargetPct, + bpInTargetPct: bpInTargetPctForScore, bpGradedScore, heightCm: user.heightCm, now, From f4f1c0c8b088e4a099103bd6cbae22505752e9da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 13:07:09 +0200 Subject: [PATCH 02/12] fix(health-score): build the BP pillar from one shared input builder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Personal Health Score was assembled differently by its two callers. The analytics route (the Insights card) graded the BP pillar off one window and passed the prior-week values for the week-over-week delta; the dashboard snapshot (the hero ring) graded it off a different window and omitted the prior-week values, so the ring's delta zeroed BP out and the pillar's presence gate could flip. The same user could see two different scores and two different bands on two surfaces. Extract `buildHealthScoreBpInputs` — one pure builder that turns the current and prior-week in-target envelopes into the BP-pillar inputs (the 90-day rate with an all-time fallback, the graded clinical-proximity score, and the prior-week counterparts for the delta). Both callers now run the same two in-target probes and feed the pillar through this builder, so the window choice, the fallback, the graded-score wiring and the delta inputs are identical by construction and can never drift between the surfaces again. The dashboard ring now runs the prior-week probe it previously skipped, so its week-over-week delta reflects BP movement to match the Insights card. --- src/app/api/analytics/route.ts | 87 +++++------------- .../__tests__/health-score-inputs.test.ts | 89 +++++++++++++++++++ src/lib/analytics/health-score-inputs.ts | 78 ++++++++++++++++ src/lib/dashboard/__tests__/snapshot.test.ts | 4 +- src/lib/dashboard/snapshot.ts | 61 ++++++++----- 5 files changed, 233 insertions(+), 86 deletions(-) create mode 100644 src/lib/analytics/__tests__/health-score-inputs.test.ts create mode 100644 src/lib/analytics/health-score-inputs.ts diff --git a/src/app/api/analytics/route.ts b/src/app/api/analytics/route.ts index 84df0a8ea..0e0438e3f 100644 --- a/src/app/api/analytics/route.ts +++ b/src/app/api/analytics/route.ts @@ -12,8 +12,12 @@ 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 { computeCorrelationHypothesesFastPath } from "@/lib/analytics/correlations-fast-path"; export const dynamic = "force-dynamic"; @@ -242,8 +246,6 @@ async function buildAnalyticsResponse(user: AuthedUser) { let bpInTargetPct: number | null = null; let bpInTargetPct7d: number | null = null; let bpInTargetPct30d: number | null = null; - // v1.17 W1d — the canonical 90-day window for the headline / score / coach. - let bpInTargetPct90d: number | null = null; let bpInTargetPctAllTime: number | null = null; /** * v1.4.22 W5 reconcile (Code-H2) — period-aligned prior-window @@ -255,25 +257,13 @@ 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; - // v1.17 W1d — prior-week 90-day BP pct. Feeds the Health-Score delta so - // its BP pillar compares the same 90-day window on both ends, identical - // to the headline scope. (Pre-v1.17 the score read all-time; W1d - // unifies headline + score + coach on 90 days.) - let windowsPriorWeekForScore: 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 — 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(); @@ -318,24 +308,17 @@ async function buildAnalyticsResponse(user: AuthedUser) { userTz, }), ]); + 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; - bpInTargetPct90d = windows.last90Days?.pct ?? null; bpInTargetPctAllTime = windows.allTime?.pct ?? null; bpInTargetPctPriorMonth = windows.priorMonth?.pct ?? null; bpInTargetPctPriorYear = windows.priorYear?.pct ?? null; - bpInTargetPctPriorWeek = windowsPriorWeek.last90Days?.pct ?? null; - bpGradedScore = windows.gradedScore; - bpGradedScorePriorWeek = windowsPriorWeek.gradedScore; - // v1.17 W1d — the Health-Score BP pillar reads the same 90-day window - // as the headline (see `bpInTargetPctForScore` below). Capture the - // prior-week run's 90-day pct so the week-over-week delta compares the - // same window on both ends. - windowsPriorWeekForScore = windowsPriorWeek.last90Days?.pct ?? null; } // Per-context glucose summaries (canonical mg/dL). @@ -393,41 +376,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. - // v1.17 W1d — the Health-Score BP pillar reads the SAME 90-day window as - // the BD-Zielbereich tile headline, so the dashboard ring and the - // insights card grade the BP pillar off one number. Fall back to the - // all-time rate only when the 90-day window is itself null so a - // sparse-but-historical account still surfaces a BP pillar. - const bpInTargetPctForScore = bpInTargetPct90d ?? bpInTargetPctAllTime; - const bpInTargetPctPriorWeekForScore = - windowsPriorWeekForScore ?? 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, 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..3975800bb --- /dev/null +++ b/src/lib/analytics/__tests__/health-score-inputs.test.ts @@ -0,0 +1,89 @@ +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, + 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("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/health-score-inputs.ts b/src/lib/analytics/health-score-inputs.ts new file mode 100644 index 000000000..b81821791 --- /dev/null +++ b/src/lib/analytics/health-score-inputs.ts @@ -0,0 +1,78 @@ +/** + * 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"; + +/** + * 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. + * - 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, + }; + } + const bpInTargetPct = current.last90Days?.pct ?? current.allTime?.pct ?? null; + const bpInTargetPctPriorWeek = + priorWeek?.last90Days?.pct ?? priorWeek?.allTime?.pct ?? null; + return { + bpInTargetPct, + bpInTargetPctPriorWeek, + bpGradedScore: current.gradedScore, + bpGradedScorePriorWeek: priorWeek?.gradedScore ?? null, + }; +} diff --git a/src/lib/dashboard/__tests__/snapshot.test.ts b/src/lib/dashboard/__tests__/snapshot.test.ts index 564a6ee0f..4cb75dae7 100644 --- a/src/lib/dashboard/__tests__/snapshot.test.ts +++ b/src/lib/dashboard/__tests__/snapshot.test.ts @@ -258,7 +258,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); }); diff --git a/src/lib/dashboard/snapshot.ts b/src/lib/dashboard/snapshot.ts index 3650de8c0..4152b0af0 100644 --- a/src/lib/dashboard/snapshot.ts +++ b/src/lib/dashboard/snapshot.ts @@ -46,7 +46,11 @@ 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 { probeRollupCoverage, type RollupCoverageMap, @@ -440,22 +444,40 @@ async function buildExtras( let bpInTargetPct: number | null = null; let bpInTargetPct7d: number | null = null; let bpInTargetPct30d: number | null = null; - // v1.17 W1d — canonical 90-day headline / score window. - let bpInTargetPct90d: number | null = null; let bpInTargetPctAllTime: number | null = null; let bpInTargetPctPriorMonth: number | null = null; let bpInTargetPctPriorYear: number | null = null; - let bpGradedScore: 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, - }); + // 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 @@ -464,28 +486,25 @@ async function buildExtras( bpInTargetPct = windows.last90Days?.pct ?? null; bpInTargetPct7d = windows.last7Days?.pct ?? null; bpInTargetPct30d = windows.last30Days?.pct ?? null; - bpInTargetPct90d = windows.last90Days?.pct ?? null; bpInTargetPctAllTime = windows.allTime?.pct ?? null; bpInTargetPctPriorMonth = windows.priorMonth?.pct ?? null; bpInTargetPctPriorYear = windows.priorYear?.pct ?? null; - bpGradedScore = windows.gradedScore ?? null; } // 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/W1d — the BP pillar reads the SAME 90-day window as the - // analytics route, so the dashboard ring and the insights card compute - // the score from identical inputs (closing the dashboard-vs-insights - // score divergence). Fall back to all-time only when the 90-day window - // is null so a sparse-but-historical account keeps its BP pillar. - const bpInTargetPctForScore = bpInTargetPct90d ?? bpInTargetPctAllTime; + // 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: bpInTargetPctForScore, - bpGradedScore, + ...bpInputs, heightCm: user.heightCm, now, coverage, From efd77830f5826c3a8996b41bb5573a0ecec603dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 13:11:59 +0200 Subject: [PATCH 03/12] fix(coach): quote medication adherence from the compliance authority MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The coach snapshot built its own medication-adherence figures: a per-day and per-week timeline tallied off a "countable ledger rows in window" denominator that differs from the cadence-aware `calculateCompliance(...).rate` every other surface — the medication card, the detail page, the dashboard pillar, the insights — reads. On an irregular, rolling, or partial-window schedule the two denominators diverge, so the coach could reason from a rate the card never shows the user. Route the coach's headline adherence % through `calculateCompliance(...).rate` with the medication context (the ledger path), aggregating taken / (taken + missed) across the user's scheduled medications over the coach window — exactly the arithmetic the card's rate uses. The snapshot now carries a single `compliance.rate` headline alongside the supplementary timeline, so the coach quotes the same number the card renders. --- src/lib/ai/coach/__tests__/snapshot.test.ts | 85 +++++++++++++++++++++ src/lib/ai/coach/snapshot.ts | 36 +++++++++ 2 files changed, 121 insertions(+) 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..c594c688b 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,20 @@ 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` authority the medication card + // shows (the ledger path, `medicationContext` supplied), so the coach + // can never quote a denominator the card doesn't use. We tally + // numerator (on-time + late takes) and denominator (taken + missed) + // across every compliance medication over the coach window, exactly the + // ledger arithmetic the card's per-med rate uses; the per-day / per-week + // timeline below stays as supplementary context. + 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 +1024,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 +1091,15 @@ 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, so the + // coach quotes the exact figure on the card rather than a per-day / + // per-week rate built off a different denominator. Integer 0-100 to + // match the card's rounding. + rate: + complianceDenominator > 0 + ? Math.round((complianceTaken / complianceDenominator) * 100) + : null, timeline: { recent: recentRows, weekly: weeklyRows }, }; metrics.add("compliance"); From 8e50a9b4cd0d4bff2fe2e328e46241efdd2218d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 13:17:48 +0200 Subject: [PATCH 04/12] fix(doctor-report): compute medication adherence from the dose ledger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The doctor-report PDF and FHIR export tallied raw intake rows — taken when takenAt was set, else skipped, else missed, with every row counting toward the denominator. That double-counted cross-source duplicate rows (a reminder row and an API row for the same slot both read as taken), ignored band timing (a pre-window or ad-hoc take counted as a plain take, a pending row counted as a miss regardless of the cutoff), and honoured no cadence, so off-week rolling or cyclic days could enter the tally. A clinician comparing the printed adherence % against the app's medication detail page could see two different numbers for the same drug. Route the report through the same dose-ledger compliance the detail page uses: per scheduled medication, mint the cadence-aware bands over the report window and tally taken / missed / skipped from the unified dose-history ledger, with the denominator being taken + missed so the renderers' taken/total rate equals the ledger rate. PRN / as-needed medications stay excluded. The compliance assembly is extracted into a pure, unit-tested `buildLedgerCompliance` helper; the medication query now loads the archived schedule eras and the report reads the user timezone so past days attribute against the schedule that was live then. --- src/lib/__tests__/doctor-report-data.test.ts | Bin 5805 -> 10248 bytes src/lib/analytics/compliance.ts | 2 +- src/lib/doctor-report-data.ts | 173 ++++++++++++++++--- 3 files changed, 154 insertions(+), 21 deletions(-) diff --git a/src/lib/__tests__/doctor-report-data.test.ts b/src/lib/__tests__/doctor-report-data.test.ts index 7047219ad2a93fc92017400ee627a90a0120d1d4..a1fae17148cb5765f054afeb688a737d98baa530 100644 GIT binary patch literal 10248 zcmdT~+in}l5zSkG{fb@!L58+Skt`$wa9em=TTYI_N~=is!M; zm`>TV!C=5{Cn8~ak_pXHh43W3B%cZ;!`fEnq8_YqW{@X1MJ6KZcKbU0P6TvN_a!uV zkeE*A3-|NO@JmGup?Uw=qLh1><2=g4P|V;^&Tatgh)IMRuxGbYPh`R_h8xDwgAb2) zXlGbxFnoj`$%scFDt!1-PyNsWjKU^s1*QwaG=f$iBc6 zbsZkzj9%%>N5Yb}wFn_k7;lsbS}S%nqbU`Y(78&Gi4*4+n~PHab`eYtNL$;_mcXcX zR50|J(|}8T)y}{#!>?L;*4N=wzmR^?%B5l7888aEOnfrTa$U2$C;{~|7HY<7mDbd} zS2$v|xUKDNZ{g~N&-;ZdRxj-Alt^8@GRfn(ad^eMsJGN@2@zqQX*66)GfJ`td3A$r z6)wXsAHwB$b-lQDNSG#>W+$g_*&a*8Ej!>^1f9`fwA~v#>kW3s!_n?wfdBf)kar!N z;!0$m2@7*jhggPEGg}Qz@3~C%grS#W30Ec)P(*nWv24Z@_Pagyd~1OB^Jdks+E_u* zr{on*^}zHSW;!2r9>y}wl_sTknbUgI8w|%rqS|7ZxNPyg-v#H`pY?n) zTK3KP=!i$>r{`M9;;Rx4u;=WnIMb85L|QLox~D zJd-zq9Nr7@UiXbZEawZX9TW`L*qUc;fb8d_zn?IJoS_*9|aRKqRlgG-szsBP?DyQf{& zW9{lK$46aK9YH>qU9MI9Z_>-5n8D+h0;O4$Y4A|juaQtK*z>7@!rw@!71_65LM;Zo z9f-ye7I@+=6e7z0XGLWTMPhlKSoFb)#lL_LJU6A>p7J|6&8K9!DFL@KiPGC&8Ux?B zR`7tLy0fGLKh*volt67);|WiK=L;7OqM!W|Ft0_H(aKYG_}84r0lc!Jk{JLM?SpbD zPi9t96>h z?m29psq{voVLa4e3=mNMcR@j*^%}+7g`!}Jk15_2GX(|MI{PU+OJ+oQNOXe@E1AFo z#DM)IRN8&0f$J50oLUsB7barN=9WPwU>*b((Q1MtEp4(FXB38p+qS=IUzM$| zx#gRYr+I5`_;kOpp%OoTZ6smF3%3)Rz|D9ldL~_Q{Swoe2FWw7Y4YY()|DfdHC63^ zDi@=Lb@#m2?~}G|6(gbf6bXuK4bwR0GrB;OcGi$ll!?|jL%{>phnDA4%wsC2cIdHr zNEa1i{3{)U3FDp2-fQf7pB(?^-*GHdVYtixj$LF9^h+wYfr@z)nOVm6&tDxK5#`k? zjafM1G{zL_K@o#OzjkIgPX0j4epNW)Z-qfk5f#-#1cPzShNHo9jcxIWQzYgd>-?$H z$H)}TIT#gf*{;BB2n$*RN1N+`!QyToHQLM+Pk{bl;r-YBSR~ha;yeKzn@xR&(kIl7 zss8rPh<;#)$1K707>ylq09R;sxs~Yn1J5(Ae$o&Hl+*y=Q`uu=#nJ{=jMK{!**a2Z zKmYw-tTvWrJemk4Fm7izlABvylxBj(W*A?~92`%s#wg?t?dl`X3WwZ$6J%8b_1lQc!@OJP5SNz7i!-Vz?cB*fPs+?ZE72@MAqk`t8 zX*m^malq%h?7~U=;9n|%PZN<)KLASNSSC3Na(%l}P`~jAl?|;6-x&Z%9z$|tk1+__ z#`v01-x|(C%u!AgQnRtiy}B|cG~N*q-Wu9pdvQN^}|IHC2aWpI>@fqb_7K4GAd5{@BKrqML2w!26u9amQ0;UppC2oY88yPyi15w(?O5R1A-%GiWr_B#)V(k(7e8tAe^ ztVn2JlFj(F*tNoFk2?tE1)osI%zR^` zmT9g;*=bToCrI|fGbnyONp)j?Xs&G0KAtxRinvHNr)^!IZay)9;)_OoC{og|ITJTB zLm{EH?xn#yz`ltI=9~?8+4$|Lg_-qq4U?vBSY!qj2m`-eI~udqi=aDfuX&@fkx@(- zUDtBQk9JK^X&MHUm<$MbuG5}7 zFN@O(+?Zo&$~H!&Mx%Jjwg-R4Ai6*eB-T{&XezE$U!d8@3&SU6s#gZlmF}_ZY{n?o z$0qo@Osl*|EvMB7(+z!wTfWrF1pK)B0x>I+pc;6u;rN~ZW%{)L3)-*$i5_f!0;$H~ zfNG_dLgPuQBrX^!N^n`dZNIH1MX2upC!=op#rwFx)GPZ{Jb=c43nkD78^3euVUb&& zwFk#8`Eu?}Z?hJ%`yNQ4(&%}t8^}JF|luSTi9!#zBCH9?eewDeElzvbdT$(I(>amCl|;Aa)@`}zW#cT zh!~r}a&*ECH1ooNY|lHXpaZt=7bL`%RM&qS)M;{6s2wzvDw z*>AdSRDyzHs`bzR_-COo0Gt_uLCu9$G?@gWYYptE@+ZqG+8KZiYK(uJ0sb&{ZW&m| z`=<_V?kP2|z$TAi>6N1pw)T7W%#1X%@97>JT5;e5S1b@V;`_Q=o>;EAuSVQ!`!Ok3OJG#s<+q@Fxi;}igTg(47YqQW^rxC(KMI*d0p*EzZ_|-~4s&1v_ zzIRv>Qjm?}9z0xNcfPaGZUg!@cKfWidSg3N!7a^8FD!dlJ13; } +/** + * 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 From 461ed5b5e0cf8ce22b60cd0837beac8bc25d14e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 13:27:21 +0200 Subject: [PATCH 05/12] fix(doctor-report): label the adherence denominator column as Expected MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dose-ledger adherence rate divides taken by taken + missed (deliberate skips excluded), so the PDF compliance table's denominator column no longer equals Taken + Skipped + Missed. Relabel it "Expected" so each row stays internally coherent — Taken + Missed = Expected, with Skipped shown alongside as informational and the rate reading Taken / Expected. Also tighten the coach adherence-block wording: the headline is the dose-weighted pool of taken / (taken + missed) across the user's scheduled medications — exactly one medication's card rate for a single-med user, and the honest overall adherence for several — never a per-day or per-week denominator of its own. --- messages/de.json | 1 + messages/en.json | 1 + messages/es.json | 1 + messages/fr.json | 1 + messages/it.json | 1 + messages/pl.json | 1 + src/lib/ai/coach/snapshot.ts | 27 ++++++++++++++++----------- src/lib/doctor-report-pdf-core.ts | 7 ++++++- 8 files changed, 28 insertions(+), 12 deletions(-) diff --git a/messages/de.json b/messages/de.json index 019dcdcde..fb5a53612 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}", diff --git a/messages/en.json b/messages/en.json index cf984502e..c3c3976a4 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}", diff --git a/messages/es.json b/messages/es.json index 7d69659f0..460dae452 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}", diff --git a/messages/fr.json b/messages/fr.json index 7b2c9bd70..d280518b2 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}", diff --git a/messages/it.json b/messages/it.json index 91f9469e4..b9a74e8cc 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}", diff --git a/messages/pl.json b/messages/pl.json index f1f976d49..8a422b2d3 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}", diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts index c594c688b..970727e6c 100644 --- a/src/lib/ai/coach/snapshot.ts +++ b/src/lib/ai/coach/snapshot.ts @@ -994,13 +994,15 @@ async function buildCoachSnapshotImpl( // 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` authority the medication card - // shows (the ledger path, `medicationContext` supplied), so the coach - // can never quote a denominator the card doesn't use. We tally - // numerator (on-time + late takes) and denominator (taken + missed) - // across every compliance medication over the coach window, exactly the - // ledger arithmetic the card's per-med rate uses; the per-day / per-week - // timeline below stays as supplementary context. + // 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( @@ -1092,10 +1094,13 @@ 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, so the - // coach quotes the exact figure on the card rather than a per-day / - // per-week rate built off a different denominator. Integer 0-100 to - // match the card's rounding. + // (`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) 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"), ], ], From e9138bb4f6038bf715451760831046f531b518bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 13:43:41 +0200 Subject: [PATCH 06/12] feat(blood-pressure): label the in-target window dynamically and gate thin data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The BD-Zielbereich tile narrated a static "· 90 T" scope even for a user with only a few weeks of history or a handful of readings — a headline that reads like a stable quarter-long average when it is really a thin sample. Add a shared, reusable window-confidence helper that, given a window length and the readings observed inside it, returns the EFFECTIVE label span (the real calendar span capped at the window, so "· 23 T" until ~90 days of history exist) and a sufficiency flag (false below five readings). The helper is metric-agnostic so the glucose time-in-range / GMI and sleep tiles can reuse it. Wire it into the in-target headline: below the confidence floor the tile shows "sammelt noch Daten" instead of a hard percentage, with the dynamic span label; above it the percentage shows with the real span. The Health-Score BP pillar reads the same sufficiency notion through the shared input builder, so a thin-data user no longer gets a confident pillar grading off a few readings — the all-time rate still rescues a deep-history account with a quiet quarter. The 90-day pair count and effective span thread from the analytics route and the dashboard snapshot through to the tile. --- messages/de.json | 2 + messages/en.json | 2 + messages/es.json | 2 + messages/fr.json | 2 + messages/it.json | 2 + messages/pl.json | 2 + src/app/api/analytics/route.ts | 17 ++++ src/app/page.tsx | 36 ++++++-- .../charts/__tests__/bd-tile.test.tsx | 22 +++++ src/components/charts/trend-card.tsx | 27 ++++++ .../__tests__/health-score-inputs.test.ts | 24 +++++ .../__tests__/window-confidence.test.ts | 73 +++++++++++++++ src/lib/analytics/bp-in-target-fast-path.ts | 20 ++++ src/lib/analytics/bp-in-target.ts | 21 +++++ src/lib/analytics/health-score-inputs.ts | 36 +++++++- src/lib/analytics/merge-slim-thick.ts | 6 ++ src/lib/analytics/window-confidence.ts | 92 +++++++++++++++++++ src/lib/dashboard/__tests__/snapshot.test.ts | 15 +-- src/lib/dashboard/snapshot.ts | 23 +++++ src/types/analytics.ts | 9 ++ 20 files changed, 416 insertions(+), 17 deletions(-) create mode 100644 src/lib/analytics/__tests__/window-confidence.test.ts create mode 100644 src/lib/analytics/window-confidence.ts diff --git a/messages/de.json b/messages/de.json index fb5a53612..72d0e546e 100644 --- a/messages/de.json +++ b/messages/de.json @@ -328,6 +328,8 @@ "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 c3c3976a4..9b689ad2b 100644 --- a/messages/en.json +++ b/messages/en.json @@ -328,6 +328,8 @@ "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 460dae452..f020d3cd4 100644 --- a/messages/es.json +++ b/messages/es.json @@ -328,6 +328,8 @@ "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 d280518b2..9f90ace3a 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -328,6 +328,8 @@ "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 b9a74e8cc..ebadb9ffe 100644 --- a/messages/it.json +++ b/messages/it.json @@ -328,6 +328,8 @@ "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 8a422b2d3..92f351f3b 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -328,6 +328,8 @@ "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/src/app/api/analytics/route.ts b/src/app/api/analytics/route.ts index 0e0438e3f..f2f4f730d 100644 --- a/src/app/api/analytics/route.ts +++ b/src/app/api/analytics/route.ts @@ -18,6 +18,7 @@ import { } 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 { computeWindowConfidence } from "@/lib/analytics/window-confidence"; import { computeCorrelationHypothesesFastPath } from "@/lib/analytics/correlations-fast-path"; export const dynamic = "force-dynamic"; @@ -257,6 +258,11 @@ async function buildAnalyticsResponse(user: AuthedUser) { */ let bpInTargetPctPriorMonth: number | null = null; let bpInTargetPctPriorYear: 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). @@ -319,6 +325,15 @@ async function buildAnalyticsResponse(user: AuthedUser) { bpInTargetPctAllTime = windows.allTime?.pct ?? null; bpInTargetPctPriorMonth = windows.priorMonth?.pct ?? null; bpInTargetPctPriorYear = windows.priorYear?.pct ?? null; + // v1.17 W1b — count + effective span for the BD-Zielbereich tile's + // confidence gate (thin-data → "collecting data"; dynamic span label). + bpInTargetCount90 = windows.last90Days?.pairs ?? 0; + bpInTargetSpanDays90 = computeWindowConfidence({ + windowDays: 90, + readingCount: bpInTargetCount90, + earliestReadingAt: windows.last90EarliestAt, + now, + }).effectiveSpanDays; } // Per-context glucose summaries (canonical mg/dL). @@ -401,6 +416,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 8eded4b07..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,6 +1097,21 @@ 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"), @@ -1102,17 +1122,21 @@ export default function DashboardPage() { // rate; the label names the window so the tile never // narrates an unlabelled scope. The `30T:` caption below // still surfaces the 30-day figure for short-horizon context. - label={t("dashboard.bpInTargetWindow90")} - latest={data?.bpInTargetPct ?? null} + // v1.17 W1b — span is dynamic until ~90 days of history exist. + label={bpLabel} + latest={bpSufficient ? (data?.bpInTargetPct ?? null) : null} + emptyHint={ + bpSufficient ? null : t("dashboard.bpInTargetCollecting") + } unit="%" - avg7={bp7} - avg30={bp30} + avg7={bpSufficient ? bp7 : null} + avg30={bpSufficient ? bp30 : null} slope30={null} - trend7Delta={bpTrendDelta} + trend7Delta={bpSufficient ? bpTrendDelta : null} icon={Target} directionSentiment="up-good" compareBaseline={compareBaseline} - compareDelta={bpCompareDelta} + compareDelta={bpSufficient ? bpCompareDelta : null} /> ), }); 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} + + )} ): BpInTargetEnvelope { last7Days: null, last30Days: null, last90Days: null, + last90EarliestAt: null, allTime: null, priorMonth: null, priorYear: null, @@ -61,6 +62,29 @@ describe("buildHealthScoreBpInputs — single Health-Score BP input builder", () 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("collapses every field to null when the current envelope is absent", () => { const out = buildHealthScoreBpInputs(null, null); expect(out).toEqual({ 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 3d43eab47..7858935c7 100644 --- a/src/lib/analytics/bp-in-target-fast-path.ts +++ b/src/lib/analytics/bp-in-target-fast-path.ts @@ -82,6 +82,13 @@ export interface BpInTargetEnvelope { * 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; @@ -289,6 +296,17 @@ async function computeFromRollups( 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. @@ -349,6 +367,7 @@ async function computeFromRollups( last7Days: last7, last30Days: last30, last90Days: last90, + last90EarliestAt, allTime, priorMonth, priorYear, @@ -464,6 +483,7 @@ async function computeFromLive( 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 8ddaed1e0..6dc86236b 100644 --- a/src/lib/analytics/bp-in-target.ts +++ b/src/lib/analytics/bp-in-target.ts @@ -245,6 +245,14 @@ export function computeBpInTargetWindows( * 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 @@ -311,11 +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/health-score-inputs.ts b/src/lib/analytics/health-score-inputs.ts index b81821791..2c26382c2 100644 --- a/src/lib/analytics/health-score-inputs.ts +++ b/src/lib/analytics/health-score-inputs.ts @@ -25,6 +25,7 @@ * 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 @@ -46,6 +47,12 @@ export interface HealthScoreBpInputs { * 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 @@ -66,13 +73,32 @@ export function buildHealthScoreBpInputs( bpGradedScorePriorWeek: null, }; } - const bpInTargetPct = current.last90Days?.pct ?? current.allTime?.pct ?? null; - const bpInTargetPctPriorWeek = - priorWeek?.last90Days?.pct ?? priorWeek?.allTime?.pct ?? 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. + const pillarPresent = bpInTargetPct !== null; return { bpInTargetPct, bpInTargetPctPriorWeek, - bpGradedScore: current.gradedScore, - bpGradedScorePriorWeek: priorWeek?.gradedScore ?? null, + bpGradedScore: pillarPresent ? current.gradedScore : null, + bpGradedScorePriorWeek: pillarPresent + ? (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..f5a4a4219 --- /dev/null +++ b/src/lib/analytics/window-confidence.ts @@ -0,0 +1,92 @@ +/** + * 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; +} + +/** + * 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 4cb75dae7..423d95f02 100644 --- a/src/lib/dashboard/__tests__/snapshot.test.ts +++ b/src/lib/dashboard/__tests__/snapshot.test.ts @@ -368,13 +368,16 @@ describe("buildDashboardSnapshot — healthScore (warm phase only)", () => { probeRollupCoverage.mockResolvedValue(coverageMap); isFullyCovered.mockReturnValue(true); computeBpInTargetFastPath.mockResolvedValue({ - last7Days: { pct: 70 }, - last30Days: { pct: 80 }, + last7Days: { pct: 70, pairs: 6 }, + last30Days: { pct: 80, pairs: 12 }, // v1.17 W1d — the BP pillar reads this 90-day window, NOT last30Days. - last90Days: { pct: 77 }, - allTime: { pct: 75 }, - priorMonth: { pct: 60 }, - priorYear: { pct: 50 }, + // 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({ diff --git a/src/lib/dashboard/snapshot.ts b/src/lib/dashboard/snapshot.ts index 4152b0af0..343a6968c 100644 --- a/src/lib/dashboard/snapshot.ts +++ b/src/lib/dashboard/snapshot.ts @@ -51,6 +51,7 @@ import { type BpInTargetEnvelope, } from "@/lib/analytics/bp-in-target-fast-path"; import { buildHealthScoreBpInputs } from "@/lib/analytics/health-score-inputs"; +import { computeWindowConfidence } from "@/lib/analytics/window-confidence"; import { probeRollupCoverage, type RollupCoverageMap, @@ -194,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; } @@ -447,6 +458,8 @@ async function buildExtras( let bpInTargetPctAllTime: number | null = null; let bpInTargetPctPriorMonth: number | null = null; let bpInTargetPctPriorYear: 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. @@ -489,6 +502,14 @@ async function buildExtras( bpInTargetPctAllTime = windows.allTime?.pct ?? null; bpInTargetPctPriorMonth = windows.priorMonth?.pct ?? null; bpInTargetPctPriorYear = windows.priorYear?.pct ?? null; + // v1.17 W1b — count + effective span for the tile's confidence gate. + bpInTargetCount90 = windows.last90Days?.pairs ?? 0; + bpInTargetSpanDays90 = computeWindowConfidence({ + windowDays: 90, + readingCount: bpInTargetCount90, + earliestReadingAt: windows.last90EarliestAt, + now, + }).effectiveSpanDays; } // Health score — reuses the BP windows captured above plus the @@ -550,6 +571,8 @@ async function buildExtras( bpInTargetPctAllTime, bpInTargetPctPriorMonth, bpInTargetPctPriorYear, + bpInTargetCount90, + bpInTargetSpanDays90, glucoseByContext, }, healthScore, 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 From 3e7aa49eb04d2fb21fa2e3eadf3917ed20db0919 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 13:51:51 +0200 Subject: [PATCH 07/12] feat(validation): bound entry timestamps with a shared plausibility guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only medication intake bounded its capture instant — a hand-rolled POST or a faulty import could backdate a measurement to epoch zero or forward-date a mood entry into next year, and both sailed through validation. Extract the intake bound into a shared `validateEntryInstant` helper: reject any instant more than a five-minute clock-skew tolerance in the future, and reject anything before a 1900 far-past floor. Callers can clamp the past window tighter — medication intake keeps its five-year floor — and override the past-bound message to preserve an established contract. Apply it to the measurement create/edit schema (`measuredAt`), the mood schema (`moodLoggedAt`), and the cycle period + day-log (`loggedAt`); medication intake now delegates to the same helper. The capture forms gain a matching `max` on the datetime picker so a future instant can't be selected in the UI either. The wire shape is unchanged, so the OpenAPI contract holds. --- .../measurements/measurement-form.tsx | 4 + .../measurements/measurement-list.tsx | 2 + src/components/mood/mood-form.tsx | 2 + src/components/mood/mood-list.tsx | 2 + .../__tests__/entry-instant.test.ts | 90 +++++++++++++++ .../validations/__tests__/measurement.test.ts | 33 ++++++ src/lib/validations/__tests__/moodlog.test.ts | 25 +++++ src/lib/validations/cycle.ts | 21 +++- src/lib/validations/entry-instant.ts | 106 ++++++++++++++++++ src/lib/validations/measurement.ts | 18 ++- src/lib/validations/medication.ts | 27 +++-- src/lib/validations/moodlog.ts | 16 ++- 12 files changed, 323 insertions(+), 23 deletions(-) create mode 100644 src/lib/validations/__tests__/entry-instant.test.ts create mode 100644 src/lib/validations/entry-instant.ts diff --git a/src/components/measurements/measurement-form.tsx b/src/components/measurements/measurement-form.tsx index eda1e6eb2..6afe01da2 100644 --- a/src/components/measurements/measurement-form.tsx +++ b/src/components/measurements/measurement-form.tsx @@ -472,6 +472,10 @@ export function MeasurementForm({ id="measuredAt" value={measuredAt} onChange={(e) => 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/validations/__tests__/entry-instant.test.ts b/src/lib/validations/__tests__/entry-instant.test.ts new file mode 100644 index 000000000..9d0132d94 --- /dev/null +++ b/src/lib/validations/__tests__/entry-instant.test.ts @@ -0,0 +1,90 @@ +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); + }); +}); 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..aa641aee9 --- /dev/null +++ b/src/lib/validations/entry-instant.ts @@ -0,0 +1,106 @@ +/** + * 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( + (d) => + isPlausibleEntryInstant(d, { ...bounds, now: Date.now() }), + { message: pastMessage }, + ) as T; +} 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..cce5059d0 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 @@ -206,16 +207,22 @@ function hhmmToMinutes(hhmm: string): number { 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({ From 3bb130d4a13ccb2e2a92d25d0b81c29050669965 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 13:59:56 +0200 Subject: [PATCH 08/12] fix(validation): stop a future timestamp emitting a spurious past-bound error Review follow-up to the shared entry-instant guard. The past-floor refine re-ran the full plausibility predicate, so a future-dated instant tripped both refines and the 422 carried a nonsensical "too far in the past" message alongside the correct future one. The floor refine now checks the past bound alone; a future value emits only the future message, matching the pre-refactor single-message contract. Also harden the Health-Score BP inputs and de-duplicate the tile-window derivation surfaced in the same review: - the prior-week graded score now keys off the prior-week window's own sufficiency rather than the current window's, so the week-over-week graded delta never pairs a confident current value against a thin-sample prior one; - the BD-Zielbereich 90-day count + effective span derive through one shared `deriveBpWindow90` helper, so the dashboard snapshot and the analytics thick slice can never compute the gate or the label span differently. --- src/app/api/analytics/route.ts | 15 ++++++------ .../__tests__/health-score-inputs.test.ts | 19 +++++++++++++++ src/lib/analytics/health-score-inputs.ts | 6 ++++- src/lib/analytics/window-confidence.ts | 21 ++++++++++++++++ src/lib/dashboard/__tests__/snapshot.test.ts | 12 ++++++---- src/lib/dashboard/snapshot.ts | 18 +++++++------- .../__tests__/entry-instant.test.ts | 11 +++++++++ src/lib/validations/entry-instant.ts | 24 +++++++++++++------ 8 files changed, 99 insertions(+), 27 deletions(-) diff --git a/src/app/api/analytics/route.ts b/src/app/api/analytics/route.ts index f2f4f730d..bd82bec73 100644 --- a/src/app/api/analytics/route.ts +++ b/src/app/api/analytics/route.ts @@ -18,7 +18,7 @@ import { } 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 { computeWindowConfidence } from "@/lib/analytics/window-confidence"; +import { deriveBpWindow90 } from "@/lib/analytics/window-confidence"; import { computeCorrelationHypothesesFastPath } from "@/lib/analytics/correlations-fast-path"; export const dynamic = "force-dynamic"; @@ -327,13 +327,14 @@ async function buildAnalyticsResponse(user: AuthedUser) { bpInTargetPctPriorYear = windows.priorYear?.pct ?? null; // v1.17 W1b — count + effective span for the BD-Zielbereich tile's // confidence gate (thin-data → "collecting data"; dynamic span label). - bpInTargetCount90 = windows.last90Days?.pairs ?? 0; - bpInTargetSpanDays90 = computeWindowConfidence({ - windowDays: 90, - readingCount: bpInTargetCount90, - earliestReadingAt: windows.last90EarliestAt, + // Shared helper keeps this identical to the dashboard snapshot. + const bpWindow = deriveBpWindow90( + windows.last90Days, + windows.last90EarliestAt, now, - }).effectiveSpanDays; + ); + bpInTargetCount90 = bpWindow.count; + bpInTargetSpanDays90 = bpWindow.spanDays; } // Per-context glucose summaries (canonical mg/dL). diff --git a/src/lib/analytics/__tests__/health-score-inputs.test.ts b/src/lib/analytics/__tests__/health-score-inputs.test.ts index b4ae183d1..214b6d4c5 100644 --- a/src/lib/analytics/__tests__/health-score-inputs.test.ts +++ b/src/lib/analytics/__tests__/health-score-inputs.test.ts @@ -85,6 +85,25 @@ describe("buildHealthScoreBpInputs — single Health-Score BP input builder", () 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({ diff --git a/src/lib/analytics/health-score-inputs.ts b/src/lib/analytics/health-score-inputs.ts index 2c26382c2..d4919f94e 100644 --- a/src/lib/analytics/health-score-inputs.ts +++ b/src/lib/analytics/health-score-inputs.ts @@ -92,12 +92,16 @@ export function buildHealthScoreBpInputs( // 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: pillarPresent + bpGradedScorePriorWeek: priorPillarPresent ? (priorWeek?.gradedScore ?? null) : null, }; diff --git a/src/lib/analytics/window-confidence.ts b/src/lib/analytics/window-confidence.ts index f5a4a4219..d49947158 100644 --- a/src/lib/analytics/window-confidence.ts +++ b/src/lib/analytics/window-confidence.ts @@ -55,6 +55,27 @@ export interface WindowConfidence { 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. diff --git a/src/lib/dashboard/__tests__/snapshot.test.ts b/src/lib/dashboard/__tests__/snapshot.test.ts index 423d95f02..d621e45a8 100644 --- a/src/lib/dashboard/__tests__/snapshot.test.ts +++ b/src/lib/dashboard/__tests__/snapshot.test.ts @@ -142,7 +142,8 @@ describe("buildDashboardSnapshot — envelope shape", () => { computeBpInTargetFastPath.mockResolvedValue({ last7Days: { pct: 70 }, last30Days: { pct: 80 }, - last90Days: { 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 }, @@ -183,7 +184,8 @@ describe("buildDashboardSnapshot — envelope shape", () => { computeBpInTargetFastPath.mockResolvedValue({ last7Days: { pct: 70 }, last30Days: { pct: 80 }, - last90Days: { 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 }, @@ -240,7 +242,8 @@ describe("buildDashboardSnapshot — per-type thick-phase gate", () => { computeBpInTargetFastPath.mockResolvedValue({ last7Days: { pct: 70 }, last30Days: { pct: 80 }, - last90Days: { 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 }, @@ -720,7 +723,8 @@ describe("buildDashboardSnapshot — additive proof", () => { computeBpInTargetFastPath.mockResolvedValue({ last7Days: { pct: 70 }, last30Days: { pct: 80 }, - last90Days: { 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 343a6968c..b0ecb259e 100644 --- a/src/lib/dashboard/snapshot.ts +++ b/src/lib/dashboard/snapshot.ts @@ -51,7 +51,7 @@ import { type BpInTargetEnvelope, } from "@/lib/analytics/bp-in-target-fast-path"; import { buildHealthScoreBpInputs } from "@/lib/analytics/health-score-inputs"; -import { computeWindowConfidence } from "@/lib/analytics/window-confidence"; +import { deriveBpWindow90 } from "@/lib/analytics/window-confidence"; import { probeRollupCoverage, type RollupCoverageMap, @@ -502,14 +502,16 @@ async function buildExtras( bpInTargetPctAllTime = windows.allTime?.pct ?? null; bpInTargetPctPriorMonth = windows.priorMonth?.pct ?? null; bpInTargetPctPriorYear = windows.priorYear?.pct ?? null; - // v1.17 W1b — count + effective span for the tile's confidence gate. - bpInTargetCount90 = windows.last90Days?.pairs ?? 0; - bpInTargetSpanDays90 = computeWindowConfidence({ - windowDays: 90, - readingCount: bpInTargetCount90, - earliestReadingAt: windows.last90EarliestAt, + // 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, - }).effectiveSpanDays; + ); + bpInTargetCount90 = bpWindow.count; + bpInTargetSpanDays90 = bpWindow.spanDays; } // Health score — reuses the BP windows captured above plus the diff --git a/src/lib/validations/__tests__/entry-instant.test.ts b/src/lib/validations/__tests__/entry-instant.test.ts index 9d0132d94..fdc7fd03d 100644 --- a/src/lib/validations/__tests__/entry-instant.test.ts +++ b/src/lib/validations/__tests__/entry-instant.test.ts @@ -87,4 +87,15 @@ describe("validateEntryInstant — Zod integration", () => { 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/entry-instant.ts b/src/lib/validations/entry-instant.ts index aa641aee9..8af95c19e 100644 --- a/src/lib/validations/entry-instant.ts +++ b/src/lib/validations/entry-instant.ts @@ -93,14 +93,24 @@ export function validateEntryInstant>( ? "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( - (d) => - d.getTime() <= Date.now() + ENTRY_INSTANT_CLOCK_SKEW_MS, - { message: "Timestamp must not be in the future" }, - ) - .refine( - (d) => - isPlausibleEntryInstant(d, { ...bounds, now: Date.now() }), + // 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, + ); +} From ae1373637f89078fd538548708726c48eef13b04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 14:02:45 +0200 Subject: [PATCH 09/12] =?UTF-8?q?chore(release):=20v1.16.14=20=E2=80=94=20?= =?UTF-8?q?one=20number=20everywhere=20across=20score,=20coach,=20and=20do?= =?UTF-8?q?ctor-report?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Coherence release: blood-pressure in-target unified on a labeled, thin-data-gated 90-day window across tile, health-score pillar and coach; the dashboard ring and insights card built from one shared health-score input builder; the coach and the doctor-report PDF/FHIR adherence drawn from the same dose ledger as the detail page; and a shared entry-timestamp plausibility guard on measurement and mood. Several displayed figures become correct-but-different (BP headline window, adherence). --- CHANGELOG.md | 13 +++++++++++++ docs/api/openapi.yaml | 2 +- package.json | 2 +- public/sw.js | 2 +- 4 files changed, 16 insertions(+), 3 deletions(-) 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/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; From e1cee6086d95eeaf09baad9374c52d9c571baa34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 14:05:32 +0200 Subject: [PATCH 10/12] fix(validation): drop the orphaned medication clock-skew constant The shared validateEntryInstant guard now owns the future clock-skew allowance, so the medication-local TAKEN_AT_CLOCK_SKEW_MS export is dead. Remove it; medication intake keeps its tighter 5-year floor via TAKEN_AT_MAX_AGE_MS. Clears the dead-code gate. --- src/lib/validations/medication.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/lib/validations/medication.ts b/src/lib/validations/medication.ts index cce5059d0..fb2748da4 100644 --- a/src/lib/validations/medication.ts +++ b/src/lib/validations/medication.ts @@ -204,7 +204,6 @@ 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; /** From 5e36664f7db40ab59deb27c4f98578db662865ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 14:26:58 +0200 Subject: [PATCH 11/12] test(blood-pressure): pin the in-target headline and BP pillar to the 90-day window The BD-Zielbereich headline, the Health-Score BP pillar and the coach number now all read the same trailing-90-day in-target window. Re-derive the integration expectations from the fixtures: the analytics headline is the independent 90-day figure (distinct from the 30-day and all-time windows), and the BP pillar grades from the 90-day window when readings predate the trailing month rather than dropping out. --- .../analytics-health-score.test.ts | 42 +++++++------ tests/integration/bp-in-target.test.ts | 60 +++++++++++-------- 2 files changed, 59 insertions(+), 43 deletions(-) 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); From f6b46dafd402ada4bc3c93e98141dfa4f4ea1622 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 14:27:04 +0200 Subject: [PATCH 12/12] test(doctor-report): give the section-toggle fixture a medication schedule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dose-ledger adherence builder only tallies scheduled medications — a medication with no schedule is PRN/as-needed and carries no expected dose, so it is excluded to keep a fabricated 100 % off the clinical report. The shared fixture seeded a schedule-less medication, which now yields an empty compliance map; seed a window so the default-sections test keeps a compliance entry. --- tests/integration/doctor-report-sections.test.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) 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({