From 1b354de68bc40bfa4f8dcc60ea4a9bfe4a81c5a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 18 Jun 2026 18:25:03 +0200 Subject: [PATCH 1/6] fix(tz): anchor medication-intake day floor at local midnight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The intake aggregator built its day floor from en-US calendar parts pinned to UTC midnight, so today's window opened off by the zone offset for any non-UTC user — a 23:30-local reading could land in the wrong day's bucket. Promote the DST-safe start-of-local-day primitive into src/lib/tz/local-day as startOfLocalDayInTz and route the intake route, the cadence start-of-day, and the recurrence day floor through it, removing three divergent copies. --- src/app/api/medications/intake/route.ts | 17 +------ src/lib/medications/scheduling/cadence.ts | 4 +- src/lib/medications/scheduling/recurrence.ts | 15 ++---- src/lib/tz/__tests__/local-day.test.ts | 44 +++++++++++++++++ src/lib/tz/local-day.ts | 52 ++++++++++++++++++++ 5 files changed, 103 insertions(+), 29 deletions(-) create mode 100644 src/lib/tz/__tests__/local-day.test.ts diff --git a/src/app/api/medications/intake/route.ts b/src/app/api/medications/intake/route.ts index 14a53b392..2490e4d98 100644 --- a/src/app/api/medications/intake/route.ts +++ b/src/app/api/medications/intake/route.ts @@ -34,7 +34,7 @@ import { expectedSlotCountForDay, lastNonSkippedTakenAt, } from "@/lib/analytics/compliance"; -import { getUserTodayBounds } from "@/lib/tz/local-day"; +import { getUserTodayBounds, startOfLocalDayInTz } from "@/lib/tz/local-day"; import { projectTodayIntakesAndRecompute } from "@/lib/medications/scheduling/project-today-intakes"; import { applyCanonicalSlotWrite, @@ -84,19 +84,6 @@ const updateSchema = z.object({ .optional(), }); -function startOfDayInTz(date: Date, tz: string): Date { - const parts = new Intl.DateTimeFormat("en-US", { - timeZone: tz, - year: "numeric", - month: "2-digit", - day: "2-digit", - }).formatToParts(date); - const y = parts.find((p) => p.type === "year")?.value ?? "1970"; - const m = parts.find((p) => p.type === "month")?.value ?? "01"; - const d = parts.find((p) => p.type === "day")?.value ?? "01"; - return new Date(`${y}-${m}-${d}T00:00:00.000Z`); -} - export const GET = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); @@ -138,7 +125,7 @@ export const GET = apiHandler(async (request: NextRequest) => { const userTz = user.timezone ?? DEFAULT_TIMEZONE; if (scope === "today") { - const todayStart = startOfDayInTz(new Date(), userTz); + const todayStart = startOfLocalDayInTz(new Date(), userTz); const todayEnd = new Date(todayStart.getTime() + 86_400_000); // v1.4.39 W-SERVER-FIX — project pending intake rows for every diff --git a/src/lib/medications/scheduling/cadence.ts b/src/lib/medications/scheduling/cadence.ts index 33431c5a2..03d75072b 100644 --- a/src/lib/medications/scheduling/cadence.ts +++ b/src/lib/medications/scheduling/cadence.ts @@ -47,6 +47,7 @@ import { } from "@/lib/medications/scheduling/recurrence"; import { normaliseDoseWindows } from "@/lib/medications/scheduling/worker-helpers"; import { wallClockInTz } from "@/lib/tz/wall-clock"; +import { startOfLocalDayInTz } from "@/lib/tz/local-day"; /** * v1.13.x compliance — opt-in retrospective-rolling expansion. @@ -336,8 +337,7 @@ function hhmmToMinutes(hhmm: string): number | null { /** Snap a Date down to the user-local midnight. */ export function startOfLocalDay(d: Date, tz: string | undefined): Date { - const parts = wallClockInTz(d, tz); - return instantInTz(parts.year, parts.month, parts.day, 0, 0, tz); + return startOfLocalDayInTz(d, tz); } /** Snap a Date down to the Sunday-rooted user-local week. */ diff --git a/src/lib/medications/scheduling/recurrence.ts b/src/lib/medications/scheduling/recurrence.ts index 3dc24dbc9..608ab4024 100644 --- a/src/lib/medications/scheduling/recurrence.ts +++ b/src/lib/medications/scheduling/recurrence.ts @@ -59,6 +59,7 @@ import { RRule } from "rrule"; import { annotate } from "@/lib/logging/context"; import { parseScheduleRecurrence } from "@/lib/medication-schedule"; import { wallClockInTz } from "@/lib/tz/wall-clock"; +import { startOfLocalDayInTz } from "@/lib/tz/local-day"; /** * v1.7.0 — schedule-type discriminator on the canonical schedule. @@ -303,7 +304,7 @@ export function nextOccurrenceAfter( // keeps a slot the user has not yet acted on visible through the rest // of its due day instead of silently rolling it forward by N. if (schedule.rollingIntervalDays !== null) { - const dayFloor = startOfDayInTz(after, ctx.timeZone); + const dayFloor = startOfLocalDayInTz(after, ctx.timeZone); // With no intake logged the first dose anchors at `startsOn ?? createdAt`. // When that anchor is on a PRIOR calendar day the dose is overdue by ≥1 // day and must still surface ("take now"); flooring only to the start of @@ -434,7 +435,7 @@ function expandOneShot( */ function firstRollingDoseDayFloor(ctx: RecurrenceContext): number { const anchor = ctx.medication.startsOn ?? ctx.medication.createdAt; - return startOfDayInTz(anchor, ctx.timeZone).getTime(); + return startOfLocalDayInTz(anchor, ctx.timeZone).getTime(); } function expandRolling( @@ -782,16 +783,6 @@ function applyTimeOfDayToDate(day: Date, hhmm: string, tz: string): Date { return guess; } -/** - * The UTC instant of midnight (00:00 wall-clock) on `instant`'s day in - * the user's IANA timezone. Used by the rolling-cadence next-due search - * to floor the window to the start of the user's current day so an - * overdue / due-earlier-today rolling dose still surfaces. - */ -function startOfDayInTz(instant: Date, tz: string): Date { - return applyTimeOfDayToDate(instant, "00:00", tz); -} - function tzOffsetMinutes(date: Date, tz: string): number { const parts = wallClockInTz(date, tz); const asIfUtc = Date.UTC( diff --git a/src/lib/tz/__tests__/local-day.test.ts b/src/lib/tz/__tests__/local-day.test.ts new file mode 100644 index 000000000..f116dd3bb --- /dev/null +++ b/src/lib/tz/__tests__/local-day.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { startOfLocalDayInTz } from "@/lib/tz/local-day"; +import { wallClockInTz } from "@/lib/tz/wall-clock"; + +describe("startOfLocalDayInTz", () => { + it("anchors at the user's local midnight, not UTC midnight", () => { + // 2026-06-15 22:00 UTC is already 2026-06-16 00:00 in Berlin (UTC+2), + // so the local day floor must land on the Berlin 2026-06-16 midnight + // instant (2026-06-15T22:00:00Z) — never 2026-06-16T00:00:00Z. + const instant = new Date("2026-06-15T23:30:00.000Z"); + const floor = startOfLocalDayInTz(instant, "Europe/Berlin"); + const parts = wallClockInTz(floor, "Europe/Berlin"); + expect(parts.hour).toBe(0); + expect(parts.minute).toBe(0); + expect(parts.second).toBe(0); + expect(floor.toISOString()).toBe("2026-06-15T22:00:00.000Z"); + }); + + it("handles negative offsets (Pacific/Auckland) — late-local reading stays on its day", () => { + // 2026-06-15 23:30 NZST (UTC+12) is 2026-06-15T11:30Z; the local day + // floor is Auckland 2026-06-15 midnight = 2026-06-14T12:00:00Z. + const instant = new Date("2026-06-15T11:30:00.000Z"); + const floor = startOfLocalDayInTz(instant, "Pacific/Auckland"); + const parts = wallClockInTz(floor, "Pacific/Auckland"); + expect(parts.hour).toBe(0); + expect(parts.day).toBe(15); + }); + + it("is DST-safe across the spring-forward boundary", () => { + // Europe/Berlin springs forward 2026-03-29 02:00→03:00. + const instant = new Date("2026-03-29T10:00:00.000Z"); + const floor = startOfLocalDayInTz(instant, "Europe/Berlin"); + const parts = wallClockInTz(floor, "Europe/Berlin"); + expect(parts.hour).toBe(0); + expect(parts.day).toBe(29); + }); + + it("falls back to host-local day when tz is undefined", () => { + const instant = new Date(2026, 5, 15, 14, 30, 0); + const floor = startOfLocalDayInTz(instant, undefined); + expect(floor.getHours()).toBe(0); + expect(floor.getDate()).toBe(15); + }); +}); diff --git a/src/lib/tz/local-day.ts b/src/lib/tz/local-day.ts index 3c6bc2631..42efc54eb 100644 --- a/src/lib/tz/local-day.ts +++ b/src/lib/tz/local-day.ts @@ -49,6 +49,58 @@ export function getUserTodayBounds( return { start, end }; } +/** + * UTC instant of local midnight (00:00 wall-clock) on the calendar day + * `instant` falls on in `tz`. DST-safe: derives the zone offset at the + * target local time so the returned instant always represents the user's + * local start-of-day, never UTC midnight of that calendar date. + * + * Canonical start-of-local-day primitive. The medication-scheduling + * `startOfLocalDay` (cadence) and `startOfDayInTz` (recurrence) route + * through this so the day-floor never drifts between surfaces. When `tz` + * is omitted the host's system-local day is used. + */ +export function startOfLocalDayInTz( + instant: Date, + tz: string | undefined, +): Date { + if (!tz) { + return new Date( + instant.getFullYear(), + instant.getMonth(), + instant.getDate(), + 0, + 0, + 0, + 0, + ); + } + const parts = wallClockInTz(instant, tz); + // Two-pass converge: treat the wall clock as UTC, then correct by the + // zone offset at that approximate instant. The second pass settles the + // offset across a DST transition for every IANA zone. + let guess = new Date( + Date.UTC(parts.year, parts.month - 1, parts.day, 0, 0, 0, 0), + ); + for (let i = 0; i < 2; i++) { + const at = wallClockInTz(guess, tz); + const asIfUtc = Date.UTC( + at.year, + at.month - 1, + at.day, + at.hour, + at.minute, + at.second, + ); + const offsetMin = Math.round((asIfUtc - guess.getTime()) / 60_000); + guess = new Date( + Date.UTC(parts.year, parts.month - 1, parts.day, 0, 0, 0, 0) - + offsetMin * 60_000, + ); + } + return guess; +} + /** * Get the day of the week (0=Sun..6=Sat) for a given instant in a specific * timezone. From cbfc4a36d2a2e621358d7bfdde954dc1b29d80ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 18 Jun 2026 18:27:06 +0200 Subject: [PATCH 2/6] refactor(tz): route day-key copies through the canonical userDayKey Compliance streaks, the measurement-consolidation day key, and the correlations weekday helper each carried their own Intl formatter (en-CA / sv-SE / short-weekday). Delegate them to userDayKey and wallClockInTz so every day-bucket key stays byte-for-byte aligned and the locale decision lives in one place. --- src/lib/analytics/correlations-fast-path.ts | 33 +++----------------- src/lib/measurements/consolidation-tz.ts | 11 ++++--- src/lib/medications/scheduling/compliance.ts | 15 ++++----- 3 files changed, 17 insertions(+), 42 deletions(-) diff --git a/src/lib/analytics/correlations-fast-path.ts b/src/lib/analytics/correlations-fast-path.ts index e5fc0e07a..cc6b00c1b 100644 --- a/src/lib/analytics/correlations-fast-path.ts +++ b/src/lib/analytics/correlations-fast-path.ts @@ -58,6 +58,7 @@ import { type CorrelationResult, } from "@/lib/insights/correlations"; import { isNearUtc, userDayKey } from "@/lib/tz/resolver"; +import { wallClockInTz } from "@/lib/tz/wall-clock"; /** * v1.4.37 W2 — cold-path correlation window. Trim from 30 to 28 days @@ -368,34 +369,8 @@ function dateFromDayKey(key: string): Date { return new Date(`${key}T00:00:00.000Z`); } -const WEEKDAY_FORMATTER_CACHE = new Map(); -function getWeekdayFormatter(timeZone: string): Intl.DateTimeFormat { - let formatter = WEEKDAY_FORMATTER_CACHE.get(timeZone); - if (!formatter) { - formatter = new Intl.DateTimeFormat("en-CA", { - timeZone, - year: "numeric", - month: "2-digit", - day: "2-digit", - weekday: "short", - }); - WEEKDAY_FORMATTER_CACHE.set(timeZone, formatter); - } - return formatter; -} - -const ISO_WEEKDAY: Record = { - Mon: 1, - Tue: 2, - Wed: 3, - Thu: 4, - Fri: 5, - Sat: 6, - Sun: 7, -}; - function isoWeekdayInTz(d: Date, timeZone: string): number { - const parts = getWeekdayFormatter(timeZone).formatToParts(d); - const weekday = parts.find((p) => p.type === "weekday")?.value ?? "Mon"; - return ISO_WEEKDAY[weekday] ?? 1; + // `wallClockInTz` returns 0=Sun..6=Sat; remap to ISO 1=Mon..7=Sun. + const weekday = wallClockInTz(d, timeZone).weekday; + return weekday === 0 ? 7 : weekday; } diff --git a/src/lib/measurements/consolidation-tz.ts b/src/lib/measurements/consolidation-tz.ts index 35fe4e361..1f58be04c 100644 --- a/src/lib/measurements/consolidation-tz.ts +++ b/src/lib/measurements/consolidation-tz.ts @@ -22,6 +22,9 @@ * every existing import site keeps working unchanged. */ import type { MeasurementType } from "@/generated/prisma/client"; +// `userDayKey` lives in the pure `@/lib/tz/format` leaf (no Prisma / +// node:module pull) so importing it keeps this dependency-free module clean. +import { userDayKey } from "@/lib/tz/format"; /** * Canonical grace window shared by the cumulative + mean drains. Rows @@ -52,12 +55,12 @@ export interface PerSampleRow { /** * Resolve the user's calendar-day key (`YYYY-MM-DD`) for a given - * timestamp + timezone. Reuses the same `sv-SE` Intl formatting choice - * the mood-entries path locked in v1.4.25 W7b so iOS-side and - * server-side day-keys round-trip byte-identically. + * timestamp + timezone. Delegates to the canonical `userDayKey` so the + * server-side and iOS-side day-keys round-trip byte-identically against + * every other day-bucketed surface. */ export function dayKeyForUserTz(date: Date, tz: string): string { - return new Intl.DateTimeFormat("sv-SE", { timeZone: tz }).format(date); + return userDayKey(date, tz); } /** diff --git a/src/lib/medications/scheduling/compliance.ts b/src/lib/medications/scheduling/compliance.ts index ab450ef98..1ee94d230 100644 --- a/src/lib/medications/scheduling/compliance.ts +++ b/src/lib/medications/scheduling/compliance.ts @@ -39,6 +39,7 @@ import { type RecurrenceContext, } from "./recurrence"; import { normaliseDoseWindows } from "./worker-helpers"; +import { userDayKey } from "@/lib/tz/resolver"; export interface ComplianceChips { /** 0-100, taken / (taken + missed). Skipped doses are excluded from @@ -62,9 +63,10 @@ const DAY_MS = 24 * 60 * 60 * 1000; /** * Day-key in the user's timezone (or system-local when `tz` is - * undefined). `Intl.DateTimeFormat` with `en-CA` reliably returns - * `YYYY-MM-DD`, which sorts lexically and matches the chart - * legend's `day` ticks. + * undefined). Routes through the canonical `userDayKey` so the + * `YYYY-MM-DD` bucket stays byte-for-byte aligned with every other + * day-bucketed surface; the host-local branch covers the pre-per-user + * callers that pass no zone. */ function localDayKey(d: Date, tz: string | undefined): string { if (!tz) { @@ -73,12 +75,7 @@ function localDayKey(d: Date, tz: string | undefined): string { const day = String(d.getDate()).padStart(2, "0"); return `${y}-${m}-${day}`; } - return new Intl.DateTimeFormat("en-CA", { - timeZone: tz, - year: "numeric", - month: "2-digit", - day: "2-digit", - }).format(d); + return userDayKey(d, tz); } /** From 1a73afa7e9c8714a851b56e1afa5cf81c10f8f1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 18 Jun 2026 18:28:37 +0200 Subject: [PATCH 3/6] perf(illness): parallelise the insights recovery-gap fan-out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cross-episode insights route looped up to 24 episodes, each running a sequential 10-vital scan with two serial reads per vital — ~500 serialised round-trips on a year of episodes, starving the shared Prisma pool. Pair the baseline + episode-window reads, fan the per-vital scan out under a p-limit(4) cap, and run the per-episode correlations concurrently under the same discipline. Series order and the gated outputs are unchanged. --- src/app/api/illness/insights/route.ts | 35 ++++++++++++++--------- src/lib/illness/correlation-read.ts | 41 +++++++++++++++++++-------- 2 files changed, 51 insertions(+), 25 deletions(-) diff --git a/src/app/api/illness/insights/route.ts b/src/app/api/illness/insights/route.ts index 48960abdd..938ebf3b4 100644 --- a/src/app/api/illness/insights/route.ts +++ b/src/app/api/illness/insights/route.ts @@ -11,6 +11,7 @@ * contributes no gap rather than a wrong one. Born-gated + owner-scoped. */ import { NextRequest } from "next/server"; +import pLimit from "p-limit"; import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; @@ -29,6 +30,9 @@ import { resolveUserTimezone } from "@/lib/measurements/consolidation-base"; const MS_PER_DAY = 24 * 60 * 60 * 1000; /** Cap the per-episode gap computations so a heavy history can't fan out. */ const MAX_GAP_COMPUTATIONS = 24; +/** Run the per-episode correlations under a bounded concurrency budget so + * the up-to-24 candidates don't serialise into ~500 round-trips. */ +const EPISODE_GAP_CONCURRENCY = 4; export const GET = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); @@ -69,19 +73,24 @@ export const GET = apiHandler(async (request: NextRequest) => { const gapCandidates = rows .filter((r) => r.resolvedAt !== null && r.lifecycle !== "CHRONIC_ONGOING") .slice(0, MAX_GAP_COMPUTATIONS); - const gapById = new Map(); - for (const r of gapCandidates) { - const derived = await computeEpisodeCorrelation( - user.id, - { id: r.id, onsetAt: r.onsetAt, resolvedAt: r.resolvedAt, lifecycle: r.lifecycle }, - tz, - now, - ); - gapById.set( - r.id, - derived.status === "ok" ? derived.value.recoveryGapDays : null, - ); - } + const limit = pLimit(EPISODE_GAP_CONCURRENCY); + const gapEntries = await Promise.all( + gapCandidates.map((r) => + limit(async (): Promise<[string, number | null]> => { + const derived = await computeEpisodeCorrelation( + user.id, + { id: r.id, onsetAt: r.onsetAt, resolvedAt: r.resolvedAt, lifecycle: r.lifecycle }, + tz, + now, + ); + return [ + r.id, + derived.status === "ok" ? derived.value.recoveryGapDays : null, + ]; + }), + ), + ); + const gapById = new Map(gapEntries); const episodes: RetrospectiveEpisode[] = rows.map((r) => ({ id: r.id, diff --git a/src/lib/illness/correlation-read.ts b/src/lib/illness/correlation-read.ts index d37c42240..a17f64909 100644 --- a/src/lib/illness/correlation-read.ts +++ b/src/lib/illness/correlation-read.ts @@ -24,6 +24,7 @@ * Rollup DAY buckets are minted at the UTC midnight of the user-tz day, so * keying their `bucketStart` through `dayKeyForUserTz` is exact. */ +import pLimit from "p-limit"; import type { MeasurementType } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; import { readBestGranularityRollups } from "@/lib/rollups/measurement-read-wmy"; @@ -43,6 +44,13 @@ import { const MS_PER_DAY = 24 * 60 * 60 * 1000; +/** + * Bound the per-vital fan-out so a single episode's scan can't pin the + * shared Prisma pool. Mirrors the W-POOL `p-limit(4)` discipline the + * analytics summaries slice applies. + */ +const VITAL_SCAN_CONCURRENCY = 4; + /** The episode shape the read layer needs (a subset of the Prisma row). */ export interface EpisodeForCorrelation { id: string; @@ -88,16 +96,30 @@ export async function computeEpisodeCorrelation( // mitigation — the same pattern the derived route uses). const coverage = await probeRollupCoverage(userId); + // Attempt every scan type under a bounded concurrency cap. The two + // windows per type (baseline + episode) are independent reads, so they + // run paired rather than serially (halving the per-type round-trips). + // The day-log fever read shares the same fan-out budget. + const limit = pLimit(VITAL_SCAN_CONCURRENCY); + const [perType, dayLogFever] = await Promise.all([ + Promise.all( + ILLNESS_SCAN_TYPES.map((type) => + limit(async () => { + const [baseline, episodeWin] = await Promise.all([ + readWindowMeans(userId, type, baselineStart, baselineEnd, coverage, tz, now), + readWindowMeans(userId, type, preOnsetStart, end, coverage, tz, now), + ]); + return { type, baseline, episodeWin }; + }), + ), + ), + limit(() => readDayLogFever(episode.id, preOnsetStart, end, tz)), + ]); + const series: VitalSeries[] = []; let anyLive = false; let anyDay = false; - - // We attempt every scan type. An untracked vital returns empty points and - // is dropped below. Each read is a single windowed query, so attempting all - // types is bounded. - for (const type of ILLNESS_SCAN_TYPES) { - const baseline = await readWindowMeans(userId, type, baselineStart, baselineEnd, coverage, tz, now); - const episodeWin = await readWindowMeans(userId, type, preOnsetStart, end, coverage, tz, now); + for (const { type, baseline, episodeWin } of perType) { if (baseline.points.length === 0 && episodeWin.points.length === 0) continue; if (baseline.source === "live" || episodeWin.source === "live") anyLive = true; if (baseline.source === "DAY" || episodeWin.source === "DAY") anyDay = true; @@ -111,11 +133,6 @@ export async function computeEpisodeCorrelation( }); } - // Union the canonical journaling-fever path: the illness day-log `feverC` - // series over the episode window. A user logging 39.2 °C for days with no - // HealthKit thermometer must still escalate. Per-day MAX. - const dayLogFever = await readDayLogFever(episode.id, preOnsetStart, end, tz); - const source: "DAY" | "live" | "none" = anyDay ? "DAY" : anyLive From 04b0f385376cbd8f51f93a031d60532bf48a60b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 18 Jun 2026 18:29:46 +0200 Subject: [PATCH 4/6] fix(measurements): isolate per-type failures in the series batch The batched daily-series endpoint ran every type through one Promise.all with no per-task catch, so a single type's transient reject 500'd the whole batch and blanked every preloaded chart in the dashboard row at once. Wrap each per-type read so a failure degrades to an empty slice for that type only; the failing chart self-fetches through its existing fallback. A degraded_types counter rides the wide event. --- .../series-batch/__tests__/route.test.ts | 20 +++++++++++++ .../api/measurements/series-batch/route.ts | 28 +++++++++++++------ 2 files changed, 39 insertions(+), 9 deletions(-) diff --git a/src/app/api/measurements/series-batch/__tests__/route.test.ts b/src/app/api/measurements/series-batch/__tests__/route.test.ts index dc6e31c02..9e465acfb 100644 --- a/src/app/api/measurements/series-batch/__tests__/route.test.ts +++ b/src/app/api/measurements/series-batch/__tests__/route.test.ts @@ -117,4 +117,24 @@ describe("GET /api/measurements/series-batch", () => { expect(res.status).toBe(200); expect(vi.mocked(readDailySeries)).toHaveBeenCalledTimes(1); }); + + it("isolates a single type's failure to an empty slice, others still return", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const weightRows = [ + { type: "WEIGHT", value: 80, measuredAt: FROM, count: 1 }, + ]; + vi.mocked(readDailySeries).mockImplementation(async ({ type }) => { + if (type === "WEIGHT") return weightRows as never; + throw new Error("transient db reject"); + }); + + const res = await GET( + req(`types=WEIGHT,BLOOD_PRESSURE_SYS&from=${FROM}&to=${TO}`), + ); + // One type rejecting must NOT 500 the whole batch. + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data.series.WEIGHT).toEqual(weightRows); + expect(body.data.series.BLOOD_PRESSURE_SYS).toEqual([]); + }); }); diff --git a/src/app/api/measurements/series-batch/route.ts b/src/app/api/measurements/series-batch/route.ts index 8f685926c..00f1ace89 100644 --- a/src/app/api/measurements/series-batch/route.ts +++ b/src/app/api/measurements/series-batch/route.ts @@ -63,18 +63,27 @@ export const GET = apiHandler(async (request: NextRequest) => { // so the batched reader doesn't re-query it per type. const priorityJson = await loadUserSourcePriority(user.id); + // Per-type isolation: a single type's transient DB reject degrades to an + // empty slice for THAT type only, never a 500 that blanks every chart in + // the row. The failing chart self-fetches through its existing fallback. const limit = pLimit(SERIES_BATCH_CONCURRENCY); + let degradedTypes = 0; const entries = await Promise.all( uniqueTypes.map((type) => - limit(async () => { - const rows = await readDailySeries({ - userId: user.id, - type, - from, - to, - priorityJson, - }); - return [type, rows] as const; + limit(async (): Promise => { + try { + const rows = await readDailySeries({ + userId: user.id, + type, + from, + to, + priorityJson, + }); + return [type, rows] as const; + } catch { + degradedTypes += 1; + return [type, []] as const; + } }), ), ); @@ -89,6 +98,7 @@ export const GET = apiHandler(async (request: NextRequest) => { meta: { type_count: uniqueTypes.length, total: entries.reduce((sum, [, rows]) => sum + rows.length, 0), + degraded_types: degradedTypes, }, }); From 075dae9ace054f3a13d2b3ba67f0b3ddc9d9ead1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 18 Jun 2026 18:31:25 +0200 Subject: [PATCH 5/6] perf(illness): dedupe the active-episode read per request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolveRestMode is resolved from several surfaces in one request — the snapshot builder, the recovery score, the analytics route, and coincident- deviation — each firing an identical active-episode findMany for the same account. Memoise the read per request keyed on (userId, asOf instant); an explicit client bypasses the memo so transaction and test reads are never served a default-client cached result. --- src/lib/illness/rest-mode.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/lib/illness/rest-mode.ts b/src/lib/illness/rest-mode.ts index 66e7a38cb..818784b45 100644 --- a/src/lib/illness/rest-mode.ts +++ b/src/lib/illness/rest-mode.ts @@ -26,6 +26,7 @@ import type { PrismaClient, IllnessEpisode } from "@/generated/prisma/client"; import { prisma as defaultPrisma } from "@/lib/db"; import { isIllnessEnabled } from "@/lib/illness/gate"; +import { memoizePerRequest } from "@/lib/request-cache"; /** Minimal shape the Rest Mode resolver needs from an episode row. */ export type ActiveIllnessEpisode = Pick< @@ -78,6 +79,24 @@ export async function getActiveIllnessEpisodes( userId: string, asOf: Date = new Date(), client: PrismaClient = defaultPrisma, +): Promise { + // Several surfaces resolve Rest Mode for the same `(userId, asOf)` inside + // one request — the snapshot builder, the recovery score it builds, the + // analytics route, and coincident-deviation — each firing an identical + // `findMany`. Dedupe them to a single read per request. An explicit client + // (a test stub or a transaction) bypasses the memo so its query is never + // served a default-client cached result. + if (client !== defaultPrisma) return readActiveIllnessEpisodes(userId, asOf, client); + return memoizePerRequest( + `illness-active-episodes:${userId}:${asOf.getTime()}`, + () => readActiveIllnessEpisodes(userId, asOf, client), + ); +} + +async function readActiveIllnessEpisodes( + userId: string, + asOf: Date, + client: PrismaClient, ): Promise { if (!(await isIllnessEnabled(userId))) return []; return client.illnessEpisode.findMany({ From 0af1fbb9d4c543f13cd2ad42f5dbb00b21d6ba7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 18 Jun 2026 18:34:27 +0200 Subject: [PATCH 6/6] refactor(targets): extract the insights-targets builder into lib The targets route held a ~1.4k-line domain function in the handler body. Move buildTargetsResponse into src/lib/targets/build-response so it sits beside the other targets helpers and can be unit-tested without the route shell; the route is now auth -> cachedSwr(buildTargetsResponse) -> apiSuccess. Follow the as-needed compliance source pin to the new file location. --- src/app/api/insights/targets/route.ts | 1554 +---------------- .../as-needed-compliance-exclusion.test.ts | 2 +- src/lib/targets/build-response.ts | 1552 ++++++++++++++++ 3 files changed, 1566 insertions(+), 1542 deletions(-) create mode 100644 src/lib/targets/build-response.ts diff --git a/src/app/api/insights/targets/route.ts b/src/app/api/insights/targets/route.ts index 4b83a14b6..7c2a2a1ad 100644 --- a/src/app/api/insights/targets/route.ts +++ b/src/app/api/insights/targets/route.ts @@ -1,146 +1,26 @@ -import { prisma } from "@/lib/db"; +/** + * `GET /api/insights/targets` — per-metric target tiles + consistency strips. + * + * Thin handler: auth → `cachedSwr(buildTargetsResponse)` → `apiSuccess`. The + * domain function lives in `@/lib/targets/build-response` so the ~1.4k-line + * walk can be unit-tested without the route shell. + */ import { apiSuccess } from "@/lib/api-response"; -import { - classifyBMI, - classifyBP, - classifySleepDuration, - classifyBodyFat, - classifySteps, - getWeightRange, - getSleepDurationRange, - getStepsRange, - getBpTargetsByAge, -} from "@/lib/analytics/classifications"; -import { - buildComplianceMedicationContext, - calculateCompliance, - lastNonSkippedTakenAt, - SCHEDULE_COMPLIANCE_SELECT, -} from "@/lib/analytics/compliance"; -import { pairByTimestamp } from "@/lib/analytics/correlations"; -import { - reconstructSleepNights, - type SleepStageRow, -} from "@/lib/analytics/sleep-night"; -import { isBpReadingInTarget } from "@/lib/analytics/bp-in-target"; -import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/resolver"; -import { - classifyPulseByTarget, - getPersonalizedPulseTarget, -} from "@/lib/analytics/pulse-targets"; -import { resolveRestingPulseSeries } from "@/lib/analytics/resting-pulse"; -import pLimit from "p-limit"; -import type { - MeasurementType, - GlucoseContext, -} from "@/generated/prisma/client"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache"; import { NO_STORE_BUT_BFCACHE } from "@/lib/http/cache-headers"; -import { - getEffectiveRange, - type ThresholdOverridesJson, -} from "@/lib/analytics/effective-range"; -import { getBodyFatTargetRange } from "@/lib/analytics/value-bands"; -import { thresholdMetricForContext, resolveGlucoseUnit } from "@/lib/glucose"; -import { - ensureUserMoodRollupsFresh, - readMoodDayRollups, -} from "@/lib/rollups/mood-rollups"; +import { buildTargetsResponse } from "@/lib/targets/build-response"; export const dynamic = "force-dynamic"; -function getAge(dateOfBirth: Date): number { - const today = new Date(); - let age = today.getFullYear() - dateOfBirth.getFullYear(); - const monthDiff = today.getMonth() - dateOfBirth.getMonth(); - if ( - monthDiff < 0 || - (monthDiff === 0 && today.getDate() < dateOfBirth.getDate()) - ) { - age--; - } - return age; -} - -interface TargetItem { - type: string; - label: string; - current: number | null; - average30: number | null; - trend: "up" | "down" | "stable" | null; - unit: string; - range: { min: number; max: number } | null; - classification: { category: string; color: string } | null; - source: string; - /** - * v1.4.25 W3e — consistency strip support. Berlin-tz day buckets over - * the last 7 days. `daysInRange7d` is the count of days whose mean - * reading landed inside the target's green band; `daysLogged7d` is - * how many of those days had at least one reading (so the strip can - * render filled / hollow / dimmed dots without ambiguity). - * - * `lastMetGoalAt` is the most recent Berlin-tz day whose mean reading - * was in the green band (ISO date YYYY-MM-DD), or null if the user - * has not hit goal in the last 30 days. - * - * `streakDays` is the count of consecutive Berlin days ending today - * where the day's mean was in the green band. Capped at 365. - * - * `insufficientData` is true when the target has fewer than 3 - * readings over the last 30 days OR fewer than 1 day of data in the - * last 7 days. The page hides the strip + percentage when this is - * true. - */ - daysInRange7d: number; - daysLogged7d: number; - daysInRange30d: number; - daysLogged30d: number; - lastMetGoalAt: string | null; - streakDays: number; - insufficientData: boolean; - /** - * v1.4.25 W3e — per-day classification of the last 7 Berlin-tz days - * for the consistency strip. Index 0 is six days ago, index 6 is - * today. `null` slots represent days with no readings; non-null - * slots represent days whose mean reading landed in the named band. - */ - consistency7d: ReadonlyArray<"in" | "near" | "out" | null>; - details?: { - medications?: Array<{ - name: string; - compliance7: number; - compliance30: number; - }>; - }; -} - -interface TargetPageSummary { - targetsMetThisWeek: number; - totalTargets: number; - streakHighlight: { metric: string; days: number } | null; -} - -/** - * v1.4.36 W1 — `/api/insights/targets` body, lifted out of the route - * handler so `cached()` can wrap it. The handler attaches the - * bfcache-friendly Cache-Control header afterward. 60 s TTL keyed on - * the user id matches the analytics cache so multiple Insights mounts - * inside a minute all hit a warm cache. Invalidated alongside the - * analytics bucket on measurement / mood / medication writes. - */ -type AuthedUser = Awaited>["user"]; - export const GET = apiHandler(async () => { const { user } = await requireAuth(); - // v1.16.8 — stale-while-revalidate read. The cold build is the - // multi-query walk below; past the 60 s fresh TTL the prior body now - // serves immediately while ONE background rebuild warms the cell, - // instead of every Insights mount more than a minute apart re-paying - // the >1 s compute inline. Writes still hard-evict the bucket - // (`invalidateUser*` → `deleteByPrefix`), so user actions always - // reflect on the next read. + // v1.16.8 — stale-while-revalidate read. The cold build is the multi-query + // walk in `buildTargetsResponse`; past the 60 s fresh TTL the prior body + // serves immediately while ONE background rebuild warms the cell. Writes + // hard-evict the bucket (`invalidateUser*` → `deleteByPrefix`), so user + // actions always reflect on the next read. const body = await cachedSwr( caches.insightsTargets as ServerCache< Awaited> @@ -153,1411 +33,3 @@ export const GET = apiHandler(async () => { response.headers.set("Cache-Control", NO_STORE_BUT_BFCACHE); return response; }); - -async function buildTargetsResponse(user: AuthedUser) { - const userId = user.id; - - // v1.4.25 W7b — every per-day bucket key in this route resolves - // against the user's display timezone so a Pacific/Auckland user gets - // their Auckland-day streaks, their Auckland-day "last met goal" date, - // and their Auckland-day consistency strip. Falls back to - // Europe/Berlin when the column is somehow missing (defensive — the - // schema's NOT NULL default normally pins it). - const userTz = user.timezone ?? DEFAULT_TIMEZONE; - const dayKey = (d: Date): string => userDayKey(d, userTz); - - // Fetch latest measurements for each type - // v1.11.5 — SLEEP_DURATION is handled by a dedicated per-night - // reconstruction read below (the generic per-type average treats each - // stage row as an independent reading and would inflate the night total), - // so it is intentionally excluded from this generic set. - const types: MeasurementType[] = [ - "WEIGHT", - "BLOOD_PRESSURE_SYS", - "BLOOD_PRESSURE_DIA", - "PULSE", - // v1.15.12 A2 — the "Resting pulse" tile scores RESTING_HEART_RATE - // (Apple's clean daily resting figure) against the resting band, - // falling back to a low-percentile PULSE proxy. Pull the resting rows - // alongside so the consistency strip uses resting data, not raw HR - // polluted by workout samples. - "RESTING_HEART_RATE", - "BODY_FAT", - "ACTIVITY_STEPS", - ]; - - const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); - const oneYearAgo = new Date(Date.now() - 365 * 24 * 60 * 60 * 1000); - - // Mood-rollup warm-up fires before the parallel read below so a legacy - // account converges onto the rollup tier on a subsequent request; the - // read itself never blocks on the warm-up (fire-and-forget). - void ensureUserMoodRollupsFresh(userId); - // Five-year window mirrors `/api/mood/analytics` so existing rollup - // coverage is consulted; the trailing 30-day filter happens in JS - // because the consistency strip already walks per-day buckets. - const moodRollupWindowMs = 5 * 365 * 24 * 60 * 60 * 1000; - const moodSince = new Date(Date.now() - moodRollupWindowMs); - - // v1.16.8 — ONE parallel round of every independent read. The cold - // build used to await eight queries strictly in sequence (profile → - // measurements → latest-per-type → sleep → medications → intakes → - // mood → glucose), stacking their round-trip latencies into the >1 s - // wall-clock the production trace showed. All of them are independent - // (the intake read depends only on the medication ids, so the pair - // chains inside its own slot); fan them out together and the cold - // build pays roughly the slowest query instead of the sum. The - // fan-out is bounded by the project's `p-limit(4)` discipline (parity - // with the derived batch route) so one cold build never claims eight - // connections from the shared Prisma pool at once. - const limit = pLimit(4); - const [ - dbUser, - recentMeasurements, - latestEverByType, - sleepStageRows, - medicationBundle, - moodRollups, - latestMoodEntry, - glucoseRows, - ] = await Promise.all([ - // User profile (height / age / gender / thresholds / source priority). - limit(() => - prisma.user.findUnique({ - where: { id: userId }, - select: { - heightCm: true, - dateOfBirth: true, - gender: true, - glucoseUnit: true, - thresholdsJson: true, - // v1.11.5 — needed to collapse a dual-source sleep night to one - // canonical source before reconstructing per-night asleep totals. - sourcePriorityJson: true, - }, - }), - ), - // All measurements in the last 30 days for the generic type set. - // Soft-delete filter mirrors the W-DELETED pattern: tombstoned rows - // must not contribute to tile averages / latest values once iOS sync - // starts emitting deletions. - limit(() => - prisma.measurement.findMany({ - where: { - userId, - type: { in: types }, - measuredAt: { gte: thirtyDaysAgo }, - deletedAt: null, - }, - orderBy: { measuredAt: "desc" }, - select: { type: true, value: true, measuredAt: true }, - }), - ), - // Absolute latest measurement per type within a one-year floor. - // - // Prisma's `distinct` does not compile to Postgres `DISTINCT ON` — the - // driver dedups after pulling the rows, so an unbounded scan on a - // 347 k-row tenant pulls every measurement back to Node only to drop - // all but seven (audit Critical Finding #2, v1.4.40). The 365-day - // floor caps the planner-side walk to the existing - // `(user_id, type, measured_at)` index range; the trade-off is that a - // user who hasn't measured a metric in over a year sees the tile's - // `current` value as null instead of pulling a stale year-old reading - // forward — which is the correct behaviour for the consistency strip - // (the strip already gates on "fewer than 3 readings in 30 days" → - // insufficientData). - limit(() => - prisma.measurement.findMany({ - where: { - userId, - type: { in: types }, - measuredAt: { gte: oneYearAgo }, - deletedAt: null, - }, - orderBy: { measuredAt: "desc" }, - distinct: ["type"], - select: { type: true, value: true }, - }), - ), - // Sleep stage rows for the per-night reconstruction (section 4). - limit(() => - prisma.measurement.findMany({ - where: { - userId, - type: "SLEEP_DURATION", - measuredAt: { gte: thirtyDaysAgo }, - deletedAt: null, - }, - orderBy: { measuredAt: "asc" }, - // Writer-level collapse: two HealthKit apps behind one source (watch - // stages vs phone in-bed) must not blend into one night. - select: { - value: true, - measuredAt: true, - sleepStage: true, - source: true, - deviceType: true, - }, - }), - ), - // Active medications + their 30-day intake events (section 8). The - // intake read depends on the medication ids, so the pair chains - // inside this slot while riding the outer fan-out. - limit(async () => { - const activeMedications = await prisma.medication.findMany({ - // v1.16.11 — as-needed (PRN) medications never surface a - // compliance rate (no expected doses). - where: { userId, active: true, asNeeded: false }, - // v1.15.20 — schedules through the shared compliance select so the - // configured per-dose windows reach this surface like every other. - include: { - schedules: { select: SCHEDULE_COMPLIANCE_SELECT }, - // v1.16.3 — archived schedule eras for era-aware compliance. - scheduleRevisions: { orderBy: { validFrom: "asc" } }, - }, - orderBy: { name: "asc" }, - }); - if (activeMedications.length === 0) { - return { activeMedications, intakeEvents: [] }; - } - const intakeEvents = await prisma.medicationIntakeEvent.findMany({ - where: { - userId, - // v1.7.0 sync — exclude tombstoned rows. - deletedAt: null, - medicationId: { - in: activeMedications.map((medication) => medication.id), - }, - scheduledFor: { gte: thirtyDaysAgo }, - }, - orderBy: { scheduledFor: "desc" }, - select: { - medicationId: true, - takenAt: true, - skipped: true, - scheduledFor: true, - }, - }); - return { activeMedications, intakeEvents }; - }), - // Mood day rollups (section 9). - limit(() => readMoodDayRollups(userId, moodSince)), - // Latest raw mood entry — bounded `take: 1` via the index. - limit(() => - prisma.moodEntry.findFirst({ - // v1.7.0 sync — exclude tombstoned rows. - where: { userId, deletedAt: null }, - orderBy: { moodLoggedAt: "desc" }, - select: { score: true, moodLoggedAt: true }, - }), - ), - // Glucose rows (section 10). Same one-year floor as the - // latest-per-type read above — the previous unbounded scan pulled a - // heavy logger's full history back to Node on every cold build; a - // context whose newest reading is over a year old now reports no - // card, matching the per-type tile posture. - limit(() => - prisma.measurement.findMany({ - where: { - userId, - type: "BLOOD_GLUCOSE", - measuredAt: { gte: oneYearAgo }, - deletedAt: null, - }, - orderBy: { measuredAt: "desc" }, - select: { value: true, measuredAt: true, glucoseContext: true }, - }), - ), - ]); - - const age = dbUser?.dateOfBirth ? getAge(new Date(dbUser.dateOfBirth)) : null; - const gender = (dbUser?.gender as "MALE" | "FEMALE" | null) ?? null; - const heightCm = dbUser?.heightCm ?? null; - - const latestByType: Record = {}; - const avg30ByType: Record = {}; - - for (const t of types) { - const latest = latestEverByType.find((row) => row.type === t); - latestByType[t] = latest?.value ?? null; - - const recentOfType = recentMeasurements.filter((m) => m.type === t); - if (recentOfType.length > 0) { - const sum = recentOfType.reduce((acc, m) => acc + m.value, 0); - avg30ByType[t] = Math.round((sum / recentOfType.length) * 10) / 10; - } else { - avg30ByType[t] = null; - } - } - - // Compute trend (compare first half of 30-day data to second half) - function computeTrend( - type: MeasurementType, - ): "up" | "down" | "stable" | null { - const data = recentMeasurements - .filter((m) => m.type === type) - .sort((a, b) => a.measuredAt.getTime() - b.measuredAt.getTime()); - if (data.length < 4) return null; - - const mid = Math.floor(data.length / 2); - const firstHalf = data.slice(0, mid); - const secondHalf = data.slice(mid); - - const avgFirst = - firstHalf.reduce((s, m) => s + m.value, 0) / firstHalf.length; - const avgSecond = - secondHalf.reduce((s, m) => s + m.value, 0) / secondHalf.length; - - const diff = avgSecond - avgFirst; - const threshold = avgFirst * 0.02; // 2% change threshold - - if (diff > threshold) return "up"; - if (diff < -threshold) return "down"; - return "stable"; - } - - // -------------------------------------------------------------------- - // v1.4.25 W3e — consistency helper. Buckets readings by Berlin-tz day - // and classifies each day's mean against the target's green band. - // Single helper shared by every target type so the rule stays stable - // (mean reading per day → in-range / near-range / out-of-range). - // -------------------------------------------------------------------- - const last7DayKeys: string[] = []; - for (let i = 6; i >= 0; i--) { - const d = new Date(Date.now() - i * 24 * 60 * 60 * 1000); - last7DayKeys.push(dayKey(d)); - } - - type DayBand = "in" | "near" | "out"; - - interface ConsistencyOutput { - daysInRange7d: number; - daysLogged7d: number; - daysInRange30d: number; - daysLogged30d: number; - lastMetGoalAt: string | null; - streakDays: number; - insufficientData: boolean; - consistency7d: ReadonlyArray; - } - - const EMPTY_CONSISTENCY: ConsistencyOutput = { - daysInRange7d: 0, - daysLogged7d: 0, - daysInRange30d: 0, - daysLogged30d: 0, - lastMetGoalAt: null, - streakDays: 0, - insufficientData: true, - consistency7d: [null, null, null, null, null, null, null] as const, - }; - - /** - * Bucket a series of `{ measuredAt, value }` events by Berlin-tz day, - * classify each day's mean against the target band, and roll up the - * 7-day strip + 30-day stats + recency + streak. - * - * `classify(meanValue)` returns the band the day's mean falls into. - * Pass `null` to mean "no band — skip this day for classification". - */ - function rollupConsistency( - events: ReadonlyArray<{ measuredAt: Date; value: number }>, - classify: (mean: number) => DayBand | null, - totalReadingsThreshold = 3, - ): ConsistencyOutput { - if (events.length < totalReadingsThreshold) { - // Cold-start path. Still emit the per-day strip if we have any - // recent readings, but mark insufficient so the UI hides the %. - const partial = computeStrip(events, classify); - return { - ...EMPTY_CONSISTENCY, - consistency7d: partial.consistency7d, - daysLogged7d: partial.daysLogged7d, - daysInRange7d: partial.daysInRange7d, - insufficientData: true, - }; - } - - const byDay = new Map(); - for (const ev of events) { - const day = dayKey(ev.measuredAt); - const bucket = byDay.get(day) ?? { sum: 0, count: 0 }; - bucket.sum += ev.value; - bucket.count += 1; - byDay.set(day, bucket); - } - - const stripPart = computeStrip(events, classify); - - // 30-day stats: walk the last 30 Berlin-tz day keys. - let daysInRange30d = 0; - let daysLogged30d = 0; - for (let i = 29; i >= 0; i--) { - const d = new Date(Date.now() - i * 24 * 60 * 60 * 1000); - const key = dayKey(d); - const bucket = byDay.get(key); - if (!bucket) continue; - daysLogged30d += 1; - const mean = bucket.sum / bucket.count; - if (classify(mean) === "in") daysInRange30d += 1; - } - - // lastMetGoalAt: walk back from today until a day is in range. - let lastMetGoalAt: string | null = null; - for (let i = 0; i < 30; i++) { - const d = new Date(Date.now() - i * 24 * 60 * 60 * 1000); - const key = dayKey(d); - const bucket = byDay.get(key); - if (!bucket) continue; - const mean = bucket.sum / bucket.count; - if (classify(mean) === "in") { - lastMetGoalAt = key; - break; - } - } - - // streakDays: consecutive days ending today that are in-range. A - // day with no reading breaks the streak (the user did not meet the - // goal that day, even if today is in-range). - let streakDays = 0; - for (let i = 0; i < 365; i++) { - const d = new Date(Date.now() - i * 24 * 60 * 60 * 1000); - const key = dayKey(d); - const bucket = byDay.get(key); - if (!bucket) break; - const mean = bucket.sum / bucket.count; - if (classify(mean) !== "in") break; - streakDays += 1; - } - - // insufficientData: < 3 readings total OR < 1 day in the last 7. - const insufficientData = - events.length < totalReadingsThreshold || stripPart.daysLogged7d < 1; - - return { - daysInRange7d: stripPart.daysInRange7d, - daysLogged7d: stripPart.daysLogged7d, - daysInRange30d, - daysLogged30d, - lastMetGoalAt, - streakDays, - insufficientData, - consistency7d: stripPart.consistency7d, - }; - } - - function computeStrip( - events: ReadonlyArray<{ measuredAt: Date; value: number }>, - classify: (mean: number) => DayBand | null, - ): { - consistency7d: ReadonlyArray; - daysInRange7d: number; - daysLogged7d: number; - } { - const byDay = new Map(); - for (const ev of events) { - const day = dayKey(ev.measuredAt); - const bucket = byDay.get(day) ?? { sum: 0, count: 0 }; - bucket.sum += ev.value; - bucket.count += 1; - byDay.set(day, bucket); - } - const strip: Array = []; - let daysInRange7d = 0; - let daysLogged7d = 0; - for (const key of last7DayKeys) { - const bucket = byDay.get(key); - if (!bucket) { - strip.push(null); - continue; - } - daysLogged7d += 1; - const mean = bucket.sum / bucket.count; - const band = classify(mean); - strip.push(band); - if (band === "in") daysInRange7d += 1; - } - return { consistency7d: strip, daysInRange7d, daysLogged7d }; - } - - /** - * Helper: classify a value against a target range with an orange band - * symmetrical to the green band (the same heuristic RangeBar uses - * client-side). When `min`/`max` is null we treat the input as - * unclassifiable and return null. - */ - function makeRangeClassifier( - range: { min: number; max: number } | null, - options?: { orangeMin?: number; orangeMax?: number }, - ): (mean: number) => DayBand | null { - if (!range) return () => null; - const span = range.max - range.min; - const orangeMin = options?.orangeMin ?? range.min - span * 0.3; - const orangeMax = options?.orangeMax ?? range.max + span * 0.3; - return (mean: number) => { - if (mean >= range.min && mean <= range.max) return "in"; - if (mean >= orangeMin && mean <= orangeMax) return "near"; - return "out"; - }; - } - - /** - * Per-day BP-in-target classifier: a day is "in" if every BP pair on - * that day (sys+dia matched within 5 min) passes `isBpReadingInTarget`. - * "near" if at least one pair passes; "out" otherwise. - * - * Returns a function (day-key, dayEvents) → band. We pre-compute the - * by-day map once because the route already paginates BP twice (sys - * + dia) and we want to avoid quadratic work. - */ - function buildBpPairsByDay(): Map { - if (!bpRange) return new Map(); - const sysByDay = new Map>(); - const diaByDay = new Map>(); - for (const m of recentMeasurements) { - if (m.type === "BLOOD_PRESSURE_SYS") { - const k = dayKey(m.measuredAt); - const arr = sysByDay.get(k) ?? []; - arr.push({ date: m.measuredAt, value: m.value }); - sysByDay.set(k, arr); - } else if (m.type === "BLOOD_PRESSURE_DIA") { - const k = dayKey(m.measuredAt); - const arr = diaByDay.get(k) ?? []; - arr.push({ date: m.measuredAt, value: m.value }); - diaByDay.set(k, arr); - } - } - const out = new Map(); - const allKeys = new Set([...sysByDay.keys(), ...diaByDay.keys()]); - for (const key of allKeys) { - const sys = sysByDay.get(key) ?? []; - const dia = diaByDay.get(key) ?? []; - const pairs = pairByTimestamp(sys, dia, 5 * 60 * 1000); - if (pairs.length === 0) { - out.set(key, null); - continue; - } - const inCount = pairs.filter((p) => - isBpReadingInTarget(p.a, p.b, bpRange), - ).length; - if (inCount === pairs.length) out.set(key, "in"); - else if (inCount > 0) out.set(key, "near"); - else out.set(key, "out"); - } - return out; - } - - // We need bpRange before the per-target loop runs so the helpers above - // can use it; reorder the local so it's available. - const bpRange = age != null ? getBpTargetsByAge(age, gender) : null; - - /** - * Roll up consistency for a target whose bands come from a pre-mapped - * `dayBandByKey` map (used for BP-in-target and medication compliance - * which have day-level classification rather than mean-classification). - */ - function rollupFromDayMap( - dayBandByKey: Map, - totalDaysThreshold = 3, - ): ConsistencyOutput { - const loggedKeys = Array.from(dayBandByKey.entries()).filter( - ([, v]) => v !== null, - ); - if (loggedKeys.length < totalDaysThreshold) { - // Cold-start: still emit the strip. - const strip: Array = last7DayKeys.map( - (k) => dayBandByKey.get(k) ?? null, - ); - const daysLogged7d = strip.filter((b) => b !== null).length; - const daysInRange7d = strip.filter((b) => b === "in").length; - return { - ...EMPTY_CONSISTENCY, - consistency7d: strip, - daysLogged7d, - daysInRange7d, - insufficientData: true, - }; - } - - const strip: Array = last7DayKeys.map( - (k) => dayBandByKey.get(k) ?? null, - ); - const daysLogged7d = strip.filter((b) => b !== null).length; - const daysInRange7d = strip.filter((b) => b === "in").length; - - let daysInRange30d = 0; - let daysLogged30d = 0; - for (let i = 29; i >= 0; i--) { - const d = new Date(Date.now() - i * 24 * 60 * 60 * 1000); - const key = dayKey(d); - const band = dayBandByKey.get(key); - if (!band) continue; - daysLogged30d += 1; - if (band === "in") daysInRange30d += 1; - } - - let lastMetGoalAt: string | null = null; - for (let i = 0; i < 30; i++) { - const d = new Date(Date.now() - i * 24 * 60 * 60 * 1000); - const key = dayKey(d); - if (dayBandByKey.get(key) === "in") { - lastMetGoalAt = key; - break; - } - } - - let streakDays = 0; - for (let i = 0; i < 365; i++) { - const d = new Date(Date.now() - i * 24 * 60 * 60 * 1000); - const key = dayKey(d); - const band = dayBandByKey.get(key); - if (band === undefined || band === null) break; - if (band !== "in") break; - streakDays += 1; - } - - return { - daysInRange7d, - daysLogged7d, - daysInRange30d, - daysLogged30d, - lastMetGoalAt, - streakDays, - insufficientData: loggedKeys.length < totalDaysThreshold, - consistency7d: strip, - }; - } - - // Memo: pre-compute the BP-pairs day map once. - const bpPairsByDay = buildBpPairsByDay(); - - // Build target items - const targets: TargetItem[] = []; - - // 1. Weight - const weightRange = heightCm ? getWeightRange(heightCm) : null; - let weightClassification: { category: string; color: string } | null = null; - if (latestByType.WEIGHT != null && heightCm) { - const heightM = heightCm / 100; - const bmi = latestByType.WEIGHT / (heightM * heightM); - const cls = classifyBMI(bmi); - weightClassification = { category: cls.category, color: cls.color }; - } - { - const consistency = rollupConsistency( - recentMeasurements.filter((m) => m.type === "WEIGHT"), - makeRangeClassifier(weightRange), - ); - targets.push({ - type: "WEIGHT", - label: "Weight", - current: latestByType.WEIGHT ?? null, - average30: avg30ByType.WEIGHT ?? null, - trend: computeTrend("WEIGHT"), - unit: "kg", - range: weightRange, - classification: weightClassification, - source: "WHO BMI", - ...consistency, - }); - } - - // 2. Blood Pressure (sys/dia combined) - // `bpRange` is hoisted to the helpers block above so the BP-pairs - // day-map can use it; do not redeclare here. - let bpClassification: { category: string; color: string } | null = null; - if ( - latestByType.BLOOD_PRESSURE_SYS != null && - latestByType.BLOOD_PRESSURE_DIA != null - ) { - const cls = classifyBP( - latestByType.BLOOD_PRESSURE_SYS, - latestByType.BLOOD_PRESSURE_DIA, - ); - bpClassification = { category: cls.category, color: cls.color }; - } - { - // Per-day BP classifier reuses the sys+dia paired classifier from - // `bpPairsByDay`. This is the right semantic for "in target band" - // because a systolic in range with a diastolic out of range is - // still an out-of-target reading. - const consistency = rollupFromDayMap(bpPairsByDay); - targets.push({ - type: "BLOOD_PRESSURE", - label: "Blood pressure", - current: latestByType.BLOOD_PRESSURE_SYS ?? null, - average30: avg30ByType.BLOOD_PRESSURE_SYS ?? null, - trend: computeTrend("BLOOD_PRESSURE_SYS"), - unit: "mmHg", - range: bpRange ? { min: bpRange.sysLow, max: bpRange.sysHigh } : null, - classification: bpClassification, - source: "ESH 2023", - ...consistency, - // Extra fields for diastolic - } as TargetItem); - } - - // 2b. Blood pressure target-hit rate over 30 days - if (bpRange) { - const sysPoints = recentMeasurements - .filter((measurement) => measurement.type === "BLOOD_PRESSURE_SYS") - .map((measurement) => ({ - date: measurement.measuredAt, - value: measurement.value, - })); - const diaPoints = recentMeasurements - .filter((measurement) => measurement.type === "BLOOD_PRESSURE_DIA") - .map((measurement) => ({ - date: measurement.measuredAt, - value: measurement.value, - })); - - const bpPairs = pairByTimestamp(sysPoints, diaPoints, 5 * 60 * 1000); - // v1.4.16 A2 — one-sided ceiling semantics with hypotension - // floor. See lib/analytics/bp-in-target.ts. - const bpInTargetCount = bpPairs.filter((pair) => - isBpReadingInTarget(pair.a, pair.b, bpRange), - ).length; - const bpInTargetRate = - bpPairs.length > 0 - ? Math.round((bpInTargetCount / bpPairs.length) * 100 * 10) / 10 - : null; - - // BP-in-target consistency mirrors the BP pair-by-day map above: - // a day is "in" when every pair on that day clears the BP target. - const inTargetConsistency = rollupFromDayMap(bpPairsByDay); - targets.push({ - type: "BLOOD_PRESSURE_IN_TARGET", - label: "Blood pressure on target", - current: bpInTargetRate, - average30: bpInTargetRate, - trend: null, - unit: "%", - range: { min: 70, max: 100 }, - classification: - bpInTargetRate != null - ? bpInTargetRate >= 70 - ? { category: "Good", color: "#50fa7b" } - : bpInTargetRate >= 40 - ? { category: "Moderate", color: "#f1fa8c" } - : { category: "Low", color: "#ff5555" } - : null, - source: "ESH 2023", - ...inTargetConsistency, - }); - } - - // 3. Resting pulse - // - // v1.15.12 A2 — judge the RESTING band against a RESTING series, not - // the raw PULSE stream (which Apple Health fills with workout HR). We - // prefer RESTING_HEART_RATE; when the user has none we fall back to a - // low-percentile-of-daily-PULSE proxy. The resting series drives the - // tile's current / average / consistency / classification so a workout - // burst no longer reads "outside target". - const pulseTarget = getPersonalizedPulseTarget(age, gender); - { - const restingResolved = resolveRestingPulseSeries({ - restingSamples: recentMeasurements - .filter((m) => m.type === "RESTING_HEART_RATE") - .map((m) => ({ measuredAt: m.measuredAt, value: m.value })), - pulseSamples: recentMeasurements - .filter((m) => m.type === "PULSE") - .map((m) => ({ measuredAt: m.measuredAt, value: m.value })), - dayKeyOf: dayKey, - }); - const restingEvents = restingResolved.series; - // Current = most recent resting value; average30 = resting mean. - const restingCurrent = - restingEvents.length > 0 - ? restingEvents[restingEvents.length - 1].value - : null; - const restingAvg30 = - restingEvents.length > 0 - ? Math.round( - (restingEvents.reduce((s, e) => s + e.value, 0) / - restingEvents.length) * - 10, - ) / 10 - : null; - - let pulseClassification: { category: string; color: string } | null = null; - if (restingCurrent != null) { - const cls = classifyPulseByTarget(restingCurrent, pulseTarget); - pulseClassification = { category: cls.category, color: cls.color }; - } - - const pulseRange = { - min: pulseTarget.greenMin, - max: pulseTarget.greenMax, - }; - const consistency = rollupConsistency( - restingEvents, - makeRangeClassifier(pulseRange, { - orangeMin: pulseTarget.orangeMin, - orangeMax: pulseTarget.orangeMax, - }), - ); - // Trend over the resting series (first-half vs second-half mean). - let restingTrend: "up" | "down" | "stable" | null = null; - if (restingEvents.length >= 4) { - const sorted = [...restingEvents].sort( - (a, b) => a.measuredAt.getTime() - b.measuredAt.getTime(), - ); - const mid = Math.floor(sorted.length / 2); - const avgFirst = - sorted.slice(0, mid).reduce((s, e) => s + e.value, 0) / mid; - const avgSecond = - sorted.slice(mid).reduce((s, e) => s + e.value, 0) / - (sorted.length - mid); - const diff = avgSecond - avgFirst; - const threshold = avgFirst * 0.02; - restingTrend = - diff > threshold ? "up" : diff < -threshold ? "down" : "stable"; - } - - targets.push({ - type: "PULSE", - label: "Resting pulse", - current: restingCurrent, - average30: restingAvg30, - trend: restingTrend, - unit: "bpm", - range: pulseRange, - classification: pulseClassification, - source: - restingResolved.which === "proxy" - ? `${pulseTarget.source} (estimated from heart rate)` - : pulseTarget.source, - ...consistency, - }); - } - - // 4. Sleep Duration - // - // v1.11.5 (deferred v1.11.4 QA L3) — SLEEP_DURATION is stored one row per - // STAGE per night in MINUTES. The generic per-type path treated every stage - // row as an independent reading, summing IN_BED + AWAKE + the bare ASLEEP - // aggregate + the granular CORE/DEEP/REM rows over 30 days and dividing by - // the row count — and then fed those MINUTES straight into a target range - // expressed in HOURS. The result was a bogus ~16 h/day average. Reconstruct - // per-night TIME-ASLEEP through the shared helper (which dedups dual-source - // nights, excludes IN_BED/AWAKE, and counts only the granular partition when - // present), convert to HOURS, then classify and roll up consistency on the - // per-night hour totals so this surface matches the dashboard / series / - // overview headline. - const sleepRange = getSleepDurationRange(); - { - // `sleepStageRows` resolved in the parallel read at the top. - const nights = reconstructSleepNights( - sleepStageRows as SleepStageRow[], - userTz, - dbUser?.sourcePriorityJson ?? null, - ) - .filter((n) => n.asleepMinutes > 0) - .sort((a, b) => a.measuredAt.getTime() - b.measuredAt.getTime()); - - const round1 = (n: number): number => Math.round(n * 100) / 100; - // One event per night carrying the night's asleep HOURS — the right unit - // for the 7–9 h target band and for the per-day consistency bucketing. - const sleepEvents = nights.map((n) => ({ - measuredAt: n.measuredAt, - value: round1(n.asleepMinutes / 60), - })); - - const latestNight = nights.length > 0 ? nights[nights.length - 1] : null; - const currentSleepHours = latestNight - ? round1(latestNight.asleepMinutes / 60) - : null; - const avgSleepHours = - sleepEvents.length > 0 - ? round1( - sleepEvents.reduce((s, e) => s + e.value, 0) / sleepEvents.length, - ) - : null; - - let sleepClassification: { category: string; color: string } | null = null; - if (currentSleepHours != null) { - const cls = classifySleepDuration(currentSleepHours); - sleepClassification = { category: cls.category, color: cls.color }; - } - - // Trend over the per-night hour series (first vs second half), mirroring - // `computeTrend` but on reconstructed nights rather than raw stage rows. - let sleepTrend: "up" | "down" | "stable" | null = null; - if (sleepEvents.length >= 4) { - const mid = Math.floor(sleepEvents.length / 2); - const firstHalf = sleepEvents.slice(0, mid); - const secondHalf = sleepEvents.slice(mid); - const avgFirst = - firstHalf.reduce((s, e) => s + e.value, 0) / firstHalf.length; - const avgSecond = - secondHalf.reduce((s, e) => s + e.value, 0) / secondHalf.length; - const diff = avgSecond - avgFirst; - const threshold = avgFirst * 0.02; - if (diff > threshold) sleepTrend = "up"; - else if (diff < -threshold) sleepTrend = "down"; - else sleepTrend = "stable"; - } - - const consistency = rollupConsistency( - sleepEvents, - makeRangeClassifier(sleepRange), - ); - targets.push({ - type: "SLEEP_DURATION", - label: "Sleep duration", - current: currentSleepHours, - average30: avgSleepHours, - trend: sleepTrend, - unit: "h", - range: sleepRange, - classification: sleepClassification, - source: "AASM/SRS", - ...consistency, - }); - } - - // 5. BMI (derived from weight + height) - if (heightCm) { - const heightM = heightCm / 100; - const heightSq = heightM * heightM; - const latestWeight = latestByType.WEIGHT ?? null; - const currentBmi = - latestWeight != null - ? Math.round((latestWeight / heightSq) * 10) / 10 - : null; - - // Compute 30-day average BMI from average weight - const avgWeight = avg30ByType.WEIGHT ?? null; - const avgBmi = - avgWeight != null ? Math.round((avgWeight / heightSq) * 10) / 10 : null; - - let bmiClassification: { category: string; color: string } | null = null; - if (currentBmi != null) { - const cls = classifyBMI(currentBmi); - bmiClassification = { category: cls.category, color: cls.color }; - } - - // BMI consistency derives from the weight series: divide each - // bucket by height\u00B2 so the day's mean BMI is classified against - // the BMI range bar (18.5 \u2014 24.9). - const bmiRange = { min: 18.5, max: 24.9 }; - const bmiEvents = recentMeasurements - .filter((m) => m.type === "WEIGHT") - .map((m) => ({ measuredAt: m.measuredAt, value: m.value / heightSq })); - const consistency = rollupConsistency( - bmiEvents, - makeRangeClassifier(bmiRange), - ); - targets.push({ - type: "BMI", - label: "BMI", - current: currentBmi, - average30: avgBmi, - trend: computeTrend("WEIGHT"), // BMI trend follows weight trend - unit: "kg/m\u00B2", - range: bmiRange, - classification: bmiClassification, - source: "WHO", - ...consistency, - }); - } - - // 6. Body Fat - let bodyFatClassification: { category: string; color: string } | null = null; - if (latestByType.BODY_FAT != null && age != null) { - const cls = classifyBodyFat(latestByType.BODY_FAT, gender, age); - bodyFatClassification = { category: cls.category, color: cls.color }; - } - // Single source of truth for the body-fat green band lives in - // `value-bands.ts`. v1.3.3 had three different hardcoded ranges across - // value-bands.ts / targets/route.ts / classifications.ts; the v1.4 - // marathon consolidated them onto `getBodyFatTargetRange` (ACE - // fitness + acceptable bands). - const bodyFatRange = age != null ? getBodyFatTargetRange(gender) : null; - { - const consistency = rollupConsistency( - recentMeasurements.filter((m) => m.type === "BODY_FAT"), - makeRangeClassifier(bodyFatRange), - ); - targets.push({ - type: "BODY_FAT", - label: "Body fat", - current: latestByType.BODY_FAT ?? null, - average30: avg30ByType.BODY_FAT ?? null, - trend: computeTrend("BODY_FAT"), - unit: "%", - range: bodyFatRange, - classification: bodyFatClassification, - source: "ACE", - ...consistency, - }); - } - - // 7. Activity Steps - const stepsRange = getStepsRange(); - let stepsClassification: { category: string; color: string } | null = null; - if (avg30ByType.ACTIVITY_STEPS != null) { - const cls = classifySteps(avg30ByType.ACTIVITY_STEPS); - stepsClassification = { category: cls.category, color: cls.color }; - } - { - const consistency = rollupConsistency( - recentMeasurements.filter((m) => m.type === "ACTIVITY_STEPS"), - makeRangeClassifier(stepsRange), - ); - targets.push({ - type: "ACTIVITY_STEPS", - label: "Steps/day", - current: latestByType.ACTIVITY_STEPS ?? null, - average30: avg30ByType.ACTIVITY_STEPS ?? null, - trend: computeTrend("ACTIVITY_STEPS"), - unit: "steps", - range: stepsRange, - classification: stepsClassification, - // WHO publishes activity *time* (150–300 min/wk moderate), - // not a step quota. The closest peer-reviewed dose-response - // for the 8 000–15 000 band is Saint-Maurice JAMA 2020. The - // AI prompts at src/lib/ai/prompts/base-system.ts and - // src/lib/ai/prompts/general-status.ts already enforce this - // attribution; this surface label was the last "WHO" mislabel - // in the codebase. - source: "Saint-Maurice JAMA 2020", - ...consistency, - }); - } - - // 8. Medication Compliance (average across active medications) — - // `activeMedications` + their 30-day `intakeEvents` resolved in the - // parallel read at the top. - const { activeMedications, intakeEvents } = medicationBundle; - - if (activeMedications.length > 0) { - const medicationStats = activeMedications.map((medication) => { - const medicationEvents = intakeEvents.filter( - (event) => event.medicationId === medication.id, - ); - // v1.7.0 SB-SCHED-2 — engine-routed denominator (userTz resolved - // above at the route entry). - const medicationContext = buildComplianceMedicationContext( - medication, - lastNonSkippedTakenAt(medicationEvents), - userTz, - ); - const compliance7 = calculateCompliance( - medicationEvents, - medication.schedules, - 7, - medication.createdAt, - { medicationContext }, - ); - const compliance30 = calculateCompliance( - medicationEvents, - medication.schedules, - 30, - medication.createdAt, - { medicationContext }, - ); - - return { - name: medication.name, - compliance7: compliance7.rate, - compliance30: compliance30.rate, - totalExpected7: compliance7.totalExpected, - taken7: compliance7.taken, - totalExpected30: compliance30.totalExpected, - taken30: compliance30.taken, - }; - }); - - const totalExpected7 = medicationStats.reduce( - (sum, medication) => sum + medication.totalExpected7, - 0, - ); - const totalTaken7 = medicationStats.reduce( - (sum, medication) => sum + medication.taken7, - 0, - ); - const totalExpected30 = medicationStats.reduce( - (sum, medication) => sum + medication.totalExpected30, - 0, - ); - const totalTaken30 = medicationStats.reduce( - (sum, medication) => sum + medication.taken30, - 0, - ); - - const complianceRate7 = - totalExpected7 > 0 - ? Math.round( - (Math.min(1, totalTaken7 / totalExpected7) * 100 + Number.EPSILON) * - 10, - ) / 10 - : null; - const complianceRate30 = - totalExpected30 > 0 - ? Math.round( - (Math.min(1, totalTaken30 / totalExpected30) * 100 + - Number.EPSILON) * - 10, - ) / 10 - : complianceRate7; - - // Per-day compliance: for each Berlin-tz day, classify as "in" if - // every scheduled dose that day was taken; "near" if at least one - // was taken; "out" if all were skipped/missed; null if there were - // no scheduled doses (the user's regimen excluded that day). - const dayCountsByKey = new Map< - string, - { taken: number; expected: number } - >(); - for (const event of intakeEvents) { - const key = dayKey(event.scheduledFor); - const cur = dayCountsByKey.get(key) ?? { taken: 0, expected: 0 }; - cur.expected += 1; - if (event.takenAt && !event.skipped) cur.taken += 1; - dayCountsByKey.set(key, cur); - } - const dayBandByKey = new Map(); - for (const [key, counts] of dayCountsByKey.entries()) { - if (counts.expected === 0) { - dayBandByKey.set(key, null); - continue; - } - const ratio = counts.taken / counts.expected; - if (ratio >= 0.99) dayBandByKey.set(key, "in"); - else if (ratio >= 0.5) dayBandByKey.set(key, "near"); - else dayBandByKey.set(key, "out"); - } - const consistency = rollupFromDayMap(dayBandByKey); - targets.push({ - type: "MEDICATION_COMPLIANCE", - label: "Medication compliance", - current: complianceRate7, - average30: complianceRate30, - trend: null, - unit: "%", - range: { min: 90, max: 100 }, - classification: - complianceRate7 != null - ? complianceRate7 >= 90 - ? { category: "Very good", color: "#50fa7b" } - : complianceRate7 >= 70 - ? { category: "Good", color: "#f1fa8c" } - : { category: "Low", color: "#ff5555" } - : null, - source: "7-day", - details: { - medications: medicationStats.map((medication) => ({ - name: medication.name, - compliance7: medication.compliance7, - compliance30: medication.compliance30, - })), - }, - ...consistency, - }); - } - - // 9. Mood targets (if mood data exists) - // - // v1.4.40 \u2014 swap the unbounded `prisma.moodEntry.findMany({ where: { - // userId } })` for the persistent mood-rollup DAY tier (audit Critical - // Finding #2). The tile reads: - // - per-day mean over the trailing 30 days (avg30, trend, stability) - // - the consistency strip (in/near/out band per day) - // - the latest score for the "current" reading + classification - // - // Rollup row = `{bucketStart, mean, count, minScore, maxScore}` per - // calendar day. Per-day mean replaces the per-entry score series the - // legacy path passed to `rollupConsistency` \u2014 the helper buckets by - // day-key anyway, so feeding one row per day with the pre-computed - // mean is mathematically identical to feeding N raw entries that - // collapse to the same mean. - // - // The "latest score" needs a single raw entry (the newest one), so we - // pull it with `take: 1` \u2014 bounded by the index, never an unbounded - // scan. The "\u2265 3 entries threshold" gate uses the rollup count sum so - // the cold-start branch stays consistent with the legacy semantics - // (which counted raw entries, not raw days). - // - // Coverage-fallback: when the user has mood entries but zero rollup - // rows yet (legacy account before the boot-time backfill caught up) - // we run the legacy live walk once and fire the warm-up so the next - // request lands on the rollup tier. Same posture as - // `/api/mood/analytics`. The warm-up trigger + the `moodRollups` / - // `latestMoodEntry` reads moved into the parallel round at the top. - const thirtyDaysAgoMood = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); - - // Build a per-day event list for the consistency strip + 30-day stats. - // Use the rollup tier when populated; fall back to a bounded raw walk - // when the user has mood entries but zero rollup coverage yet. - let moodDailyEvents: Array<{ measuredAt: Date; value: number }>; - let moodEntryCount: number; - let moodSumScores: number; - let moodVarianceN: number; - let moodSumSquares: number; - if (moodRollups.length > 0) { - moodEntryCount = moodRollups.reduce((s, r) => s + r.count, 0); - moodDailyEvents = moodRollups - .filter((r) => r.bucketStart >= thirtyDaysAgoMood) - .map((r) => ({ measuredAt: r.bucketStart, value: r.mean })); - moodSumScores = moodDailyEvents.reduce((s, e) => s + e.value, 0); - moodVarianceN = moodDailyEvents.length; - moodSumSquares = moodDailyEvents.reduce((s, e) => s + e.value * e.value, 0); - } else if (latestMoodEntry !== null) { - // Coverage-fallback: rollups empty but raw entries exist. Pull the - // trailing 30-day window only (instead of every entry ever) \u2014 that's - // the bound the legacy code was missing. - const recentRawMood = await prisma.moodEntry.findMany({ - // v1.7.0 sync — exclude tombstoned rows. - where: { - userId, - deletedAt: null, - moodLoggedAt: { gte: thirtyDaysAgoMood }, - }, - orderBy: { moodLoggedAt: "asc" }, - select: { score: true, moodLoggedAt: true }, - }); - moodEntryCount = recentRawMood.length; - moodDailyEvents = recentRawMood.map((e) => ({ - measuredAt: e.moodLoggedAt, - value: e.score, - })); - moodSumScores = recentRawMood.reduce((s, e) => s + e.score, 0); - moodVarianceN = recentRawMood.length; - moodSumSquares = recentRawMood.reduce((s, e) => s + e.score * e.score, 0); - } else { - moodEntryCount = 0; - moodDailyEvents = []; - moodSumScores = 0; - moodVarianceN = 0; - moodSumSquares = 0; - } - - if (moodEntryCount >= 3) { - const latestMoodScore = latestMoodEntry?.score ?? null; - - const moodAvg30 = - moodDailyEvents.length > 0 - ? Math.round((moodSumScores / moodDailyEvents.length) * 10) / 10 - : null; - - // Mood trend: compare first half to second half of last 30 days. - // The rollup tier carries one DataPoint per day (the daily mean), - // matching the legacy comparison semantics on single-entry days. - let moodTrend: "up" | "down" | "stable" | null = null; - if (moodDailyEvents.length >= 4) { - const sorted = [...moodDailyEvents].sort( - (a, b) => a.measuredAt.getTime() - b.measuredAt.getTime(), - ); - const mid = Math.floor(sorted.length / 2); - const firstHalf = sorted.slice(0, mid); - const secondHalf = sorted.slice(mid); - const avgFirst = - firstHalf.reduce((s, e) => s + e.value, 0) / firstHalf.length; - const avgSecond = - secondHalf.reduce((s, e) => s + e.value, 0) / secondHalf.length; - const diff = avgSecond - avgFirst; - if (diff > 0.2) moodTrend = "up"; - else if (diff < -0.2) moodTrend = "down"; - else moodTrend = "stable"; - } - - const moodClassification = - latestMoodScore != null - ? latestMoodScore >= 3.5 - ? { category: "Good", color: "#50fa7b" } - : latestMoodScore >= 2 - ? { category: "Moderate", color: "#f1fa8c" } - : { category: "Low", color: "#ff5555" } - : null; - - const moodRange = { min: 3.5, max: 5 }; - const moodConsistency = rollupConsistency( - moodDailyEvents, - makeRangeClassifier(moodRange, { orangeMin: 2, orangeMax: 5 }), - ); - targets.push({ - type: "MOOD_SCORE", - label: "Mood", - current: latestMoodScore, - average30: moodAvg30, - trend: moodTrend, - unit: "/ 5", - range: moodRange, - classification: moodClassification, - source: "moodLog", - ...moodConsistency, - }); - - // Mood stability: standard deviation of the recent series. The - // rollup tier surfaces one mean per day, which is the right - // resolution for "is the user's mood stable across days" \u2014 a power - // user logging multiple times per day with a wide intraday spread - // still gets a "stable" badge if the daily mean barely moves. The - // legacy path conflated intraday variance with day-to-day stability - // by passing per-entry scores; the rollup-tier semantic is closer - // to what the UI label suggests. - if (moodVarianceN >= 5) { - const seriesMean = moodSumScores / moodVarianceN; - const variance = moodSumSquares / moodVarianceN - seriesMean * seriesMean; - const stdDev = Math.round(Math.sqrt(Math.max(0, variance)) * 100) / 100; - - const stabilityClassification = - stdDev <= 0.5 - ? { category: "Very stable", color: "#50fa7b" } - : stdDev <= 1.0 - ? { category: "Stable", color: "#f1fa8c" } - : { category: "Fluctuating", color: "#ff5555" }; - - // Mood stability is computed from the same recent-mood window; - // consistency mirrors MOOD_SCORE so the strip aligns with the - // user's logging cadence rather than introducing a synthetic - // "\u03C3 in range per day" rule that would be hard to reason about. - targets.push({ - type: "MOOD_STABILITY", - label: "Mood stability", - current: stdDev, - average30: stdDev, - trend: null, - unit: "\u03C3", - range: { min: 0, max: 0.5 }, - classification: stabilityClassification, - source: "moodLog", - ...moodConsistency, - }); - } - } - - // 10. Blood glucose — one card per logged context. - const glucoseContexts: GlucoseContext[] = [ - "FASTING", - "POSTPRANDIAL", - "RANDOM", - "BEDTIME", - ]; - const glucoseUnit = resolveGlucoseUnit(dbUser?.glucoseUnit ?? null); - const overrides = (dbUser?.thresholdsJson ?? - null) as ThresholdOverridesJson | null; - const profileForRange = { - heightCm, - dateOfBirth: dbUser?.dateOfBirth ?? null, - gender: dbUser?.gender ?? null, - }; - // `glucoseRows` resolved (one-year floor) in the parallel read at the top. - const thirtyDaysAgoGlucose = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); - - const labelKeyByContext: Record = { - FASTING: "targets.glucoseFasting", - POSTPRANDIAL: "targets.glucosePostprandial", - RANDOM: "targets.glucoseRandom", - BEDTIME: "targets.glucoseBedtime", - }; - - for (const ctx of glucoseContexts) { - const ctxRows = glucoseRows.filter((r) => r.glucoseContext === ctx); - if (ctxRows.length === 0) continue; - const latest = ctxRows[0].value; - const recent = ctxRows.filter((r) => r.measuredAt >= thirtyDaysAgoGlucose); - const avg30 = - recent.length > 0 - ? Math.round( - (recent.reduce((s, r) => s + r.value, 0) / recent.length) * 10, - ) / 10 - : null; - - const metric = thresholdMetricForContext(ctx); - const eff = getEffectiveRange(metric, profileForRange, overrides); - const range = eff.range - ? { min: eff.range.greenMin, max: eff.range.greenMax } - : null; - - let classification: { category: string; color: string } | null = null; - if (range) { - if (latest >= range.min && latest <= range.max) { - classification = { category: "Optimal", color: "#50fa7b" }; - } else if ( - eff.range && - latest >= eff.range.orangeMin && - latest <= eff.range.orangeMax - ) { - classification = { category: "Elevated", color: "#f1fa8c" }; - } else { - classification = { category: "High", color: "#ff5555" }; - } - } - - const consistency = rollupConsistency( - recent.map((r) => ({ measuredAt: r.measuredAt, value: r.value })), - makeRangeClassifier( - range, - eff.range - ? { orangeMin: eff.range.orangeMin, orangeMax: eff.range.orangeMax } - : undefined, - ), - ); - targets.push({ - type: `BLOOD_GLUCOSE_${ctx}`, - label: labelKeyByContext[ctx], - current: latest, - average30: avg30, - trend: null, - unit: glucoseUnit, - range, - classification, - source: eff.isOverride ? "Custom" : "ADA 2024 / DDG", - ...consistency, - }); - } - - // -------------------------------------------------------------------- - // v1.4.25 W3e — page summary. "X of Y targets met this week" is the - // single most-asked-about glanceable answer. We define "met this - // week" as the target having `daysInRange7d >= 4` AND not flagged as - // `insufficientData`. The streak highlight surfaces the metric with - // the longest current ≥ 3 day streak; nothing renders client-side - // when no target hits the bar. - // -------------------------------------------------------------------- - const targetsMetThisWeek = targets.filter( - (target) => !target.insufficientData && target.daysInRange7d >= 4, - ).length; - let streakHighlight: TargetPageSummary["streakHighlight"] = null; - for (const target of targets) { - if (target.streakDays < 3) continue; - if (!streakHighlight || target.streakDays > streakHighlight.days) { - streakHighlight = { metric: target.type, days: target.streakDays }; - } - } - const pageSummary: TargetPageSummary = { - targetsMetThisWeek, - totalTargets: targets.length, - streakHighlight, - }; - - annotate({ - action: { name: "insights.targets" }, - meta: { - targetCount: targets.length, - targetsMetThisWeek, - streakHighlightMetric: streakHighlight?.metric ?? null, - }, - }); - - return { - targets, - pageSummary, - // Extra diastolic data for BP display - bpDiastolic: { - current: latestByType.BLOOD_PRESSURE_DIA ?? null, - average30: avg30ByType.BLOOD_PRESSURE_DIA ?? null, - range: bpRange ? { min: bpRange.diaLow, max: bpRange.diaHigh } : null, - }, - profile: { - heightCm, - age, - gender, - glucoseUnit, - }, - }; -} diff --git a/src/lib/analytics/__tests__/as-needed-compliance-exclusion.test.ts b/src/lib/analytics/__tests__/as-needed-compliance-exclusion.test.ts index b3e36ad46..209ff82c9 100644 --- a/src/lib/analytics/__tests__/as-needed-compliance-exclusion.test.ts +++ b/src/lib/analytics/__tests__/as-needed-compliance-exclusion.test.ts @@ -36,7 +36,7 @@ const CALL_SITES: Array<{ file: string; surface: string }> = [ surface: "insight feature extraction (c7/c30/c90)", }, { - file: "src/app/api/insights/targets/route.ts", + file: "src/lib/targets/build-response.ts", surface: "insight targets", }, { diff --git a/src/lib/targets/build-response.ts b/src/lib/targets/build-response.ts new file mode 100644 index 000000000..7bae2df9d --- /dev/null +++ b/src/lib/targets/build-response.ts @@ -0,0 +1,1552 @@ +/** + * `GET /api/insights/targets` response builder. + * + * Extracted from the route handler (v1.18.6) so the ~1.4k-line domain + * function lives under `src/lib/targets/` next to the other targets + * helpers and can be unit-tested without the route shell. The route is + * now auth → `cachedSwr(buildTargetsResponse)` → `apiSuccess`. + * + * Every per-day bucket key resolves against the user's display timezone + * (`userDayKey`) so streaks, "last met goal", and the consistency strip + * are read in the user's own calendar. + */ +import { prisma } from "@/lib/db"; +import { annotate } from "@/lib/logging/context"; +import { + classifyBMI, + classifyBP, + classifySleepDuration, + classifyBodyFat, + classifySteps, + getWeightRange, + getSleepDurationRange, + getStepsRange, + getBpTargetsByAge, +} from "@/lib/analytics/classifications"; +import { + buildComplianceMedicationContext, + calculateCompliance, + lastNonSkippedTakenAt, + SCHEDULE_COMPLIANCE_SELECT, +} from "@/lib/analytics/compliance"; +import { pairByTimestamp } from "@/lib/analytics/correlations"; +import { + reconstructSleepNights, + type SleepStageRow, +} from "@/lib/analytics/sleep-night"; +import { isBpReadingInTarget } from "@/lib/analytics/bp-in-target"; +import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/resolver"; +import { + classifyPulseByTarget, + getPersonalizedPulseTarget, +} from "@/lib/analytics/pulse-targets"; +import { resolveRestingPulseSeries } from "@/lib/analytics/resting-pulse"; +import pLimit from "p-limit"; +import type { + MeasurementType, + GlucoseContext, +} from "@/generated/prisma/client"; +import { + getEffectiveRange, + type ThresholdOverridesJson, +} from "@/lib/analytics/effective-range"; +import { getBodyFatTargetRange } from "@/lib/analytics/value-bands"; +import { thresholdMetricForContext, resolveGlucoseUnit } from "@/lib/glucose"; +import { + ensureUserMoodRollupsFresh, + readMoodDayRollups, +} from "@/lib/rollups/mood-rollups"; + + +import type { User } from "@/generated/prisma/client"; + +function getAge(dateOfBirth: Date): number { + const today = new Date(); + let age = today.getFullYear() - dateOfBirth.getFullYear(); + const monthDiff = today.getMonth() - dateOfBirth.getMonth(); + if ( + monthDiff < 0 || + (monthDiff === 0 && today.getDate() < dateOfBirth.getDate()) + ) { + age--; + } + return age; +} + +interface TargetItem { + type: string; + label: string; + current: number | null; + average30: number | null; + trend: "up" | "down" | "stable" | null; + unit: string; + range: { min: number; max: number } | null; + classification: { category: string; color: string } | null; + source: string; + /** + * v1.4.25 W3e — consistency strip support. Berlin-tz day buckets over + * the last 7 days. `daysInRange7d` is the count of days whose mean + * reading landed inside the target's green band; `daysLogged7d` is + * how many of those days had at least one reading (so the strip can + * render filled / hollow / dimmed dots without ambiguity). + * + * `lastMetGoalAt` is the most recent Berlin-tz day whose mean reading + * was in the green band (ISO date YYYY-MM-DD), or null if the user + * has not hit goal in the last 30 days. + * + * `streakDays` is the count of consecutive Berlin days ending today + * where the day's mean was in the green band. Capped at 365. + * + * `insufficientData` is true when the target has fewer than 3 + * readings over the last 30 days OR fewer than 1 day of data in the + * last 7 days. The page hides the strip + percentage when this is + * true. + */ + daysInRange7d: number; + daysLogged7d: number; + daysInRange30d: number; + daysLogged30d: number; + lastMetGoalAt: string | null; + streakDays: number; + insufficientData: boolean; + /** + * v1.4.25 W3e — per-day classification of the last 7 Berlin-tz days + * for the consistency strip. Index 0 is six days ago, index 6 is + * today. `null` slots represent days with no readings; non-null + * slots represent days whose mean reading landed in the named band. + */ + consistency7d: ReadonlyArray<"in" | "near" | "out" | null>; + details?: { + medications?: Array<{ + name: string; + compliance7: number; + compliance30: number; + }>; + }; +} + +interface TargetPageSummary { + targetsMetThisWeek: number; + totalTargets: number; + streakHighlight: { metric: string; days: number } | null; +} + +/** + * The authenticated-user shape the builder reads directly. The profile + * fields (height / dob / gender / glucose unit / thresholds) are re-read + * from the DB inside the builder, so the handler only threads id + tz. + */ +export type AuthedUser = Pick; + +/** + * `/api/insights/targets` body. Wrapped in `cachedSwr` by the route so the + * cold multi-query walk is paid once per 60 s window; writes hard-evict the + * bucket so user actions reflect on the next read. + */ +export async function buildTargetsResponse(user: AuthedUser) { + const userId = user.id; + + // v1.4.25 W7b — every per-day bucket key in this route resolves + // against the user's display timezone so a Pacific/Auckland user gets + // their Auckland-day streaks, their Auckland-day "last met goal" date, + // and their Auckland-day consistency strip. Falls back to + // Europe/Berlin when the column is somehow missing (defensive — the + // schema's NOT NULL default normally pins it). + const userTz = user.timezone ?? DEFAULT_TIMEZONE; + const dayKey = (d: Date): string => userDayKey(d, userTz); + + // Fetch latest measurements for each type + // v1.11.5 — SLEEP_DURATION is handled by a dedicated per-night + // reconstruction read below (the generic per-type average treats each + // stage row as an independent reading and would inflate the night total), + // so it is intentionally excluded from this generic set. + const types: MeasurementType[] = [ + "WEIGHT", + "BLOOD_PRESSURE_SYS", + "BLOOD_PRESSURE_DIA", + "PULSE", + // v1.15.12 A2 — the "Resting pulse" tile scores RESTING_HEART_RATE + // (Apple's clean daily resting figure) against the resting band, + // falling back to a low-percentile PULSE proxy. Pull the resting rows + // alongside so the consistency strip uses resting data, not raw HR + // polluted by workout samples. + "RESTING_HEART_RATE", + "BODY_FAT", + "ACTIVITY_STEPS", + ]; + + const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); + const oneYearAgo = new Date(Date.now() - 365 * 24 * 60 * 60 * 1000); + + // Mood-rollup warm-up fires before the parallel read below so a legacy + // account converges onto the rollup tier on a subsequent request; the + // read itself never blocks on the warm-up (fire-and-forget). + void ensureUserMoodRollupsFresh(userId); + // Five-year window mirrors `/api/mood/analytics` so existing rollup + // coverage is consulted; the trailing 30-day filter happens in JS + // because the consistency strip already walks per-day buckets. + const moodRollupWindowMs = 5 * 365 * 24 * 60 * 60 * 1000; + const moodSince = new Date(Date.now() - moodRollupWindowMs); + + // v1.16.8 — ONE parallel round of every independent read. The cold + // build used to await eight queries strictly in sequence (profile → + // measurements → latest-per-type → sleep → medications → intakes → + // mood → glucose), stacking their round-trip latencies into the >1 s + // wall-clock the production trace showed. All of them are independent + // (the intake read depends only on the medication ids, so the pair + // chains inside its own slot); fan them out together and the cold + // build pays roughly the slowest query instead of the sum. The + // fan-out is bounded by the project's `p-limit(4)` discipline (parity + // with the derived batch route) so one cold build never claims eight + // connections from the shared Prisma pool at once. + const limit = pLimit(4); + const [ + dbUser, + recentMeasurements, + latestEverByType, + sleepStageRows, + medicationBundle, + moodRollups, + latestMoodEntry, + glucoseRows, + ] = await Promise.all([ + // User profile (height / age / gender / thresholds / source priority). + limit(() => + prisma.user.findUnique({ + where: { id: userId }, + select: { + heightCm: true, + dateOfBirth: true, + gender: true, + glucoseUnit: true, + thresholdsJson: true, + // v1.11.5 — needed to collapse a dual-source sleep night to one + // canonical source before reconstructing per-night asleep totals. + sourcePriorityJson: true, + }, + }), + ), + // All measurements in the last 30 days for the generic type set. + // Soft-delete filter mirrors the W-DELETED pattern: tombstoned rows + // must not contribute to tile averages / latest values once iOS sync + // starts emitting deletions. + limit(() => + prisma.measurement.findMany({ + where: { + userId, + type: { in: types }, + measuredAt: { gte: thirtyDaysAgo }, + deletedAt: null, + }, + orderBy: { measuredAt: "desc" }, + select: { type: true, value: true, measuredAt: true }, + }), + ), + // Absolute latest measurement per type within a one-year floor. + // + // Prisma's `distinct` does not compile to Postgres `DISTINCT ON` — the + // driver dedups after pulling the rows, so an unbounded scan on a + // 347 k-row tenant pulls every measurement back to Node only to drop + // all but seven (audit Critical Finding #2, v1.4.40). The 365-day + // floor caps the planner-side walk to the existing + // `(user_id, type, measured_at)` index range; the trade-off is that a + // user who hasn't measured a metric in over a year sees the tile's + // `current` value as null instead of pulling a stale year-old reading + // forward — which is the correct behaviour for the consistency strip + // (the strip already gates on "fewer than 3 readings in 30 days" → + // insufficientData). + limit(() => + prisma.measurement.findMany({ + where: { + userId, + type: { in: types }, + measuredAt: { gte: oneYearAgo }, + deletedAt: null, + }, + orderBy: { measuredAt: "desc" }, + distinct: ["type"], + select: { type: true, value: true }, + }), + ), + // Sleep stage rows for the per-night reconstruction (section 4). + limit(() => + prisma.measurement.findMany({ + where: { + userId, + type: "SLEEP_DURATION", + measuredAt: { gte: thirtyDaysAgo }, + deletedAt: null, + }, + orderBy: { measuredAt: "asc" }, + // Writer-level collapse: two HealthKit apps behind one source (watch + // stages vs phone in-bed) must not blend into one night. + select: { + value: true, + measuredAt: true, + sleepStage: true, + source: true, + deviceType: true, + }, + }), + ), + // Active medications + their 30-day intake events (section 8). The + // intake read depends on the medication ids, so the pair chains + // inside this slot while riding the outer fan-out. + limit(async () => { + const activeMedications = await prisma.medication.findMany({ + // v1.16.11 — as-needed (PRN) medications never surface a + // compliance rate (no expected doses). + where: { userId, active: true, asNeeded: false }, + // v1.15.20 — schedules through the shared compliance select so the + // configured per-dose windows reach this surface like every other. + include: { + schedules: { select: SCHEDULE_COMPLIANCE_SELECT }, + // v1.16.3 — archived schedule eras for era-aware compliance. + scheduleRevisions: { orderBy: { validFrom: "asc" } }, + }, + orderBy: { name: "asc" }, + }); + if (activeMedications.length === 0) { + return { activeMedications, intakeEvents: [] }; + } + const intakeEvents = await prisma.medicationIntakeEvent.findMany({ + where: { + userId, + // v1.7.0 sync — exclude tombstoned rows. + deletedAt: null, + medicationId: { + in: activeMedications.map((medication) => medication.id), + }, + scheduledFor: { gte: thirtyDaysAgo }, + }, + orderBy: { scheduledFor: "desc" }, + select: { + medicationId: true, + takenAt: true, + skipped: true, + scheduledFor: true, + }, + }); + return { activeMedications, intakeEvents }; + }), + // Mood day rollups (section 9). + limit(() => readMoodDayRollups(userId, moodSince)), + // Latest raw mood entry — bounded `take: 1` via the index. + limit(() => + prisma.moodEntry.findFirst({ + // v1.7.0 sync — exclude tombstoned rows. + where: { userId, deletedAt: null }, + orderBy: { moodLoggedAt: "desc" }, + select: { score: true, moodLoggedAt: true }, + }), + ), + // Glucose rows (section 10). Same one-year floor as the + // latest-per-type read above — the previous unbounded scan pulled a + // heavy logger's full history back to Node on every cold build; a + // context whose newest reading is over a year old now reports no + // card, matching the per-type tile posture. + limit(() => + prisma.measurement.findMany({ + where: { + userId, + type: "BLOOD_GLUCOSE", + measuredAt: { gte: oneYearAgo }, + deletedAt: null, + }, + orderBy: { measuredAt: "desc" }, + select: { value: true, measuredAt: true, glucoseContext: true }, + }), + ), + ]); + + const age = dbUser?.dateOfBirth ? getAge(new Date(dbUser.dateOfBirth)) : null; + const gender = (dbUser?.gender as "MALE" | "FEMALE" | null) ?? null; + const heightCm = dbUser?.heightCm ?? null; + + const latestByType: Record = {}; + const avg30ByType: Record = {}; + + for (const t of types) { + const latest = latestEverByType.find((row) => row.type === t); + latestByType[t] = latest?.value ?? null; + + const recentOfType = recentMeasurements.filter((m) => m.type === t); + if (recentOfType.length > 0) { + const sum = recentOfType.reduce((acc, m) => acc + m.value, 0); + avg30ByType[t] = Math.round((sum / recentOfType.length) * 10) / 10; + } else { + avg30ByType[t] = null; + } + } + + // Compute trend (compare first half of 30-day data to second half) + function computeTrend( + type: MeasurementType, + ): "up" | "down" | "stable" | null { + const data = recentMeasurements + .filter((m) => m.type === type) + .sort((a, b) => a.measuredAt.getTime() - b.measuredAt.getTime()); + if (data.length < 4) return null; + + const mid = Math.floor(data.length / 2); + const firstHalf = data.slice(0, mid); + const secondHalf = data.slice(mid); + + const avgFirst = + firstHalf.reduce((s, m) => s + m.value, 0) / firstHalf.length; + const avgSecond = + secondHalf.reduce((s, m) => s + m.value, 0) / secondHalf.length; + + const diff = avgSecond - avgFirst; + const threshold = avgFirst * 0.02; // 2% change threshold + + if (diff > threshold) return "up"; + if (diff < -threshold) return "down"; + return "stable"; + } + + // -------------------------------------------------------------------- + // v1.4.25 W3e — consistency helper. Buckets readings by Berlin-tz day + // and classifies each day's mean against the target's green band. + // Single helper shared by every target type so the rule stays stable + // (mean reading per day → in-range / near-range / out-of-range). + // -------------------------------------------------------------------- + const last7DayKeys: string[] = []; + for (let i = 6; i >= 0; i--) { + const d = new Date(Date.now() - i * 24 * 60 * 60 * 1000); + last7DayKeys.push(dayKey(d)); + } + + type DayBand = "in" | "near" | "out"; + + interface ConsistencyOutput { + daysInRange7d: number; + daysLogged7d: number; + daysInRange30d: number; + daysLogged30d: number; + lastMetGoalAt: string | null; + streakDays: number; + insufficientData: boolean; + consistency7d: ReadonlyArray; + } + + const EMPTY_CONSISTENCY: ConsistencyOutput = { + daysInRange7d: 0, + daysLogged7d: 0, + daysInRange30d: 0, + daysLogged30d: 0, + lastMetGoalAt: null, + streakDays: 0, + insufficientData: true, + consistency7d: [null, null, null, null, null, null, null] as const, + }; + + /** + * Bucket a series of `{ measuredAt, value }` events by Berlin-tz day, + * classify each day's mean against the target band, and roll up the + * 7-day strip + 30-day stats + recency + streak. + * + * `classify(meanValue)` returns the band the day's mean falls into. + * Pass `null` to mean "no band — skip this day for classification". + */ + function rollupConsistency( + events: ReadonlyArray<{ measuredAt: Date; value: number }>, + classify: (mean: number) => DayBand | null, + totalReadingsThreshold = 3, + ): ConsistencyOutput { + if (events.length < totalReadingsThreshold) { + // Cold-start path. Still emit the per-day strip if we have any + // recent readings, but mark insufficient so the UI hides the %. + const partial = computeStrip(events, classify); + return { + ...EMPTY_CONSISTENCY, + consistency7d: partial.consistency7d, + daysLogged7d: partial.daysLogged7d, + daysInRange7d: partial.daysInRange7d, + insufficientData: true, + }; + } + + const byDay = new Map(); + for (const ev of events) { + const day = dayKey(ev.measuredAt); + const bucket = byDay.get(day) ?? { sum: 0, count: 0 }; + bucket.sum += ev.value; + bucket.count += 1; + byDay.set(day, bucket); + } + + const stripPart = computeStrip(events, classify); + + // 30-day stats: walk the last 30 Berlin-tz day keys. + let daysInRange30d = 0; + let daysLogged30d = 0; + for (let i = 29; i >= 0; i--) { + const d = new Date(Date.now() - i * 24 * 60 * 60 * 1000); + const key = dayKey(d); + const bucket = byDay.get(key); + if (!bucket) continue; + daysLogged30d += 1; + const mean = bucket.sum / bucket.count; + if (classify(mean) === "in") daysInRange30d += 1; + } + + // lastMetGoalAt: walk back from today until a day is in range. + let lastMetGoalAt: string | null = null; + for (let i = 0; i < 30; i++) { + const d = new Date(Date.now() - i * 24 * 60 * 60 * 1000); + const key = dayKey(d); + const bucket = byDay.get(key); + if (!bucket) continue; + const mean = bucket.sum / bucket.count; + if (classify(mean) === "in") { + lastMetGoalAt = key; + break; + } + } + + // streakDays: consecutive days ending today that are in-range. A + // day with no reading breaks the streak (the user did not meet the + // goal that day, even if today is in-range). + let streakDays = 0; + for (let i = 0; i < 365; i++) { + const d = new Date(Date.now() - i * 24 * 60 * 60 * 1000); + const key = dayKey(d); + const bucket = byDay.get(key); + if (!bucket) break; + const mean = bucket.sum / bucket.count; + if (classify(mean) !== "in") break; + streakDays += 1; + } + + // insufficientData: < 3 readings total OR < 1 day in the last 7. + const insufficientData = + events.length < totalReadingsThreshold || stripPart.daysLogged7d < 1; + + return { + daysInRange7d: stripPart.daysInRange7d, + daysLogged7d: stripPart.daysLogged7d, + daysInRange30d, + daysLogged30d, + lastMetGoalAt, + streakDays, + insufficientData, + consistency7d: stripPart.consistency7d, + }; + } + + function computeStrip( + events: ReadonlyArray<{ measuredAt: Date; value: number }>, + classify: (mean: number) => DayBand | null, + ): { + consistency7d: ReadonlyArray; + daysInRange7d: number; + daysLogged7d: number; + } { + const byDay = new Map(); + for (const ev of events) { + const day = dayKey(ev.measuredAt); + const bucket = byDay.get(day) ?? { sum: 0, count: 0 }; + bucket.sum += ev.value; + bucket.count += 1; + byDay.set(day, bucket); + } + const strip: Array = []; + let daysInRange7d = 0; + let daysLogged7d = 0; + for (const key of last7DayKeys) { + const bucket = byDay.get(key); + if (!bucket) { + strip.push(null); + continue; + } + daysLogged7d += 1; + const mean = bucket.sum / bucket.count; + const band = classify(mean); + strip.push(band); + if (band === "in") daysInRange7d += 1; + } + return { consistency7d: strip, daysInRange7d, daysLogged7d }; + } + + /** + * Helper: classify a value against a target range with an orange band + * symmetrical to the green band (the same heuristic RangeBar uses + * client-side). When `min`/`max` is null we treat the input as + * unclassifiable and return null. + */ + function makeRangeClassifier( + range: { min: number; max: number } | null, + options?: { orangeMin?: number; orangeMax?: number }, + ): (mean: number) => DayBand | null { + if (!range) return () => null; + const span = range.max - range.min; + const orangeMin = options?.orangeMin ?? range.min - span * 0.3; + const orangeMax = options?.orangeMax ?? range.max + span * 0.3; + return (mean: number) => { + if (mean >= range.min && mean <= range.max) return "in"; + if (mean >= orangeMin && mean <= orangeMax) return "near"; + return "out"; + }; + } + + /** + * Per-day BP-in-target classifier: a day is "in" if every BP pair on + * that day (sys+dia matched within 5 min) passes `isBpReadingInTarget`. + * "near" if at least one pair passes; "out" otherwise. + * + * Returns a function (day-key, dayEvents) → band. We pre-compute the + * by-day map once because the route already paginates BP twice (sys + * + dia) and we want to avoid quadratic work. + */ + function buildBpPairsByDay(): Map { + if (!bpRange) return new Map(); + const sysByDay = new Map>(); + const diaByDay = new Map>(); + for (const m of recentMeasurements) { + if (m.type === "BLOOD_PRESSURE_SYS") { + const k = dayKey(m.measuredAt); + const arr = sysByDay.get(k) ?? []; + arr.push({ date: m.measuredAt, value: m.value }); + sysByDay.set(k, arr); + } else if (m.type === "BLOOD_PRESSURE_DIA") { + const k = dayKey(m.measuredAt); + const arr = diaByDay.get(k) ?? []; + arr.push({ date: m.measuredAt, value: m.value }); + diaByDay.set(k, arr); + } + } + const out = new Map(); + const allKeys = new Set([...sysByDay.keys(), ...diaByDay.keys()]); + for (const key of allKeys) { + const sys = sysByDay.get(key) ?? []; + const dia = diaByDay.get(key) ?? []; + const pairs = pairByTimestamp(sys, dia, 5 * 60 * 1000); + if (pairs.length === 0) { + out.set(key, null); + continue; + } + const inCount = pairs.filter((p) => + isBpReadingInTarget(p.a, p.b, bpRange), + ).length; + if (inCount === pairs.length) out.set(key, "in"); + else if (inCount > 0) out.set(key, "near"); + else out.set(key, "out"); + } + return out; + } + + // We need bpRange before the per-target loop runs so the helpers above + // can use it; reorder the local so it's available. + const bpRange = age != null ? getBpTargetsByAge(age, gender) : null; + + /** + * Roll up consistency for a target whose bands come from a pre-mapped + * `dayBandByKey` map (used for BP-in-target and medication compliance + * which have day-level classification rather than mean-classification). + */ + function rollupFromDayMap( + dayBandByKey: Map, + totalDaysThreshold = 3, + ): ConsistencyOutput { + const loggedKeys = Array.from(dayBandByKey.entries()).filter( + ([, v]) => v !== null, + ); + if (loggedKeys.length < totalDaysThreshold) { + // Cold-start: still emit the strip. + const strip: Array = last7DayKeys.map( + (k) => dayBandByKey.get(k) ?? null, + ); + const daysLogged7d = strip.filter((b) => b !== null).length; + const daysInRange7d = strip.filter((b) => b === "in").length; + return { + ...EMPTY_CONSISTENCY, + consistency7d: strip, + daysLogged7d, + daysInRange7d, + insufficientData: true, + }; + } + + const strip: Array = last7DayKeys.map( + (k) => dayBandByKey.get(k) ?? null, + ); + const daysLogged7d = strip.filter((b) => b !== null).length; + const daysInRange7d = strip.filter((b) => b === "in").length; + + let daysInRange30d = 0; + let daysLogged30d = 0; + for (let i = 29; i >= 0; i--) { + const d = new Date(Date.now() - i * 24 * 60 * 60 * 1000); + const key = dayKey(d); + const band = dayBandByKey.get(key); + if (!band) continue; + daysLogged30d += 1; + if (band === "in") daysInRange30d += 1; + } + + let lastMetGoalAt: string | null = null; + for (let i = 0; i < 30; i++) { + const d = new Date(Date.now() - i * 24 * 60 * 60 * 1000); + const key = dayKey(d); + if (dayBandByKey.get(key) === "in") { + lastMetGoalAt = key; + break; + } + } + + let streakDays = 0; + for (let i = 0; i < 365; i++) { + const d = new Date(Date.now() - i * 24 * 60 * 60 * 1000); + const key = dayKey(d); + const band = dayBandByKey.get(key); + if (band === undefined || band === null) break; + if (band !== "in") break; + streakDays += 1; + } + + return { + daysInRange7d, + daysLogged7d, + daysInRange30d, + daysLogged30d, + lastMetGoalAt, + streakDays, + insufficientData: loggedKeys.length < totalDaysThreshold, + consistency7d: strip, + }; + } + + // Memo: pre-compute the BP-pairs day map once. + const bpPairsByDay = buildBpPairsByDay(); + + // Build target items + const targets: TargetItem[] = []; + + // 1. Weight + const weightRange = heightCm ? getWeightRange(heightCm) : null; + let weightClassification: { category: string; color: string } | null = null; + if (latestByType.WEIGHT != null && heightCm) { + const heightM = heightCm / 100; + const bmi = latestByType.WEIGHT / (heightM * heightM); + const cls = classifyBMI(bmi); + weightClassification = { category: cls.category, color: cls.color }; + } + { + const consistency = rollupConsistency( + recentMeasurements.filter((m) => m.type === "WEIGHT"), + makeRangeClassifier(weightRange), + ); + targets.push({ + type: "WEIGHT", + label: "Weight", + current: latestByType.WEIGHT ?? null, + average30: avg30ByType.WEIGHT ?? null, + trend: computeTrend("WEIGHT"), + unit: "kg", + range: weightRange, + classification: weightClassification, + source: "WHO BMI", + ...consistency, + }); + } + + // 2. Blood Pressure (sys/dia combined) + // `bpRange` is hoisted to the helpers block above so the BP-pairs + // day-map can use it; do not redeclare here. + let bpClassification: { category: string; color: string } | null = null; + if ( + latestByType.BLOOD_PRESSURE_SYS != null && + latestByType.BLOOD_PRESSURE_DIA != null + ) { + const cls = classifyBP( + latestByType.BLOOD_PRESSURE_SYS, + latestByType.BLOOD_PRESSURE_DIA, + ); + bpClassification = { category: cls.category, color: cls.color }; + } + { + // Per-day BP classifier reuses the sys+dia paired classifier from + // `bpPairsByDay`. This is the right semantic for "in target band" + // because a systolic in range with a diastolic out of range is + // still an out-of-target reading. + const consistency = rollupFromDayMap(bpPairsByDay); + targets.push({ + type: "BLOOD_PRESSURE", + label: "Blood pressure", + current: latestByType.BLOOD_PRESSURE_SYS ?? null, + average30: avg30ByType.BLOOD_PRESSURE_SYS ?? null, + trend: computeTrend("BLOOD_PRESSURE_SYS"), + unit: "mmHg", + range: bpRange ? { min: bpRange.sysLow, max: bpRange.sysHigh } : null, + classification: bpClassification, + source: "ESH 2023", + ...consistency, + // Extra fields for diastolic + } as TargetItem); + } + + // 2b. Blood pressure target-hit rate over 30 days + if (bpRange) { + const sysPoints = recentMeasurements + .filter((measurement) => measurement.type === "BLOOD_PRESSURE_SYS") + .map((measurement) => ({ + date: measurement.measuredAt, + value: measurement.value, + })); + const diaPoints = recentMeasurements + .filter((measurement) => measurement.type === "BLOOD_PRESSURE_DIA") + .map((measurement) => ({ + date: measurement.measuredAt, + value: measurement.value, + })); + + const bpPairs = pairByTimestamp(sysPoints, diaPoints, 5 * 60 * 1000); + // v1.4.16 A2 — one-sided ceiling semantics with hypotension + // floor. See lib/analytics/bp-in-target.ts. + const bpInTargetCount = bpPairs.filter((pair) => + isBpReadingInTarget(pair.a, pair.b, bpRange), + ).length; + const bpInTargetRate = + bpPairs.length > 0 + ? Math.round((bpInTargetCount / bpPairs.length) * 100 * 10) / 10 + : null; + + // BP-in-target consistency mirrors the BP pair-by-day map above: + // a day is "in" when every pair on that day clears the BP target. + const inTargetConsistency = rollupFromDayMap(bpPairsByDay); + targets.push({ + type: "BLOOD_PRESSURE_IN_TARGET", + label: "Blood pressure on target", + current: bpInTargetRate, + average30: bpInTargetRate, + trend: null, + unit: "%", + range: { min: 70, max: 100 }, + classification: + bpInTargetRate != null + ? bpInTargetRate >= 70 + ? { category: "Good", color: "#50fa7b" } + : bpInTargetRate >= 40 + ? { category: "Moderate", color: "#f1fa8c" } + : { category: "Low", color: "#ff5555" } + : null, + source: "ESH 2023", + ...inTargetConsistency, + }); + } + + // 3. Resting pulse + // + // v1.15.12 A2 — judge the RESTING band against a RESTING series, not + // the raw PULSE stream (which Apple Health fills with workout HR). We + // prefer RESTING_HEART_RATE; when the user has none we fall back to a + // low-percentile-of-daily-PULSE proxy. The resting series drives the + // tile's current / average / consistency / classification so a workout + // burst no longer reads "outside target". + const pulseTarget = getPersonalizedPulseTarget(age, gender); + { + const restingResolved = resolveRestingPulseSeries({ + restingSamples: recentMeasurements + .filter((m) => m.type === "RESTING_HEART_RATE") + .map((m) => ({ measuredAt: m.measuredAt, value: m.value })), + pulseSamples: recentMeasurements + .filter((m) => m.type === "PULSE") + .map((m) => ({ measuredAt: m.measuredAt, value: m.value })), + dayKeyOf: dayKey, + }); + const restingEvents = restingResolved.series; + // Current = most recent resting value; average30 = resting mean. + const restingCurrent = + restingEvents.length > 0 + ? restingEvents[restingEvents.length - 1].value + : null; + const restingAvg30 = + restingEvents.length > 0 + ? Math.round( + (restingEvents.reduce((s, e) => s + e.value, 0) / + restingEvents.length) * + 10, + ) / 10 + : null; + + let pulseClassification: { category: string; color: string } | null = null; + if (restingCurrent != null) { + const cls = classifyPulseByTarget(restingCurrent, pulseTarget); + pulseClassification = { category: cls.category, color: cls.color }; + } + + const pulseRange = { + min: pulseTarget.greenMin, + max: pulseTarget.greenMax, + }; + const consistency = rollupConsistency( + restingEvents, + makeRangeClassifier(pulseRange, { + orangeMin: pulseTarget.orangeMin, + orangeMax: pulseTarget.orangeMax, + }), + ); + // Trend over the resting series (first-half vs second-half mean). + let restingTrend: "up" | "down" | "stable" | null = null; + if (restingEvents.length >= 4) { + const sorted = [...restingEvents].sort( + (a, b) => a.measuredAt.getTime() - b.measuredAt.getTime(), + ); + const mid = Math.floor(sorted.length / 2); + const avgFirst = + sorted.slice(0, mid).reduce((s, e) => s + e.value, 0) / mid; + const avgSecond = + sorted.slice(mid).reduce((s, e) => s + e.value, 0) / + (sorted.length - mid); + const diff = avgSecond - avgFirst; + const threshold = avgFirst * 0.02; + restingTrend = + diff > threshold ? "up" : diff < -threshold ? "down" : "stable"; + } + + targets.push({ + type: "PULSE", + label: "Resting pulse", + current: restingCurrent, + average30: restingAvg30, + trend: restingTrend, + unit: "bpm", + range: pulseRange, + classification: pulseClassification, + source: + restingResolved.which === "proxy" + ? `${pulseTarget.source} (estimated from heart rate)` + : pulseTarget.source, + ...consistency, + }); + } + + // 4. Sleep Duration + // + // v1.11.5 (deferred v1.11.4 QA L3) — SLEEP_DURATION is stored one row per + // STAGE per night in MINUTES. The generic per-type path treated every stage + // row as an independent reading, summing IN_BED + AWAKE + the bare ASLEEP + // aggregate + the granular CORE/DEEP/REM rows over 30 days and dividing by + // the row count — and then fed those MINUTES straight into a target range + // expressed in HOURS. The result was a bogus ~16 h/day average. Reconstruct + // per-night TIME-ASLEEP through the shared helper (which dedups dual-source + // nights, excludes IN_BED/AWAKE, and counts only the granular partition when + // present), convert to HOURS, then classify and roll up consistency on the + // per-night hour totals so this surface matches the dashboard / series / + // overview headline. + const sleepRange = getSleepDurationRange(); + { + // `sleepStageRows` resolved in the parallel read at the top. + const nights = reconstructSleepNights( + sleepStageRows as SleepStageRow[], + userTz, + dbUser?.sourcePriorityJson ?? null, + ) + .filter((n) => n.asleepMinutes > 0) + .sort((a, b) => a.measuredAt.getTime() - b.measuredAt.getTime()); + + const round1 = (n: number): number => Math.round(n * 100) / 100; + // One event per night carrying the night's asleep HOURS — the right unit + // for the 7–9 h target band and for the per-day consistency bucketing. + const sleepEvents = nights.map((n) => ({ + measuredAt: n.measuredAt, + value: round1(n.asleepMinutes / 60), + })); + + const latestNight = nights.length > 0 ? nights[nights.length - 1] : null; + const currentSleepHours = latestNight + ? round1(latestNight.asleepMinutes / 60) + : null; + const avgSleepHours = + sleepEvents.length > 0 + ? round1( + sleepEvents.reduce((s, e) => s + e.value, 0) / sleepEvents.length, + ) + : null; + + let sleepClassification: { category: string; color: string } | null = null; + if (currentSleepHours != null) { + const cls = classifySleepDuration(currentSleepHours); + sleepClassification = { category: cls.category, color: cls.color }; + } + + // Trend over the per-night hour series (first vs second half), mirroring + // `computeTrend` but on reconstructed nights rather than raw stage rows. + let sleepTrend: "up" | "down" | "stable" | null = null; + if (sleepEvents.length >= 4) { + const mid = Math.floor(sleepEvents.length / 2); + const firstHalf = sleepEvents.slice(0, mid); + const secondHalf = sleepEvents.slice(mid); + const avgFirst = + firstHalf.reduce((s, e) => s + e.value, 0) / firstHalf.length; + const avgSecond = + secondHalf.reduce((s, e) => s + e.value, 0) / secondHalf.length; + const diff = avgSecond - avgFirst; + const threshold = avgFirst * 0.02; + if (diff > threshold) sleepTrend = "up"; + else if (diff < -threshold) sleepTrend = "down"; + else sleepTrend = "stable"; + } + + const consistency = rollupConsistency( + sleepEvents, + makeRangeClassifier(sleepRange), + ); + targets.push({ + type: "SLEEP_DURATION", + label: "Sleep duration", + current: currentSleepHours, + average30: avgSleepHours, + trend: sleepTrend, + unit: "h", + range: sleepRange, + classification: sleepClassification, + source: "AASM/SRS", + ...consistency, + }); + } + + // 5. BMI (derived from weight + height) + if (heightCm) { + const heightM = heightCm / 100; + const heightSq = heightM * heightM; + const latestWeight = latestByType.WEIGHT ?? null; + const currentBmi = + latestWeight != null + ? Math.round((latestWeight / heightSq) * 10) / 10 + : null; + + // Compute 30-day average BMI from average weight + const avgWeight = avg30ByType.WEIGHT ?? null; + const avgBmi = + avgWeight != null ? Math.round((avgWeight / heightSq) * 10) / 10 : null; + + let bmiClassification: { category: string; color: string } | null = null; + if (currentBmi != null) { + const cls = classifyBMI(currentBmi); + bmiClassification = { category: cls.category, color: cls.color }; + } + + // BMI consistency derives from the weight series: divide each + // bucket by height\u00B2 so the day's mean BMI is classified against + // the BMI range bar (18.5 \u2014 24.9). + const bmiRange = { min: 18.5, max: 24.9 }; + const bmiEvents = recentMeasurements + .filter((m) => m.type === "WEIGHT") + .map((m) => ({ measuredAt: m.measuredAt, value: m.value / heightSq })); + const consistency = rollupConsistency( + bmiEvents, + makeRangeClassifier(bmiRange), + ); + targets.push({ + type: "BMI", + label: "BMI", + current: currentBmi, + average30: avgBmi, + trend: computeTrend("WEIGHT"), // BMI trend follows weight trend + unit: "kg/m\u00B2", + range: bmiRange, + classification: bmiClassification, + source: "WHO", + ...consistency, + }); + } + + // 6. Body Fat + let bodyFatClassification: { category: string; color: string } | null = null; + if (latestByType.BODY_FAT != null && age != null) { + const cls = classifyBodyFat(latestByType.BODY_FAT, gender, age); + bodyFatClassification = { category: cls.category, color: cls.color }; + } + // Single source of truth for the body-fat green band lives in + // `value-bands.ts`. v1.3.3 had three different hardcoded ranges across + // value-bands.ts / targets/route.ts / classifications.ts; the v1.4 + // marathon consolidated them onto `getBodyFatTargetRange` (ACE + // fitness + acceptable bands). + const bodyFatRange = age != null ? getBodyFatTargetRange(gender) : null; + { + const consistency = rollupConsistency( + recentMeasurements.filter((m) => m.type === "BODY_FAT"), + makeRangeClassifier(bodyFatRange), + ); + targets.push({ + type: "BODY_FAT", + label: "Body fat", + current: latestByType.BODY_FAT ?? null, + average30: avg30ByType.BODY_FAT ?? null, + trend: computeTrend("BODY_FAT"), + unit: "%", + range: bodyFatRange, + classification: bodyFatClassification, + source: "ACE", + ...consistency, + }); + } + + // 7. Activity Steps + const stepsRange = getStepsRange(); + let stepsClassification: { category: string; color: string } | null = null; + if (avg30ByType.ACTIVITY_STEPS != null) { + const cls = classifySteps(avg30ByType.ACTIVITY_STEPS); + stepsClassification = { category: cls.category, color: cls.color }; + } + { + const consistency = rollupConsistency( + recentMeasurements.filter((m) => m.type === "ACTIVITY_STEPS"), + makeRangeClassifier(stepsRange), + ); + targets.push({ + type: "ACTIVITY_STEPS", + label: "Steps/day", + current: latestByType.ACTIVITY_STEPS ?? null, + average30: avg30ByType.ACTIVITY_STEPS ?? null, + trend: computeTrend("ACTIVITY_STEPS"), + unit: "steps", + range: stepsRange, + classification: stepsClassification, + // WHO publishes activity *time* (150–300 min/wk moderate), + // not a step quota. The closest peer-reviewed dose-response + // for the 8 000–15 000 band is Saint-Maurice JAMA 2020. The + // AI prompts at src/lib/ai/prompts/base-system.ts and + // src/lib/ai/prompts/general-status.ts already enforce this + // attribution; this surface label was the last "WHO" mislabel + // in the codebase. + source: "Saint-Maurice JAMA 2020", + ...consistency, + }); + } + + // 8. Medication Compliance (average across active medications) — + // `activeMedications` + their 30-day `intakeEvents` resolved in the + // parallel read at the top. + const { activeMedications, intakeEvents } = medicationBundle; + + if (activeMedications.length > 0) { + const medicationStats = activeMedications.map((medication) => { + const medicationEvents = intakeEvents.filter( + (event) => event.medicationId === medication.id, + ); + // v1.7.0 SB-SCHED-2 — engine-routed denominator (userTz resolved + // above at the route entry). + const medicationContext = buildComplianceMedicationContext( + medication, + lastNonSkippedTakenAt(medicationEvents), + userTz, + ); + const compliance7 = calculateCompliance( + medicationEvents, + medication.schedules, + 7, + medication.createdAt, + { medicationContext }, + ); + const compliance30 = calculateCompliance( + medicationEvents, + medication.schedules, + 30, + medication.createdAt, + { medicationContext }, + ); + + return { + name: medication.name, + compliance7: compliance7.rate, + compliance30: compliance30.rate, + totalExpected7: compliance7.totalExpected, + taken7: compliance7.taken, + totalExpected30: compliance30.totalExpected, + taken30: compliance30.taken, + }; + }); + + const totalExpected7 = medicationStats.reduce( + (sum, medication) => sum + medication.totalExpected7, + 0, + ); + const totalTaken7 = medicationStats.reduce( + (sum, medication) => sum + medication.taken7, + 0, + ); + const totalExpected30 = medicationStats.reduce( + (sum, medication) => sum + medication.totalExpected30, + 0, + ); + const totalTaken30 = medicationStats.reduce( + (sum, medication) => sum + medication.taken30, + 0, + ); + + const complianceRate7 = + totalExpected7 > 0 + ? Math.round( + (Math.min(1, totalTaken7 / totalExpected7) * 100 + Number.EPSILON) * + 10, + ) / 10 + : null; + const complianceRate30 = + totalExpected30 > 0 + ? Math.round( + (Math.min(1, totalTaken30 / totalExpected30) * 100 + + Number.EPSILON) * + 10, + ) / 10 + : complianceRate7; + + // Per-day compliance: for each Berlin-tz day, classify as "in" if + // every scheduled dose that day was taken; "near" if at least one + // was taken; "out" if all were skipped/missed; null if there were + // no scheduled doses (the user's regimen excluded that day). + const dayCountsByKey = new Map< + string, + { taken: number; expected: number } + >(); + for (const event of intakeEvents) { + const key = dayKey(event.scheduledFor); + const cur = dayCountsByKey.get(key) ?? { taken: 0, expected: 0 }; + cur.expected += 1; + if (event.takenAt && !event.skipped) cur.taken += 1; + dayCountsByKey.set(key, cur); + } + const dayBandByKey = new Map(); + for (const [key, counts] of dayCountsByKey.entries()) { + if (counts.expected === 0) { + dayBandByKey.set(key, null); + continue; + } + const ratio = counts.taken / counts.expected; + if (ratio >= 0.99) dayBandByKey.set(key, "in"); + else if (ratio >= 0.5) dayBandByKey.set(key, "near"); + else dayBandByKey.set(key, "out"); + } + const consistency = rollupFromDayMap(dayBandByKey); + targets.push({ + type: "MEDICATION_COMPLIANCE", + label: "Medication compliance", + current: complianceRate7, + average30: complianceRate30, + trend: null, + unit: "%", + range: { min: 90, max: 100 }, + classification: + complianceRate7 != null + ? complianceRate7 >= 90 + ? { category: "Very good", color: "#50fa7b" } + : complianceRate7 >= 70 + ? { category: "Good", color: "#f1fa8c" } + : { category: "Low", color: "#ff5555" } + : null, + source: "7-day", + details: { + medications: medicationStats.map((medication) => ({ + name: medication.name, + compliance7: medication.compliance7, + compliance30: medication.compliance30, + })), + }, + ...consistency, + }); + } + + // 9. Mood targets (if mood data exists) + // + // v1.4.40 \u2014 swap the unbounded `prisma.moodEntry.findMany({ where: { + // userId } })` for the persistent mood-rollup DAY tier (audit Critical + // Finding #2). The tile reads: + // - per-day mean over the trailing 30 days (avg30, trend, stability) + // - the consistency strip (in/near/out band per day) + // - the latest score for the "current" reading + classification + // + // Rollup row = `{bucketStart, mean, count, minScore, maxScore}` per + // calendar day. Per-day mean replaces the per-entry score series the + // legacy path passed to `rollupConsistency` \u2014 the helper buckets by + // day-key anyway, so feeding one row per day with the pre-computed + // mean is mathematically identical to feeding N raw entries that + // collapse to the same mean. + // + // The "latest score" needs a single raw entry (the newest one), so we + // pull it with `take: 1` \u2014 bounded by the index, never an unbounded + // scan. The "\u2265 3 entries threshold" gate uses the rollup count sum so + // the cold-start branch stays consistent with the legacy semantics + // (which counted raw entries, not raw days). + // + // Coverage-fallback: when the user has mood entries but zero rollup + // rows yet (legacy account before the boot-time backfill caught up) + // we run the legacy live walk once and fire the warm-up so the next + // request lands on the rollup tier. Same posture as + // `/api/mood/analytics`. The warm-up trigger + the `moodRollups` / + // `latestMoodEntry` reads moved into the parallel round at the top. + const thirtyDaysAgoMood = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); + + // Build a per-day event list for the consistency strip + 30-day stats. + // Use the rollup tier when populated; fall back to a bounded raw walk + // when the user has mood entries but zero rollup coverage yet. + let moodDailyEvents: Array<{ measuredAt: Date; value: number }>; + let moodEntryCount: number; + let moodSumScores: number; + let moodVarianceN: number; + let moodSumSquares: number; + if (moodRollups.length > 0) { + moodEntryCount = moodRollups.reduce((s, r) => s + r.count, 0); + moodDailyEvents = moodRollups + .filter((r) => r.bucketStart >= thirtyDaysAgoMood) + .map((r) => ({ measuredAt: r.bucketStart, value: r.mean })); + moodSumScores = moodDailyEvents.reduce((s, e) => s + e.value, 0); + moodVarianceN = moodDailyEvents.length; + moodSumSquares = moodDailyEvents.reduce((s, e) => s + e.value * e.value, 0); + } else if (latestMoodEntry !== null) { + // Coverage-fallback: rollups empty but raw entries exist. Pull the + // trailing 30-day window only (instead of every entry ever) \u2014 that's + // the bound the legacy code was missing. + const recentRawMood = await prisma.moodEntry.findMany({ + // v1.7.0 sync — exclude tombstoned rows. + where: { + userId, + deletedAt: null, + moodLoggedAt: { gte: thirtyDaysAgoMood }, + }, + orderBy: { moodLoggedAt: "asc" }, + select: { score: true, moodLoggedAt: true }, + }); + moodEntryCount = recentRawMood.length; + moodDailyEvents = recentRawMood.map((e) => ({ + measuredAt: e.moodLoggedAt, + value: e.score, + })); + moodSumScores = recentRawMood.reduce((s, e) => s + e.score, 0); + moodVarianceN = recentRawMood.length; + moodSumSquares = recentRawMood.reduce((s, e) => s + e.score * e.score, 0); + } else { + moodEntryCount = 0; + moodDailyEvents = []; + moodSumScores = 0; + moodVarianceN = 0; + moodSumSquares = 0; + } + + if (moodEntryCount >= 3) { + const latestMoodScore = latestMoodEntry?.score ?? null; + + const moodAvg30 = + moodDailyEvents.length > 0 + ? Math.round((moodSumScores / moodDailyEvents.length) * 10) / 10 + : null; + + // Mood trend: compare first half to second half of last 30 days. + // The rollup tier carries one DataPoint per day (the daily mean), + // matching the legacy comparison semantics on single-entry days. + let moodTrend: "up" | "down" | "stable" | null = null; + if (moodDailyEvents.length >= 4) { + const sorted = [...moodDailyEvents].sort( + (a, b) => a.measuredAt.getTime() - b.measuredAt.getTime(), + ); + const mid = Math.floor(sorted.length / 2); + const firstHalf = sorted.slice(0, mid); + const secondHalf = sorted.slice(mid); + const avgFirst = + firstHalf.reduce((s, e) => s + e.value, 0) / firstHalf.length; + const avgSecond = + secondHalf.reduce((s, e) => s + e.value, 0) / secondHalf.length; + const diff = avgSecond - avgFirst; + if (diff > 0.2) moodTrend = "up"; + else if (diff < -0.2) moodTrend = "down"; + else moodTrend = "stable"; + } + + const moodClassification = + latestMoodScore != null + ? latestMoodScore >= 3.5 + ? { category: "Good", color: "#50fa7b" } + : latestMoodScore >= 2 + ? { category: "Moderate", color: "#f1fa8c" } + : { category: "Low", color: "#ff5555" } + : null; + + const moodRange = { min: 3.5, max: 5 }; + const moodConsistency = rollupConsistency( + moodDailyEvents, + makeRangeClassifier(moodRange, { orangeMin: 2, orangeMax: 5 }), + ); + targets.push({ + type: "MOOD_SCORE", + label: "Mood", + current: latestMoodScore, + average30: moodAvg30, + trend: moodTrend, + unit: "/ 5", + range: moodRange, + classification: moodClassification, + source: "moodLog", + ...moodConsistency, + }); + + // Mood stability: standard deviation of the recent series. The + // rollup tier surfaces one mean per day, which is the right + // resolution for "is the user's mood stable across days" \u2014 a power + // user logging multiple times per day with a wide intraday spread + // still gets a "stable" badge if the daily mean barely moves. The + // legacy path conflated intraday variance with day-to-day stability + // by passing per-entry scores; the rollup-tier semantic is closer + // to what the UI label suggests. + if (moodVarianceN >= 5) { + const seriesMean = moodSumScores / moodVarianceN; + const variance = moodSumSquares / moodVarianceN - seriesMean * seriesMean; + const stdDev = Math.round(Math.sqrt(Math.max(0, variance)) * 100) / 100; + + const stabilityClassification = + stdDev <= 0.5 + ? { category: "Very stable", color: "#50fa7b" } + : stdDev <= 1.0 + ? { category: "Stable", color: "#f1fa8c" } + : { category: "Fluctuating", color: "#ff5555" }; + + // Mood stability is computed from the same recent-mood window; + // consistency mirrors MOOD_SCORE so the strip aligns with the + // user's logging cadence rather than introducing a synthetic + // "\u03C3 in range per day" rule that would be hard to reason about. + targets.push({ + type: "MOOD_STABILITY", + label: "Mood stability", + current: stdDev, + average30: stdDev, + trend: null, + unit: "\u03C3", + range: { min: 0, max: 0.5 }, + classification: stabilityClassification, + source: "moodLog", + ...moodConsistency, + }); + } + } + + // 10. Blood glucose — one card per logged context. + const glucoseContexts: GlucoseContext[] = [ + "FASTING", + "POSTPRANDIAL", + "RANDOM", + "BEDTIME", + ]; + const glucoseUnit = resolveGlucoseUnit(dbUser?.glucoseUnit ?? null); + const overrides = (dbUser?.thresholdsJson ?? + null) as ThresholdOverridesJson | null; + const profileForRange = { + heightCm, + dateOfBirth: dbUser?.dateOfBirth ?? null, + gender: dbUser?.gender ?? null, + }; + // `glucoseRows` resolved (one-year floor) in the parallel read at the top. + const thirtyDaysAgoGlucose = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); + + const labelKeyByContext: Record = { + FASTING: "targets.glucoseFasting", + POSTPRANDIAL: "targets.glucosePostprandial", + RANDOM: "targets.glucoseRandom", + BEDTIME: "targets.glucoseBedtime", + }; + + for (const ctx of glucoseContexts) { + const ctxRows = glucoseRows.filter((r) => r.glucoseContext === ctx); + if (ctxRows.length === 0) continue; + const latest = ctxRows[0].value; + const recent = ctxRows.filter((r) => r.measuredAt >= thirtyDaysAgoGlucose); + const avg30 = + recent.length > 0 + ? Math.round( + (recent.reduce((s, r) => s + r.value, 0) / recent.length) * 10, + ) / 10 + : null; + + const metric = thresholdMetricForContext(ctx); + const eff = getEffectiveRange(metric, profileForRange, overrides); + const range = eff.range + ? { min: eff.range.greenMin, max: eff.range.greenMax } + : null; + + let classification: { category: string; color: string } | null = null; + if (range) { + if (latest >= range.min && latest <= range.max) { + classification = { category: "Optimal", color: "#50fa7b" }; + } else if ( + eff.range && + latest >= eff.range.orangeMin && + latest <= eff.range.orangeMax + ) { + classification = { category: "Elevated", color: "#f1fa8c" }; + } else { + classification = { category: "High", color: "#ff5555" }; + } + } + + const consistency = rollupConsistency( + recent.map((r) => ({ measuredAt: r.measuredAt, value: r.value })), + makeRangeClassifier( + range, + eff.range + ? { orangeMin: eff.range.orangeMin, orangeMax: eff.range.orangeMax } + : undefined, + ), + ); + targets.push({ + type: `BLOOD_GLUCOSE_${ctx}`, + label: labelKeyByContext[ctx], + current: latest, + average30: avg30, + trend: null, + unit: glucoseUnit, + range, + classification, + source: eff.isOverride ? "Custom" : "ADA 2024 / DDG", + ...consistency, + }); + } + + // -------------------------------------------------------------------- + // v1.4.25 W3e — page summary. "X of Y targets met this week" is the + // single most-asked-about glanceable answer. We define "met this + // week" as the target having `daysInRange7d >= 4` AND not flagged as + // `insufficientData`. The streak highlight surfaces the metric with + // the longest current ≥ 3 day streak; nothing renders client-side + // when no target hits the bar. + // -------------------------------------------------------------------- + const targetsMetThisWeek = targets.filter( + (target) => !target.insufficientData && target.daysInRange7d >= 4, + ).length; + let streakHighlight: TargetPageSummary["streakHighlight"] = null; + for (const target of targets) { + if (target.streakDays < 3) continue; + if (!streakHighlight || target.streakDays > streakHighlight.days) { + streakHighlight = { metric: target.type, days: target.streakDays }; + } + } + const pageSummary: TargetPageSummary = { + targetsMetThisWeek, + totalTargets: targets.length, + streakHighlight, + }; + + annotate({ + action: { name: "insights.targets" }, + meta: { + targetCount: targets.length, + targetsMetThisWeek, + streakHighlightMetric: streakHighlight?.metric ?? null, + }, + }); + + return { + targets, + pageSummary, + // Extra diastolic data for BP display + bpDiastolic: { + current: latestByType.BLOOD_PRESSURE_DIA ?? null, + average30: avg30ByType.BLOOD_PRESSURE_DIA ?? null, + range: bpRange ? { min: bpRange.diaLow, max: bpRange.diaHigh } : null, + }, + profile: { + heightCm, + age, + gender, + glucoseUnit, + }, + }; +}