diff --git a/src/app/api/analytics/route.ts b/src/app/api/analytics/route.ts index 0065d0502..f10438b2c 100644 --- a/src/app/api/analytics/route.ts +++ b/src/app/api/analytics/route.ts @@ -8,14 +8,6 @@ import { summarize, type DataPoint } from "@/lib/analytics/trends"; import { computeSummariesSlice } from "@/lib/analytics/summaries-slice"; import { getBpTargets } from "@/lib/analytics/bp-targets"; import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/resolver"; -import { calculateCompliance } from "@/lib/analytics/compliance"; -import { - computeHealthScore, - defaultWeightTargetFromHeight, - type ContributingSource, - type HealthScoreInput, - type HealthScoreResult, -} from "@/lib/analytics/health-score"; import { correlateBpCompliance, correlateMoodPulse, @@ -32,6 +24,7 @@ import type { SourcePriorityMetricKey } from "@/lib/validations/source-priority" import { ensureUserRollupsFresh } from "@/lib/measurements/rollups"; import { probeRollupCoverage } from "@/lib/measurements/rollup-coverage"; import { computeBpInTargetFastPath } from "@/lib/analytics/bp-in-target-fast-path"; +import { computeUserHealthScoreFastPath } from "@/lib/analytics/health-score-fast-path"; import { isCumulativeDaySumType, pickCumulativeDaySum, @@ -407,21 +400,20 @@ async function buildAnalyticsResponse(user: AuthedUser) { // composite of BP-in-target % + weight-trend alignment + mood // stability + medication compliance. The "vs last week" delta // re-runs the same compute against a 7-day-shifted snapshot. - const healthScore = await computeUserHealthScore(user.id, { + // + // v1.4.37 W2 — probe-gated helper. The weight pillar derives from + // DAY-bucket means on `measurement_rollups` when the user has full + // coverage; the source-attribution accordion still pulls a narrow + // 2-column projection from `measurements` for the ingest-path + // pills. Falls back to the legacy 37-day raw read on partial / + // missing coverage. Path annotate sits on `meta.healthScore.path`. + const healthScore = await computeUserHealthScoreFastPath({ + userId: user.id, bpInTargetPct, heightCm: user.heightCm ?? null, + now: new Date(), + coverage, }); - if (healthScore) { - annotate({ - meta: { - healthScore: { - score: healthScore.score, - band: healthScore.band, - delta: healthScore.delta, - }, - }, - }); - } return { summaries: results, @@ -821,286 +813,8 @@ function isoWeekdayInTz(d: Date, timeZone: string): number { } -/** - * v1.4.25 W8e — collapse the persisted `MeasurementSource` enum onto - * the camelCase token set the health-score analytics layer consumes. - * - * - `MANUAL` and `IMPORT` (CSV import — still user-supplied data) both - * surface as `"manual"`. - * - `WITHINGS` and `APPLE_HEALTH` ride one-to-one. - * - * Returns `null` for source values that don't fall into the three - * exposed buckets — defence-in-depth in case the enum grows before the - * client is taught about it. - */ -function mapMeasurementSourceToLabel( - source: MeasurementSource, -): ContributingSource | null { - switch (source) { - case "MANUAL": - case "IMPORT": - return "manual"; - case "WITHINGS": - return "withings"; - case "APPLE_HEALTH": - return "appleHealth"; - default: - return null; - } -} - -/** - * v1.4.25 W8e — deduplicate the contributing-source list for a single - * component. Returns the empty array when nothing in the input maps - * onto a known label so downstream `resolveSourceLabel` falls through - * to `none` (matches the empty-state branch). - */ -function uniqueComponentSources( - rows: ReadonlyArray, -): ReadonlyArray { - const seen = new Set(); - for (const src of rows) { - const label = mapMeasurementSourceToLabel(src); - if (label) seen.add(label); - } - return Array.from(seen); -} - -/** - * Build the Health Score input from the user's last-30-day weight, - * mood, and medication-compliance data, plus the already-computed - * `bpInTargetPct` headline. Re-runs the same compute against a - * 7-day-shifted window to populate the "vs last week" delta. - * - * Returns null when the score wouldn't carry any signal (every - * component nullable + no medications). The route surfaces the - * `null` to the UI so the hero panel hides cleanly. - */ -async function computeUserHealthScore( - userId: string, - input: { bpInTargetPct: number | null; heightCm: number | null }, -): Promise { - const DAY_MS = 24 * 60 * 60 * 1000; - const now = new Date(); - const since30d = new Date(now.getTime() - 30 * DAY_MS); - // Prior week's snapshot — shift everything 7 days into the past so - // both windows close at the same wall-clock-of-day boundary. - const prevSince30d = new Date(now.getTime() - 37 * DAY_MS); - const prevUntil = new Date(now.getTime() - 7 * DAY_MS); - - // v1.4.25 W8e — read the `source` column alongside the value so the - // health-score provenance accordion knows which ingest path drove - // each component. The weight + BP reads already paid the row cost; - // adding the column to the SELECT is free in Postgres terms (no extra - // round-trip, no extra plan node) and the alternative — a second - // aggregate just for the source pill — would burn another findMany. - const [weightRows, bpSysRowsForSource, moodRows, medications] = - await Promise.all([ - prisma.measurement.findMany({ - where: { - userId, - type: "WEIGHT", - measuredAt: { gte: prevSince30d, lte: now }, - }, - select: { value: true, measuredAt: true, source: true }, - orderBy: { measuredAt: "asc" }, - }), - // BP source attribution rides on the systolic-readings row set — - // diastolic rows always carry the same `source` because both - // halves of a pair are persisted in the same write. Pull only the - // trailing 30 days to keep the call bounded for power users. - prisma.measurement.findMany({ - where: { - userId, - type: "BLOOD_PRESSURE_SYS", - measuredAt: { gte: since30d, lte: now }, - }, - select: { measuredAt: true, source: true }, - orderBy: { measuredAt: "asc" }, - }), - prisma.moodEntry.findMany({ - where: { - userId, - moodLoggedAt: { gte: prevSince30d, lte: now }, - }, - select: { score: true, moodLoggedAt: true }, - orderBy: { moodLoggedAt: "asc" }, - }), - prisma.medication.findMany({ - where: { userId, active: true }, - select: { - id: true, - createdAt: true, - schedules: { - select: { - windowStart: true, - windowEnd: true, - }, - }, - }, - }), - ]); - - // Compliance30 per active medication, then again for the prior-week - // snapshot. The compliance helper anchors on `Date.now()` internally; - // for the previous-week snapshot we reuse the same helper but pass a - // shifted "createdAt" floor so the window mathematically reflects the - // [-37d, -7d] period — equivalent to running the helper a week ago. - let medicationCompliance30: number[] = []; - let medicationCompliance30Previous: number[] = []; - if (medications.length > 0) { - const medIds = medications.map((m) => m.id); - const intakeEvents = await prisma.medicationIntakeEvent.findMany({ - where: { - userId, - medicationId: { in: medIds }, - scheduledFor: { gte: prevSince30d, lte: now }, - }, - select: { - medicationId: true, - scheduledFor: true, - takenAt: true, - skipped: true, - }, - }); - const eventsByMed = new Map(); - for (const ev of intakeEvents) { - const list = eventsByMed.get(ev.medicationId); - if (list) list.push(ev); - else eventsByMed.set(ev.medicationId, [ev]); - } - medicationCompliance30 = medications.map((med) => { - const events = eventsByMed.get(med.id) ?? []; - return calculateCompliance(events, med.schedules, 30, med.createdAt).rate; - }); - medicationCompliance30Previous = medications.map((med) => { - const events = (eventsByMed.get(med.id) ?? []).filter( - (e) => e.scheduledFor <= prevUntil, - ); - // Compute compliance against the prior-week-aligned window by - // remapping the helper's "now": shift each event's scheduledFor - // and takenAt forward by 7 days so the helper's internal `now` - // anchor still captures the same logical 30 days. - const shifted = events.map((e) => ({ - scheduledFor: new Date(e.scheduledFor.getTime() + 7 * DAY_MS), - takenAt: e.takenAt ? new Date(e.takenAt.getTime() + 7 * DAY_MS) : null, - skipped: e.skipped, - })); - return calculateCompliance(shifted, med.schedules, 30, med.createdAt) - .rate; - }); - } - - const fallbackTarget = defaultWeightTargetFromHeight(input.heightCm); - - const weightSeriesLast30d = weightRows - .filter((r) => r.measuredAt >= since30d) - .map((r) => ({ date: r.measuredAt.toISOString(), kg: r.value })); - const weightSeriesPrev30d = weightRows - .filter((r) => r.measuredAt >= prevSince30d && r.measuredAt <= prevUntil) - .map((r) => ({ date: r.measuredAt.toISOString(), kg: r.value })); - - const moodSeriesLast30d = moodRows - .filter((r) => r.moodLoggedAt >= since30d) - .map((r) => ({ - date: r.moodLoggedAt.toISOString(), - score: r.score, - })); - const moodSeriesPrev30d = moodRows - .filter( - (r) => r.moodLoggedAt >= prevSince30d && r.moodLoggedAt <= prevUntil, - ) - .map((r) => ({ - date: r.moodLoggedAt.toISOString(), - score: r.score, - })); - - // Skip any input shape where literally nothing is computable — the - // hero panel hides instead of painting a misleading "0". - if ( - input.bpInTargetPct === null && - weightSeriesLast30d.length === 0 && - moodSeriesLast30d.length === 0 && - medicationCompliance30.length === 0 - ) { - // Tag-only annotation so admin observability can see the empty path. - annotate({ - meta: { - healthScore: { score: null, reason: "no_components_available" }, - }, - }); - return null; - } - - // v1.4.25 W8e — build per-component source attribution from the rows - // we already hold in memory. `mapMeasurementSourceToLabel` collapses - // the persisted `MeasurementSource` enum onto the camelCase token list - // the analytics helper consumes; `IMPORT` rides under `manual` - // because the v1.4.20 CSV importer ingests user-supplied data — it's - // not a wearable stream. - const windowEndAt = now.toISOString(); - - const weightSourcesIn30d = uniqueComponentSources( - weightRows - .filter((r) => r.measuredAt >= since30d) - .map((r) => r.source), - ); - const latestWeightInWindow = weightRows - .filter((r) => r.measuredAt >= since30d) - .at(-1); - - const bpSourceTokens = uniqueComponentSources( - bpSysRowsForSource.map((r) => r.source), - ); - const latestBpInWindow = bpSysRowsForSource.at(-1); - - // Mood doesn't yet have a non-manual ingest (v1.5 will introduce - // Apple Health mood) so the source list is always `["manual"]` when - // there are entries in window. Keep the lookup explicit so the - // v1.5 ingest path slot just drops in. - const moodSourceTokens = moodSeriesLast30d.length > 0 - ? (["manual"] as const) - : []; - const latestMoodInWindow = moodRows - .filter((r) => r.moodLoggedAt >= since30d) - .at(-1); - - // Medication compliance always derives from logged intake events — - // user-driven manual logging today. - const complianceSourceTokens = medicationCompliance30.length > 0 - ? (["manual"] as const) - : []; - - const current: HealthScoreInput = { - bpInTargetRate: input.bpInTargetPct, - weightSeriesLast30d, - weightTargetKg: fallbackTarget, - moodEntriesLast30d: moodSeriesLast30d, - medicationCompliance30, - attribution: { - bpSources: bpSourceTokens, - asOfBp: latestBpInWindow?.measuredAt.toISOString() ?? null, - weightSources: weightSourcesIn30d, - asOfWeight: latestWeightInWindow?.measuredAt.toISOString() ?? null, - moodSources: moodSourceTokens, - asOfMood: latestMoodInWindow?.moodLoggedAt.toISOString() ?? null, - complianceSources: complianceSourceTokens, - asOfCompliance: complianceSourceTokens.length > 0 ? windowEndAt : null, - windowEndAt, - }, - }; - // The all-time `bpInTargetPct` is a slow-moving aggregate and would - // need a full historical re-pair to "rewind" by a week. We pass the - // same value to the previous snapshot so the delta primarily - // reflects week-over-week changes in the weight / mood / compliance - // pillars — the components that actually move on a weekly cadence. - const previous: HealthScoreInput = { - bpInTargetRate: input.bpInTargetPct, - weightSeriesLast30d: weightSeriesPrev30d, - weightTargetKg: fallbackTarget, - moodEntriesLast30d: moodSeriesPrev30d, - medicationCompliance30: medicationCompliance30Previous, - }; - - return computeHealthScore(current, previous); -} +// v1.4.37 W2 — `mapMeasurementSourceToLabel`, `uniqueComponentSources`, +// and the inline `computeUserHealthScore` body relocated to +// `src/lib/analytics/health-score-fast-path.ts` so the probe-gated +// rollup / live dispatcher can be unit-tested independently of the +// route. The route now delegates through `computeUserHealthScoreFastPath`. diff --git a/src/lib/analytics/__tests__/health-score-fast-path.test.ts b/src/lib/analytics/__tests__/health-score-fast-path.test.ts new file mode 100644 index 000000000..4a08d846a --- /dev/null +++ b/src/lib/analytics/__tests__/health-score-fast-path.test.ts @@ -0,0 +1,241 @@ +/** + * v1.4.37 W2 — unit pin for the probe-gated Health Score helper. + * + * Mocks Prisma + the rollup-coverage probe so the path-selection + * contract is verifiable without a Postgres container. Real-rollup + * coverage lives in the integration suite at + * `tests/integration/analytics-health-score.test.ts`. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + measurement: { findMany: vi.fn() }, + measurementRollup: { findMany: vi.fn() }, + moodEntry: { findMany: vi.fn() }, + medication: { findMany: vi.fn() }, + medicationIntakeEvent: { findMany: vi.fn() }, + }, +})); + +vi.mock("@/lib/logging/context", () => ({ + annotate: vi.fn(), +})); + +vi.mock("@/lib/measurements/rollup-coverage", () => ({ + isFullyCovered: vi.fn(), + probeRollupCoverage: vi.fn(), +})); + +import { prisma } from "@/lib/db"; +import { annotate } from "@/lib/logging/context"; +import { + isFullyCovered, + probeRollupCoverage, +} from "@/lib/measurements/rollup-coverage"; +import { computeUserHealthScoreFastPath } from "../health-score-fast-path"; + +const MEASUREMENT_FIND_MANY = prisma.measurement.findMany as unknown as ReturnType; +const ROLLUP_FIND_MANY = prisma.measurementRollup.findMany as unknown as ReturnType; +const MOOD_FIND_MANY = prisma.moodEntry.findMany as unknown as ReturnType; +const MEDICATION_FIND_MANY = prisma.medication.findMany as unknown as ReturnType; +const INTAKE_FIND_MANY = prisma.medicationIntakeEvent.findMany as unknown as ReturnType; +const PROBE = probeRollupCoverage as unknown as ReturnType; +const FULLY_COVERED = isFullyCovered as unknown as ReturnType; +const ANNOTATE = annotate as unknown as ReturnType; + +beforeEach(() => { + MEASUREMENT_FIND_MANY.mockReset(); + ROLLUP_FIND_MANY.mockReset(); + MOOD_FIND_MANY.mockReset(); + MEDICATION_FIND_MANY.mockReset(); + INTAKE_FIND_MANY.mockReset(); + PROBE.mockReset(); + FULLY_COVERED.mockReset(); + ANNOTATE.mockReset(); + + // Defaults — empty everywhere. Individual tests override. + ROLLUP_FIND_MANY.mockResolvedValue([]); + MOOD_FIND_MANY.mockResolvedValue([]); + MEDICATION_FIND_MANY.mockResolvedValue([]); + INTAKE_FIND_MANY.mockResolvedValue([]); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("computeUserHealthScoreFastPath", () => { + describe("rollup-fast-path — WEIGHT fully covered", () => { + it("derives the weight series from DAY buckets and pins path:rollup", async () => { + const coverage = new Map([ + ["WEIGHT", true], + ["BLOOD_PRESSURE_SYS", true], + ["BLOOD_PRESSURE_DIA", true], + ]); + FULLY_COVERED.mockReturnValue(true); + PROBE.mockResolvedValue(coverage); + + const now = new Date("2026-05-17T12:00:00.000Z"); + // Two day-buckets inside the trailing 30 days, descending mean. + ROLLUP_FIND_MANY.mockResolvedValueOnce([ + { + bucketStart: new Date("2026-05-10T00:00:00.000Z"), + count: 1, + mean: 82.5, + minValue: 82.5, + maxValue: 82.5, + sd: null, + slope: null, + r2: null, + computedAt: now, + }, + { + bucketStart: new Date("2026-05-14T00:00:00.000Z"), + count: 1, + mean: 81.5, + minValue: 81.5, + maxValue: 81.5, + sd: null, + slope: null, + r2: null, + computedAt: now, + }, + ]); + // Weight source-attribution read (narrow 2-column). + MEASUREMENT_FIND_MANY + .mockResolvedValueOnce([ + { + measuredAt: new Date("2026-05-10T08:00:00.000Z"), + source: "WITHINGS", + }, + { + measuredAt: new Date("2026-05-14T08:00:00.000Z"), + source: "WITHINGS", + }, + ]) + // BP-SYS source attribution read (parallel after rollup). + .mockResolvedValueOnce([]); + + const result = await computeUserHealthScoreFastPath({ + userId: "user-rollup", + bpInTargetPct: 85, + heightCm: 178, + now, + coverage, + }); + + expect(result).not.toBeNull(); + // Path annotate proves the rollup branch fired. + const calls = ANNOTATE.mock.calls.map((c) => c[0]); + const pathCall = calls.find( + (c) => c?.meta?.healthScore?.path === "rollup", + ); + expect(pathCall).toBeDefined(); + // The rollup branch must NOT have called `measurement.findMany` + // for the 37-day raw weight read; only the narrow 2-column source + // pull + the BP source pull. + expect(MEASUREMENT_FIND_MANY).toHaveBeenCalledTimes(2); + // Both findMany calls projected a narrow select (no `value`). + const calledArgs = MEASUREMENT_FIND_MANY.mock.calls.map((c) => c[0]); + for (const arg of calledArgs) { + expect(arg.select.value).toBeUndefined(); + } + // Weight pillar value should reflect the downward slope from + // bucket means (helper reports a numeric weight value). + expect(result?.components.weight.value).not.toBeNull(); + }); + }); + + describe("live fallback — WEIGHT not covered", () => { + it("reads raw weight rows and pins path:live", async () => { + const coverage = new Map([ + ["WEIGHT", false], + ]); + FULLY_COVERED.mockReturnValue(false); + PROBE.mockResolvedValue(coverage); + + const now = new Date("2026-05-17T12:00:00.000Z"); + MEASUREMENT_FIND_MANY + // Weight (live) — 37-day window, 3-column projection. + .mockResolvedValueOnce([ + { + measuredAt: new Date("2026-05-10T08:00:00.000Z"), + value: 82.5, + source: "MANUAL", + }, + { + measuredAt: new Date("2026-05-14T08:00:00.000Z"), + value: 81.5, + source: "MANUAL", + }, + ]) + // BP-SYS source attribution. + .mockResolvedValueOnce([]); + + const result = await computeUserHealthScoreFastPath({ + userId: "user-live", + bpInTargetPct: 75, + heightCm: 178, + now, + coverage, + }); + + expect(result).not.toBeNull(); + const calls = ANNOTATE.mock.calls.map((c) => c[0]); + const pathCall = calls.find( + (c) => c?.meta?.healthScore?.path === "live", + ); + expect(pathCall).toBeDefined(); + // Live path read raw weight (value selected). + const weightCall = MEASUREMENT_FIND_MANY.mock.calls[0][0]; + expect(weightCall.select.value).toBe(true); + expect(weightCall.where.type).toBe("WEIGHT"); + expect(ROLLUP_FIND_MANY).not.toHaveBeenCalled(); + }); + }); + + describe("empty path", () => { + it("returns null with reason:no_components_available when nothing is computable", async () => { + const coverage = new Map([]); + FULLY_COVERED.mockReturnValue(false); + PROBE.mockResolvedValue(coverage); + + // No weight rows, no BP rows, no mood, no medications. + MEASUREMENT_FIND_MANY.mockResolvedValueOnce([]).mockResolvedValueOnce([]); + + const result = await computeUserHealthScoreFastPath({ + userId: "user-empty", + bpInTargetPct: null, + heightCm: null, + now: new Date("2026-05-17T12:00:00.000Z"), + coverage, + }); + + expect(result).toBeNull(); + const calls = ANNOTATE.mock.calls.map((c) => c[0]); + const nullCall = calls.find( + (c) => c?.meta?.healthScore?.reason === "no_components_available", + ); + expect(nullCall).toBeDefined(); + }); + }); + + describe("coverage probing", () => { + it("probes coverage when the caller omits the map", async () => { + const coverage = new Map([]); + FULLY_COVERED.mockReturnValue(false); + PROBE.mockResolvedValue(coverage); + MEASUREMENT_FIND_MANY.mockResolvedValueOnce([]).mockResolvedValueOnce([]); + + await computeUserHealthScoreFastPath({ + userId: "user-fresh", + bpInTargetPct: null, + heightCm: null, + now: new Date("2026-05-17T12:00:00.000Z"), + }); + + expect(PROBE).toHaveBeenCalledWith("user-fresh"); + }); + }); +}); diff --git a/src/lib/analytics/health-score-fast-path.ts b/src/lib/analytics/health-score-fast-path.ts new file mode 100644 index 000000000..4ebe856ed --- /dev/null +++ b/src/lib/analytics/health-score-fast-path.ts @@ -0,0 +1,383 @@ +/** + * v1.4.37 W2 — probe-gated Health Score builder. + * + * The `/api/analytics` default slice builds the Personal Health Score + * by combining BP-in-target % (already computed upstream) with three + * additional pillars: weight-trend alignment, mood stability, and + * medication compliance. Up to v1.4.37 the route inlined four parallel + * `findMany` reads (weight 37 days, BP-SYS 30 days for source pills, + * mood 37 days, active medications) plus a fifth for intake events. + * On a cold connection pool the round-trip latency stacks — the + * v1.4.36 perf-verify recorded the bp_in_target + healthScore + + * correlations fan-out at 111 s wall-clock against Marc's + * 311 779-row tenant. + * + * Read shape (v1.4.37) + * -------------------- + * 1. **Probe** — `probeRollupCoverage` returns the per-type DAY-bucket + * coverage map. When WEIGHT is covered we take the rollup-fast-path + * for the weight pillar; the slope/alignment math runs against + * per-day MEAN weight derived from `measurement_rollups` instead + * of the raw `measurements` table. + * + * 2. **Source attribution** — only the raw rows know which ingest + * path produced each reading, so the per-component + * `HealthScoreSourceAttribution` slice still needs a narrow read + * against `measurements` (selecting only `source` + `measuredAt`). + * That read is cheap (30-day window, 2-column projection) and + * stays live regardless of coverage. + * + * 3. **Live fallback** — when the probe shows WEIGHT is not covered + * (brand-new user, no buckets yet) the helper reads the raw + * weight rows the same way the legacy route did so the score + * stays correct on first cold-mount. + * + * The mood / medication / compliance pillars don't have rollup + * equivalents (the rollup table only carries `Measurement` aggregates) + * — those reads remain live regardless. The path annotate on + * `meta.healthScore.path` makes the branch selection visible in prod + * logs. + */ +import { prisma } from "@/lib/db"; +import { annotate } from "@/lib/logging/context"; +import { readRollupBuckets } from "@/lib/measurements/rollups"; +import { + isFullyCovered, + probeRollupCoverage, + type RollupCoverageMap, +} from "@/lib/measurements/rollup-coverage"; +import { calculateCompliance } from "./compliance"; +import { + computeHealthScore, + defaultWeightTargetFromHeight, + type ContributingSource, + type HealthScoreInput, + type HealthScoreResult, +} from "./health-score"; +import type { MeasurementSource } from "@/generated/prisma/client"; + +/** + * v1.4.25 W8e — collapse the persisted `MeasurementSource` enum onto + * the camelCase token set the health-score analytics layer consumes. + * + * - `MANUAL` and `IMPORT` (CSV import — still user-supplied data) both + * surface as `"manual"`. + * - `WITHINGS` and `APPLE_HEALTH` ride one-to-one. + * + * Returns `null` for source values that don't fall into the three + * exposed buckets — defence-in-depth in case the enum grows before the + * client is taught about it. + */ +function mapMeasurementSourceToLabel( + source: MeasurementSource, +): ContributingSource | null { + switch (source) { + case "MANUAL": + case "IMPORT": + return "manual"; + case "WITHINGS": + return "withings"; + case "APPLE_HEALTH": + return "appleHealth"; + default: + return null; + } +} + +function uniqueComponentSources( + rows: ReadonlyArray, +): ReadonlyArray { + const seen = new Set(); + for (const src of rows) { + const label = mapMeasurementSourceToLabel(src); + if (label) seen.add(label); + } + return Array.from(seen); +} + +export interface HealthScoreFastPathInput { + userId: string; + bpInTargetPct: number | null; + heightCm: number | null; + now: Date; + /** + * Per-type rollup coverage map. The caller (analytics route) probes + * once and shares the result across all three fast-path helpers so + * the probe cost stays flat in the fan-out. + */ + coverage?: RollupCoverageMap; +} + +/** + * Public entrypoint. Returns the same shape `computeHealthScore` + * returns, plus a `path` annotate emitted on the meta dict. + * + * Returns null when no component can be computed (the route surfaces + * `null` to the UI so the hero panel hides cleanly). + */ +export async function computeUserHealthScoreFastPath( + input: HealthScoreFastPathInput, +): Promise { + const DAY_MS = 24 * 60 * 60 * 1000; + const { userId, bpInTargetPct, heightCm, now } = input; + const since30d = new Date(now.getTime() - 30 * DAY_MS); + const prevSince30d = new Date(now.getTime() - 37 * DAY_MS); + const prevUntil = new Date(now.getTime() - 7 * DAY_MS); + + const coverage = input.coverage ?? (await probeRollupCoverage(userId)); + const weightCovered = + isFullyCovered(coverage) && coverage.get("WEIGHT") === true; + + // Weight series — rollup-fast-path when fully covered, raw findMany + // otherwise. The raw read selects only the columns the slope helper + // and the source-attribution accordion need (value + measuredAt + + // source) so the projection stays narrow. + let weightSeriesLast30d: Array<{ date: string; kg: number }>; + let weightSeriesPrev30d: Array<{ date: string; kg: number }>; + let weightSourcesIn30d: ReadonlyArray; + let latestWeightAsOf: string | null; + + if (weightCovered) { + // Rollup-fast-path — per-day MEAN weight from `measurement_rollups`. + // Linear regression on per-day means produces a slope equivalent + // to per-event regression for a series with consistent sampling + // cadence (one weigh-in per day is the canonical pattern). + const dayBuckets = await readRollupBuckets( + userId, + "WEIGHT", + "DAY", + prevSince30d, + now, + ); + weightSeriesLast30d = dayBuckets + .filter((b) => b.bucketStart >= since30d) + .map((b) => ({ + date: b.bucketStart.toISOString(), + kg: b.mean, + })); + weightSeriesPrev30d = dayBuckets + .filter((b) => b.bucketStart >= prevSince30d && b.bucketStart <= prevUntil) + .map((b) => ({ + date: b.bucketStart.toISOString(), + kg: b.mean, + })); + // Source attribution still needs the raw rows — only the raw table + // carries the `source` enum. Pull the narrow 30-day window with a + // 2-column projection so the round-trip stays minimal. + const sourceRows = await prisma.measurement.findMany({ + where: { + userId, + type: "WEIGHT", + measuredAt: { gte: since30d, lte: now }, + }, + select: { measuredAt: true, source: true }, + orderBy: { measuredAt: "asc" }, + }); + weightSourcesIn30d = uniqueComponentSources(sourceRows.map((r) => r.source)); + latestWeightAsOf = sourceRows.at(-1)?.measuredAt.toISOString() ?? null; + } else { + // Live fallback — raw rows over the 37-day window. Mirrors the + // pre-v1.4.37 behaviour exactly. + const weightRows = await prisma.measurement.findMany({ + where: { + userId, + type: "WEIGHT", + measuredAt: { gte: prevSince30d, lte: now }, + }, + select: { value: true, measuredAt: true, source: true }, + orderBy: { measuredAt: "asc" }, + }); + weightSeriesLast30d = weightRows + .filter((r) => r.measuredAt >= since30d) + .map((r) => ({ date: r.measuredAt.toISOString(), kg: r.value })); + weightSeriesPrev30d = weightRows + .filter((r) => r.measuredAt >= prevSince30d && r.measuredAt <= prevUntil) + .map((r) => ({ date: r.measuredAt.toISOString(), kg: r.value })); + weightSourcesIn30d = uniqueComponentSources( + weightRows + .filter((r) => r.measuredAt >= since30d) + .map((r) => r.source), + ); + latestWeightAsOf = + weightRows.filter((r) => r.measuredAt >= since30d).at(-1)?.measuredAt + .toISOString() ?? null; + } + + // Remaining reads — mood, BP-SYS source attribution, medications + + // intake — run in parallel. None has a rollup equivalent today so + // these are always live. + const [bpSysRowsForSource, moodRows, medications] = await Promise.all([ + prisma.measurement.findMany({ + where: { + userId, + type: "BLOOD_PRESSURE_SYS", + measuredAt: { gte: since30d, lte: now }, + }, + select: { measuredAt: true, source: true }, + orderBy: { measuredAt: "asc" }, + }), + prisma.moodEntry.findMany({ + where: { + userId, + moodLoggedAt: { gte: prevSince30d, lte: now }, + }, + select: { score: true, moodLoggedAt: true }, + orderBy: { moodLoggedAt: "asc" }, + }), + prisma.medication.findMany({ + where: { userId, active: true }, + select: { + id: true, + createdAt: true, + schedules: { + select: { + windowStart: true, + windowEnd: true, + }, + }, + }, + }), + ]); + + let medicationCompliance30: number[] = []; + let medicationCompliance30Previous: number[] = []; + if (medications.length > 0) { + const medIds = medications.map((m) => m.id); + const intakeEvents = await prisma.medicationIntakeEvent.findMany({ + where: { + userId, + medicationId: { in: medIds }, + scheduledFor: { gte: prevSince30d, lte: now }, + }, + select: { + medicationId: true, + scheduledFor: true, + takenAt: true, + skipped: true, + }, + }); + const eventsByMed = new Map(); + for (const ev of intakeEvents) { + const list = eventsByMed.get(ev.medicationId); + if (list) list.push(ev); + else eventsByMed.set(ev.medicationId, [ev]); + } + medicationCompliance30 = medications.map((med) => { + const events = eventsByMed.get(med.id) ?? []; + return calculateCompliance(events, med.schedules, 30, med.createdAt).rate; + }); + medicationCompliance30Previous = medications.map((med) => { + const events = (eventsByMed.get(med.id) ?? []).filter( + (e) => e.scheduledFor <= prevUntil, + ); + // Shift forward by 7 days so the helper's `now` anchor still + // captures the same logical 30 days (pre-v1.4.37 behaviour). + const shifted = events.map((e) => ({ + scheduledFor: new Date(e.scheduledFor.getTime() + 7 * DAY_MS), + takenAt: e.takenAt ? new Date(e.takenAt.getTime() + 7 * DAY_MS) : null, + skipped: e.skipped, + })); + return calculateCompliance(shifted, med.schedules, 30, med.createdAt) + .rate; + }); + } + + const fallbackTarget = defaultWeightTargetFromHeight(heightCm); + + const moodSeriesLast30d = moodRows + .filter((r) => r.moodLoggedAt >= since30d) + .map((r) => ({ + date: r.moodLoggedAt.toISOString(), + score: r.score, + })); + const moodSeriesPrev30d = moodRows + .filter( + (r) => r.moodLoggedAt >= prevSince30d && r.moodLoggedAt <= prevUntil, + ) + .map((r) => ({ + date: r.moodLoggedAt.toISOString(), + score: r.score, + })); + + // Empty-path early-out — nothing computable, hero hides cleanly. + if ( + bpInTargetPct === null && + weightSeriesLast30d.length === 0 && + moodSeriesLast30d.length === 0 && + medicationCompliance30.length === 0 + ) { + annotate({ + meta: { + healthScore: { + score: null, + reason: "no_components_available", + path: weightCovered ? "rollup" : "live", + }, + }, + }); + return null; + } + + const windowEndAt = now.toISOString(); + + const bpSourceTokens = uniqueComponentSources( + bpSysRowsForSource.map((r) => r.source), + ); + const latestBpInWindow = bpSysRowsForSource.at(-1); + + // Mood doesn't yet have a non-manual ingest (v1.5 will introduce + // Apple Health mood) — preserve the manual-only contract. + const moodSourceTokens = + moodSeriesLast30d.length > 0 ? (["manual"] as const) : []; + const latestMoodInWindow = moodRows + .filter((r) => r.moodLoggedAt >= since30d) + .at(-1); + + const complianceSourceTokens = + medicationCompliance30.length > 0 ? (["manual"] as const) : []; + + const current: HealthScoreInput = { + bpInTargetRate: bpInTargetPct, + weightSeriesLast30d, + weightTargetKg: fallbackTarget, + moodEntriesLast30d: moodSeriesLast30d, + medicationCompliance30, + attribution: { + bpSources: bpSourceTokens, + asOfBp: latestBpInWindow?.measuredAt.toISOString() ?? null, + weightSources: weightSourcesIn30d, + asOfWeight: latestWeightAsOf, + moodSources: moodSourceTokens, + asOfMood: latestMoodInWindow?.moodLoggedAt.toISOString() ?? null, + complianceSources: complianceSourceTokens, + asOfCompliance: complianceSourceTokens.length > 0 ? windowEndAt : null, + windowEndAt, + }, + }; + + // Previous-window snapshot — same logic, prior-week-shifted series. + // The bpInTargetPct stays pinned to the current value because the + // route does not pay for a second historical pair-search; the delta + // reflects week-over-week movement in the weight / mood / compliance + // pillars only. + const previous: HealthScoreInput = { + bpInTargetRate: bpInTargetPct, + weightSeriesLast30d: weightSeriesPrev30d, + weightTargetKg: fallbackTarget, + moodEntriesLast30d: moodSeriesPrev30d, + medicationCompliance30: medicationCompliance30Previous, + }; + + const result = computeHealthScore(current, previous); + annotate({ + meta: { + healthScore: { + score: result.score, + band: result.band, + delta: result.delta, + path: weightCovered ? "rollup" : "live", + }, + }, + }); + return result; +}