mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
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:
1 parent
461ed5b5e0
commit
e9138bb4f6
20 files changed
+416
-17
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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"');
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user