From a55816bf58351283d0537d68c8859ff209d9cfed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 5 Jun 2026 22:08:59 +0200 Subject: [PATCH 1/4] fix(medications): retrospective rolling-cadence compliance + currentCycle descriptor --- docs/api/openapi.yaml | 41 +++ .../api/medications/[id]/compliance/route.ts | 5 + .../analytics/__tests__/compliance.test.ts | 257 +++++++++++++++- src/lib/analytics/compliance.ts | 282 +++++++++++++++++- src/lib/medications/scheduling/cadence.ts | 49 ++- src/lib/medications/scheduling/recurrence.ts | 132 ++++++++ src/lib/openapi/routes.ts | 26 ++ 7 files changed, 777 insertions(+), 15 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 174c490b3..fd805f6a9 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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. diff --git a/src/app/api/medications/[id]/compliance/route.ts b/src/app/api/medications/[id]/compliance/route.ts index 779506b34..1afbdcccb 100644 --- a/src/app/api/medications/[id]/compliance/route.ts +++ b/src/app/api/medications/[id]/compliance/route.ts @@ -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] = { diff --git a/src/lib/analytics/__tests__/compliance.test.ts b/src/lib/analytics/__tests__/compliance.test.ts index aa76b6b22..7e190da28 100644 --- a/src/lib/analytics/__tests__/compliance.test.ts +++ b/src/lib/analytics/__tests__/compliance.test.ts @@ -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 diff --git a/src/lib/analytics/compliance.ts b/src/lib/analytics/compliance.ts index ad530ee25..97aad3372 100644 --- a/src/lib/analytics/compliance.ts +++ b/src/lib/analytics/compliance.ts @@ -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; diff --git a/src/lib/medications/scheduling/cadence.ts b/src/lib/medications/scheduling/cadence.ts index 1e5cb7ab3..6dab236a7 100644 --- a/src/lib/medications/scheduling/cadence.ts +++ b/src/lib/medications/scheduling/cadence.ts @@ -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); diff --git a/src/lib/medications/scheduling/recurrence.ts b/src/lib/medications/scheduling/recurrence.ts index c0db43330..600835ee5 100644 --- a/src/lib/medications/scheduling/recurrence.ts +++ b/src/lib/medications/scheduling/recurrence.ts @@ -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 // ──────────────────────────────────────────────────────────────────── diff --git a/src/lib/openapi/routes.ts b/src/lib/openapi/routes.ts index 6d08d06d5..276e31402 100644 --- a/src/lib/openapi/routes.ts +++ b/src/lib/openapi/routes.ts @@ -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", From 874794e0e43dd5b1309db0e5757bc1376dbfde24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 5 Jun 2026 22:08:59 +0200 Subject: [PATCH 2/4] fix(insights): sleep average via deduped reconstruction + night-reconstruction guard --- src/app/api/insights/generate/route.ts | 53 +++++++++-- .../api/sleep/night/__tests__/route.test.ts | 33 +++++++ .../analytics/__tests__/sleep-night.test.ts | 95 ++++++++++++++++++- src/lib/analytics/sleep-night.ts | 15 +++ .../insights/__tests__/graded-series.test.ts | 31 ++++++ .../insights/__tests__/metric-status.test.ts | 62 ++++++++++++ src/lib/insights/comprehensive-generate.ts | 53 +++++++++-- src/lib/insights/features.ts | 30 +++++- src/lib/insights/metric-status.ts | 20 +++- 9 files changed, 366 insertions(+), 26 deletions(-) diff --git a/src/app/api/insights/generate/route.ts b/src/app/api/insights/generate/route.ts index 3415c1109..4b4ded190 100644 --- a/src/app/api/insights/generate/route.ts +++ b/src/app/api/insights/generate/route.ts @@ -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) diff --git a/src/app/api/sleep/night/__tests__/route.test.ts b/src/app/api/sleep/night/__tests__/route.test.ts index 0be2ab6b5..a4ee765d4 100644 --- a/src/app/api/sleep/night/__tests__/route.test.ts +++ b/src/app/api/sleep/night/__tests__/route.test.ts @@ -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 + }); }); diff --git a/src/lib/analytics/__tests__/sleep-night.test.ts b/src/lib/analytics/__tests__/sleep-night.test.ts index 3d6f7ee4b..a3febd779 100644 --- a/src/lib/analytics/__tests__/sleep-night.test.ts +++ b/src/lib/analytics/__tests__/sleep-night.test.ts @@ -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(); + }); +}); diff --git a/src/lib/analytics/sleep-night.ts b/src/lib/analytics/sleep-night.ts index 70c57fd63..360e4f85d 100644 --- a/src/lib/analytics/sleep-night.ts +++ b/src/lib/analytics/sleep-night.ts @@ -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> = {}; 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; diff --git a/src/lib/insights/__tests__/graded-series.test.ts b/src/lib/insights/__tests__/graded-series.test.ts index 6beeef77b..aee792454 100644 --- a/src/lib/insights/__tests__/graded-series.test.ts +++ b/src/lib/insights/__tests__/graded-series.test.ts @@ -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 + }); }); diff --git a/src/lib/insights/__tests__/metric-status.test.ts b/src/lib/insights/__tests__/metric-status.test.ts index 9f0beb8ec..1f418837e 100644 --- a/src/lib/insights/__tests__/metric-status.test.ts +++ b/src/lib/insights/__tests__/metric-status.test.ts @@ -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", () => { diff --git a/src/lib/insights/comprehensive-generate.ts b/src/lib/insights/comprehensive-generate.ts index 4f1953c0c..ac82db54a 100644 --- a/src/lib/insights/comprehensive-generate.ts +++ b/src/lib/insights/comprehensive-generate.ts @@ -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) diff --git a/src/lib/insights/features.ts b/src/lib/insights/features.ts index fb2ed5a03..5e429520b 100644 --- a/src/lib/insights/features.ts +++ b/src/lib/insights/features.ts @@ -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, + ), }; } diff --git a/src/lib/insights/metric-status.ts b/src/lib/insights/metric-status.ts index 032ee9f7a..4855464d8 100644 --- a/src/lib/insights/metric-status.ts +++ b/src/lib/insights/metric-status.ts @@ -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 })), ); From e7d34eb85312506710a70b21e95828465f7eaa68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 5 Jun 2026 22:09:00 +0200 Subject: [PATCH 3/4] feat(insights): premium wellness-score tiles + SVG ring redesign --- messages/de.json | 2 +- messages/en.json | 2 +- messages/es.json | 2 +- messages/fr.json | 2 +- messages/it.json | 2 +- messages/pl.json | 2 +- src/app/globals.css | 39 ++- .../derived/__tests__/score-ring.test.tsx | 8 +- .../__tests__/wellness-scores.test.tsx | 14 +- src/components/insights/derived/ring-hues.ts | 62 ++++ .../insights/derived/score-ring.tsx | 298 ++++++++---------- .../insights/derived/wellness-scores.tsx | 42 +-- 12 files changed, 249 insertions(+), 226 deletions(-) create mode 100644 src/components/insights/derived/ring-hues.ts diff --git a/messages/de.json b/messages/de.json index c252373ab..17ead6fb8 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2782,7 +2782,7 @@ } }, "scores": { - "sectionTitle": "Wellness-Werte", + "sectionTitle": "Deine Gesundheitswerte", "loadError": "Deine Wellness-Werte konnten gerade nicht geladen werden.", "recovery": "Erholung", "stress": "Stress", diff --git a/messages/en.json b/messages/en.json index de823f4ee..0472b7588 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index 3d49d0d69..2fce32501 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index bb058fce4..4e52ce2ed 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", diff --git a/messages/it.json b/messages/it.json index b061550b9..ad4a4b251 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/pl.json b/messages/pl.json index a20339a0f..edcf0b5ba 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", diff --git a/src/app/globals.css b/src/app/globals.css index 7a88a0c7d..edb59abff 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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); } /* diff --git a/src/components/insights/derived/__tests__/score-ring.test.tsx b/src/components/insights/derived/__tests__/score-ring.test.tsx index 5afbaa253..e7d19cc45 100644 --- a/src/components/insights/derived/__tests__/score-ring.test.tsx +++ b/src/components/insights/derived/__tests__/score-ring.test.tsx @@ -53,12 +53,10 @@ describe("", () => { 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( - , - ); + const html = render(); expect(html).toContain('data-band="green"'); expect(html).toContain('role="img"'); expect(html).toContain("aria-label"); diff --git a/src/components/insights/derived/__tests__/wellness-scores.test.tsx b/src/components/insights/derived/__tests__/wellness-scores.test.tsx index 0e3ae67ac..5632a9871 100644 --- a/src/components/insights/derived/__tests__/wellness-scores.test.tsx +++ b/src/components/insights/derived/__tests__/wellness-scores.test.tsx @@ -66,7 +66,7 @@ describe("", () => { 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( ", () => { />, ); 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"'); }); diff --git a/src/components/insights/derived/ring-hues.ts b/src/components/insights/derived/ring-hues.ts new file mode 100644 index 000000000..01f8aff83 --- /dev/null +++ b/src/components/insights/derived/ring-hues.ts @@ -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 ``. */ +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 `` stops. + */ +export const RING_GRADIENT: Record = { + // 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 = { + readiness: "var(--success)", + sleep: "var(--info)", + recovery: "var(--dracula-cyan)", + stress: "var(--warning)", + strain: "var(--dracula-cyan)", +}; diff --git a/src/components/insights/derived/score-ring.tsx b/src/components/insights/derived/score-ring.tsx index 260a92664..98f88857f 100644 --- a/src/components/insights/derived/score-ring.tsx +++ b/src/components/insights/derived/score-ring.tsx @@ -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 `` swept by `stroke-dashoffset`, + * filled with a JSX `` (NOT a `dangerouslySetInnerHTML` — + * ``/`` are real SVG JSX). The `` 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 ``), 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, - { 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 `` 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 (
- - - {/* Pins the value→angle scale so the bar sweeps `value`% of the - track instead of always filling the full circle. */} - - - - - - + } + /> + + {/* Centred number as real DOM text — easier to style with the design + tokens + tabular-nums, and no SVG-text clipping on a long label. */} +
+ + {hasScore ? displayedRounded : "—"} + + {label ? ( + + {label} + + ) : null} +
{!hasScore && ( ; }) { 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" > -
+
{/* 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. */} - + 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. */} + {bandWord} @@ -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} />, ); From 25bed4262c0fead27113098dd1b962e1846fbf2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 5 Jun 2026 22:27:27 +0200 Subject: [PATCH 4/4] =?UTF-8?q?chore(release):=20v1.13.2=20=E2=80=94=20hon?= =?UTF-8?q?est=20weekly-med=20compliance,=20correct=20sleep=20average,=20c?= =?UTF-8?q?almer=20wellness=20scores?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rolling-cadence (weekly GLP-1 injection) compliance now reconstructs the full per-cycle history across the window instead of counting only the next-due dose, so a faithful injection history reads an honest rate, not a 0%/100% flip; adds a per-medication currentCycle state (on_track/due/missed/none) for a non-scary card. - Sleep average reads from the de-duplicated per-night reconstruction (bare ASLEEP + granular stages no longer double-count → no impossible ~20 h); night reconstruction hardened against an empty-segment throw. - Wellness-score tiles redesigned: gentle card-toned surface (was a saturated slab), a larger thinner custom SVG ring with a soft per-score gradient, renamed "Your health scores", fully legible in light + dark. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 11 +++++++++++ docs/api/openapi.yaml | 2 +- package.json | 2 +- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4476063e4..b22fac5bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index fd805f6a9..498df2d86 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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. diff --git a/package.json b/package.json index 9c0991674..13715e11a 100644 --- a/package.json +++ b/package.json @@ -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",