Merge pull request #272 from MBombeck/release/v1.13.2

release/v1.13.2 — compliance + sleep fixes + wellness redesign
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-06-05 22:34:41 +02:00
commit 3c87ee9230
30 files changed
+1405 -269

No files matched your search

+11
View File
@@ -2,6 +2,17 @@
## [Unreleased]
## [1.13.2] — 2026-06-05 — honest weekly-med compliance, correct sleep average, calmer wellness scores
### Fixed
- **Weekly injections (and other "every N days" medications) report honest adherence.** A rolling-cadence medication — the typical weekly GLP-1 injection — only ever counted its single next-due dose, so the rate flipped between a meaningless 100% and a hard 0% on a single dose and ignored a faithful history of shots. Compliance now reconstructs the full per-cycle history across the window: every logged dose counts, a genuinely skipped cycle reads as one miss, and the current cycle that simply hasn't come due yet no longer drags the number to zero. A new per-medication "current cycle" state (on track / due today / overdue / no cycles yet) lets the card show "next dose in N days" instead of a scary 0%.
- **The sleep average is correct again.** When a device wrote both a combined "asleep" block and the detailed light/deep/REM breakdown for the same night, the average double-counted them and could show an impossible figure (e.g. ~20 h). The average now reads from the de-duplicated per-night totals. The night-detail reconstruction is also hardened so it never errors on an unusual night.
### Changed
- **The wellness scores look calmer and clearer.** The "Your health scores" tiles (renamed from the old label) now use a gentle, card-toned surface instead of a heavy saturated slab, and each score draws a larger, thinner ring with a soft single-colour gradient tuned per score (readiness greener, sleep bluer, recovery and strain turquoise). The number and band stay fully legible in both light and dark themes.
## [1.13.1] — 2026-06-05 — wellness rings show the real score
### Fixed
+42 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.13.1
version: 1.13.2
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
@@ -6761,6 +6761,46 @@ components:
required:
- rate
additionalProperties: false
currentCycle:
type: object
properties:
state:
type: string
enum:
- on_track
- due
- missed
- none
description: "Open-cycle state, decoupled from the percentage rows: `on_track` = next dose not yet due; `due` = due now
/ in grace; `missed` = past grace with no logged intake (the only red state); `none` = no projected next
dose (PRN / paused / ended)."
nextDueAt:
anyOf:
- type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
- type: "null"
description: The open cycle's due instant. Null when `state` is `none`.
graceUntil:
anyOf:
- type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
- type: "null"
description: End of the due slot's grace window. Null when `state` is `none`.
hasClosedCycles:
type: boolean
description: False for a brand-new sparse med with zero closed dose cycles — the percentage rows are vacuous and the
card should show a neutral 'not enough data yet' state.
required:
- state
- nextDueAt
- graceUntil
- hasClosedCycles
additionalProperties: false
description: v1.13.x — the current (open) dose cycle, surfaced so a between-doses sparse med renders a neutral 'next
dose in N days' line instead of a scary red 0%. The percentage rows above already exclude the open forward
cycle from their denominator.
required:
- shortDays
- longDays
@@ -6769,6 +6809,7 @@ components:
- minStableDoses
- short
- long
- currentCycle
additionalProperties: false
description: The two-row card block whose windows scale with dosing cadence (dense meds keep 7 / 30 days, sparse meds
step both windows up). NOT the 30-day denominator — read `compliance30.totalExpected` for that.
+1 -1
View File
@@ -2782,7 +2782,7 @@
}
},
"scores": {
"sectionTitle": "Wellness-Werte",
"sectionTitle": "Deine Gesundheitswerte",
"loadError": "Deine Wellness-Werte konnten gerade nicht geladen werden.",
"recovery": "Erholung",
"stress": "Stress",
+1 -1
View File
@@ -2782,7 +2782,7 @@
}
},
"scores": {
"sectionTitle": "Wellness scores",
"sectionTitle": "Your health scores",
"loadError": "Could not load your wellness scores right now.",
"recovery": "Recovery",
"stress": "Stress",
+1 -1
View File
@@ -2782,7 +2782,7 @@
}
},
"scores": {
"sectionTitle": "Puntuaciones de bienestar",
"sectionTitle": "Tus indicadores de salud",
"loadError": "No se pudieron cargar tus puntuaciones de bienestar en este momento.",
"recovery": "Recuperación",
"stress": "Estrés",
+1 -1
View File
@@ -2782,7 +2782,7 @@
}
},
"scores": {
"sectionTitle": "Scores de bien-être",
"sectionTitle": "Tes indicateurs de santé",
"loadError": "Impossible de charger vos scores de bien-être pour le moment.",
"recovery": "Récupération",
"stress": "Stress",
+1 -1
View File
@@ -2782,7 +2782,7 @@
}
},
"scores": {
"sectionTitle": "Punteggi di benessere",
"sectionTitle": "I tuoi indicatori di salute",
"loadError": "Al momento non è stato possibile caricare i tuoi punteggi di benessere.",
"recovery": "Recupero",
"stress": "Stress",
+1 -1
View File
@@ -2782,7 +2782,7 @@
}
},
"scores": {
"sectionTitle": "Wyniki dobrostanu",
"sectionTitle": "Twoje wskaźniki zdrowia",
"loadError": "Nie udało się teraz wczytać Twoich wyników dobrostanu.",
"recovery": "Regeneracja",
"stress": "Stres",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.13.1",
"version": "1.13.2",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "AGPL-3.0-only",
"homepage": "https://healthlog.dev",
+44 -9
View File
@@ -23,6 +23,12 @@ import {
import { buildSystemPromptWithReferences } from "@/lib/ai/prompts/insight-generator";
import { metricsFromPresentSections } from "@/lib/ai/medical-references";
import { summarize, type DataPoint } from "@/lib/analytics/trends";
import {
reconstructSleepNights,
type SleepStageRow,
} from "@/lib/analytics/sleep-night";
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import {
resolveDashboardLayout,
type ComparisonBaseline,
@@ -153,16 +159,45 @@ async function buildComparisonSnapshotForUser(
const types = Object.keys(typeToSnapshotKey) as MeasurementType[];
const rows = await Promise.all(
types.map(async (type) => {
const measurements = await prisma.measurement.findMany({
where: { userId, type, deletedAt: null },
orderBy: { measuredAt: "asc" },
select: { measuredAt: true, value: true },
});
const summary = summarize(
measurements.map(
// SLEEP_DURATION is stored ONE ROW PER STAGE per night; summarising the
// raw stage rows mislabels a per-stage average as a night total (and
// double-counts a bare ASLEEP aggregate against its granular twin). Route
// the comparison through the per-night dedup reconstruction so the
// current/baseline averages are per-night TIME-ASLEEP totals in minutes.
let dataPoints: DataPoint[];
if (type === "SLEEP_DURATION") {
const [sleepRows, sleepTz, sleepPriority] = await Promise.all([
prisma.measurement.findMany({
where: { userId, type, deletedAt: null },
orderBy: { measuredAt: "asc" },
select: {
measuredAt: true,
value: true,
sleepStage: true,
source: true,
},
}),
resolveUserTimezone(userId),
loadUserSourcePriority(userId),
]);
dataPoints = reconstructSleepNights(
sleepRows as SleepStageRow[],
sleepTz,
sleepPriority,
)
.filter((n) => n.asleepMinutes > 0)
.map((n) => ({ date: n.measuredAt, value: n.asleepMinutes }));
} else {
const measurements = await prisma.measurement.findMany({
where: { userId, type, deletedAt: null },
orderBy: { measuredAt: "asc" },
select: { measuredAt: true, value: true },
});
dataPoints = measurements.map(
(m): DataPoint => ({ date: m.measuredAt, value: m.value }),
),
);
);
}
const summary = summarize(dataPoints);
const baselineAvg =
baseline === "lastMonth"
? (summary.avg30LastMonth ?? null)
@@ -265,6 +265,11 @@ export const GET = apiHandler(
dayStart,
dayEnd,
medicationContext,
// v1.13.x — pass the intake history so a ROLLING schedule's per-day
// `due` flags come from the retrospective grid (each logged dose is a
// due day) instead of the engine's single forward slot — keeping the
// heatmap, the denominator, and the displayed rate in agreement.
mapped,
);
dailyCompliance[dateKey] = {
@@ -139,4 +139,37 @@ describe("GET /api/sleep/night", () => {
expect(body.data.main).toBeNull();
expect(body.data.naps).toEqual([]);
});
// A5 — a reconstruction edge (a session of only bare ASLEEP / stage-less rows
// alongside a granular partition for the same span) must return a valid empty
// night, NEVER a 500 from the unguarded `segments[0]` access.
it("returns 200 + empty night on an IN_BED/AWAKE-only reconstruction, never 500", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
stage("2026-06-04T06:00:00.000Z", "IN_BED", 60),
stage("2026-06-04T06:00:00.000Z", "AWAKE", 60),
] as never);
const res = await GET(req("date=2026-06-04"));
expect(res.status).toBe(200); // not 500
const body = await res.json();
expect(body.data.main).toBeNull();
expect(body.data.naps).toEqual([]);
});
it("returns 200 on a bare ASLEEP + stage-less + granular mix, never 500", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
stage("2026-06-04T02:00:00.000Z", "DEEP", 1),
stage("2026-06-04T06:00:00.000Z", "ASLEEP", 480),
// stage-less twin (sleepStage: null) for the same span.
{
value: 480,
measuredAt: new Date("2026-06-04T06:00:00.000Z"),
sleepStage: null,
source: "APPLE_HEALTH",
},
] as never);
const res = await GET(req("date=2026-06-04"));
expect(res.status).toBe(200); // never 500 on the reconstruction edge
});
});
+23 -16
View File
@@ -382,27 +382,34 @@
}
/*
* `.score-tile-gradient` — the saturated purple→pink wellness-tile
* background that echoes the hero band while staying dark enough for a
* WHITE score ring + white centre number to clear WCAG AA in BOTH themes.
* `.wellness-tile` — the v1.13.x premium-redesign wellness-tile surface.
*
* Unlike `.hero-gradient` (which mixes over the theme-dependent
* `var(--card)` — near-white in Alucard light, so a white ring would
* vanish), this variant mixes purple/pink over the *fixed* dark
* `var(--dracula-bg)` (#282a36). That keeps the surface identical and
* dark in light + dark mode, so white text/ring read ≥ 4.5:1 either way:
* white on the purple endpoint ≈ 5.0:1, on the pink endpoint ≈ 6.1:1.
* A subtler glow than the hero's keeps a row of five tiles from
* overwhelming the page.
* Replaces the old saturated `.score-tile-gradient` dark slab. Mirrors the
* gentle, calm `.hero-gradient` the maintainer loves: a LOW-opacity mix of
* the metric's `--tile-hue` over the *theme* `var(--card)` (not the fixed
* dark bg), so the surface stays luminous in both Dracula dark + Alucard
* light. The mix is even softer than the hero's 18/12 (14/7) so a *row* of
* five tinted tiles never overwhelms the page. Each `RingTile` sets
* `--tile-hue` inline per metric; the per-metric `hue` arc on the ring is
* the only saturated element, letting the dial be the hero.
*
* Text rides the theme `--foreground` (dark in light mode, light in dark
* mode), so AA is trivial over the `--card`-based surface in both themes —
* the old white-pinned copy + white arc are gone.
*/
.score-tile-gradient {
.wellness-tile {
--tile-hue: var(--dracula-purple);
background: linear-gradient(
140deg,
color-mix(in srgb, var(--dracula-purple) 55%, var(--dracula-bg)) 0%,
color-mix(in srgb, var(--dracula-pink) 45%, var(--dracula-bg)) 100%
color-mix(in srgb, var(--tile-hue) 14%, var(--card)) 0%,
color-mix(in srgb, var(--tile-hue) 7%, var(--card)) 100%
);
border: 1px solid color-mix(in srgb, var(--dracula-purple) 28%, transparent);
box-shadow: 0 12px 32px -24px color-mix(in srgb, var(--dracula-purple) 45%, transparent);
border: 1px solid color-mix(in srgb, var(--tile-hue) 22%, transparent);
box-shadow: 0 10px 30px -26px color-mix(in srgb, var(--tile-hue) 40%, transparent);
}
.wellness-tile:hover {
border-color: color-mix(in srgb, var(--tile-hue) 38%, transparent);
}
/*
@@ -53,12 +53,10 @@ describe("<ScoreRing>", () => {
expect(html).toContain("aria-label");
});
it("keeps the band semantic on the white-arc onGradient variant", () => {
// The white arc drops the band colour, so the band must still ride the
it("keeps the band semantic regardless of the arc hue (per-metric or band)", () => {
// The arc paints a per-metric / band hue, so the band must still ride the
// data attribute + aria-label for the colour-blind/non-visual read.
const html = render(
<ScoreRing score={82} variant="onGradient" label="/100" />,
);
const html = render(<ScoreRing score={82} hue="readiness" label="/100" />);
expect(html).toContain('data-band="green"');
expect(html).toContain('role="img"');
expect(html).toContain("aria-label");
@@ -66,7 +66,7 @@ describe("<WellnessScores>", () => {
expect(html).toBe("");
});
it("renders a ring tile with an icon + heading and a gradient surface", () => {
it("renders a ring tile with an icon + heading on the gentle wellness surface", () => {
const html = render(
<WellnessScores
read={readFrom({ READINESS: ok({ score: 82, band: "green" }) })}
@@ -74,14 +74,14 @@ describe("<WellnessScores>", () => {
/>,
);
expect(html).toContain('data-slot="wellness-scores"');
expect(html).toContain('data-slot="wellness-score-tile"');
expect(html).toContain('data-metric="READINESS"');
// The tile carries the metric heading and rides the saturated
// purple→pink gradient that echoes the hero card.
expect(html).toContain("Readiness");
expect(html).toContain("score-tile-gradient");
// The ring carries its band on the arc + data-attribute (not the number).
// The tile rides the gentle, hero-family `.wellness-tile` surface
// (asserted by the stable class, not viewport text).
expect(html).toContain("wellness-tile");
// The ring carries its band on the data-attribute.
expect(html).toContain('data-band="green"');
// The band word renders under the ring (semantic kept off the white arc).
// The band word renders under the ring (semantic kept off the arc hue).
expect(html).toContain('data-slot="wellness-score-band-word"');
});
@@ -0,0 +1,62 @@
/**
* v1.13.x — per-metric ring hues for the wellness-score strip.
*
* The premium-redesign signature is Apple's: a SINGLE-hue, TWO-stop
* gradient per ring (dark→light tones of the *same* colour), never a
* multi-hue rainbow. Each metric leans toward an intentional hue (Oura's
* per-metric move): readiness greener, sleep bluer/indigo, recovery +
* strain turquoise, stress amber.
*
* Every stop rides the SEMANTIC / `--dracula-*` tokens (not raw hex), so
* the Alucard light-mode overrides (`--success #14720a`, `--info #036a96`,
* `--warning #a34d14`, `--destructive #cb3a2a`) apply automatically and the
* arc holds contrast in both themes. The band semantic is never carried by
* the hue: it still rides `data-band` + the band word + the aria-label, so
* a red-band readiness still reads "low" in copy while its ring leans green.
*/
import type { ScoreBand } from "./band-tokens";
/** The per-metric hue keys the wellness strip passes to `<ScoreRing>`. */
export type RingHue = "readiness" | "sleep" | "recovery" | "stress" | "strain";
/**
* Single-hue two-stop gradient `[from, to]` (dark → light) per metric, plus
* a band fallback for the anatomy detail view (which passes no `hue`, so the
* ring keeps its green/yellow/red band semantics there). Values are CSS
* colour strings consumed straight by the SVG `<linearGradient>` stops.
*/
export const RING_GRADIENT: Record<RingHue | ScoreBand, [string, string]> = {
// Readiness / Tagesform — a touch greener.
readiness: ["color-mix(in srgb, var(--success) 88%, #000)", "var(--success)"],
// Sleep score — bluer, leaning indigo via `--primary`.
sleep: ["color-mix(in srgb, var(--info) 65%, var(--primary))", "var(--info)"],
// Recovery — turquoise (cyan↔green).
recovery: [
"color-mix(in srgb, var(--dracula-cyan) 78%, var(--dracula-green))",
"var(--dracula-cyan)",
],
// Stress — amber (the "tension" metric; keeps the green/amber/red vocab honest).
stress: ["color-mix(in srgb, var(--warning) 88%, #000)", "var(--warning)"],
// Strain — turquoise leaning slightly violet to distinguish from Recovery.
strain: [
"color-mix(in srgb, var(--dracula-cyan) 80%, var(--dracula-purple))",
"var(--dracula-cyan)",
],
// Band fallback for the anatomy detail view (no per-metric hue passed):
green: ["color-mix(in srgb, var(--success) 88%, #000)", "var(--success)"],
yellow: ["color-mix(in srgb, var(--warning) 88%, #000)", "var(--warning)"],
red: ["color-mix(in srgb, var(--destructive) 88%, #000)", "var(--destructive)"],
};
/**
* The surface tint per metric — the `--tile-hue` the `.wellness-tile` CSS
* mixes (gently) over the theme `--card`. Set inline by each `RingTile`.
*/
export const TILE_HUE: Record<RingHue, string> = {
readiness: "var(--success)",
sleep: "var(--info)",
recovery: "var(--dracula-cyan)",
stress: "var(--warning)",
strain: "var(--dracula-cyan)",
};
+125 -173
View File
@@ -1,62 +1,58 @@
"use client";
import {
Label,
PolarAngleAxis,
PolarRadiusAxis,
RadialBar,
RadialBarChart,
ResponsiveContainer,
} from "recharts";
import { useId } from "react";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import { useCountUp } from "@/hooks/use-count-up";
import {
bandForScore,
BAND_VAR,
clampScore,
type ScoreBand,
} from "./band-tokens";
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
import { bandForScore, clampScore, type ScoreBand } from "./band-tokens";
import { RING_GRADIENT, type RingHue } from "./ring-hues";
/**
* v1.10.0 — the composite-score dial. A radial Recharts ring with the
* `tabular-nums` number centred, band-coloured, sweeping to fill on first
* paint. Rendered small in a grid tile and large on the score-anatomy
* detail view from the same component via the `size` prop — one score
* language, two intensities (the Hybrid direction).
* v1.13.x — the composite-score dial, now a hand-rolled SVG arc gauge
* (Recharts retired FROM THIS COMPONENT ONLY; it stays the default for real
* data charts elsewhere). A score dial is a fixed-geometry gauge, not a data
* chart, so a ~40-LOC SVG gives exact control over a thin round-cap arc, a
* faint same-hue track, and a single-hue two-stop gradient — the premium
* Apple/Oura signature — at 0 KB runtime and with no per-tile resize
* observer.
*
* Built on raw Recharts `RadialBarChart` (the existing house pattern — 6
* sites already use Recharts via `ResponsiveContainer`), NOT the shadcn
* `chart` primitive: the ring needs no per-series CSS-var injection, so it
* paints its fill straight from the `--dracula-*` band token and avoids
* adding a second `dangerouslySetInnerHTML` to the tree. 0 KB new runtime.
* The arc is a single `<circle stroke-dasharray>` swept by `stroke-dashoffset`,
* filled with a JSX `<linearGradient>` (NOT a `dangerouslySetInnerHTML` —
* `<defs>`/`<linearGradient>` are real SVG JSX). The `<svg>` is rotated -90°
* so the arc starts at 12 o'clock and sweeps clockwise without per-point trig.
* The centred number is real DOM text (not SVG `<text>`), so a long label can
* never clip under the SVG viewbox and the number uses the design tokens.
*
* `score === null` renders the provisional/empty state: an unfilled ring
* with an em-dash and a localised "not enough data yet" caption, so a
* sparse-data hero never goes blank.
* `score === null` renders the provisional/empty state: an unfilled ring with
* an em-dash and a localised "not enough data yet" caption.
*
* a11y: `role="img"` + an aria-label restating the number and band, so the
* ring is never colour-only. `prefers-reduced-motion` disables the sweep
* (Recharts `isAnimationActive`) and the count-up.
* a11y: `role="img"` + an aria-label restating the number + band, so the ring
* is never colour-only. `prefers-reduced-motion` paints the final arc offset
* with no transition and disables the count-up.
*
* `variant` picks the arc palette. `"band"` (default) paints the filled
* arc in the score's band token (green/yellow/red) over a muted track —
* the legible treatment on a plain `bg-card` surface (the score-anatomy
* detail view). `"onGradient"` paints a WHITE arc over a translucent-white
* track for use on the saturated `.score-tile-gradient` wellness cards,
* where a band tint would muddy against the purple/pink and white reads at
* high contrast. The band semantic is never lost to the white arc: it
* still rides the `data-band` attribute, the aria-label, and the band-word
* label the wellness tile renders beneath the ring.
* `hue` selects a per-metric single-hue gradient (readiness greener, sleep
* bluer, recovery/strain turquoise, stress amber). When omitted — the anatomy
* detail view — the gradient falls back to the score's band token
* (green/yellow/red), preserving the band semantics there. The band is always
* carried by `data-band` + the band word the tile renders + the aria-label,
* regardless of the ring hue.
*/
// The SVG is a fixed 0 0 100 100 viewBox; CSS scales it to `dims.px`.
const VIEW = 100;
const STROKE = 9; // ~9% of size → Apple-class thinness (was ~22%).
const R = (VIEW - STROKE) / 2; // radius so the stroke sits inside the box.
const C = 2 * Math.PI * R; // circumference for the dash math.
const CX = VIEW / 2;
const SIZE: Record<
NonNullable<ScoreRingProps["size"]>,
{ px: number; numberClass: string; labelClass: string; barSize: number }
{ px: number; numberClass: string; labelClass: string }
> = {
sm: { px: 96, numberClass: "text-2xl", labelClass: "text-[10px]", barSize: 10 },
md: { px: 140, numberClass: "text-4xl", labelClass: "text-xs", barSize: 14 },
lg: { px: 200, numberClass: "text-6xl", labelClass: "text-sm", barSize: 18 },
sm: { px: 120, numberClass: "text-3xl", labelClass: "text-[11px]" },
md: { px: 168, numberClass: "text-5xl", labelClass: "text-xs" },
lg: { px: 232, numberClass: "text-7xl", labelClass: "text-sm" },
};
export interface ScoreRingProps {
@@ -71,11 +67,16 @@ export interface ScoreRingProps {
/** Disable the sweep + count-up (e.g. when already animated by a parent). */
animate?: boolean;
/**
* Arc palette. `"band"` (default) tints the arc by score band over a
* muted track — legible on a plain card. `"onGradient"` paints a white
* arc over a translucent-white track, for the saturated gradient
* wellness tiles. See the component doc for why the band semantic is not
* lost to the white arc.
* Per-metric hue for the single-hue two-stop arc gradient. When omitted the
* gradient falls back to the score's band token (green/yellow/red) — the
* anatomy detail view keeps band semantics that way.
*/
hue?: RingHue;
/**
* Retained for API compatibility. The premium redesign drops the old
* white-arc-on-dark-slab treatment in favour of the per-metric `hue`
* gradient on the gentle `.wellness-tile`, so this no longer changes the
* paint; it is kept so existing call sites + tests don't break.
*/
variant?: "band" | "onGradient";
className?: string;
@@ -87,25 +88,30 @@ export function ScoreRing({
label,
size = "md",
animate = true,
variant = "band",
hue,
className,
}: ScoreRingProps) {
const { t } = useTranslations();
const dims = SIZE[size];
const onGradient = variant === "onGradient";
const gid = useId();
const hasScore = score != null && Number.isFinite(score);
const clamped = hasScore ? clampScore(score) : 0;
const resolvedBand: ScoreBand = band ?? bandForScore(clamped);
const fill = onGradient
? "#ffffff"
: hasScore
? BAND_VAR[resolvedBand]
: "var(--muted-foreground)";
// Count-up only feeds the centred number; the ring arc reads the final
// value (Recharts owns its own sweep via isAnimationActive).
const displayed = useCountUp(clamped, { enabled: animate && hasScore });
// Single-hue two-stop gradient. The per-metric `hue` leans the colour
// (Oura's move); with no `hue` the anatomy view falls back to the band
// token so its green/yellow/red semantics survive.
const [from, to] = RING_GRADIENT[hue ?? resolvedBand];
const reduced = prefersReducedMotion();
const dash = (clamped / 100) * C;
// Count-up only feeds the centred number; the arc sweeps via the CSS
// stroke-dashoffset transition (or paints final under reduced motion).
const displayed = useCountUp(clamped, {
enabled: animate && hasScore && !reduced,
});
const displayedRounded = Math.round(displayed);
const ariaLabel = hasScore
@@ -115,20 +121,6 @@ export function ScoreRing({
})
: t("insights.derived.scoreRing.ariaProvisional");
// The RadialBar's angular sweep is governed by the `<PolarAngleAxis>` number
// domain [0,100] below — NOT the PolarRadiusAxis (which positions the bar
// radially + anchors the centred number). Without the angle axis Recharts
// auto-scales the angular domain to the single datum, so every ring filled a
// full circle regardless of its score; pinning the domain makes a value of
// 74 sweep 74% of the track (full clockwise circle from 12 o'clock).
const data = [{ name: "score", value: hasScore ? clamped : 0, fill }];
const startAngle = 90;
const endAngle = -270;
// Geometry — keep the inner ring thin and the centre clear for the number.
const outerRadius = "100%";
const innerRadius = "78%";
return (
<div
data-slot="score-ring"
@@ -139,108 +131,68 @@ export function ScoreRing({
className={cn("relative shrink-0", className)}
style={{ width: dims.px, height: dims.px }}
>
<ResponsiveContainer width="100%" height="100%">
<RadialBarChart
data={data}
startAngle={startAngle}
endAngle={endAngle}
innerRadius={innerRadius}
outerRadius={outerRadius}
barSize={dims.barSize}
>
{/* Pins the value→angle scale so the bar sweeps `value`% of the
track instead of always filling the full circle. */}
<PolarAngleAxis
type="number"
domain={[0, 100]}
tick={false}
axisLine={false}
/>
<RadialBar
dataKey="value"
background={
onGradient
? { fill: "#ffffff", opacity: 0.18 }
: { fill: "var(--muted)", opacity: 0.4 }
}
cornerRadius={dims.barSize}
isAnimationActive={animate && hasScore}
animationDuration={600}
aria-hidden
/>
<PolarRadiusAxis
type="number"
domain={[0, 100]}
tick={false}
tickLine={false}
axisLine={false}
>
<Label
content={({ viewBox }) => {
if (!viewBox || !("cx" in viewBox) || !("cy" in viewBox)) {
return null;
<svg
viewBox={`0 0 ${VIEW} ${VIEW}`}
className="h-full w-full -rotate-90"
aria-hidden="true"
>
<defs>
{/* Single-hue two-stop gradient — JSX SVG, not dangerouslySetInnerHTML. */}
<linearGradient id={gid} x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stopColor={from} />
<stop offset="100%" stopColor={to} />
</linearGradient>
</defs>
{/* Faint same-hue track — 12% of the ring colour, never a bright
competing ring. */}
<circle
cx={CX}
cy={CX}
r={R}
fill="none"
stroke={`url(#${gid})`}
strokeOpacity={0.12}
strokeWidth={STROKE}
/>
{/* The progress arc — single-hue gradient, round cap. */}
<circle
cx={CX}
cy={CX}
r={R}
fill="none"
stroke={`url(#${gid})`}
strokeWidth={STROKE}
strokeLinecap="round"
strokeDasharray={C}
style={
reduced
? { strokeDashoffset: C - dash }
: {
strokeDashoffset: C - dash,
transition:
"stroke-dashoffset 700ms cubic-bezier(.22,.61,.36,1)",
}
const cx = viewBox.cx ?? 0;
const cy = viewBox.cy ?? 0;
return (
<text
x={cx}
y={cy}
textAnchor="middle"
dominantBaseline="middle"
>
<tspan
x={cx}
y={cy}
className={cn(
"font-semibold tabular-nums",
dims.numberClass,
// v1.12.6 — the centred number reads in the
// foreground colour (white on the dark wellness
// card, near-black on the light card), never the
// band tint. The band-coloured number cleared only
// ~1.5:1 on `--card` and read as illegible. The
// band semantic is carried by the ring arc
// (`fill` = BAND_VAR) + the `data-band` attribute +
// the aria-label, so dropping it from the number
// costs no information while clearing AA.
// On the saturated gradient tile the number is
// pinned white in both themes (the dark gradient
// clears AA either way); `fill-foreground` would go
// near-black on the Alucard light card and vanish.
onGradient
? "fill-white"
: hasScore
? "fill-foreground"
: "fill-muted-foreground",
)}
>
{hasScore ? displayedRounded : "—"}
</tspan>
{label ? (
<tspan
x={cx}
y={cy + (size === "lg" ? 30 : size === "md" ? 22 : 16)}
className={cn(
// On the gradient tile a translucent white reads
// cleanly; on a plain card the muted-foreground
// token keeps the sub-label quiet.
onGradient
? "fill-white/80"
: "fill-muted-foreground",
dims.labelClass,
)}
>
{label}
</tspan>
) : null}
</text>
);
}}
/>
</PolarRadiusAxis>
</RadialBarChart>
</ResponsiveContainer>
}
/>
</svg>
{/* Centred number as real DOM text — easier to style with the design
tokens + tabular-nums, and no SVG-text clipping on a long label. */}
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center">
<span
className={cn(
"font-semibold tracking-tight tabular-nums",
dims.numberClass,
hasScore ? "text-foreground" : "text-muted-foreground",
)}
>
{hasScore ? displayedRounded : "—"}
</span>
{label ? (
<span className={cn("text-muted-foreground", dims.labelClass)}>
{label}
</span>
) : null}
</div>
{!hasScore && (
<span
data-slot="score-ring-provisional"
@@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
import { TileHeader } from "@/components/insights/tile-header";
import { ScoreRing } from "./score-ring";
import { TILE_HUE, type RingHue } from "./ring-hues";
import type { DerivedBatchRead } from "./use-derived-metric";
import type { ScoreBand } from "./band-tokens";
// Type-only — the compute payloads never drag the server graph into the bundle.
@@ -57,6 +58,7 @@ function RingTile({
bandWord,
href,
metricSlot,
hue,
icon,
}: {
score: number;
@@ -65,6 +67,7 @@ function RingTile({
bandWord: string;
href: string;
metricSlot: string;
hue: RingHue;
icon: ComponentType<{ className?: string }>;
}) {
const Icon = icon;
@@ -73,19 +76,17 @@ function RingTile({
href={href}
data-slot="wellness-score-tile"
data-metric={metricSlot}
// v1.12.8 — the wellness tile adopts the saturated purple→pink
// `.score-tile-gradient` so the dial strip echoes the hero card. The
// gradient is dark in BOTH themes (mixed over the fixed
// `--dracula-bg`, not the theme `--card`), so the white ring + white
// copy clear WCAG AA either way (white on the purple endpoint ≈ 5.0:1,
// on the pink endpoint ≈ 6.1:1). The icon + heading + band word are
// pinned white to match (TileHeader's `text-foreground` would go
// near-black on the Alucard light card and disappear into the
// gradient), so this tile renders its own white header inline rather
// than the shared TileHeader.
className="score-tile-gradient hover:border-white/35 focus-visible:ring-ring flex flex-col gap-3 rounded-xl p-4 transition-colors focus-visible:ring-2 focus-visible:outline-none"
// v1.13.x — the wellness tile drops the saturated `.score-tile-gradient`
// dark slab for the gentle, hero-family `.wellness-tile`: a low-opacity
// mix of the metric's `--tile-hue` over the theme `--card`. The surface
// is luminous in both themes, so the header/number ride `--foreground`
// (no pinned white) and the per-metric `hue` arc is the only saturated
// thing on the tile — calm, like the greeting tile. The band stays
// conveyed by the word label below + the ring's `data-band` + aria-label.
style={{ "--tile-hue": TILE_HUE[hue] } as React.CSSProperties}
className="wellness-tile focus-visible:ring-ring flex flex-col gap-4 rounded-xl p-5 transition-colors focus-visible:ring-2 focus-visible:outline-none"
>
<div className="flex items-center gap-2 text-white">
<div className="text-foreground flex items-center gap-2">
<Icon className="h-5 w-5 shrink-0" aria-hidden="true" />
<span className="truncate text-base leading-none font-semibold">
{label}
@@ -93,15 +94,13 @@ function RingTile({
</div>
<div className="flex flex-col items-center gap-1.5">
{/* The ring keeps just the number centred; the metric name lives in
the header above (a long localised title would overflow the
small ring's centred SVG text and clip under recharts'
`overflow:hidden`). The white-arc `onGradient` variant reads
cleanly on the gradient; the band stays conveyed by the word
label below + the ring's `data-band` + aria-label. */}
<ScoreRing score={score} band={band} size="sm" variant="onGradient" />
the header above. The per-metric `hue` gradient leans the arc
colour; the band stays conveyed by the word label below + the
ring's `data-band` + aria-label. */}
<ScoreRing score={score} band={band} size="sm" hue={hue} />
<span
data-slot="wellness-score-band-word"
className="text-center text-xs text-white/80"
className="text-muted-foreground text-center text-xs"
>
{bandWord}
</span>
@@ -176,6 +175,7 @@ export function WellnessScores({
bandWord={bandWord(readiness.value.band)}
href="/insights/scores/readiness"
metricSlot="READINESS"
hue="readiness"
icon={Gauge}
/>,
);
@@ -190,6 +190,7 @@ export function WellnessScores({
bandWord={bandWord(recovery.value.band)}
href="/insights/scores/recovery"
metricSlot="RECOVERY_SCORE"
hue="recovery"
icon={HeartPulse}
/>,
);
@@ -204,6 +205,7 @@ export function WellnessScores({
bandWord={bandWord(sleep.value.band)}
href="/insights/scores/sleep"
metricSlot="SLEEP_SCORE"
hue="sleep"
icon={Moon}
/>,
);
@@ -218,6 +220,7 @@ export function WellnessScores({
bandWord={bandWord(stress.value.band)}
href="/insights/scores/stress"
metricSlot="STRESS_SCORE"
hue="stress"
icon={Activity}
/>,
);
@@ -232,6 +235,7 @@ export function WellnessScores({
bandWord={bandWord(strain.value.band)}
href="/insights/scores/strain"
metricSlot="STRAIN_SCORE"
hue="strain"
icon={Flame}
/>,
);
+254 -3
View File
@@ -510,7 +510,7 @@ describe("calculateCompliance — engine-routed (medicationContext)", () => {
expect(result.totalExpected).toBeLessThanOrEqual(3);
});
it("rolling rollingIntervalDays=7 — only the next-due slot counts", () => {
it("rolling rollingIntervalDays=7 — the open forward cycle is upcoming, not a slot (no events → empty)", () => {
const schedules: ComplianceSchedule[] = [
{
windowStart: "08:00",
@@ -520,8 +520,14 @@ describe("calculateCompliance — engine-routed (medicationContext)", () => {
timesOfDay: ["08:00"],
},
];
// Last intake 5 days ago → next due in 2 days (future) → no past
// expected slot inside the window → empty-window contract → 100.
// v1.13.x — this test previously asserted the OLD forward-only behaviour
// ("only the next-due slot counts"). With the retrospective rolling
// expansion the grid is built from the LOGGED intakes — here there are
// none, and `lastIntakeAt` is 5 days ago so the forward next-due slot is
// 2 days out (future, > now) → excluded as upcoming. With no logged
// intakes in the window and no past-due slot the window is empty →
// empty-window contract → 100 / totalExpected 0. (A faithful history now
// reads its true rate — see the dedicated 12-shot test below.)
const result = calculateCompliance([], schedules, 30, undefined, {
medicationContext: ctx({
lastIntakeAt: new Date(NOW.getTime() - 5 * DAY_MS),
@@ -531,6 +537,127 @@ describe("calculateCompliance — engine-routed (medicationContext)", () => {
expect(result.rate).toBe(100);
});
// ──────────────────────────────────────────────────────────────────
// v1.13.x — ROLLING retrospective expansion. The canonical engine's
// `expandRolling` is forward-only: it emits at most the single
// immediately-next slot, so a historical compliance window over a
// rolling weekly injection (the GLP-1 default) saw either zero expected
// slots (vacuous 100%) or one overdue slot (hard 0%) — never the true
// multi-dose adherence. The compliance layer now reconstructs the
// historical grid from the logged intakes (each dose is one satisfied
// expected slot, with synthesized misses for skipped whole cycles + a
// past-due forward slot). These tests pin that the live "0% despite many
// recorded intakes" defect is fixed.
// ──────────────────────────────────────────────────────────────────
it("rolling rollingIntervalDays=7 — 12 weekly shots logged → high rate, not 0%/100%-vacuous", () => {
const schedules: ComplianceSchedule[] = [
{
windowStart: "08:00",
windowEnd: "09:00",
daysOfWeek: null,
rollingIntervalDays: 7,
timesOfDay: ["08:00"],
},
];
// 12 consecutive weekly shots, each ~7 days apart, taken at varying
// times of day (the real-world off-HH:mm pattern). Most recent 3 days ago.
const lastShot = new Date(NOW.getTime() - 3 * DAY_MS);
const intakes = Array.from({ length: 12 }, (_, i) => {
const at = new Date(lastShot.getTime() - i * 7 * DAY_MS);
at.setUTCHours(19 + (i % 3), 0, 0, 0); // 19:00/20:00/21:00 — off the 08:00 slot
return { scheduledFor: at, takenAt: at, skipped: false };
});
const lastIntakeAt = intakes[0].takenAt;
for (const days of [30, 90]) {
const result = calculateCompliance(intakes, schedules, days, undefined, {
medicationContext: ctx({ lastIntakeAt, startsOn: intakes[11].takenAt }),
});
// Each logged shot is a satisfied expected slot → taken tracks the
// number of shots in the window; rate is high; not the broken 0%.
expect(result.taken).toBeGreaterThanOrEqual(days === 30 ? 4 : 12);
expect(result.rate).toBeGreaterThanOrEqual(90);
expect(result.missed).toBe(0);
}
});
it("rolling weekly — a shot logged a day late from its cycle still pairs (2 of 2, no phantom miss)", () => {
const schedules: ComplianceSchedule[] = [
{
windowStart: "08:00",
windowEnd: "09:00",
daysOfWeek: null,
rollingIntervalDays: 7,
timesOfDay: ["08:00"],
},
];
// Two shots: cycle anchor + a second shot logged ~8 days later (1 day
// late, inside the 1.5·N tolerance), then nothing — both count taken,
// none missed (the late dose must NOT synthesize a phantom missed cycle).
const first = new Date(NOW.getTime() - 16 * DAY_MS);
const second = new Date(first.getTime() + 8 * DAY_MS); // 1 day late
const intakes = [
{ scheduledFor: second, takenAt: second, skipped: false },
{ scheduledFor: first, takenAt: first, skipped: false },
];
const result = calculateCompliance(intakes, schedules, 30, undefined, {
medicationContext: ctx({ lastIntakeAt: second, startsOn: first }),
});
expect(result.taken).toBe(2);
expect(result.missed).toBe(0);
expect(result.rate).toBe(100);
});
it("rolling weekly — a 3-week gap between shots synthesizes the skipped cycles as missed", () => {
const schedules: ComplianceSchedule[] = [
{
windowStart: "08:00",
windowEnd: "09:00",
daysOfWeek: null,
rollingIntervalDays: 7,
timesOfDay: ["08:00"],
},
];
// Shot A, then a 21-day gap (2 missed cycles), then shot B.
const a = new Date(NOW.getTime() - 28 * DAY_MS);
const b = new Date(a.getTime() + 21 * DAY_MS);
const intakes = [
{ scheduledFor: b, takenAt: b, skipped: false },
{ scheduledFor: a, takenAt: a, skipped: false },
];
const result = calculateCompliance(intakes, schedules, 30, undefined, {
medicationContext: ctx({ lastIntakeAt: b, startsOn: a }),
});
// 2 taken (A, B) + ~2 synthesized missed cycles in the gap → ~50%.
expect(result.taken).toBe(2);
expect(result.missed).toBeGreaterThanOrEqual(1);
expect(result.rate).toBeLessThan(100);
});
it("rolling weekly — last shot 3 days ago, next due in 4 days → upcoming, excluded from denominator", () => {
const schedules: ComplianceSchedule[] = [
{
windowStart: "08:00",
windowEnd: "09:00",
daysOfWeek: null,
rollingIntervalDays: 7,
timesOfDay: ["08:00"],
},
];
const lastShot = new Date(NOW.getTime() - 3 * DAY_MS);
const intakes = [
{ scheduledFor: lastShot, takenAt: lastShot, skipped: false },
];
const result = calculateCompliance(intakes, schedules, 30, undefined, {
medicationContext: ctx({ lastIntakeAt: lastShot, startsOn: lastShot }),
});
// The one closed cycle (the logged shot) is taken; the open forward
// cycle is upcoming and must NOT count as missed → 100%, missed 0.
expect(result.missed).toBe(0);
expect(result.taken).toBe(1);
expect(result.rate).toBe(100);
});
// ──────────────────────────────────────────────────────────────────
// v1.12.0 — weekly-injectable matching radius. A weekly med (Mounjaro)
// is dosed once a week, but a real intake is almost never logged within
@@ -1376,6 +1503,130 @@ describe("buildComplianceDisplay — two rows, cadence-scaled windows", () => {
expect(display.long).toBeDefined();
});
it("7-day rolling injection with history → [30, 90] windows (not the widest fallback)", () => {
// v1.13.x Fix 3B — after the retrospective rolling expansion,
// `expectedSlotsBetween` returns the real per-cycle grid for rolling, so
// a 7-day rolling injection with a faithful history clears the floor on
// the [30, 90] rung instead of falling through to the widest [90, 365]
// fallback (the pre-fix behaviour, where rolling emitted ≤ 1 slot).
const schedules: ComplianceSchedule[] = [
{
windowStart: "08:00",
windowEnd: "09:00",
daysOfWeek: null,
rollingIntervalDays: 7,
timesOfDay: ["08:00"],
},
];
// ~16 weekly shots back from a most-recent shot 3 days ago.
const lastShot = new Date(NOW.getTime() - 3 * DAY_MS);
const events = Array.from({ length: 16 }, (_, i) => {
const at = new Date(lastShot.getTime() - i * 7 * DAY_MS);
at.setUTCHours(20, 0, 0, 0);
return { scheduledFor: at, takenAt: at, skipped: false };
});
const display = buildComplianceDisplay(
events,
schedules,
ctx({
startsOn: events[events.length - 1].takenAt,
lastIntakeAt: events[0].takenAt,
}),
{ now: NOW },
);
expect(display.shortDays).toBe(30);
expect(display.longDays).toBe(90);
expect(display.expectedShort).toBeGreaterThanOrEqual(MIN_STABLE_DOSES);
expect(display.expectedLong).toBeGreaterThanOrEqual(MIN_STABLE_DOSES);
});
it("currentCycle — rolling weekly, last shot 3 days ago → on_track (not a red miss)", () => {
// v1.13.x Fix 4 — a between-doses sparse med must NOT render a scary
// state. Last shot 3 days ago → next due in 4 days → the open cycle is
// on_track with a future nextDueAt.
const schedules: ComplianceSchedule[] = [
{
windowStart: "08:00",
windowEnd: "09:00",
daysOfWeek: null,
rollingIntervalDays: 7,
timesOfDay: ["08:00"],
},
];
const lastShot = new Date(NOW.getTime() - 3 * DAY_MS);
const events = [
{ scheduledFor: lastShot, takenAt: lastShot, skipped: false },
];
const display = buildComplianceDisplay(
events,
schedules,
ctx({ startsOn: lastShot, lastIntakeAt: lastShot }),
{ now: NOW },
);
expect(display.currentCycle.state).toBe("on_track");
expect(display.currentCycle.nextDueAt).not.toBeNull();
expect(display.currentCycle.nextDueAt!.getTime()).toBeGreaterThan(
NOW.getTime(),
);
expect(display.currentCycle.hasClosedCycles).toBe(true);
});
it("currentCycle — rolling weekly overdue past grace → missed", () => {
// v1.13.x Fix 4 — last shot ~10 days ago (cadence 7) → next due was 3
// days ago, well past grace → the open cycle is `missed` (the only red
// state). nextDueAt is in the past.
const schedules: ComplianceSchedule[] = [
{
windowStart: "08:00",
windowEnd: "09:00",
daysOfWeek: null,
rollingIntervalDays: 7,
timesOfDay: ["08:00"],
},
];
const lastShot = new Date(NOW.getTime() - 10 * DAY_MS);
const events = [
{ scheduledFor: lastShot, takenAt: lastShot, skipped: false },
];
const display = buildComplianceDisplay(
events,
schedules,
ctx({ startsOn: lastShot, lastIntakeAt: lastShot }),
{ now: NOW },
);
expect(display.currentCycle.state).toBe("missed");
expect(display.currentCycle.nextDueAt).not.toBeNull();
expect(display.currentCycle.nextDueAt!.getTime()).toBeLessThan(
NOW.getTime(),
);
});
it("currentCycle — brand-new sparse med with zero closed cycles → hasClosedCycles false", () => {
// v1.13.x Fix 4 — a med created today with no logged intake has no
// closed dose cycle; the percentage rows are vacuous and the card should
// show a neutral state rather than a misleading 100% / 0%.
const schedules: ComplianceSchedule[] = [
{
windowStart: "08:00",
windowEnd: "09:00",
daysOfWeek: null,
rollingIntervalDays: 7,
timesOfDay: ["08:00"],
},
];
const display = buildComplianceDisplay(
[],
schedules,
ctx({
startsOn: new Date(NOW.getTime() + 4 * DAY_MS), // first dose in the future
createdAt: NOW,
}),
{ now: NOW },
);
expect(display.currentCycle.hasClosedCycles).toBe(false);
expect(display.currentCycle.state).toBe("on_track");
});
it("a 2-day-old daily med's expected counts reflect its real age", () => {
// Created two days ago: the expected-dose counts must reflect ~2 days
// of daily doses, not the 7 / 30 a full-window walk would report. No
@@ -16,8 +16,14 @@ function row(
iso: string,
stage: SleepStage | null,
minutes: number,
source?: MeasurementSource,
): SleepStageRow {
return { measuredAt: new Date(iso), sleepStage: stage, value: minutes };
return {
measuredAt: new Date(iso),
sleepStage: stage,
value: minutes,
...(source ? { source } : {}),
};
}
/** Build a stage row tagged with an ingest source. */
@@ -535,3 +541,90 @@ describe("summarizeSleepNights", () => {
expect(summary.latest).toBe(570);
});
});
// A4 — the Insights sleep AVERAGE must come from the deduped per-night totals,
// never the raw per-stage sum that double-counts a bare ASLEEP aggregate against
// its granular twin (and folds IN_BED / AWAKE in on top). The ~20.3 h symptom is
// what a single night's stage rows summed without dedup produces.
describe("sleep average dedup (A4)", () => {
it("one source emits BOTH bare ASLEEP + granular for the same span → dedup total, not the sum", () => {
// Apple-Health-style double write for ONE overnight session: the bare
// ASLEEP aggregate (480) PLUS the granular CORE/DEEP/REM partition (also
// 480), plus IN_BED + AWAKE. Summed raw this is ~1490 min (~24.8 h); the
// deduped night total is the granular 480 min (8 h).
const rows: SleepStageRow[] = [
row("2026-06-04T06:00:00.000Z", "ASLEEP", 480, "APPLE_HEALTH"), // bare aggregate
row("2026-06-04T02:00:00.000Z", "CORE", 240, "APPLE_HEALTH"),
row("2026-06-04T04:00:00.000Z", "DEEP", 120, "APPLE_HEALTH"),
row("2026-06-04T06:00:00.000Z", "REM", 120, "APPLE_HEALTH"),
row("2026-06-04T06:30:00.000Z", "IN_BED", 470, "APPLE_HEALTH"),
row("2026-06-04T03:00:00.000Z", "AWAKE", 20, "APPLE_HEALTH"),
];
const { summary } = summarizeSleepNights(rows, "UTC");
// Granular partition wins: 240 + 120 + 120 = 480 min — NOT 480 + 480 + …
expect(summary.latest).toBe(480);
expect(summary.max).toBe(480);
// One night, not 6 stage rows.
expect(summary.count).toBe(1);
});
it("multi-source bare+granular collapses to the canonical source, no cross-source sum", () => {
// WHOOP granular + Apple bare-only for the SAME night. The canonical source
// (WHOOP, top of the sleep ladder) wins; the night total is WHOOP's dedup
// total, never WHOOP + Apple summed.
const rows: SleepStageRow[] = [
// WHOOP granular — 240 + 120 + 120 = 480.
row("2026-06-04T02:00:00.000Z", "CORE", 240, "WHOOP"),
row("2026-06-04T04:00:00.000Z", "DEEP", 120, "WHOOP"),
row("2026-06-04T06:00:00.000Z", "REM", 120, "WHOOP"),
// Apple bare aggregate for the same span — must NOT add on top.
row("2026-06-04T06:00:00.000Z", "ASLEEP", 470, "APPLE_HEALTH"),
];
const { summary } = summarizeSleepNights(rows, "UTC");
expect(summary.count).toBe(1);
expect(summary.latest).toBe(480); // WHOOP only, not 480 + 470.
});
});
// A5 — `reconstructSleepSessions` must never throw on a session the dedup
// empties (the unguarded `segments[0]` access). A read returns a valid empty
// night, never a 500.
describe("reconstructSleepSessions total-safety (A5)", () => {
it("never throws on an IN_BED/AWAKE-only session and yields no main night", () => {
const rows: SleepStageRow[] = [
row("2026-06-04T06:00:00.000Z", "IN_BED", 60),
row("2026-06-04T06:00:00.000Z", "AWAKE", 60),
];
expect(() => reconstructSleepSessions(rows, "UTC")).not.toThrow();
const sessions = reconstructSleepSessions(rows, "UTC");
// Every returned session has at least one renderable segment.
expect(sessions.every((s) => s.segments.length > 0)).toBe(true);
expect(pickMainNightAndNaps(sessions).main).toBeNull();
});
it("skips a session that empties after the granular-over-bare filter, never throws", () => {
// A session whose canonical pool, after dropping the redundant bare ASLEEP
// aggregate / stage-less twins under `sawGranular`, has zero renderable
// segments. The DEEP granular row marks the session granular; the bare +
// stage-less rows are then filtered out. The granular row survives here, so
// assert the load-bearing contract: no throw, and every returned session is
// non-empty.
const rows: SleepStageRow[] = [
row("2026-06-04T02:00:00.000Z", "DEEP", 1), // tiny granular marker
row("2026-06-04T06:00:00.000Z", "ASLEEP", 480), // redundant bare twin
row("2026-06-04T06:00:00.000Z", null, 480), // stage-less twin
];
expect(() => reconstructSleepSessions(rows, "UTC")).not.toThrow();
const sessions = reconstructSleepSessions(rows, "UTC");
expect(sessions.every((s) => s.segments.length > 0)).toBe(true);
});
it("never throws on a stage-less-only session", () => {
// Legacy / manual stage-less rows only — no granular partition, so they are
// the fallback signal and survive the filter; still must not throw.
const rows: SleepStageRow[] = [
row("2026-06-04T06:00:00.000Z", null, 480),
];
expect(() => reconstructSleepSessions(rows, "UTC")).not.toThrow();
});
});
+276 -6
View File
@@ -27,6 +27,8 @@ import {
} from "@/lib/medications/scheduling/cadence";
import { streaksFromTimeline } from "@/lib/medications/scheduling/compliance";
import {
expandRollingRetrospective,
nextOccurrenceAfter,
occurrencesBetween,
type CanonicalSchedule,
type Occurrence,
@@ -253,6 +255,28 @@ export function lastNonSkippedTakenAt(
}, null);
}
/**
* v1.13.x — the non-skipped `takenAt` instants at or before `now`, ascending.
* These anchor the retrospective rolling expected-dose grid (each logged dose
* is one satisfied expected slot). The full history is passed — not just the
* compliance window — so the gap-walk between consecutive intakes (which
* synthesizes skipped-cycle misses) and the forward next-due anchor stay
* correct across the window boundary; `expandRollingRetrospective` clamps the
* emitted slots to its own `[from, to]`.
*/
function rollingIntakeInstants(
events: { takenAt: Date | null; skipped: boolean }[],
now: Date,
): Date[] {
return events
.filter(
(e): e is { takenAt: Date; skipped: boolean } =>
!e.skipped && e.takenAt !== null && e.takenAt.getTime() <= now.getTime(),
)
.map((e) => e.takenAt)
.sort((a, b) => a.getTime() - b.getTime());
}
/**
* v1.8.5 — adapt a compliance medication context to the canonical engine's
* {@link RecurrenceContext}. The synthetic medication `id` only labels the
@@ -300,6 +324,59 @@ function toCanonicalSchedule(
};
}
/**
* v1.13.x — non-skipped intake instants for the retrospective rolling grid,
* mirroring {@link rollingIntakeInstants} but reading the public
* `{ takenAt, skipped }[]` shape the `expectedSlots*` callers pass. The
* `expectedSlots*` helpers accept this so their rolling denominator uses the
* SAME expansion as the displayed-rate numerator in {@link calculateCompliance}.
*/
export interface ComplianceIntakeInstant {
takenAt: Date | null;
skipped: boolean;
}
function intakeInstantsAtOrBefore(
intakes: ComplianceIntakeInstant[],
now: Date,
): Date[] {
return intakes
.filter(
(e): e is { takenAt: Date; skipped: boolean } =>
!e.skipped && e.takenAt !== null && e.takenAt.getTime() <= now.getTime(),
)
.map((e) => e.takenAt)
.sort((a, b) => a.getTime() - b.getTime());
}
/**
* v1.13.x — expand one schedule's expected occurrences over `[from, to]`,
* routing a ROLLING schedule through the retrospective builder when intake
* history is supplied (`intakeInstants` + `now`) and the forward-only engine
* path for every other shape. This is the single expansion the slot-count
* helpers and the displayed-rate timeline both delegate to, so the heatmap
* `due` flags, the window-selection denominator, and the percentage agree.
*/
function expandComplianceOccurrences(
canonical: CanonicalSchedule,
recurrenceCtx: RecurrenceContext,
from: Date,
to: Date,
retro: { intakeInstants: Date[]; now: Date } | undefined,
): Occurrence[] {
if (retro && canonical.rollingIntervalDays !== null) {
return expandRollingRetrospective(
canonical,
recurrenceCtx,
from,
to,
retro.intakeInstants,
retro.now,
);
}
return occurrencesBetween(canonical, from, to, recurrenceCtx);
}
/**
* v1.7.0 item 5 — count the expected dose slots a medication's schedules
* emit inside `[dayStart, dayEnd)`, routed through the canonical engine.
@@ -307,23 +384,36 @@ function toCanonicalSchedule(
* compliance payload so iOS history renders a "missed" mark only on days
* the schedule actually expected a dose (not off-weeks / non-matching
* weekdays / PRN days).
*
* v1.13.x — pass `intakes` so a ROLLING schedule routes through the
* retrospective grid (each logged dose is a `due` day, plus skipped-cycle
* misses + a past-due forward slot) instead of the engine's single forward
* slot. Omitting `intakes` keeps the forward-only behaviour for callers that
* don't have the intake history at hand.
*/
export function expectedSlotCountForDay(
schedules: ComplianceSchedule[],
dayStart: Date,
dayEnd: Date,
ctx: ComplianceMedicationContext,
intakes?: ComplianceIntakeInstant[],
): number {
let count = 0;
const recurrenceCtx = toRecurrenceCtx(ctx, "compliance-daily");
const now = new Date();
const retro =
intakes && schedules.some((s) => s.rollingIntervalDays != null)
? { intakeInstants: intakeInstantsAtOrBefore(intakes, now), now }
: undefined;
for (let i = 0; i < schedules.length; i++) {
count += occurrencesBetween(
count += expandComplianceOccurrences(
toCanonicalSchedule(schedules[i], `compliance-daily-${i}`),
recurrenceCtx,
dayStart,
// occurrencesBetween is inclusive of both ends; subtract 1 ms so a
// slot exactly at the next day's midnight doesn't double-count.
new Date(dayEnd.getTime() - 1),
recurrenceCtx,
retro,
).length;
}
return count;
@@ -356,18 +446,29 @@ export function expectedSlotsBetween(
from: Date,
to: Date,
ctx: ComplianceMedicationContext,
intakes?: ComplianceIntakeInstant[],
): Occurrence[] {
const recurrenceCtx = toRecurrenceCtx(ctx, "compliance-slots");
const effectiveFrom =
ctx.createdAt.getTime() > from.getTime() ? ctx.createdAt : from;
// v1.13.x — for a ROLLING schedule the expected grid is reconstructed from
// the intake history (each logged dose is one satisfied expected slot) so
// the window-selection denominator matches the displayed rate. The forward
// next-due slot counts only when past-due relative to `to` (the window's
// upper bound), so a not-yet-due open cycle never inflates the count.
const retro =
intakes && schedules.some((s) => s.rollingIntervalDays != null)
? { intakeInstants: intakeInstantsAtOrBefore(intakes, to), now: to }
: undefined;
const all: Occurrence[] = [];
for (let i = 0; i < schedules.length; i++) {
all.push(
...occurrencesBetween(
...expandComplianceOccurrences(
toCanonicalSchedule(schedules[i], `compliance-slots-${i}`),
recurrenceCtx,
effectiveFrom,
to,
recurrenceCtx,
retro,
),
);
}
@@ -432,7 +533,7 @@ export interface ComplianceWindowSelection {
export function selectComplianceWindows(
schedules: ComplianceSchedule[],
ctx: ComplianceMedicationContext,
options?: { now?: Date },
options?: { now?: Date; intakes?: ComplianceIntakeInstant[] },
): ComplianceWindowSelection {
const now = options?.now ?? new Date();
const DAY_MS = 24 * 60 * 60 * 1000;
@@ -443,6 +544,7 @@ export function selectComplianceWindows(
new Date(now.getTime() - days * DAY_MS),
now,
ctx,
options?.intakes,
).length;
// Memoise per distinct window so a shared rung boundary (e.g. 30 / 90)
@@ -488,6 +590,43 @@ export function selectComplianceWindows(
* steps both windows up so each row covers enough expected doses to be
* meaningful.
*/
/**
* v1.13.x Fix 4 — the current-cycle descriptor. SEPARATE from the
* percentage rows: a sparse med (weekly+ injection) whose current cycle is
* not yet due must NOT render a scary red 0%. The percentage rows reflect
* only CLOSED cycles (the open forward cycle is `upcoming`, excluded from
* the denominator); this descriptor carries the open-cycle state so the
* card can render a neutral "next dose in N days" / "due today" / "overdue"
* line decoupled from the rate.
*
* States:
* - `on_track`: `now < nextDueAt`. The next dose simply hasn't come round.
* - `due`: `nextDueAt ≤ now ≤ graceUntil`. A neutral / amber call-to-action.
* - `missed`: `now > graceUntil` and the cycle has no logged intake. The
* only state that should tint red.
* - `none`: no schedule projects a next dose (PRN, paused, ended). The
* card shows no current-cycle line.
*
* `hasClosedCycles` is false for a brand-new sparse med with zero closed
* dose cycles — the card then surfaces a neutral "no closed dose cycles
* yet" state instead of a misleading 100% / 0%.
*/
export type CurrentCycleState = "on_track" | "due" | "missed" | "none";
export interface CurrentCycle {
state: CurrentCycleState;
/** The open cycle's due instant (ISO via JSON). Null when `state === "none"`. */
nextDueAt: Date | null;
/** End of the due slot's grace window. Null when `state === "none"`. */
graceUntil: Date | null;
/**
* Whether the trailing window holds at least one CLOSED dose cycle. When
* false the percentage rows are vacuous (no closed cycle to score) and the
* card should show a neutral "not enough data yet" state.
*/
hasClosedCycles: boolean;
}
export interface ComplianceDisplay {
shortDays: number;
longDays: number;
@@ -501,6 +640,102 @@ export interface ComplianceDisplay {
short: { rate: number; streak: number };
/** Compliance percentage over the long window. */
long: { rate: number };
/**
* v1.13.x Fix 4 — the open-cycle state, decoupled from the percentage
* rows so a between-doses sparse med never renders a scary red number.
*/
currentCycle: CurrentCycle;
}
/**
* v1.13.x Fix 4 — classify the medication's open (current) dose cycle.
*
* Uses the canonical engine's {@link nextOccurrenceAfter} to find the
* earliest projected dose at or after the start of the search (one ms before
* `now` so a slot landing exactly now is captured). The rolling branch of
* `nextOccurrenceAfter` floors to the start of the user's current day, so an
* overdue rolling dose still surfaces as the next slot — that lets us tell a
* not-yet-due (`on_track`) cycle apart from an overdue one (`due` / `missed`).
*
* `hasClosedCycles` reads `expectedShort` from the already-computed window
* selection: a window holding zero expected (closed) doses means the
* percentage rows are vacuous and the card should show a neutral state.
*/
export function buildCurrentCycle(
schedules: ComplianceSchedule[],
ctx: ComplianceMedicationContext,
now: Date,
expectedShort: number,
intakes?: ComplianceIntakeInstant[],
): CurrentCycle {
const recurrenceCtx = toRecurrenceCtx(ctx, "compliance-cycle");
const DAY_MS = 24 * 60 * 60 * 1000;
const lastIntakeAt =
lastNonSkippedTakenAt(intakes ?? []) ?? ctx.lastIntakeAt;
// The open cycle's due instant + grace, picked as the SOONEST across every
// schedule. Two paths:
// - ROLLING: the engine's `nextOccurrenceAfter` deliberately rolls an
// overdue re-anchored dose forward (it floors to the start of the
// user's current day), so it cannot surface an open cycle that is more
// than a day overdue. For the current-cycle descriptor we instead
// compute the due instant directly: `(lastNonSkippedIntake) + N` (or
// `startsOn ?? createdAt` with no intake). This is what lets `due` /
// `missed` be distinguished from `on_track`.
// - NON-ROLLING: `nextOccurrenceAfter` already surfaces the next due
// slot (RRULE / legacy / one-shot / cyclic).
let dueAt: Date | null = null;
let graceUntil: Date | null = null;
const consider = (at: Date, grace: Date): void => {
if (dueAt === null || at.getTime() < dueAt.getTime()) {
dueAt = at;
graceUntil = grace;
}
};
for (let i = 0; i < schedules.length; i++) {
const s = schedules[i];
const canonical = toCanonicalSchedule(s, `compliance-cycle-${i}`);
if (canonical.scheduleType === "PRN") continue;
const n = canonical.rollingIntervalDays;
if (n !== null && n > 0) {
const anchor =
lastIntakeAt !== null
? new Date(lastIntakeAt.getTime() + n * DAY_MS)
: ctx.startsOn ?? ctx.createdAt;
if (ctx.endsOn && anchor.getTime() > ctx.endsOn.getTime()) continue;
const graceMs =
(canonical.reminderGraceMinutes ?? 60) * 60 * 1000;
consider(anchor, new Date(anchor.getTime() + graceMs));
continue;
}
const occ = nextOccurrenceAfter(
canonical,
new Date(now.getTime() - 1),
recurrenceCtx,
);
if (occ) consider(occ.at, occ.graceUntil);
}
const hasClosedCycles = expectedShort > 0;
if (dueAt === null || graceUntil === null) {
return { state: "none", nextDueAt: null, graceUntil: null, hasClosedCycles };
}
// Narrow the union-mutated locals for the comparisons below.
const due: Date = dueAt;
const grace: Date = graceUntil;
let state: CurrentCycleState;
if (now.getTime() < due.getTime()) {
state = "on_track";
} else if (now.getTime() <= grace.getTime()) {
state = "due";
} else {
state = "missed";
}
return { state, nextDueAt: due, graceUntil: grace, hasClosedCycles };
}
/**
@@ -519,8 +754,12 @@ export function buildComplianceDisplay(
options?: { now?: Date },
): ComplianceDisplay {
const now = options?.now ?? new Date();
// v1.13.x — thread the intake history so a ROLLING cadence's window
// selection scores the retrospective grid (each logged dose is a closed
// cycle) rather than the engine's single forward slot. Without this a
// weekly rolling med always falls through to the widest `[90, 365]` rung.
const { shortDays, longDays, expectedShort, expectedLong } =
selectComplianceWindows(schedules, ctx, { now });
selectComplianceWindows(schedules, ctx, { now, intakes: events });
const short = calculateCompliance(events, schedules, shortDays, ctx.createdAt, {
now,
@@ -531,6 +770,15 @@ export function buildComplianceDisplay(
medicationContext: ctx,
});
// v1.13.x Fix 4 — the open-cycle descriptor, separable from the rates.
const currentCycle = buildCurrentCycle(
schedules,
ctx,
now,
expectedShort,
events,
);
return {
shortDays,
longDays,
@@ -539,6 +787,7 @@ export function buildComplianceDisplay(
minStableDoses: MIN_STABLE_DOSES,
short: { rate: short.rate, streak: short.streak },
long: { rate: long.rate },
currentCycle,
};
}
@@ -665,6 +914,26 @@ export function calculateCompliance(
skipped: e.skipped,
}));
// v1.13.x — ROLLING retrospective expansion. A rolling cadence
// (`rollingIntervalDays`, the canonical GLP-1 "every N days" shape) is
// forward-only in the engine: `expandRolling` emits at most the single
// immediately-next slot, so a historical compliance window saw either
// zero expected slots (vacuous 100%) or one overdue slot (hard 0%) —
// never the true multi-dose adherence over the trailing window. When a
// medication context is threaded AND any schedule is rolling, route the
// rolling schedules through the retrospective builder so each logged dose
// is one satisfied expected slot (plus synthesized misses for skipped
// whole cycles + a past-due forward slot). Non-rolling schedules keep the
// forward-only engine path; both share `buildCadenceTimeline` so the
// numerator and denominator agree (the v1.7.3 B15 convergence rule).
const hasRolling = schedules.some(
(s) => s.rollingIntervalDays != null && s.rollingIntervalDays > 0,
);
const retro =
engineCtx && hasRolling
? { intakeInstants: rollingIntakeInstants(events, now), now }
: undefined;
const timeline = buildCadenceTimeline(
normalisedSchedules,
normalisedEvents,
@@ -673,6 +942,7 @@ export function calculateCompliance(
medicationCreatedAt ?? effectiveStart,
engineCtx?.timeZone,
engineCtx,
retro,
);
let taken = 0;
+15
View File
@@ -332,6 +332,12 @@ export function reconstructSleepNights(
let sawInBed = false;
let sawAwake = false;
let asleep = 0;
// `nightSessions` and each session pool are non-empty by construction: a
// key is only added with a `pool` that has ≥ 1 row (the source-filter falls
// back to the full session when the canonical filter empties it), so
// `nightSessions[0][0]` is safe. Unlike `reconstructSleepSessions`, this
// function never applies the granular-over-bare filter to the indexed pool,
// so it cannot empty the array here. Keep that invariant on future edits.
let latest = nightSessions[0][0].measuredAt;
const stages: Partial<Record<SleepStage, number>> = {};
for (const sessionRows of nightSessions) {
@@ -522,6 +528,15 @@ export function reconstructSleepSessions(
.map(segmentOf)
.sort((a, b) => a.start.getTime() - b.start.getTime());
// The granular-over-bare filter can empty a non-empty pool (a session whose
// only rows are the redundant bare ASLEEP aggregate / stage-less twins of a
// granular partition that did not survive into this pool). A session with no
// renderable/scorable segment contributes nothing — skip it before indexing
// `segments[0]`. This keeps `reconstructSleepSessions` total: it must NEVER
// throw on a shape the dedup empties, so `GET /api/sleep/night` (and every
// other consumer) returns a valid empty night, never a 500.
if (segments.length === 0) continue;
let inBed = 0;
let awake = 0;
let sawInBed = false;
@@ -4,6 +4,10 @@ import {
buildGradedSeriesFromPoints,
type GradedSeries,
} from "../graded-series";
import {
reconstructSleepNights,
type SleepStageRow,
} from "@/lib/analytics/sleep-night";
const dayMs = 24 * 60 * 60 * 1000;
@@ -111,4 +115,31 @@ describe("buildGradedSeriesFromPoints", () => {
expect(s.recent[0].mean).toBe(80);
expect(s.recent[0].n).toBe(3);
});
// A4 — the sleep AVERAGE the Insights assessment ships is the graded series
// built from DEDUPED per-night totals, never the raw per-stage sum that
// double-counts a bare ASLEEP aggregate against its granular twin (the
// impossible ~20.3 h symptom). This pins the path metric-status now uses:
// reconstruct nights → per-night points → buildGradedSeriesFromPoints.
it("sleep graded series uses the deduped night total, never the ~20 h stage sum (A4)", () => {
const sleepNow = new Date("2026-06-04T12:00:00.000Z");
// One overnight session, Apple-Health-style double write: bare ASLEEP
// aggregate (480) + granular CORE/DEEP/REM (also 480) + IN_BED + AWAKE.
// Raw-summed this is ~1490 min (~24.8 h); deduped it is 480 min (8 h).
const rows: SleepStageRow[] = [
{ measuredAt: new Date("2026-06-04T06:00:00.000Z"), sleepStage: "ASLEEP", value: 480, source: "APPLE_HEALTH" },
{ measuredAt: new Date("2026-06-04T02:00:00.000Z"), sleepStage: "CORE", value: 240, source: "APPLE_HEALTH" },
{ measuredAt: new Date("2026-06-04T04:00:00.000Z"), sleepStage: "DEEP", value: 120, source: "APPLE_HEALTH" },
{ measuredAt: new Date("2026-06-04T06:00:00.000Z"), sleepStage: "REM", value: 120, source: "APPLE_HEALTH" },
{ measuredAt: new Date("2026-06-04T06:30:00.000Z"), sleepStage: "IN_BED", value: 470, source: "APPLE_HEALTH" },
{ measuredAt: new Date("2026-06-04T03:00:00.000Z"), sleepStage: "AWAKE", value: 20, source: "APPLE_HEALTH" },
];
const points = reconstructSleepNights(rows, "UTC")
.filter((n) => n.asleepMinutes > 0)
.map((n) => ({ measuredAt: n.measuredAt, value: n.asleepMinutes }));
const graded = buildGradedSeriesFromPoints(points, sleepNow);
const recentMean = graded.recent.at(-1)?.mean ?? 0;
expect(recentMean).toBe(480); // night total, not ~1218 (stage sum)
expect(recentMean).toBeLessThan(960); // < 16 h — never impossible
});
});
@@ -135,6 +135,68 @@ describe("generateMetricStatus — SLEEP_DURATION night reconstruction (iOS E2)"
expect(prompt).not.toContain('"value": 75');
expect(prompt).not.toContain('"value": 90');
});
it("the snapshot graded series is the deduped night total, never the ~20 h raw-stage sum (A4)", async () => {
// The ~20.3 h symptom: a source writes BOTH a bare ASLEEP aggregate AND the
// granular CORE/DEEP/REM partition for the same span. Pre-fix the graded
// `series` came from `buildGradedSeriesWithRollups`, which folds bare + each
// granular stage + IN_BED + AWAKE into one day bucket (~1490 min ≈ 24.8 h).
// Post-fix the graded series is built from the deduped per-night points, so
// every recent bucket mean is the night total (480 min = 8 h).
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dateOfBirth: null,
gender: null,
} as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: new Date(),
} as never);
// A recent night (last 24 h) so it lands in `graded.recent`.
const wake = new Date(Date.now() - 6 * 60 * 60 * 1000);
const m = 60 * 1000;
const stageRows = [
{ value: 480, measuredAt: wake, sleepStage: "ASLEEP", source: "APPLE_HEALTH" }, // bare aggregate
{ value: 240, measuredAt: new Date(wake.getTime() - 240 * m), sleepStage: "CORE", source: "APPLE_HEALTH" },
{ value: 120, measuredAt: new Date(wake.getTime() - 120 * m), sleepStage: "DEEP", source: "APPLE_HEALTH" },
{ value: 120, measuredAt: wake, sleepStage: "REM", source: "APPLE_HEALTH" },
{ value: 470, measuredAt: wake, sleepStage: "IN_BED", source: "APPLE_HEALTH" },
{ value: 20, measuredAt: new Date(wake.getTime() - 200 * m), sleepStage: "AWAKE", source: "APPLE_HEALTH" },
];
vi.mocked(prisma.measurement.count).mockResolvedValue(
stageRows.length as never,
);
vi.mocked(prisma.measurement.findMany).mockResolvedValue(
stageRows as never,
);
const capture = { systemPrompt: null, userPrompt: null } as {
systemPrompt: string | null;
userPrompt: string | null;
};
stubCompletion("Summary: solide Nacht.", capture);
await generateMetricStatus({
metric: "SLEEP_DURATION",
userId: "u1",
locale: "de",
force: true,
});
const prompt = capture.userPrompt as string;
const match = prompt.match(/\{[\s\S]*\}/);
expect(match).toBeTruthy();
const snapshot = JSON.parse(match![0]);
const recent = snapshot.SLEEP_DURATION.series.recent as Array<{
mean: number;
}>;
expect(recent.length).toBeGreaterThan(0);
for (const bucket of recent) {
// Night total (480 min = 8 h), never the impossible ~1490-min stage sum.
expect(bucket.mean).toBeLessThanOrEqual(960); // ≤ 16 h
}
expect(recent.at(-1)?.mean).toBe(480);
});
});
describe("metric-status registry", () => {
+44 -9
View File
@@ -36,6 +36,12 @@ import {
import { buildSystemPromptWithReferences } from "@/lib/ai/prompts/insight-generator";
import { metricsFromPresentSections } from "@/lib/ai/medical-references";
import { summarize, type DataPoint } from "@/lib/analytics/trends";
import {
reconstructSleepNights,
type SleepStageRow,
} from "@/lib/analytics/sleep-night";
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import {
resolveDashboardLayout,
type ComparisonBaseline,
@@ -145,16 +151,45 @@ export async function buildComparisonSnapshotForUser(
const types = Object.keys(typeToSnapshotKey) as MeasurementType[];
const rows = await Promise.all(
types.map(async (type) => {
const measurements = await prisma.measurement.findMany({
where: { userId, type, deletedAt: null },
orderBy: { measuredAt: "asc" },
select: { measuredAt: true, value: true },
});
const summary = summarize(
measurements.map(
// SLEEP_DURATION is stored ONE ROW PER STAGE per night; summarising the
// raw stage rows mislabels a per-stage average as a night total (and
// double-counts a bare ASLEEP aggregate against its granular twin). Route
// the comparison through the per-night dedup reconstruction so the
// current/baseline averages are per-night TIME-ASLEEP totals in minutes.
let dataPoints: DataPoint[];
if (type === "SLEEP_DURATION") {
const [sleepRows, sleepTz, sleepPriority] = await Promise.all([
prisma.measurement.findMany({
where: { userId, type, deletedAt: null },
orderBy: { measuredAt: "asc" },
select: {
measuredAt: true,
value: true,
sleepStage: true,
source: true,
},
}),
resolveUserTimezone(userId),
loadUserSourcePriority(userId),
]);
dataPoints = reconstructSleepNights(
sleepRows as SleepStageRow[],
sleepTz,
sleepPriority,
)
.filter((n) => n.asleepMinutes > 0)
.map((n) => ({ date: n.measuredAt, value: n.asleepMinutes }));
} else {
const measurements = await prisma.measurement.findMany({
where: { userId, type, deletedAt: null },
orderBy: { measuredAt: "asc" },
select: { measuredAt: true, value: true },
});
dataPoints = measurements.map(
(m): DataPoint => ({ date: m.measuredAt, value: m.value }),
),
);
);
}
const summary = summarize(dataPoints);
const baselineAvg =
baseline === "lastMonth"
? (summary.avg30LastMonth ?? null)
+28 -2
View File
@@ -12,6 +12,11 @@ import {
lastNonSkippedTakenAt,
} from "@/lib/analytics/compliance";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import {
reconstructSleepNights,
type SleepStageRow,
} from "@/lib/analytics/sleep-night";
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
import { getBpTargets } from "@/lib/analytics/bp-targets";
import { isBpReadingInTarget } from "@/lib/analytics/bp-in-target";
import {
@@ -568,14 +573,35 @@ export async function extractFeatures(
}
// Sleep Duration
//
// SLEEP_DURATION is stored ONE ROW PER STAGE per night, so summarising the
// raw stage rows would average individual stages (and double-count a bare
// ASLEEP aggregate against its granular CORE/DEEP/REM twin). Route the
// feature block through the per-night dedup reconstruction — the same helper
// the dashboard / series / status path use — so `avg7` / `avg30` / `latest`
// are per-night TIME-ASLEEP totals in minutes, never stage averages.
const sleepData = byType("SLEEP_DURATION");
if (sleepData.length > 0) {
const summary = summarize(toDataPoints(sleepData));
const [sleepTz, sleepPriority] = await Promise.all([
resolveUserTimezone(userId),
loadUserSourcePriority(userId),
]);
const sleepNights = reconstructSleepNights(
sleepData as unknown as SleepStageRow[],
sleepTz,
sleepPriority,
).filter((n) => n.asleepMinutes > 0);
const summary = summarize(
sleepNights.map((n) => ({ date: n.measuredAt, value: n.asleepMinutes })),
);
features.sleep = {
avg7: summary.avg7,
avg30: summary.avg30,
latest: summary.latest,
coverage: computeCoverage(sleepData, now),
coverage: computeCoverage(
sleepNights.map((n) => ({ measuredAt: n.measuredAt })),
now,
),
};
}
+15 -5
View File
@@ -47,6 +47,7 @@ import { lookupNormalRange } from "@/lib/insights/derived/norms";
import { getNoKeyGeneralStatusText } from "@/lib/insights/no-key-fallbacks";
import { applyPayloadBudget } from "@/lib/insights/bucket-series";
import {
buildGradedSeriesFromPoints,
buildGradedSeriesWithRollups,
degradeStatusSnapshotToBudget,
} from "@/lib/insights/graded-series";
@@ -271,11 +272,20 @@ export async function generateMetricStatus(args: {
measurements = rows.map((m) => ({ measuredAt: m.measuredAt }));
}
const series = applyPayloadBudget(points, { now });
const graded = await buildGradedSeriesWithRollups(
args.userId,
meta.measurementType,
now,
);
// SLEEP_DURATION must never go through `buildGradedSeriesWithRollups`: that
// path reads RAW per-stage rows (and the stage-summed `measurement_rollups`
// tier) and folds IN_BED + AWAKE + bare ASLEEP + CORE/DEEP/REM into one
// bucket, double-counting the bare aggregate against its granular partition —
// it produces an impossible ~20 h "average sleep". Build the graded series
// straight from the deduped per-night TIME-ASLEEP `points` instead, so every
// recent/weekly/monthly/yearly bucket is a night total, not a stage sum.
const graded = isSleep
? buildGradedSeriesFromPoints(points, now)
: await buildGradedSeriesWithRollups(
args.userId,
meta.measurementType,
now,
);
const summary = summarizeSeries(
series.daily.map((bucket) => ({ value: bucket.value })),
);
+43 -6
View File
@@ -32,10 +32,31 @@ import {
type CanonicalSchedule,
type RecurrenceContext,
type ScheduleType,
expandRollingRetrospective,
occurrencesBetween,
} from "@/lib/medications/scheduling/recurrence";
import { wallClockInTz } from "@/lib/tz/wall-clock";
/**
* v1.13.x compliance — opt-in retrospective-rolling expansion.
*
* The canonical engine's `expandRolling` is forward-only (correct for the
* projector / next-due surfaces). For the compliance + detail-page
* timeline a ROLLING cadence must instead reconstruct its historical
* expected-dose grid from the logged intakes. When a caller threads this
* option, rolling schedules route through `expandRollingRetrospective`
* (each logged intake is one satisfied slot, plus synthesized misses for
* skipped whole cycles + a past-due forward slot). Every non-rolling
* shape and every caller that omits the option keeps the forward-only
* engine path byte-for-byte.
*/
export interface RetrospectiveRollingOptions {
/** Non-skipped intake instants for the medication (the slot anchors). */
intakeInstants: Date[];
/** Wall-clock "now" — decides whether the forward next-due slot is past-due. */
now: Date;
}
export interface ScheduleLike {
/** "HH:mm" 24h, user-tz reference (per existing schema). */
windowStart: string;
@@ -307,6 +328,7 @@ export function expandScheduleSlots(
anchor: Date = from,
timeZone?: string,
engineCtx?: CadenceEngineContext,
retro?: RetrospectiveRollingOptions,
): ExpectedDose[] {
if (to <= from) return [];
@@ -337,12 +359,22 @@ export function expandScheduleSlots(
// landing exactly at the window boundary isn't counted by the engine
// path but dropped by the legacy path — the two branches stay
// denominator-equivalent at the edge.
const occurrences = occurrencesBetween(
canonical,
from,
new Date(to.getTime() - 1),
recurrenceCtx,
);
const inclusiveTo = new Date(to.getTime() - 1);
// v1.13.x — ROLLING + retrospective opt-in: reconstruct the historical
// expected-dose grid from the logged intakes rather than the engine's
// single forward slot. Only rolling schedules take this branch; every
// other shape still routes through `occurrencesBetween`.
const occurrences =
retro && canonical.rollingIntervalDays !== null
? expandRollingRetrospective(
canonical,
recurrenceCtx,
from,
inclusiveTo,
retro.intakeInstants,
retro.now,
)
: occurrencesBetween(canonical, from, inclusiveTo, recurrenceCtx);
return occurrences.map((occ) => ({
day: startOfLocalDay(occ.at, timeZone),
windowStart: occ.at,
@@ -594,6 +626,7 @@ export function buildCadenceTimeline(
anchor?: Date,
timeZone?: string,
engineCtx?: CadenceEngineContext,
retro?: RetrospectiveRollingOptions,
): PairedDose[] {
const from = new Date(asOf.getTime() - windowDays * DAY_MS);
const slots: ExpectedDose[] = [];
@@ -607,6 +640,7 @@ export function buildCadenceTimeline(
anchor ?? from,
timeZone,
engineCtx,
retro,
),
);
}
@@ -622,6 +656,7 @@ export function buildCadenceTimeline(
anchor,
timeZone,
engineCtx,
retro,
);
return pairDoses(slots, events, asOf, { radiusFloorMs });
}
@@ -649,6 +684,7 @@ function cadenceRadiusFloor(
anchor: Date | undefined,
timeZone: string | undefined,
engineCtx: CadenceEngineContext | undefined,
retro?: RetrospectiveRollingOptions,
): number {
const probeDays = Math.max(windowDays, 16 * 7);
const probeFrom = new Date(asOf.getTime() - probeDays * DAY_MS);
@@ -662,6 +698,7 @@ function cadenceRadiusFloor(
anchor ?? probeFrom,
timeZone,
engineCtx,
retro,
);
for (const slot of probeSlots) {
centres.push((slot.windowStart.getTime() + slot.windowEnd.getTime()) / 2);
@@ -423,6 +423,138 @@ function expandRolling(
return [buildOccurrence(at, time, schedule)];
}
/**
* v1.13.x compliance — RETROSPECTIVE rolling expansion.
*
* `expandRolling` is forward-only by contract (the projector + reminder
* worker depend on it emitting ONLY the immediately-next slot). That is
* correct for "what's next" surfaces but wrong for the compliance grid,
* which must reconstruct the *historical* expected-dose slots a rolling
* cadence implies over a trailing window so a faithfully-adherent weekly
* injection reads its true rate instead of the vacuous 100%-or-0% flip
* the forward-only path produced.
*
* Medically-correct reading: a rolling "every N days from last dose"
* cadence re-anchors on each intake, so the retrospective expected grid
* *is* the observed intake history (each logged dose is exactly one
* satisfied expected slot) PLUS:
*
* - back-filled `missed` slots for genuinely skipped whole cycles: when
* two consecutive intakes are more than `1.5 · N` apart the user
* skipped one or more cycles → emit a slot at `prevIntake + k·N` for
* each whole cycle the gap spans (the `1.5·N` tolerance gate means a
* dose logged a day late never synthesizes a phantom miss); and
* - the single forward next-due slot, emitted ONLY when it is past-due
* (`nextDue ≤ now`). A not-yet-due forward slot is `upcoming` and is
* excluded so the open current cycle never counts against the
* denominator.
*
* Because the back-filled / forward slots carry no nearby intake, they
* pair to nothing in `pairDoses` and read `missed`; the per-intake slots
* are anchored AT the intake instant so they pair (distance 0) and read
* `taken`. The same grid feeds the count-only callers
* (`expectedSlotsBetween` / `expectedSlotCountForDay`) so numerator,
* denominator, and the heatmap `due` flags route through ONE expansion
* (the v1.7.3 B15 numerator/denominator-convergence rule).
*
* Kept here (not folded into `expandRolling`) so the engine's forward
* contract is untouched; compliance opts in explicitly via
* `expandRollingRetrospective`.
*
* @param intakeInstants Non-skipped intake instants for THIS medication
* (ascending or unsorted — sorted internally). The compliance layer
* supplies these; the engine has no DB access.
* @param now The wall-clock anchor (the compliance window's `to`-side
* "now"), used to decide whether the forward next-due slot is past-due.
*/
export function expandRollingRetrospective(
schedule: CanonicalSchedule,
ctx: RecurrenceContext,
from: Date,
to: Date,
intakeInstants: Date[],
now: Date,
): Occurrence[] {
const n = schedule.rollingIntervalDays;
if (n === null || n <= 0) return [];
const time = schedule.timesOfDay[0] ?? schedule.windowStart;
const cycleMs = n * DAY_MS;
// Tolerance: a gap up to 1.5·N is "on time" (a dose logged a little
// late). Only a gap strictly beyond this synthesizes skipped cycles.
const gapToleranceMs = 1.5 * cycleMs;
const endsCap = ctx.medication.endsOn
? endOfUtcDay(ctx.medication.endsOn).getTime()
: Infinity;
const inWindow = (at: Date): boolean =>
at.getTime() >= from.getTime() &&
at.getTime() <= to.getTime() &&
at.getTime() <= endsCap;
const slots: Occurrence[] = [];
// Each logged intake is a satisfied expected slot at its own instant.
// Sort ascending so the gap walk between consecutive intakes is correct.
const sorted = [...intakeInstants].sort((a, b) => a.getTime() - b.getTime());
for (const instant of sorted) {
if (inWindow(instant)) {
slots.push(buildOccurrence(instant, time, schedule));
}
}
// Back-fill missed slots for genuinely skipped whole cycles between
// consecutive intakes (gap > 1.5·N). The synthesized slots land at the
// schedule's time-of-day on each skipped cycle's day so they read as a
// real expected dose in the user's timezone.
for (let i = 1; i < sorted.length; i++) {
const prev = sorted[i - 1];
const next = sorted[i];
const gap = next.getTime() - prev.getTime();
if (gap <= gapToleranceMs) continue;
// Number of whole cycles the user skipped: floor(gap/N) − 1 missed
// cycles sit strictly between the two intakes.
const cyclesInGap = Math.floor(gap / cycleMs);
for (let k = 1; k < cyclesInGap; k++) {
const anchor = new Date(prev.getTime() + k * cycleMs);
const at = applyTimeOfDayToDate(anchor, time, ctx.timeZone);
if (inWindow(at)) {
slots.push(buildOccurrence(at, time, schedule));
}
}
}
// The single forward next-due slot — emitted as a missed slot ONLY when
// the open cycle is GENUINELY overdue, i.e. `now` is more than half a
// cycle past `nextDue`. A dose merely a little late (within `0.5·N`) is
// the current open cycle still in its window — it must NOT synthesize a
// phantom miss (the N-tolerance gate). A not-yet-due forward slot
// (`nextDue > now`) is excluded entirely (upcoming → out of the
// denominator). This keeps the open current cycle off the percentage rows
// until it is unambiguously missed; the `currentCycle` descriptor carries
// the softer on_track / due / missed state for the card.
const forwardToleranceMs = cycleMs / 2;
const lastInstant =
sorted.length > 0 ? sorted[sorted.length - 1] : ctx.lastIntakeAt;
const nextDue =
lastInstant !== null
? new Date(lastInstant.getTime() + cycleMs)
: ctx.medication.startsOn ?? ctx.medication.createdAt;
if (now.getTime() - nextDue.getTime() > forwardToleranceMs) {
const at = applyTimeOfDayToDate(nextDue, time, ctx.timeZone);
// Dedupe: a forward slot already represented by a logged intake (the
// intake re-anchored exactly N days out) must not double-count.
const alreadyPresent = slots.some(
(s) => Math.abs(s.at.getTime() - at.getTime()) < cycleMs / 2,
);
if (inWindow(at) && !alreadyPresent) {
slots.push(buildOccurrence(at, time, schedule));
}
}
return slots.sort((a, b) => a.at.getTime() - b.at.getTime());
}
// ────────────────────────────────────────────────────────────────────
// Dispatch — RRULE
// ────────────────────────────────────────────────────────────────────
+26
View File
@@ -1243,6 +1243,32 @@ const complianceDisplay = z
streak: z.number().int().nonnegative(),
}),
long: z.object({ rate: z.number().int().min(0).max(100) }),
currentCycle: z
.object({
state: z
.enum(["on_track", "due", "missed", "none"])
.describe(
"Open-cycle state, decoupled from the percentage rows: `on_track` = next dose not yet due; `due` = due now / in grace; `missed` = past grace with no logged intake (the only red state); `none` = no projected next dose (PRN / paused / ended).",
),
nextDueAt: z.iso
.datetime({ offset: true })
.nullable()
.describe("The open cycle's due instant. Null when `state` is `none`."),
graceUntil: z.iso
.datetime({ offset: true })
.nullable()
.describe(
"End of the due slot's grace window. Null when `state` is `none`.",
),
hasClosedCycles: z
.boolean()
.describe(
"False for a brand-new sparse med with zero closed dose cycles — the percentage rows are vacuous and the card should show a neutral 'not enough data yet' state.",
),
})
.describe(
"v1.13.x — the current (open) dose cycle, surfaced so a between-doses sparse med renders a neutral 'next dose in N days' line instead of a scary red 0%. The percentage rows above already exclude the open forward cycle from their denominator.",
),
})
.meta({
id: "ComplianceDisplay",