mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Merge pull request #272 from MBombeck/release/v1.13.2
release/v1.13.2 — compliance + sleep fixes + wellness redesign
This commit is contained in:
30 files changed
+1405
-269
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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)",
|
||||
};
|
||||
@@ -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}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 })),
|
||||
);
|
||||
|
||||
@@ -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
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user