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