mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
Quality-leap release between two HealthKit milestones. The headline is a P0 hotfix for the `/api/analytics` 500 that broke the Insights mother page for any account with more than a few thousand measurements: the six per-metric status helpers were spreading the entire numeric history into `Math.min` / `Math.max`, which trips a stack overflow once a single argument list exceeds the V8 spread cap. Both the surfaced symptom and the latent risk in five sibling helpers were folded onto a reduce-based min/max path. Around that hotfix landed a deep polish pass: the Insights mother page now defers three below-the-fold blocks behind `next/dynamic` to trim the cold mount cost, the analytics endpoint gained a `?slice=summaries` slim slice that the per-metric sub-pages now ride, the Coach snapshot builder picked up a 60 s LRU keyed on `(userId, scope)`, the Settings shell consolidated three duplicate copies of the about / notifications / about-dropdown surfaces, the navigation strip dropped a redundant "Home" group label and a colliding "Notifications" sibling, every icon-only button on the medications and admin surfaces gained an accessible name, every Progress bar gained one too, three pages had their heading hierarchy repaired to a sequential h1 → h2 → h3, the mobile Coach FAB now hides while a chart tooltip is open so it cannot occlude the read-out, and the auth shell normalised every authenticated route to a single `max-w-screen-xl` container. Also lands the Apple App Site Association handler at `/.well-known/apple-app-site-association` on every host that fronts the app, advertising the iOS bundle's App ID prefix (S8WDX4W5KX.dev.healthlog.app) under `webcredentials.apps` so the passkey ceremony shares cleanly between the web origin and the iOS app. The release closes nineteen issues from a five-surface audit + a runtime bug-hunt pass, plus six follow-up tracks of polish across the affected surfaces.
990 lines
37 KiB
TypeScript
990 lines
37 KiB
TypeScript
import { prisma } from "@/lib/db";
|
||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||
import { annotate } from "@/lib/logging/context";
|
||
import { apiSuccess } from "@/lib/api-response";
|
||
import { summarize, type DataPoint } from "@/lib/analytics/trends";
|
||
import { computeSummariesSlice } from "@/lib/analytics/summaries-slice";
|
||
import { getBpTargets } from "@/lib/analytics/bp-targets";
|
||
import { computeBpInTargetWindows } from "@/lib/analytics/bp-in-target";
|
||
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,
|
||
correlateWeightWeekday,
|
||
type CorrelationResult,
|
||
} from "@/lib/insights/correlations";
|
||
import type {
|
||
MeasurementSource,
|
||
MeasurementType,
|
||
} from "@/generated/prisma/client";
|
||
import { measurementTypeEnum } from "@/lib/validations/measurement";
|
||
import { pickCanonicalSourceRows } from "@/lib/analytics/source-priority";
|
||
|
||
export const dynamic = "force-dynamic";
|
||
|
||
/**
|
||
* v1.4.33 C1 — pull `?slice=…` from either a NextRequest (the
|
||
* route's production wrapper) or a plain `Request` (the integration
|
||
* tests instantiate `new Request("http://localhost/api/analytics")`
|
||
* and cast through). Falls back to the raw `URL(request.url)` parse
|
||
* so the slim slice branch is reachable from both call shapes.
|
||
*/
|
||
function readSliceParam(request: Request | undefined): string | null {
|
||
if (!request) return null;
|
||
try {
|
||
return new URL(request.url).searchParams.get("slice");
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
export const GET = apiHandler(async (request?: Request) => {
|
||
const { user } = await requireAuth();
|
||
annotate({ action: { name: "analytics.get" } });
|
||
|
||
// v1.4.33 C1 — slim summaries slice for the dashboard tile strip.
|
||
// The default path runs 30+ chunked findMany reads to feed
|
||
// `summarize()` per type; under `?slice=summaries` the route
|
||
// resolves the same per-type DataSummary shape from 2 SQL passes
|
||
// (a `groupBy` for count/min/max/mean plus a single `$queryRaw`
|
||
// carrying the windowed averages and Postgres `regr_slope` /
|
||
// `regr_r2` for the slope tuples). The slim slice drops the thick
|
||
// `correlations` / `healthScore` / `bpInTargetPct` / `glucoseByContext`
|
||
// / `sleepStages` / `bmi` blocks — those stay on the default path
|
||
// behind a hover-prefetch from the Coach drawer + the correlation
|
||
// tile's `<InView>` boundary. Consumers that need only the headline
|
||
// tile values land on this branch and skip the heavy chain.
|
||
if (readSliceParam(request) === "summaries") {
|
||
const slim = await computeSummariesSlice(user.id);
|
||
return apiSuccess(slim);
|
||
}
|
||
|
||
// v1.4.25 W7b — every day-bucket call inside this route now honours
|
||
// the user's display timezone. The legacy `berlinDayKey()` import
|
||
// remains for sleep-stage and correlation paths that share their
|
||
// helper signature with non-tz-aware code (`computeSleepStageBreakdown`
|
||
// is called with a userId only); the per-call sites below all pass
|
||
// `userTz` through `userDayKey()`.
|
||
const userTz = user.timezone ?? DEFAULT_TIMEZONE;
|
||
|
||
// Derived from canonical enum so a new measurement type is auto-summarised
|
||
// by /api/analytics (V3 audit: enum drift cousins).
|
||
const types = [...measurementTypeEnum.options] as MeasurementType[];
|
||
|
||
// v1.4.23 Sr-H1 — every per-type read goes through the chunked helper
|
||
// so the route's working set stays bounded at MEASUREMENT_CHUNK_SIZE
|
||
// rows per Prisma round-trip even for users with multi-year HealthKit
|
||
// sync history. `summarize()` requires the full series (slope7/30/90,
|
||
// anomalies) so groupBy cannot replace this read; the chunked path is
|
||
// the smallest pagination contract that still satisfies the helper.
|
||
// v1.4.25 W5e — per-metric-class source priority pulled once. The
|
||
// aggregator passes the persisted JSON straight through so the
|
||
// helper's `parseSourcePriority` runs and falls back to defaults
|
||
// when the column is null or malformed.
|
||
const sourcePriorityJson = user.sourcePriorityJson;
|
||
|
||
let totalRowsReadForAggregate = 0;
|
||
const measurementsByType = await Promise.all(
|
||
types.map((type) =>
|
||
fetchMeasurementSeriesChunked(user.id, type, {
|
||
includeSleepStage: true,
|
||
}).then((measurements) => {
|
||
totalRowsReadForAggregate += measurements.length;
|
||
// v1.4.23 — Apple Health's sleep ingest stores one row per
|
||
// stage per night. Summarising the raw rows would treat each
|
||
// stage as its own datapoint and grossly understate "average
|
||
// sleep". Aggregate per Berlin day before summarising so the
|
||
// summary matches the user's intuition (one number per night
|
||
// = total minutes asleep).
|
||
let datapoints: DataPoint[];
|
||
if (type === "SLEEP_DURATION") {
|
||
// v1.4.25 W5e — pick ONE source per day before summing the
|
||
// night's stages. With only WITHINGS + MANUAL today, the
|
||
// picker passes everything through; once iOS passthrough
|
||
// lands (v1.5) the picker prevents double-counted nights
|
||
// (HealthKit forwards Withings' Sleep summary to iOS in
|
||
// addition to ScanWatch's own stream).
|
||
const sleepRows = pickCanonicalSourceRows(
|
||
measurements,
|
||
"sleep",
|
||
sourcePriorityJson,
|
||
(d) => userDayKey(d, userTz),
|
||
).canonicalRows;
|
||
const byDay = new Map<string, { total: number; date: Date }>();
|
||
for (const m of sleepRows) {
|
||
const key = userDayKey(m.measuredAt, userTz);
|
||
const slot = byDay.get(key) ?? {
|
||
total: 0,
|
||
date: m.measuredAt,
|
||
};
|
||
slot.total += m.value;
|
||
if (m.measuredAt > slot.date) slot.date = m.measuredAt;
|
||
byDay.set(key, slot);
|
||
}
|
||
datapoints = Array.from(byDay.values()).map(
|
||
(s): DataPoint => ({ date: s.date, value: s.total }),
|
||
);
|
||
datapoints.sort((a, b) => a.date.getTime() - b.date.getTime());
|
||
} else {
|
||
datapoints = measurements.map(
|
||
(m): DataPoint => ({
|
||
date: m.measuredAt,
|
||
value: m.value,
|
||
}),
|
||
);
|
||
}
|
||
return {
|
||
type,
|
||
summary: summarize(datapoints),
|
||
};
|
||
}),
|
||
),
|
||
);
|
||
|
||
const results: Record<string, ReturnType<typeof summarize>> = {};
|
||
for (const { type, summary } of measurementsByType) {
|
||
results[type] = summary;
|
||
}
|
||
|
||
// v1.4.23 Sr-H1 — slow-query attribution. Total rows pulled across
|
||
// every per-type chunked read so ops can spot outlier users whose
|
||
// analytics requests dominate the route's tail latency.
|
||
annotate({
|
||
meta: {
|
||
analytics: {
|
||
bp_aggregate: { row_count: totalRowsReadForAggregate },
|
||
},
|
||
},
|
||
});
|
||
|
||
// v1.4.23 — sleep-stage breakdown for the trailing 30 days. Only
|
||
// included when the user has stage-tagged rows in window; null
|
||
// otherwise so the UI can render a plain total without the
|
||
// breakdown card painting empty.
|
||
const sleepStages = await computeSleepStageBreakdown(user.id, userTz);
|
||
|
||
// BMI calculation
|
||
let bmi: number | null = null;
|
||
if (user.heightCm && results.WEIGHT?.latest) {
|
||
const heightM = user.heightCm / 100;
|
||
bmi = Math.round((results.WEIGHT.latest / (heightM * heightM)) * 10) / 10;
|
||
}
|
||
|
||
// BP in-target percentage (auto-calculated from date of birth)
|
||
let bpInTargetPct: number | null = null;
|
||
let bpInTargetPct7d: number | null = null;
|
||
let bpInTargetPct30d: number | null = null;
|
||
let bpInTargetPctAllTime: number | null = null;
|
||
/**
|
||
* v1.4.22 W5 reconcile (Code-H2) — period-aligned prior-window
|
||
* pcts so the BD-Zielbereich tile's comparison-overlay caption
|
||
* stops mismatching its math with its label. The tile's
|
||
* `compareDelta` is `last30Days - priorMonth` (or `… - priorYear`)
|
||
* matching the user's `comparisonBaseline` selection, never
|
||
* `last30Days - allTime` (the v1.4.22 A2 shortcut).
|
||
*/
|
||
let bpInTargetPctPriorMonth: number | null = null;
|
||
let bpInTargetPctPriorYear: number | null = null;
|
||
const bpTargets = getBpTargets(user.dateOfBirth);
|
||
if (bpTargets) {
|
||
const now = new Date();
|
||
// v1.4.22 A1 — re-anchor the BD-Zielbereich tile headline to the
|
||
// last-30-day window. Up to v1.4.19 the headline pinned to the
|
||
// 30-day average (so 7d / 30d / total all read 50 %). v1.4.19 A1
|
||
// flipped the headline to all-time, which made the tile correct
|
||
// but emotionally wrong: the headline was the slowest-moving
|
||
// aggregate possible, punishing recent improvement. v1.4.22 A1
|
||
// re-routes the headline to last-30-days and surfaces 7d / 30d /
|
||
// all-time as a 3-line sub-row so power users still see the
|
||
// long-arc number without it dominating. The helper still returns
|
||
// every window — only the headline pick changed.
|
||
//
|
||
// v1.4.23 H2 — chunked aggregation replaces an unbounded findMany.
|
||
// The W2-of-v1.4.20 fix did the right thing semantically (all-time
|
||
// window for the headline) but read the entire BP table into one
|
||
// array per type. A 5-year power user holds ~9 000 rows × 2; the
|
||
// single-shot fetch produced a 50-100 ms Prisma round-trip plus a
|
||
// ~2 MB allocation per request. Page through in 5 000-row chunks
|
||
// so the working set stays bounded; accumulate into the same
|
||
// `BpReading[]` shape the existing helper expects. The
|
||
// `analytics.bp_in_target.row_count` wide-event meta lets ops
|
||
// attribute slow requests to specific outlier users.
|
||
// v1.4.29 M1 — bound the BP-in-target reads to the trailing
|
||
// 365 days. `computeBpInTargetWindows` only needs the last
|
||
// year (the longest sub-window it computes is `priorYear`);
|
||
// pre-bound, each chunked walk pulled the entire BP history
|
||
// every dashboard mount.
|
||
const bpInTargetSince = new Date(
|
||
now.getTime() - 365 * 24 * 60 * 60 * 1000,
|
||
);
|
||
const [sysData, diaData] = await Promise.all([
|
||
fetchMeasurementSeriesChunked(user.id, "BLOOD_PRESSURE_SYS", {
|
||
since: bpInTargetSince,
|
||
}),
|
||
fetchMeasurementSeriesChunked(user.id, "BLOOD_PRESSURE_DIA", {
|
||
since: bpInTargetSince,
|
||
}),
|
||
]);
|
||
|
||
annotate({
|
||
meta: {
|
||
analytics: {
|
||
bp_in_target: {
|
||
row_count: sysData.length + diaData.length,
|
||
sys_rows: sysData.length,
|
||
dia_rows: diaData.length,
|
||
},
|
||
},
|
||
},
|
||
});
|
||
|
||
const windows = computeBpInTargetWindows(sysData, diaData, bpTargets, now);
|
||
bpInTargetPct = windows.last30Days?.pct ?? null;
|
||
bpInTargetPct7d = windows.last7Days?.pct ?? null;
|
||
bpInTargetPct30d = windows.last30Days?.pct ?? null;
|
||
bpInTargetPctAllTime = windows.allTime?.pct ?? null;
|
||
bpInTargetPctPriorMonth = windows.priorMonth?.pct ?? null;
|
||
bpInTargetPctPriorYear = windows.priorYear?.pct ?? null;
|
||
}
|
||
|
||
// Per-context glucose summaries (canonical mg/dL).
|
||
//
|
||
// v1.4.29 H2 — bound to the trailing 30 days. The dashboard tile
|
||
// path only reads the trailing window; pre-bound this query
|
||
// walked every persisted BG row a multi-year user has written.
|
||
const glucoseSince = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
||
const glucoseRows = await prisma.measurement.findMany({
|
||
where: {
|
||
userId: user.id,
|
||
type: "BLOOD_GLUCOSE",
|
||
measuredAt: { gte: glucoseSince },
|
||
},
|
||
orderBy: { measuredAt: "asc" },
|
||
select: { value: true, measuredAt: true, glucoseContext: true },
|
||
});
|
||
const glucoseByContext: Record<string, ReturnType<typeof summarize>> = {};
|
||
if (glucoseRows.length > 0) {
|
||
const contexts = ["FASTING", "POSTPRANDIAL", "RANDOM", "BEDTIME"] as const;
|
||
for (const ctx of contexts) {
|
||
const ctxRows = glucoseRows.filter((r) => r.glucoseContext === ctx);
|
||
if (ctxRows.length === 0) continue;
|
||
glucoseByContext[ctx] = summarize(
|
||
ctxRows.map((r): DataPoint => ({ date: r.measuredAt, value: r.value })),
|
||
);
|
||
}
|
||
}
|
||
|
||
// v1.4.20 phase B3 — three pre-defined correlation hypotheses.
|
||
// All three run on the trailing 30 days so a sparse account doesn't
|
||
// burn the n >= 20 gate on a stale window (v1.4.23 H6 raised the
|
||
// floor from 14 → 20). Each runner gates on n >= 20 + p < 0.05;
|
||
// below the bar the result.status === "insufficient" and the UI
|
||
// paints an EmptyState.
|
||
const correlations = await computeCorrelationHypotheses(user.id, userTz);
|
||
|
||
// v1.4.20 phase B5 — Personal Health Score. Server-deterministic
|
||
// 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, {
|
||
bpInTargetPct,
|
||
heightCm: user.heightCm ?? null,
|
||
});
|
||
if (healthScore) {
|
||
annotate({
|
||
meta: {
|
||
healthScore: {
|
||
score: healthScore.score,
|
||
band: healthScore.band,
|
||
delta: healthScore.delta,
|
||
},
|
||
},
|
||
});
|
||
}
|
||
|
||
return apiSuccess({
|
||
summaries: results,
|
||
bmi,
|
||
bpInTargetPct,
|
||
bpInTargetPct7d,
|
||
bpInTargetPct30d,
|
||
bpInTargetPctAllTime,
|
||
bpInTargetPctPriorMonth,
|
||
bpInTargetPctPriorYear,
|
||
glucoseByContext,
|
||
correlations,
|
||
healthScore,
|
||
sleepStages,
|
||
});
|
||
});
|
||
|
||
/**
|
||
* Per-stage sleep-minutes breakdown over the trailing 30 days.
|
||
*
|
||
* Returns `null` when the user has no stage-tagged sleep rows in
|
||
* window — the analytics consumer renders the existing
|
||
* `summaries.SLEEP_DURATION` totals without a stage card in that
|
||
* case. Returns the sum-per-stage AND the day count covered so the
|
||
* UI can render an "averaged across N nights" caption truthfully.
|
||
*
|
||
* v1.4.25 W3f — also returns a `perNight` array: one entry per
|
||
* Berlin-tz day inside the window, with minutes-per-stage for the
|
||
* stacked-bar chart. Days with zero stage-tagged rows are omitted
|
||
* from `perNight` (the chart treats them as gaps).
|
||
*/
|
||
async function computeSleepStageBreakdown(
|
||
userId: string,
|
||
userTz: string,
|
||
): Promise<{
|
||
windowDays: number;
|
||
nights: number;
|
||
totalMinutes: number;
|
||
stages: Record<string, number>;
|
||
perNight: Array<{ dayKey: string; stages: Record<string, number> }>;
|
||
} | null> {
|
||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||
const since = new Date(Date.now() - 30 * DAY_MS);
|
||
const rows = await prisma.measurement.findMany({
|
||
where: {
|
||
userId,
|
||
type: "SLEEP_DURATION",
|
||
sleepStage: { not: null },
|
||
measuredAt: { gte: since },
|
||
},
|
||
select: { value: true, measuredAt: true, sleepStage: true },
|
||
});
|
||
|
||
if (rows.length === 0) return null;
|
||
|
||
const stages: Record<string, number> = {};
|
||
const dayKeys = new Set<string>();
|
||
let totalMinutes = 0;
|
||
// v1.4.25 W3f — per-day accumulator. Keyed by Berlin-tz day so the
|
||
// chart's 7/14/30-day slicer can build a left-aligned series.
|
||
const perNightMap = new Map<string, Record<string, number>>();
|
||
for (const row of rows) {
|
||
if (!row.sleepStage) continue;
|
||
stages[row.sleepStage] = (stages[row.sleepStage] ?? 0) + row.value;
|
||
totalMinutes += row.value;
|
||
const dayKey = userDayKey(row.measuredAt, userTz);
|
||
dayKeys.add(dayKey);
|
||
const nightStages = perNightMap.get(dayKey) ?? {};
|
||
nightStages[row.sleepStage] =
|
||
(nightStages[row.sleepStage] ?? 0) + row.value;
|
||
perNightMap.set(dayKey, nightStages);
|
||
}
|
||
|
||
// Sort per-night ascending so the chart consumer can slice the
|
||
// trailing N entries for the 7d / 14d / 30d toggle.
|
||
const perNight = Array.from(perNightMap.entries())
|
||
.sort((a, b) => a[0].localeCompare(b[0]))
|
||
.map(([dayKey, stages]) => ({ dayKey, stages }));
|
||
|
||
return {
|
||
windowDays: 30,
|
||
nights: dayKeys.size,
|
||
totalMinutes,
|
||
stages,
|
||
perNight,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Build inputs for the three pre-defined hypotheses + run them.
|
||
* Pure-ish — only Prisma reads, no external calls.
|
||
*
|
||
* Window: trailing 30 days. Anything older falls outside the surface
|
||
* because the user-facing "based on N paired readings · last 30 days"
|
||
* source-chip has to remain truthful.
|
||
*/
|
||
async function computeCorrelationHypotheses(
|
||
userId: string,
|
||
userTz: string,
|
||
): Promise<{
|
||
bpCompliance: CorrelationResult;
|
||
moodPulse: CorrelationResult;
|
||
weightWeekday: CorrelationResult;
|
||
}> {
|
||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||
const since = new Date(Date.now() - 30 * DAY_MS);
|
||
|
||
// v1.4.23 Sr-H1 — the four measurement reads route through the
|
||
// chunked helper so even a noisy 30-day window (e.g. minute-level
|
||
// HealthKit pulse samples) cannot allocate an unbounded buffer. The
|
||
// helper still returns the full filtered series the Pearson runners
|
||
// need; we just bound the per-page Prisma round-trip.
|
||
const [sysRows, pulseRows, weightRows, moodRows, intakeRows] =
|
||
await Promise.all([
|
||
fetchMeasurementSeriesChunked(userId, "BLOOD_PRESSURE_SYS", { since }),
|
||
fetchMeasurementSeriesChunked(userId, "PULSE", { since }),
|
||
fetchMeasurementSeriesChunked(userId, "WEIGHT", { since }),
|
||
prisma.moodEntry.findMany({
|
||
where: { userId, moodLoggedAt: { gte: since } },
|
||
select: { score: true, moodLoggedAt: true, date: true },
|
||
}),
|
||
prisma.medicationIntakeEvent.findMany({
|
||
where: { userId, scheduledFor: { gte: since } },
|
||
select: { scheduledFor: true, takenAt: true, skipped: true },
|
||
}),
|
||
]);
|
||
|
||
// ── Hypothesis 1: BP × medication compliance ────────────────
|
||
// Aggregate by the user's display-tz day key so DST + UTC boundary
|
||
// issues don't split a day's readings. The day's "compliance %" is
|
||
// taken / expected for that calendar day across all medications.
|
||
const dayKey = (d: Date): string => userDayKey(d, userTz);
|
||
|
||
const dailySys = new Map<string, number[]>();
|
||
for (const row of sysRows) {
|
||
const key = dayKey(row.measuredAt);
|
||
const list = dailySys.get(key) ?? [];
|
||
list.push(row.value);
|
||
dailySys.set(key, list);
|
||
}
|
||
|
||
const dailyCompliance = new Map<
|
||
string,
|
||
{ expected: number; taken: number }
|
||
>();
|
||
for (const event of intakeRows) {
|
||
const key = dayKey(event.scheduledFor);
|
||
const slot = dailyCompliance.get(key) ?? { expected: 0, taken: 0 };
|
||
slot.expected += 1;
|
||
if (event.takenAt && !event.skipped) slot.taken += 1;
|
||
dailyCompliance.set(key, slot);
|
||
}
|
||
|
||
const bpCompliancePairs: Array<{
|
||
date: Date;
|
||
systolic: number;
|
||
compliancePct: number;
|
||
}> = [];
|
||
for (const [key, sysValues] of dailySys.entries()) {
|
||
const slot = dailyCompliance.get(key);
|
||
if (!slot || slot.expected === 0) continue;
|
||
const compliancePct = (slot.taken / slot.expected) * 100;
|
||
const meanSys = sysValues.reduce((s, v) => s + v, 0) / sysValues.length;
|
||
bpCompliancePairs.push({
|
||
date: dateFromDayKey(key),
|
||
systolic: meanSys,
|
||
compliancePct,
|
||
});
|
||
}
|
||
const bpCompliance = correlateBpCompliance({ daily: bpCompliancePairs });
|
||
|
||
// ── Hypothesis 2: Mood × resting pulse ──────────────────────
|
||
// Same-day pairing: take the day's mean mood vs the day's mean pulse.
|
||
// "Resting" is approximated by mean — HealthLog has no separate
|
||
// resting-pulse field, so we accept the noise rather than skip.
|
||
const dailyMood = new Map<string, number[]>();
|
||
for (const row of moodRows) {
|
||
const key = userDayKey(row.moodLoggedAt, userTz);
|
||
const list = dailyMood.get(key) ?? [];
|
||
list.push(row.score);
|
||
dailyMood.set(key, list);
|
||
}
|
||
const dailyPulse = new Map<string, number[]>();
|
||
for (const row of pulseRows) {
|
||
const key = userDayKey(row.measuredAt, userTz);
|
||
const list = dailyPulse.get(key) ?? [];
|
||
list.push(row.value);
|
||
dailyPulse.set(key, list);
|
||
}
|
||
const moodPulsePairs: Array<{
|
||
date: Date;
|
||
mood: number;
|
||
restingPulse: number;
|
||
}> = [];
|
||
for (const [key, moodScores] of dailyMood.entries()) {
|
||
const pulseValues = dailyPulse.get(key);
|
||
if (!pulseValues || pulseValues.length === 0) continue;
|
||
const meanMood = moodScores.reduce((s, v) => s + v, 0) / moodScores.length;
|
||
const meanPulse =
|
||
pulseValues.reduce((s, v) => s + v, 0) / pulseValues.length;
|
||
moodPulsePairs.push({
|
||
date: dateFromDayKey(key),
|
||
mood: meanMood,
|
||
restingPulse: meanPulse,
|
||
});
|
||
}
|
||
const moodPulse = correlateMoodPulse({ daily: moodPulsePairs });
|
||
|
||
// ── Hypothesis 3: Weight × weekday ──────────────────────────
|
||
// 0 = Monday … 6 = Sunday. ISO weekday minus 1.
|
||
//
|
||
// v1.4.25 W10 reconcile (Code-H1) — buckets the weekday by the
|
||
// user's display tz, matching W7's per-user-tz threading that the
|
||
// BP, mood, sleep, and pulse aggregators above already honour. The
|
||
// pre-W10 helper was pinned to `Europe/Berlin` and would land a
|
||
// user-local 23:30 weight reading from `Pacific/Auckland` under
|
||
// Sunday's bucket instead of Monday's — Pearson on the wrong
|
||
// weekday column.
|
||
const weightWeekdayPairs: Array<{ weekday: number; weight: number }> = [];
|
||
for (const row of weightRows) {
|
||
const isoWeekday = isoWeekdayInTz(row.measuredAt, userTz); // 1..7, 1=Mon
|
||
weightWeekdayPairs.push({
|
||
weekday: isoWeekday - 1,
|
||
weight: row.value,
|
||
});
|
||
}
|
||
const weightWeekday = correlateWeightWeekday({ daily: weightWeekdayPairs });
|
||
|
||
// Annotate so admin observability can attribute coverage to the
|
||
// corresponding wide-event rather than chasing it via DB queries.
|
||
annotate({
|
||
meta: {
|
||
correlations: {
|
||
bpCompliance: bpCompliance.status,
|
||
moodPulse: moodPulse.status,
|
||
weightWeekday: weightWeekday.status,
|
||
},
|
||
},
|
||
});
|
||
|
||
return { bpCompliance, moodPulse, weightWeekday };
|
||
}
|
||
|
||
/**
|
||
* v1.4.23 Sr-H1 — paged read of every Measurement of a given type for
|
||
* a single user.
|
||
*
|
||
* Boundary contract:
|
||
* - The route's per-type loop, the BD-Zielbereich BP windowing, and
|
||
* the correlation-hypothesis reads ALL pull through this helper so
|
||
* no analytics path holds an unbounded `findMany` against
|
||
* `measurement` any more.
|
||
* - `summarize()` (slope7/30/90, anomaly z-scores) and
|
||
* `computeBpInTargetWindows` (paired sys/dia matching) both need
|
||
* row-level access that `prisma.groupBy` cannot provide; chunked
|
||
* paging is the smallest contract that bounds the working set
|
||
* without changing the helpers.
|
||
* - The cursor is `id` with a stable `(measuredAt, id)` order so two
|
||
* rows sharing a timestamp (bulk-imported manual entries) don't
|
||
* stall the cursor or duplicate a row across pages.
|
||
*
|
||
* Page size is `MEASUREMENT_CHUNK_SIZE`; the safety-bound loop caps
|
||
* total pages at 1 000 (= 5 M rows) which is well above any plausible
|
||
* single-user single-type plausibility range — defence in depth against
|
||
* a cursor-staleness infinite-loop bug.
|
||
*
|
||
* `since` lets the correlation path pull only the trailing 30 days
|
||
* without first reading older rows. `includeSleepStage` opts the
|
||
* per-type loop into the SLEEP_DURATION-only field.
|
||
*/
|
||
const MEASUREMENT_CHUNK_SIZE = 5000;
|
||
|
||
interface ChunkedRow {
|
||
measuredAt: Date;
|
||
value: number;
|
||
sleepStage: string | null;
|
||
/** v1.4.25 W5e — needed by `pickCanonicalSourceRows` so the SLEEP /
|
||
* cumulative aggregators can pick ONE source per day when more than
|
||
* one ingest path contributes to the same metric. */
|
||
source: MeasurementSource;
|
||
/** v1.4.25 W8c — second axis of the canonical picker. Nullable until
|
||
* the iOS app starts shipping HKDevice.model with each sample;
|
||
* legacy / Withings rows stay NULL and the picker treats them as
|
||
* `unknown`. Carried on every type's read so the cumulative-metric
|
||
* path can break Apple-Watch-vs-iPhone ties.
|
||
*/
|
||
deviceType: string | null;
|
||
/** v1.4.25 W8c — feeds the per-MeasurementType device-type override
|
||
* inside the picker. The picker keys
|
||
* `deviceTypePriority[type]` off this so the user's "phone wins for
|
||
* steps but watch wins for HR" config is honoured. */
|
||
type: MeasurementType;
|
||
}
|
||
|
||
async function fetchMeasurementSeriesChunked(
|
||
userId: string,
|
||
type: MeasurementType,
|
||
options: { since?: Date; includeSleepStage?: boolean } = {},
|
||
): Promise<ChunkedRow[]> {
|
||
const out: ChunkedRow[] = [];
|
||
let cursorId: string | undefined;
|
||
for (let page = 0; page < 1000; page++) {
|
||
const chunk = await prisma.measurement.findMany({
|
||
where: {
|
||
userId,
|
||
type,
|
||
...(options.since ? { measuredAt: { gte: options.since } } : {}),
|
||
},
|
||
orderBy: [{ measuredAt: "asc" }, { id: "asc" }],
|
||
select: {
|
||
id: true,
|
||
measuredAt: true,
|
||
value: true,
|
||
source: true,
|
||
// v1.4.25 W8c — read deviceType so the canonical picker can
|
||
// honour the per-metric / per-device override. Nullable until
|
||
// iOS sends it.
|
||
deviceType: true,
|
||
...(options.includeSleepStage ? { sleepStage: true } : {}),
|
||
},
|
||
take: MEASUREMENT_CHUNK_SIZE,
|
||
...(cursorId ? { cursor: { id: cursorId }, skip: 1 } : {}),
|
||
});
|
||
if (chunk.length === 0) break;
|
||
for (const row of chunk) {
|
||
out.push({
|
||
measuredAt: row.measuredAt,
|
||
value: row.value,
|
||
source: row.source,
|
||
deviceType: row.deviceType ?? null,
|
||
type,
|
||
sleepStage:
|
||
"sleepStage" in row
|
||
? ((row.sleepStage as string | null) ?? null)
|
||
: null,
|
||
});
|
||
}
|
||
if (chunk.length < MEASUREMENT_CHUNK_SIZE) break;
|
||
cursorId = chunk[chunk.length - 1].id;
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// v1.4.22 W5 reconcile (Code-MED-3) — `berlinDayKey()` lifted to
|
||
// `src/lib/analytics/berlin-day.ts` so the targets route's sparkline
|
||
// bucketing shares the same Europe/Berlin contract. The
|
||
// `weekday: "short"` formatter still lives here because it's only
|
||
// used by `isoWeekdayInTz()` below.
|
||
//
|
||
// v1.4.25 W10 reconcile (Code-H1) — formatter is now per-tz so the
|
||
// weight-weekday correlator honours the same per-user-tz contract the
|
||
// BP/mood/pulse aggregators above already use. Memoised by tz so the
|
||
// formatter is built once per unique timezone per process (tz strings
|
||
// rarely change at runtime; the cache is bounded by the IANA database
|
||
// to a few hundred entries even in the worst case).
|
||
const WEEKDAY_FORMATTER_CACHE = new Map<string, Intl.DateTimeFormat>();
|
||
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;
|
||
}
|
||
|
||
function dateFromDayKey(key: string): Date {
|
||
// Anchor to UTC midnight — the date is a sortable bucket label rather
|
||
// than a wall-clock timestamp, so DST drift is irrelevant. The tz
|
||
// info is already baked into the key (built via `userDayKey` upstream).
|
||
return new Date(`${key}T00:00:00.000Z`);
|
||
}
|
||
|
||
const ISO_WEEKDAY: Record<string, number> = {
|
||
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;
|
||
}
|
||
|
||
|
||
/**
|
||
* 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);
|
||
}
|