feat(blood-pressure): label the in-target window dynamically and gate thin data

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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-14 13:43:41 +02:00
1 parent 461ed5b5e0
commit e9138bb4f6
20 files changed
+416 -17

No files matched your search

+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+17
View File
@@ -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,
+30 -6
View File
@@ -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<string, DataSummary>
| 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<string, DataSummary>
| 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}
/>
),
});
@@ -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(
<TrendCard
label="BP in Target · 23d"
latest={null}
emptyHint="collecting data"
unit="%"
avg7={null}
avg30={null}
slope30={null}
icon={Target}
directionSentiment="up-good"
/>,
);
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"');
});
});
+27
View File
@@ -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.
<span
className="text-muted-foreground min-w-0 truncate text-base font-medium leading-none"
data-slot="trend-card-empty-hint"
>
{emptyHint}
</span>
) : (
<>
<span
className={cn(
"shrink-0 whitespace-nowrap font-semibold tracking-tight tabular-nums",
@@ -253,6 +278,8 @@ export function TrendCard({
<span className="text-muted-foreground min-w-0 truncate text-sm tabular-nums">
{unit}
</span>
</>
)}
<span
className="ml-auto inline-flex h-4 w-4 shrink-0 items-center justify-center"
data-slot="trend-card-arrow"
@@ -7,6 +7,7 @@ function env(partial: Partial<BpInTargetEnvelope>): 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({
@@ -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);
});
});
@@ -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,
+21
View File
@@ -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`.
+31 -5
View File
@@ -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,
};
}
+6
View File
@@ -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<string, unknown> | 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<string, unknown> | 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,
};
}
+92
View File
@@ -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 };
}
+9 -6
View File
@@ -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({
+23
View File
@@ -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<string, DataSummary>;
}
@@ -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,
+9
View File
@@ -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<string, DataSummary>;
/**
* v1.4.34 IW-B — per-type freshness map from `/api/analytics`. The