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:
Marc-André Bombeck authored and GitHub committed 2026-06-14 14:34:18 +02:00
commit 63f398042d
46 files changed
+1533 -175

No files matched your search

+13
View File
@@ -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 -1
View File
@@ -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.
+4
View File
@@ -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",
+4
View File
@@ -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",
+4
View File
@@ -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",
+4
View File
@@ -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",
+4
View File
@@ -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",
+4
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+45 -56
View File
@@ -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
View File
@@ -104,6 +104,7 @@ import { useDashboardSnapshot } from "@/lib/queries/use-dashboard-snapshot";
import { isDashboardSnapshotEnabled } from "@/lib/dashboard/snapshot-flag";
import type { DataSummary } from "@/lib/analytics/trends";
import { mergeSlimAndThickAnalytics } from "@/lib/analytics/merge-slim-thick";
import { isWindowSufficient } from "@/lib/analytics/window-confidence";
import { getBpTargets } from "@/lib/analytics/bp-targets";
import {
buildTrafficLightBands,
@@ -297,6 +298,8 @@ export default function DashboardPage() {
bpInTargetPctAllTime: snap.extras?.bpInTargetPctAllTime ?? null,
bpInTargetPctPriorMonth: snap.extras?.bpInTargetPctPriorMonth ?? null,
bpInTargetPctPriorYear: snap.extras?.bpInTargetPctPriorYear ?? null,
bpInTargetCount90: snap.extras?.bpInTargetCount90 ?? null,
bpInTargetSpanDays90: snap.extras?.bpInTargetSpanDays90 ?? null,
glucoseByContext: snap.extras?.glucoseByContext as
| Record<string, DataSummary>
| undefined,
@@ -325,6 +328,8 @@ export default function DashboardPage() {
bpInTargetPctAllTime: merged.bpInTargetPctAllTime,
bpInTargetPctPriorMonth: merged.bpInTargetPctPriorMonth,
bpInTargetPctPriorYear: merged.bpInTargetPctPriorYear,
bpInTargetCount90: merged.bpInTargetCount90,
bpInTargetSpanDays90: merged.bpInTargetSpanDays90,
glucoseByContext: merged.glucoseByContext as
| Record<string, DataSummary>
| undefined,
@@ -1092,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"');
});
});
+27
View File
@@ -89,6 +89,16 @@ interface TrendCardProps {
* `null` / `undefined` suppresses the caption (current data path).
*/
staleDays?: number | null;
/**
* v1.17 W1b — short text shown in the headline slot in place of the
* "—" placeholder when `latest === null`. Used by the BD-Zielbereich
* tile to render "collecting data" while the trailing window has too
* few readings to narrate a confident percentage. When omitted the
* legacy "—" placeholder renders. Rendered at a smaller size than a
* numeric headline so the longer string fits the deterministic tile
* height.
*/
emptyHint?: string | null;
}
export function TrendCard({
@@ -109,6 +119,7 @@ export function TrendCard({
compareBaseline = "none",
compareDelta = null,
staleDays = null,
emptyHint = null,
}: TrendCardProps) {
const { t } = useTranslations();
const fmt = useFormatters();
@@ -215,6 +226,20 @@ export function TrendCard({
className="mt-2 flex items-baseline gap-x-1.5"
data-slot="trend-card-value-row"
>
{latest === null && emptyHint ? (
// v1.17 W1b — thin-data placeholder. Renders the caller's short
// text (e.g. "collecting data") where the numeric headline would
// sit, at a smaller size so the longer phrase fits the tile's
// deterministic height. No unit; the arrow slot below still
// renders its muted placeholder so the row width stays stable.
<span
className="text-muted-foreground min-w-0 truncate text-base font-medium leading-none"
data-slot="trend-card-empty-hint"
>
{emptyHint}
</span>
) : (
<>
<span
className={cn(
"shrink-0 whitespace-nowrap font-semibold tracking-tight tabular-nums",
@@ -253,6 +278,8 @@ export function TrendCard({
<span className="text-muted-foreground min-w-0 truncate text-sm tabular-nums">
{unit}
</span>
</>
)}
<span
className="ml-auto inline-flex h-4 w-4 shrink-0 items-center justify-center"
data-slot="trend-card-arrow"
@@ -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}
+2
View File
@@ -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}
+2
View File
@@ -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 () => {
+41
View File
@@ -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,
+38
View File
@@ -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`.
+1 -1
View File
@@ -53,7 +53,7 @@ import {
type ScheduleType,
} from "@/lib/medications/scheduling/recurrence";
interface IntakeEvent {
export interface IntakeEvent {
takenAt: Date | null;
skipped: boolean;
scheduledFor: Date;
+108
View File
@@ -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,
};
}
+6
View File
@@ -46,6 +46,8 @@ export interface AnalyticsThickLike extends AnalyticsSlimLike {
bpInTargetPctAllTime?: number | null;
bpInTargetPctPriorMonth?: number | null;
bpInTargetPctPriorYear?: number | null;
bpInTargetCount90?: number | null;
bpInTargetSpanDays90?: number | null;
glucoseByContext?: Record<string, unknown> | null;
}
@@ -60,6 +62,8 @@ export interface MergedDashboardAnalytics {
bpInTargetPctAllTime: number | null;
bpInTargetPctPriorMonth: number | null;
bpInTargetPctPriorYear: number | null;
bpInTargetCount90: number | null;
bpInTargetSpanDays90: number | null;
glucoseByContext: Record<string, unknown> | undefined;
}
@@ -109,6 +113,8 @@ export function mergeSlimAndThickAnalytics(
bpInTargetPctAllTime: thick?.bpInTargetPctAllTime ?? null,
bpInTargetPctPriorMonth: thick?.bpInTargetPctPriorMonth ?? null,
bpInTargetPctPriorYear: thick?.bpInTargetPctPriorYear ?? null,
bpInTargetCount90: thick?.bpInTargetCount90 ?? null,
bpInTargetSpanDays90: thick?.bpInTargetSpanDays90 ?? null,
glucoseByContext: thick?.glucoseByContext ?? undefined,
};
}
+113
View File
@@ -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 };
}
+27 -7
View File
@@ -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 },
+72 -13
View File
@@ -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
View File
@@ -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
+6 -1
View File
@@ -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", () => {
+18 -3
View File
@@ -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 ──────────────────────────────────── */
+116
View File
@@ -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,
);
}
+13 -5
View File
@@ -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(
+17 -11
View File
@@ -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({
+11 -5
View File
@@ -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({
+9
View File
@@ -86,6 +86,15 @@ export interface DashboardAnalyticsData {
*/
bpInTargetPctPriorMonth?: number | null;
bpInTargetPctPriorYear?: number | null;
/**
* v1.17 W1b — paired BP readings inside the trailing-90-day window, and
* the EFFECTIVE label span (real calendar span capped at 90 days). The
* BD-Zielbereich tile compares the count against the confidence floor to
* decide between a percentage and a "collecting data" placeholder, and
* renders the span in the label ("· 23 T" until ~90 days of history exist).
*/
bpInTargetCount90?: number | null;
bpInTargetSpanDays90?: number | null;
glucoseByContext?: Record<string, DataSummary>;
/**
* v1.4.34 IW-B — per-type freshness map from `/api/analytics`. The
@@ -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 () => {
+35 -25
View File
@@ -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({