Files
HealthLog/src/lib/analytics/health-score-fast-path.ts
T
Marc-André Bombeck a550031a84 chore(release): v1.4.38
Robustness sweep, perf hotspots, full localization, coach gate
cascade closure.

- /api/dashboard/summary cold-mount 4.6 s → ~500 ms via DAY-bucket
  sparkline + 60 s response cache.
- Coach API gate inventory test surfaced and closed two orphan
  routes (chat[id] GET+DELETE, chat-message feedback POST).
- Cross-tz fast-path runtime guard falls back to live for users
  more than ±3 h from UTC; proper bucket minting stays v1.5.
- es / fr / it / pl coverage 27 % → 63 % (~3 400 new strings) plus
  three medication-cluster placeholder restorations.
- IANA timezone validation closes the User.timezone self-DoS
  regression the new dashboard SQL would have surfaced.
- Unit suite 4520 → 4524.

See CHANGELOG.md for the full set.
2026-05-17 19:07:03 +02:00

409 lines
14 KiB
TypeScript

/**
* 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;
/**
* v1.4.38 — prior-week BP-in-target pct so the week-over-week delta
* reflects BP movement instead of always landing at zero for the
* pillar. The caller is expected to run a second
* `computeBpInTargetFastPath` against `now - 7d` and pass the
* resulting `last30Days.pct` through. When omitted (legacy callers
* predating v1.4.38) the helper falls back to `bpInTargetPct` —
* preserves the pre-v1.4.38 behaviour exactly so the bump is
* additive.
*/
bpInTargetPctPriorWeek?: 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;
// v1.4.38 — prior-week BP fallback. When the caller omits the field
// we pin to the current value (pre-v1.4.38 behaviour) so the helper
// stays a drop-in replacement for legacy callers; when the caller
// supplies it we feed it into the previous-window snapshot so the
// delta reflects week-over-week BP movement instead of always
// zeroing the BP pillar.
const bpInTargetPctPriorWeek =
input.bpInTargetPctPriorWeek === undefined
? bpInTargetPct
: input.bpInTargetPctPriorWeek;
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.
//
// v1.4.38 — when the caller supplies `bpInTargetPctPriorWeek` we
// use it so the delta reflects week-over-week BP movement. Legacy
// callers that omit the field keep the pre-v1.4.38 behaviour where
// BP cancels out of the delta because the helper pins both windows
// to the same value. Re-computing the prior-week BP costs one
// extra rollup-coverage read on the route side; the helper itself
// stays single-pass.
const previous: HealthScoreInput = {
bpInTargetRate: bpInTargetPctPriorWeek,
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;
}