From 3de53f549cb84ac619d545eb81f90f1849901f3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 16 Jun 2026 00:39:55 +0200 Subject: [PATCH 1/3] fix(health-score): drop the mood pillar when the mood module is disabled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Personal Health Score weighted the 0.20 mood-stability pillar unconditionally — the fast-path read mood entries regardless of the account's module state, so a user who turned the mood module off was still scored (and silently penalised or rewarded) by a pillar hidden everywhere else in the product. The dashboard already blanks the mood tile and the coach snapshot unions mood out; the score ring and the insights card pillar list did not. Thread a default-on `moodEnabled` flag through the fast-path. When the caller resolves the mood module as disabled the helper skips the mood read entirely and leaves the series empty, so `moodStability` returns null and the existing proportional null-redistribution re-scales the surviving pillars to sum to 100. The analytics route resolves the module server-side and threads the flag; the insights card hides the Mood row via a new SSR-safe `useModuleEnabled` accessor so it never advertises a pillar the account turned off. Golden fixtures pin mood-on vs mood-off to different, correctly re-weighted scores and assert the disabled read is never issued. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app/api/analytics/route.ts | 8 ++ src/components/insights/health-score-card.tsx | 22 +++- src/components/insights/hero-strip.tsx | 7 + src/hooks/use-module-enabled.ts | 33 +++++ .../__tests__/health-score-fast-path.test.ts | 121 ++++++++++++++++++ src/lib/analytics/health-score-fast-path.ts | 46 +++++-- 6 files changed, 224 insertions(+), 13 deletions(-) create mode 100644 src/hooks/use-module-enabled.ts diff --git a/src/app/api/analytics/route.ts b/src/app/api/analytics/route.ts index 628a28b4a..7d37fde48 100644 --- a/src/app/api/analytics/route.ts +++ b/src/app/api/analytics/route.ts @@ -17,6 +17,7 @@ import { type BpInTargetEnvelope, } from "@/lib/analytics/bp-in-target-fast-path"; import { computeUserHealthScoreFastPath } from "@/lib/analytics/health-score-fast-path"; +import { isModuleEnabled } from "@/lib/modules/gate"; import { buildHealthScoreBpInputs } from "@/lib/analytics/health-score-inputs"; import { deriveBpWindow90 } from "@/lib/analytics/window-confidence"; import { computeCorrelationHypothesesFastPath } from "@/lib/analytics/correlations-fast-path"; @@ -420,12 +421,19 @@ async function buildAnalyticsResponse(user: AuthedUser) { // same prior-week delta values). The hand-rolled per-surface assembly that // let the two diverge is gone. const bpInputs = buildHealthScoreBpInputs(bpEnvelope, bpEnvelopePriorWeek); + // v1.18.0 R4 — resolve the `mood` module server-side so a disabled-mood + // account drops the mood-stability pillar from the ring instead of + // being silently scored on data hidden everywhere else. The dashboard + // already blanks the mood tile + the coach snapshot unions mood out; + // this closes the matching gap on the Health Score. + const moodEnabled = await isModuleEnabled(user.id, "mood"); const healthScore = await computeUserHealthScoreFastPath({ userId: user.id, ...bpInputs, heightCm: user.heightCm ?? null, now: new Date(), coverage, + moodEnabled, }); return { diff --git a/src/components/insights/health-score-card.tsx b/src/components/insights/health-score-card.tsx index 228304661..ee2091d43 100644 --- a/src/components/insights/health-score-card.tsx +++ b/src/components/insights/health-score-card.tsx @@ -78,6 +78,15 @@ export interface HealthScoreCardProps { * starts collapsed. */ initiallyExpanded?: boolean; + /** + * v1.18.0 R4 — when the `mood` module is disabled for the account the + * server already drops the mood-stability pillar from the score (the + * pillar arrives null-weighted). This hides the Mood row from the + * component + provenance lists too so the card never advertises a + * pillar the user turned off. Default-on so omitting it keeps the + * four-row layout for the common (mood-enabled) case. + */ + moodEnabled?: boolean; } type ComponentKey = keyof HealthScoreCardProps["components"]; @@ -164,6 +173,7 @@ export function HealthScoreCard({ // eslint-disable-next-line @typescript-eslint/no-unused-vars onAskCoach: _onAskCoach, initiallyExpanded = false, + moodEnabled = true, }: HealthScoreCardProps) { const { t, locale } = useTranslations(); // The asOf timestamps render under the source pill as a tooltip @@ -198,9 +208,14 @@ export function HealthScoreCard({ // without relying on visual proximity alone. const deltaExplainerId = useId(); - const componentEntries = ( - Object.keys(components) as Array - ).map((key) => ({ + // v1.18.0 R4 — drop the Mood pillar from the card's row lists when the + // module is disabled. The server already null-weights it in the score; + // hiding the row keeps the card from naming a pillar the account turned + // off (it would otherwise render as a permanently empty "Mood" line). + const visibleKeys = (Object.keys(components) as Array).filter( + (key) => moodEnabled || key !== "mood", + ); + const componentEntries = visibleKeys.map((key) => ({ key, label: t(COMPONENT_LABEL_KEY[key]), value: components[key].value, @@ -211,6 +226,7 @@ export function HealthScoreCard({ // null values sink to the bottom (`weight * 0 === 0`); the tie-break // is the alphabetical key order so determinism holds across renders. const provenanceRows = COMPONENT_ORDER + .filter((key) => moodEnabled || key !== "mood") .map((key) => { const c = components[key]; const inferredSource: HealthScoreComponentSource = diff --git a/src/components/insights/hero-strip.tsx b/src/components/insights/hero-strip.tsx index 88fd6221c..9b71bd770 100644 --- a/src/components/insights/hero-strip.tsx +++ b/src/components/insights/hero-strip.tsx @@ -7,6 +7,7 @@ import { useTranslations } from "@/lib/i18n/context"; import { formatRelativeTime } from "@/lib/i18n/relative-time"; import { useFeatureFlags } from "@/hooks/use-feature-flags"; import { useDisableCoach } from "@/hooks/use-disable-coach"; +import { useModuleEnabled } from "@/hooks/use-module-enabled"; import { cn } from "@/lib/utils"; import { SuggestedPrompts } from "./suggested-prompts"; import { @@ -149,6 +150,11 @@ export function HeroStrip({ // tests render the strip without a QueryClient. const disableCoach = useDisableCoach(); const coachEnabled = flags.coach && !disableCoach; + // v1.18.0 R4 — mood-module state so the score card hides its Mood row + // when the account turned the module off (the server already drops the + // pillar from the number itself). SSR-safe + default-on, matching the + // disabled-allowlist gate. + const moodEnabled = useModuleEnabled("mood"); const greetingKey = resolveGreetingKey(now ?? new Date()); const greetingBase = t(greetingKey); const greeting = userName ? `${greetingBase}, ${userName}` : greetingBase; @@ -339,6 +345,7 @@ export function HeroStrip({ band={healthScore.band} components={healthScore.components} delta={healthScore.delta} + moodEnabled={moodEnabled} onAskCoach={ // v1.4.37 W5 — short-circuit the prop drilling even // though `HealthScoreCard` retired its inline button in diff --git a/src/hooks/use-module-enabled.ts b/src/hooks/use-module-enabled.ts new file mode 100644 index 000000000..fd4d6bf3e --- /dev/null +++ b/src/hooks/use-module-enabled.ts @@ -0,0 +1,33 @@ +"use client"; + +import { useAuth } from "@/hooks/use-auth"; +import { useQueryClientMounted } from "@/hooks/_internal/use-query-client-safe"; +import type { ModuleKey } from "@/lib/modules/registry"; + +/** + * v1.18.0 R4 — SSR-safe accessor for a per-account module flag. + * + * Mirrors `useDisableCoach()`: `useAuth()` calls `useQuery` directly and + * crashes when a legacy presentational component (``, the + * isolated score-card snapshots, …) renders without a + * ``. We gate the inner hook on + * `useQueryClientMounted()` and short-circuit to the default-on result + * when no client is in the tree — the branch is stable across the + * component's lifetime, so the conditional call is safe per the Rules of + * Hooks. + * + * Default-on (disabled allowlist): an absent map, a missing key, or any + * non-`false` value reads as enabled — matching the server gate in + * `src/lib/modules/gate.ts`. + */ +export function useModuleEnabled(moduleKey: ModuleKey): boolean { + const hasClient = useQueryClientMounted(); + if (!hasClient) return true; + // eslint-disable-next-line react-hooks/rules-of-hooks + return useModuleEnabledInner(moduleKey); +} + +function useModuleEnabledInner(moduleKey: ModuleKey): boolean { + const { user } = useAuth(); + return user?.modules?.[moduleKey] !== false; +} diff --git a/src/lib/analytics/__tests__/health-score-fast-path.test.ts b/src/lib/analytics/__tests__/health-score-fast-path.test.ts index 14ae10efc..ace458c43 100644 --- a/src/lib/analytics/__tests__/health-score-fast-path.test.ts +++ b/src/lib/analytics/__tests__/health-score-fast-path.test.ts @@ -667,6 +667,127 @@ describe("computeUserHealthScoreFastPath", () => { }); }); + /** + * v1.18.0 R4 — module-aware mood pillar. With the mood module disabled + * the helper must NOT read mood, must null-weight the mood pillar, and + * must re-normalise the surviving pillars so the score isn't penalised + * (or inflated) by a pillar the user can no longer see. Golden fixture: + * an identical profile with strong, stable mood scored mood-on vs + * mood-off must produce a different, correctly re-weighted score. + */ + describe("module-aware mood pillar (R4)", () => { + // A profile with BP + steady high mood (low variance → high + // stability) + nothing else, so the mood pillar's contribution is + // unambiguous. Six entries clears the 5-entry stability floor. + function steadyMood(now: Date) { + return Array.from({ length: 6 }, (_v, i) => ({ + score: 4, + moodLoggedAt: new Date(now.getTime() - i * 24 * 60 * 60 * 1000), + })); + } + + it("weights the mood pillar when moodEnabled is omitted (pre-v1.18.0 default)", async () => { + const coverage = new Map(); + FULLY_COVERED.mockReturnValue(false); + PROBE.mockResolvedValue(coverage); + MEASUREMENT_FIND_MANY.mockResolvedValueOnce([]).mockResolvedValueOnce([]); + const now = new Date("2026-06-07T12:00:00.000Z"); + MOOD_FIND_MANY.mockResolvedValue(steadyMood(now)); + + const result = await computeUserHealthScoreFastPath({ + userId: "user-mood-on", + bpInTargetPct: 60, + bpGradedScore: 60, + heightCm: null, + now, + coverage, + }); + + expect(result).not.toBeNull(); + // Mood read fired and the pillar carries a non-zero weight. + expect(MOOD_FIND_MANY).toHaveBeenCalled(); + expect(result?.components.mood.value).not.toBeNull(); + expect(result?.components.mood.weight).toBeGreaterThan(0); + }); + + it("drops the mood pillar and re-normalises the score when moodEnabled is false", async () => { + const coverage = new Map(); + FULLY_COVERED.mockReturnValue(false); + PROBE.mockResolvedValue(coverage); + MEASUREMENT_FIND_MANY.mockResolvedValueOnce([]).mockResolvedValueOnce([]); + const now = new Date("2026-06-07T12:00:00.000Z"); + // Even if mood rows existed they must be ignored — the helper + // should never issue the read when the module is off. + MOOD_FIND_MANY.mockResolvedValue(steadyMood(now)); + + const result = await computeUserHealthScoreFastPath({ + userId: "user-mood-off", + bpInTargetPct: 60, + bpGradedScore: 60, + heightCm: null, + now, + coverage, + moodEnabled: false, + }); + + expect(result).not.toBeNull(); + // The disabled module is off the round-trip budget entirely. + expect(MOOD_FIND_MANY).not.toHaveBeenCalled(); + // Mood pillar null-weighted; surviving pillar (BP) absorbs the + // freed weight via proportional redistribution. + expect(result?.components.mood.value).toBeNull(); + expect(result?.components.mood.weight).toBe(0); + expect(result?.components.bp.weight).toBe(1); + // BP is the only present pillar → score is the BP graded value. + expect(result?.score).toBe(60); + }); + + it("golden: mood-on vs mood-off yield different, correctly re-weighted scores", async () => { + const coverage = new Map(); + FULLY_COVERED.mockReturnValue(false); + PROBE.mockResolvedValue(coverage); + const now = new Date("2026-06-07T12:00:00.000Z"); + + // Mood-ON: BP=60 (0.30) + perfect-stability mood=100 (0.20). + // present base weights = 0.50 → bp 0.6, mood 0.4 → + // 60*0.6 + 100*0.4 = 36 + 40 = 76. + MEASUREMENT_FIND_MANY.mockResolvedValueOnce([]).mockResolvedValueOnce([]); + MOOD_FIND_MANY.mockResolvedValue( + // Constant score → coefficient of variation 0 → stability 100. + Array.from({ length: 6 }, (_v, i) => ({ + score: 5, + moodLoggedAt: new Date(now.getTime() - i * 24 * 60 * 60 * 1000), + })), + ); + const onResult = await computeUserHealthScoreFastPath({ + userId: "user-golden-on", + bpInTargetPct: 60, + bpGradedScore: 60, + heightCm: null, + now, + coverage, + }); + + // Mood-OFF: only BP=60 survives → score 60. + MEASUREMENT_FIND_MANY.mockResolvedValueOnce([]).mockResolvedValueOnce([]); + const offResult = await computeUserHealthScoreFastPath({ + userId: "user-golden-off", + bpInTargetPct: 60, + bpGradedScore: 60, + heightCm: null, + now, + coverage, + moodEnabled: false, + }); + + expect(onResult?.score).toBe(76); + expect(offResult?.score).toBe(60); + // The mood-off score is NOT silently dragged toward 0 by a phantom + // pillar — it equals the honest single-pillar BP score. + expect(offResult?.score).not.toBe(onResult?.score); + }); + }); + /** * v1.15.12 A1 — the graded clinical-proximity score, when supplied, * becomes the BP pillar VALUE; the binary in-target rate only gates diff --git a/src/lib/analytics/health-score-fast-path.ts b/src/lib/analytics/health-score-fast-path.ts index b6bb3fb1b..eb1f461eb 100644 --- a/src/lib/analytics/health-score-fast-path.ts +++ b/src/lib/analytics/health-score-fast-path.ts @@ -136,6 +136,19 @@ export interface HealthScoreFastPathInput { * the probe cost stays flat in the fan-out. */ coverage?: RollupCoverageMap; + /** + * v1.18.0 R4 — module-aware mood pillar. When the caller resolves the + * `mood` module as disabled it threads `moodEnabled: false` and the + * helper drops the 0.20 mood-stability pillar entirely: the mood read + * is skipped and the series stays empty, so `moodStability` returns + * `null` and the existing proportional null-redistribution re-scales + * the surviving pillars to sum to 100. Without this a disabled-mood + * account is silently penalised (or rewarded) by a pillar it can no + * longer see anywhere else in the product. Omit (or pass `true`) to + * keep the pre-v1.18.0 always-weighted behaviour — the field is + * additive so legacy callers are unchanged. + */ + moodEnabled?: boolean; } /** @@ -150,6 +163,10 @@ export async function computeUserHealthScoreFastPath( ): Promise { const DAY_MS = 24 * 60 * 60 * 1000; const { userId, bpInTargetPct, heightCm, now } = input; + // v1.18.0 R4 — default-on so omitting the flag preserves the + // pre-v1.18.0 always-weighted mood pillar. Only an explicit `false` + // (the caller resolved `mood` as a disabled module) drops the pillar. + const moodEnabled = input.moodEnabled !== false; // v1.4.38 — prior-week BP fallback. When the caller omits the field // we pin to the current value (pre-v1.4.38 behaviour) so the helper // stays a drop-in replacement for legacy callers; when the caller @@ -294,16 +311,25 @@ export async function computeUserHealthScoreFastPath( select: { measuredAt: true, source: true }, orderBy: { measuredAt: "asc" }, }), - prisma.moodEntry.findMany({ - where: { - userId, - // v1.7.0 sync — exclude tombstoned rows. - deletedAt: null, - moodLoggedAt: { gte: prevSince30d, lte: now }, - }, - select: { score: true, moodLoggedAt: true }, - orderBy: { moodLoggedAt: "asc" }, - }), + // v1.18.0 R4 — skip the read entirely when the mood module is off. + // An empty series feeds `moodStability` → null → the 0.20 pillar + // weight redistributes across the surviving pillars. Skipping the + // query (rather than reading-then-discarding) keeps the disabled + // module off the round-trip budget too. + moodEnabled + ? prisma.moodEntry.findMany({ + where: { + userId, + // v1.7.0 sync — exclude tombstoned rows. + deletedAt: null, + moodLoggedAt: { gte: prevSince30d, lte: now }, + }, + select: { score: true, moodLoggedAt: true }, + orderBy: { moodLoggedAt: "asc" }, + }) + : Promise.resolve( + [] as Array<{ score: number; moodLoggedAt: Date }>, + ), prisma.medication.findMany({ // v1.16.11 — as-needed (PRN) medications carry no expected doses // and are excluded from every compliance rate: they must From 9a5e562001a5ea241640aa6b2fa48ac9cfaac022 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 16 Jun 2026 00:54:00 +0200 Subject: [PATCH 2/3] fix(coach-nudge): correct the sleep-debt trigger unit so it can fire The sleep-debt nudge compared a minutes value against an hours threshold, so the trigger was effectively dead. Align the comparison to the unit the sleep-debt value is produced in and pin it with a firing-case test. --- src/lib/jobs/__tests__/coach-nudge.test.ts | 7 ++++ src/lib/jobs/coach-nudge.ts | 44 +++++++++++++++------- 2 files changed, 38 insertions(+), 13 deletions(-) diff --git a/src/lib/jobs/__tests__/coach-nudge.test.ts b/src/lib/jobs/__tests__/coach-nudge.test.ts index 20f68e6af..64378ebef 100644 --- a/src/lib/jobs/__tests__/coach-nudge.test.ts +++ b/src/lib/jobs/__tests__/coach-nudge.test.ts @@ -45,6 +45,13 @@ vi.mock("@/lib/ai/coach/bytes-codec", () => ({ decryptFromBytes: vi.fn(() => "morning blood pressure"), encryptToBytes: vi.fn(), })); +// The sleep-debt branch resolves the user's source priority through the +// real `@/lib/db` prisma; stub it so the unit suite stays DB-free. A +// null priority is the no-custom-priority default — `reconstructSleepNights` +// (pure) then runs normally on the stubbed measurement rows. +vi.mock("@/lib/rollups/measurement-read", () => ({ + loadUserSourcePriority: vi.fn(async () => null), +})); function dose(taken: boolean, skipped = false, autoMissed?: boolean) { return { diff --git a/src/lib/jobs/coach-nudge.ts b/src/lib/jobs/coach-nudge.ts index b92107a2e..baea8459a 100644 --- a/src/lib/jobs/coach-nudge.ts +++ b/src/lib/jobs/coach-nudge.ts @@ -78,6 +78,9 @@ import type { Locale } from "@/lib/i18n/config"; import { defaultLocale, locales } from "@/lib/i18n/config"; import { getEvent } from "@/lib/logging/context"; import { resolveCanonicalRecovery } from "@/lib/insights/derived/recovery-resolve"; +import { reconstructSleepNights } from "@/lib/analytics/sleep-night"; +import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver"; // Trend thresholds shared with the dashboard hero's verdict resolver // live in a client-safe leaf module; re-exported below so server-side // imports keep their path. @@ -551,9 +554,19 @@ export async function findTriggerForUser( } } - // 5) Sleep-deficit series: per-night hours over the last 7 days, - // longest record per calendar night (multi-source rows collapse - // to the fullest one). + // 5) Sleep-deficit series: per-night asleep HOURS over the last 7 + // days, reconstructed through the canonical sleep engine. + // + // `SLEEP_DURATION` is stored one row per STAGE per night, in + // MINUTES. The trigger compares against + // `effectiveRange("SLEEP_DURATION").greenMin`, which is in HOURS + // (7). The prior inline model kept each night's MAX single stage + // row (a fragment, not the night) AND fed minutes straight into + // the hours comparison — so a real ~360-480 minute night never + // cleared `< 6.5` and the nudge could never fire. Reusing + // `reconstructSleepNights` gives the proper asleep total + // (CORE+DEEP+REM, multi-source de-duped, user-tz wake-day keyed); + // dividing by 60 converts to the hours the trigger expects. const sleepRows = await prisma.measurement.findMany({ where: { userId: user.id, @@ -561,20 +574,25 @@ export async function findTriggerForUser( deletedAt: null, measuredAt: { gte: sevenDaysAgo, lte: now }, }, - select: { value: true, measuredAt: true }, + select: { + value: true, + measuredAt: true, + sleepStage: true, + source: true, + deviceType: true, + }, }); if (sleepRows.length > 0) { - const byNight = new Map(); - for (const row of sleepRows) { - const night = row.measuredAt.toISOString().slice(0, 10); - const prev = byNight.get(night); - if (prev === undefined || row.value > prev) { - byNight.set(night, row.value); - } - } + const sourcePriority = await loadUserSourcePriority(user.id); + const nights = reconstructSleepNights( + sleepRows, + user.timezone ?? DEFAULT_TIMEZONE, + sourcePriority, + ); + const nightlyHours = nights.map((n) => n.asleepMinutes / 60); if ( evaluateSleepDebtTrigger( - [...byNight.values()], + nightlyHours, effectiveRange("SLEEP_DURATION")?.greenMin ?? null, ) ) { From 79f27624689fb1ace0f259b2aa05c3e1a80008a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 16 Jun 2026 00:59:04 +0200 Subject: [PATCH 3/3] fix(insights): make AI medication-compliance per-day series cadence-aware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-day compliance series in the medication-compliance insight snapshot divided every event-day by the schedule count, treating each calendar day as fully dose-due. A weekly-cadence medication (a Monday-only schedule, a bi-weekly interval) therefore had its series read off-cadence weekdays as misses, contradicting the canonical compliance30 value shown in the same snapshot. Route the per-day series through the unified dose-history ledger that already backs compliance7 / compliance30: add dailyComplianceRatesFromLedger, which groups the ledger's scheduled slot rows by day and scores each day with the same taken / (taken + missed) tally — on-time-plus-late numerator, skip / ad-hoc / upcoming exclusions — that tallyLedgerRows applies to the window rates. Only days the cadence actually expected a dose produce a point, so a non-due day can no longer be counted as a miss, and the per-day series and compliance30 are computed from one ledger and cannot disagree. The insight generator now derives both the canonical compliance results and the ledger from a single buildMedicationComplianceBundle pass instead of two calculateCompliance calls plus a hand-rolled per-day map. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../__tests__/compliance-bundle.test.ts | 114 ++++++++++++++++++ src/lib/analytics/compliance.ts | 80 ++++++++++++ .../insights/medication-compliance-status.ts | 65 ++++------ 3 files changed, 219 insertions(+), 40 deletions(-) diff --git a/src/lib/analytics/__tests__/compliance-bundle.test.ts b/src/lib/analytics/__tests__/compliance-bundle.test.ts index 519975a8f..0fae41d2d 100644 --- a/src/lib/analytics/__tests__/compliance-bundle.test.ts +++ b/src/lib/analytics/__tests__/compliance-bundle.test.ts @@ -17,6 +17,7 @@ import { buildMedicationComplianceBundle, buildComplianceDisplay, calculateCompliance, + dailyComplianceRatesFromLedger, lastNonSkippedTakenAt, tallyComplianceFromLedger, tallyLedgerRows, @@ -44,6 +45,21 @@ function dailySchedule(): ComplianceSchedule { }; } +function weeklyMondaySchedule(): ComplianceSchedule { + return { + windowStart: "08:00", + windowEnd: "09:00", + timesOfDay: ["08:00"], + daysOfWeek: null, + rrule: "FREQ=WEEKLY;BYDAY=MO", + rollingIntervalDays: null, + reminderGraceMinutes: null, + scheduleType: "SCHEDULED", + cyclicOnWeeks: null, + cyclicOffWeeks: null, + }; +} + function rollingSchedule(intervalDays: number): ComplianceSchedule { return { windowStart: "10:00", @@ -205,6 +221,104 @@ describe("buildMedicationComplianceBundle — parity with the per-window composi }); }); +describe("dailyComplianceRatesFromLedger — cadence-aware per-day series", () => { + it("weekly Monday-only med: per-day series agrees with compliance30 and never marks non-due days", () => { + // NOW (2026-06-01) is a Monday; build five trailing Mondays and take + // every one on time. A weekly-cadence med expects a dose ONLY on Mondays. + const createdAt = new Date(NOW.getTime() - 60 * DAY_MS); + const schedules = [weeklyMondaySchedule()]; + const ctx = buildComplianceMedicationContext( + { startsOn: null, endsOn: null, oneShot: false, createdAt }, + null, + TZ, + ); + + const events: FixtureEvent[] = []; + for (let week = 0; week < 5; week++) { + const daysAgo = week * 7; // every Monday back from NOW + events.push({ + takenAt: slotAt(daysAgo, "08:05"), + skipped: false, + scheduledFor: slotAt(daysAgo, "08:00"), + }); + } + ctx.lastIntakeAt = lastNonSkippedTakenAt(events); + + const bundle = buildMedicationComplianceBundle(events, schedules, ctx, NOW); + + // Took every expected Monday → 100% over 30 days. + expect(bundle.compliance30.rate).toBe(100); + + // The per-day series carves the same 30-day window out of the shared + // ledger and must agree with compliance30 — no off-cadence weekday is + // counted as a miss. + const from = new Date(NOW.getTime() - 30 * DAY_MS); + const windowRows = bundle.ledgerRows.filter( + (r) => r.at.getTime() >= from.getTime() && r.at.getTime() <= NOW.getTime(), + ); + const series = dailyComplianceRatesFromLedger(windowRows); + + // Only the Mondays in the window produce a point — Tuesday…Sunday emit + // nothing, so a 7×-denser daily denominator can never drag the rate down. + // NOW is itself a Monday, so the 30-day window holds five Mondays. + expect(series.length).toBe(5); + for (const day of series) { + expect(day.rate).toBe(100); + expect(day.missed).toBe(0); + } + + // The series' mean rate equals the canonical compliance30 rate: the two + // are computed from the SAME ledger and cannot contradict each other. + const meanRate = + series.reduce((sum, d) => sum + d.rate, 0) / series.length; + expect(Math.round(meanRate)).toBe(bundle.compliance30.rate); + }); + + it("weekly med with one missed Monday: the missed day reads 0%, due days only", () => { + const createdAt = new Date(NOW.getTime() - 60 * DAY_MS); + const schedules = [weeklyMondaySchedule()]; + const ctx = buildComplianceMedicationContext( + { startsOn: null, endsOn: null, oneShot: false, createdAt }, + null, + TZ, + ); + + // Four Mondays in the 30-day window; skip the take on the 2-weeks-ago one. + const events: FixtureEvent[] = []; + for (let week = 0; week < 5; week++) { + const daysAgo = week * 7; + if (week === 2) continue; // forgotten dose → an expected slot reads missed + events.push({ + takenAt: slotAt(daysAgo, "08:05"), + skipped: false, + scheduledFor: slotAt(daysAgo, "08:00"), + }); + } + ctx.lastIntakeAt = lastNonSkippedTakenAt(events); + + const bundle = buildMedicationComplianceBundle(events, schedules, ctx, NOW); + + const from = new Date(NOW.getTime() - 30 * DAY_MS); + const windowRows = bundle.ledgerRows.filter( + (r) => r.at.getTime() >= from.getTime() && r.at.getTime() <= NOW.getTime(), + ); + const series = dailyComplianceRatesFromLedger(windowRows); + + // Still one point per due Monday — never a point on a non-due weekday. + // Five Mondays in the window; the skipped take leaves one as a miss. + expect(series.length).toBe(5); + const zeroDays = series.filter((d) => d.rate === 0); + expect(zeroDays.length).toBe(1); + expect(zeroDays[0].missed).toBe(1); + + // Aggregate taken / (taken + missed) across due days equals compliance30. + const taken = series.reduce((sum, d) => sum + d.taken, 0); + const missed = series.reduce((sum, d) => sum + d.missed, 0); + const aggregateRate = Math.round((taken / (taken + missed)) * 100); + expect(aggregateRate).toBe(bundle.compliance30.rate); + }); +}); + describe("tallyLedgerRows — windowed tallies over one shared ledger", () => { it("the unwindowed tally equals tallyComplianceFromLedger byte-for-byte", () => { const createdAt = new Date(NOW.getTime() - 40 * DAY_MS); diff --git a/src/lib/analytics/compliance.ts b/src/lib/analytics/compliance.ts index 54b7a73aa..dc89a9dce 100644 --- a/src/lib/analytics/compliance.ts +++ b/src/lib/analytics/compliance.ts @@ -52,6 +52,7 @@ import { type RecurrenceContext, type ScheduleType, } from "@/lib/medications/scheduling/recurrence"; +import { toBerlinDayKey } from "@/lib/tz/resolver"; export interface IntakeEvent { takenAt: Date | null; @@ -1836,6 +1837,85 @@ export function buildMedicationComplianceBundle( return { compliance7, compliance30, complianceDisplay, ledgerRows, ledgerFrom }; } +/** + * One day of the cadence-aware compliance series: the day's compliance % + * over the doses the schedule actually expected that day, plus the raw + * taken / missed counts behind it. + */ +export interface DailyComplianceRate { + /** The day's anchor instant (the first expected slot of the day). */ + date: Date; + /** round(100 · taken / (taken + missed)), capped at 100. */ + rate: number; + /** Doses taken (on-time + late) on the day. */ + taken: number; + /** Expected doses never acted on past their miss cutoff, on the day. */ + missed: number; +} + +/** + * v1.18.0 — collapse unified dose-history ledger rows into a cadence-aware + * per-day compliance series. + * + * Only days the schedule's cadence actually expected a dose produce a point: + * the series is grouped over the ledger's scheduled `slot` rows, so an + * off-cadence weekday (a weekly Monday-only med on a Tuesday) or an off-week + * (the off-week of a bi-weekly schedule) emits no point at all and therefore + * can never be read as a 0% "miss". Each day's rate uses the SAME + * taken / (taken + missed) tally — and the SAME on-time-plus-late numerator, + * skip / ad-hoc / upcoming exclusions — that {@link tallyLedgerRows} applies + * to `compliance7` / `compliance30`, so the per-day series and the window + * rates are computed from one ledger and cannot contradict each other. + * + * `ad_hoc` rows (off-schedule top-ups, no defensible slot) and `upcoming` + * rows (the window hasn't opened) are dropped before grouping, exactly as the + * window tally excludes them. A day whose only ledger rows are skips yields no + * point (zero denominator) rather than a misleading 0%. + */ +export function dailyComplianceRatesFromLedger( + ledgerRows: DoseHistoryRow[], +): DailyComplianceRate[] { + const byDay = new Map< + string, + { taken: number; missed: number; date: Date } + >(); + + for (const row of ledgerRows) { + // Only scheduled slots have a defensible denominator; ad-hoc top-ups are + // excluded from the rate exactly as `tallyLedgerRows` excludes them. + if (row.kind !== "slot") continue; + const isTaken = + row.status === "taken_on_time" || row.status === "taken_late"; + const isMissed = row.status === "missed"; + // `skipped` (deliberate) and `upcoming` (window not open) advance neither + // counter — they never enter the day's denominator. + if (!isTaken && !isMissed) continue; + + const dayKey = toBerlinDayKey(row.at); + const bucket = byDay.get(dayKey) ?? { taken: 0, missed: 0, date: row.at }; + if (isTaken) bucket.taken += 1; + else bucket.missed += 1; + // Keep the earliest slot instant of the day as the point's anchor. + if (row.at.getTime() < bucket.date.getTime()) bucket.date = row.at; + byDay.set(dayKey, bucket); + } + + return Array.from(byDay.values()) + .sort((a, b) => a.date.getTime() - b.date.getTime()) + .map((day) => { + const denom = day.taken + day.missed; + return { + date: day.date, + rate: + denom > 0 + ? Math.min(100, Math.round((day.taken / denom) * 100)) + : 100, + taken: day.taken, + missed: day.missed, + }; + }); +} + /** * The cadence-timeline construction from {@link calculateCompliance}, * extracted so {@link buildMedicationComplianceBundle} can build it once diff --git a/src/lib/insights/medication-compliance-status.ts b/src/lib/insights/medication-compliance-status.ts index 69637f6e4..d8e7a8c14 100644 --- a/src/lib/insights/medication-compliance-status.ts +++ b/src/lib/insights/medication-compliance-status.ts @@ -5,7 +5,8 @@ import { } from "@/lib/ai/prompts/medication-compliance"; import { buildComplianceMedicationContext, - calculateCompliance, + buildMedicationComplianceBundle, + dailyComplianceRatesFromLedger, lastNonSkippedTakenAt, SCHEDULE_COMPLIANCE_SELECT, } from "@/lib/analytics/compliance"; @@ -224,50 +225,34 @@ export async function generateMedicationComplianceStatusForUser( lastNonSkippedTakenAt(events), userTz, ); - const compliance7 = calculateCompliance( + // v1.18.0 — one shared cadence-aware expansion pass yields BOTH the + // canonical compliance7 / compliance30 (the values shown in this same + // snapshot) AND the unified dose-history ledger the per-day series is + // built from, so the series can never disagree with compliance30. + const bundle = buildMedicationComplianceBundle( events, medication.schedules, - 7, - medication.createdAt, - { medicationContext }, + medicationContext, + now, ); - const compliance30 = calculateCompliance( - events, - medication.schedules, - 30, - medication.createdAt, - { medicationContext }, + const compliance7 = bundle.compliance7; + const compliance30 = bundle.compliance30; + + // v1.18.0 — the per-day series honours the medication's cadence + // (daysOfWeek + intervalWeeks + rolling / RRULE / cyclic): only days the + // schedule actually expected a dose produce a point, and each day's rate + // uses the SAME taken / (taken + missed) tally as compliance30. The old + // path divided every event-day by `schedules.length`, treating every + // calendar day as fully dose-due — which made a weekly-cadence med's + // per-day series contradict its own compliance30. Reusing the canonical + // ledger fixes that by construction. + const perDayRecords = dailyComplianceRatesFromLedger(bundle.ledgerRows).map( + (day) => ({ + measuredAt: day.date, + value: round(day.rate, 1), + }), ); - // Collapse the events into one rate-per-day record, then run them - // through the shared bucketing helper so the model receives the - // canonical 360 daily + 24 monthly view per medication. - const byDay = new Map< - string, - { expected: number; taken: number; skipped: number; date: Date } - >(); - const expectedPerDay = Math.max(1, medication.schedules.length); - - for (const event of events) { - const dayKey = toBerlinDayKey(event.scheduledFor); - const bucket = byDay.get(dayKey) ?? { - expected: medication.schedules.length, - taken: 0, - skipped: 0, - date: event.scheduledFor, - }; - if (event.takenAt && !event.skipped) bucket.taken += 1; - if (event.skipped) bucket.skipped += 1; - byDay.set(dayKey, bucket); - } - - const perDayRecords = Array.from(byDay.values()) - .sort((a, b) => a.date.getTime() - b.date.getTime()) - .map((stats) => ({ - measuredAt: stats.date, - value: round(Math.min(100, (stats.taken / expectedPerDay) * 100), 1), - })); - // `applyPayloadBudget` gives the latest-day focus; the compact // graded series is what reaches the prompt. const dailyBudgeted = applyPayloadBudget(perDayRecords, { now });