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] 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