perf(analytics): probe-gated healthScore on rollup-fast-path

The Personal Health Score builder used to read 37 days of raw weight
rows from the measurements table on every dashboard mount. When DAY
rollup coverage exists for WEIGHT, the new helper derives the weight
series from per-day MEAN values in measurement_rollups and only
issues a narrow 2-column projection against measurements for the
source-attribution pill (the rollup table does not carry the source
enum). Partial-coverage and brand-new accounts keep the legacy 37-day
raw read so the score stays correct on first cold-mount.

Mood, medications, and intake events still read live regardless of
coverage — none has a rollup equivalent today. A path:"rollup"|
"live" annotate on meta.healthScore.path makes the branch selection
provable in prod logs, matching the bp_in_target and summaries-slice
patterns.

Body relocated from route.ts to lib/analytics/health-score-fast-path.ts
so the dispatcher can be unit-tested against mocked Prisma without a
container. Unit test pins both branches (probe → rollup, probe →
live), the empty-component-set early-out, and the implicit-probe
shortcut for callers that omit the coverage map.
This commit is contained in:
Marc-André Bombeck committed 2026-05-17 14:22:59 +02:00
1 parent 5688a47f34
commit cc37e4bdcd
3 files changed
+641 -303

No files matched your search

+17 -303
View File
@@ -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<MeasurementSource>,
): ReadonlyArray<ContributingSource> {
const seen = new Set<ContributingSource>();
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<HealthScoreResult | null> {
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<string, typeof intakeEvents>();
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`.
@@ -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<typeof vi.fn>;
const ROLLUP_FIND_MANY = prisma.measurementRollup.findMany as unknown as ReturnType<typeof vi.fn>;
const MOOD_FIND_MANY = prisma.moodEntry.findMany as unknown as ReturnType<typeof vi.fn>;
const MEDICATION_FIND_MANY = prisma.medication.findMany as unknown as ReturnType<typeof vi.fn>;
const INTAKE_FIND_MANY = prisma.medicationIntakeEvent.findMany as unknown as ReturnType<typeof vi.fn>;
const PROBE = probeRollupCoverage as unknown as ReturnType<typeof vi.fn>;
const FULLY_COVERED = isFullyCovered as unknown as ReturnType<typeof vi.fn>;
const ANNOTATE = annotate as unknown as ReturnType<typeof vi.fn>;
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<string, boolean>([
["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<string, boolean>([
["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<string, boolean>([]);
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<string, boolean>([]);
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");
});
});
});
+383
View File
@@ -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<MeasurementSource>,
): ReadonlyArray<ContributingSource> {
const seen = new Set<ContributingSource>();
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<HealthScoreResult | null> {
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<ContributingSource>;
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<string, typeof intakeEvents>();
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;
}