mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
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.
696 lines
29 KiB
TypeScript
696 lines
29 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 { NO_STORE_BUT_BFCACHE } from "@/lib/http/cache-headers";
|
||
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
|
||
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 type {
|
||
MeasurementSource,
|
||
MeasurementType,
|
||
} from "@/generated/prisma/client";
|
||
import { measurementTypeEnum } from "@/lib/validations/measurement";
|
||
import { pickCanonicalSourceRows } from "@/lib/analytics/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 { computeCorrelationHypothesesFastPath } from "@/lib/analytics/correlations-fast-path";
|
||
import {
|
||
cumulativeMetricKey,
|
||
isCumulativeDaySumType,
|
||
pickCumulativeDaySum,
|
||
} from "@/lib/measurements/cumulative-day-sum";
|
||
|
||
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") {
|
||
// v1.4.34 IW-G — read-through the analytics cache keyed on
|
||
// (userId, slice). The slim slice is the dashboard tile strip's hot
|
||
// path; multiple dashboard mounts inside a 60-second TTL all hit a
|
||
// warm cache.
|
||
const slim = await cached(
|
||
caches.analytics as ServerCache<Awaited<ReturnType<typeof computeSummariesSlice>>>,
|
||
`${user.id}|summaries`,
|
||
() => computeSummariesSlice(user.id),
|
||
annotate,
|
||
);
|
||
// v1.4.34 IW-B — bfcache-friendly directive on the slim slice too
|
||
// so a back-forward navigation that landed on the dashboard tile
|
||
// strip can restore from memory instead of paying a full reload.
|
||
const slimRes = apiSuccess(slim);
|
||
slimRes.headers.set("Cache-Control", NO_STORE_BUT_BFCACHE);
|
||
return slimRes;
|
||
}
|
||
|
||
// v1.4.34 IW-G — wrap the heavy default-slice body in the analytics
|
||
// cache keyed on (userId, "default"). The dashboard, the checklist
|
||
// mount, and the Coach drawer all hit this endpoint within seconds
|
||
// of each other; the 60s TTL converts the 7.99s combined dashboard
|
||
// wait to a Map lookup on every subsequent caller.
|
||
const cachedBody = await cached(
|
||
caches.analytics as ServerCache<Awaited<ReturnType<typeof buildAnalyticsResponse>>>,
|
||
`${user.id}|default`,
|
||
() => buildAnalyticsResponse(user),
|
||
annotate,
|
||
);
|
||
|
||
// v1.4.38 — `lastSeenByType` lives inside the cached envelope as
|
||
// `{ lastSeenAt }` only; derive the `daysAgo` caption per call so
|
||
// the staleness label updates correctly across day boundaries.
|
||
// The cache TTL is 60s, but the day boundary it can straddle would
|
||
// otherwise leave the cached `daysAgo` off by one until the next
|
||
// refresh. Re-deriving on read is a single Date subtraction per
|
||
// type and stays on the hot path's Map-lookup side of the cache.
|
||
const body = enrichLastSeenDaysAgo(cachedBody);
|
||
|
||
const response = apiSuccess(body);
|
||
// v1.4.34 IW-B — bfcache-friendly directive so back-forward navigation
|
||
// restores the dashboard from memory. Per `src/lib/http/cache-headers.ts`:
|
||
// `private` keeps shared caches out of personal data, `max-age=0` forces
|
||
// revalidation on every navigation so session swaps detect on the wire,
|
||
// and `must-revalidate` holds the staleness contract. Replaces the
|
||
// framework's stock `no-store` which Chromium treats as a hard
|
||
// bfcache breaker.
|
||
response.headers.set("Cache-Control", NO_STORE_BUT_BFCACHE);
|
||
return response;
|
||
});
|
||
|
||
type AuthedUser = Awaited<ReturnType<typeof requireAuth>>["user"];
|
||
|
||
/**
|
||
* v1.4.38 — derive `daysAgo` from the cached `lastSeenAt` ISO string
|
||
* per request. The cached envelope deliberately omits `daysAgo` so
|
||
* the dashboard tile staleness caption stays correct across day
|
||
* boundaries even when the body has been sitting in the 60s LRU.
|
||
*
|
||
* Returns a shallow-copied envelope with the enriched
|
||
* `lastSeenByType` map; everything else is passed through unchanged.
|
||
*/
|
||
function enrichLastSeenDaysAgo<
|
||
T extends {
|
||
lastSeenByType: Record<string, { lastSeenAt: string } | null>;
|
||
},
|
||
>(
|
||
body: T,
|
||
): Omit<T, "lastSeenByType"> & {
|
||
lastSeenByType: Record<
|
||
string,
|
||
{ lastSeenAt: string; daysAgo: number } | null
|
||
>;
|
||
} {
|
||
const nowMs = Date.now();
|
||
const enriched: Record<
|
||
string,
|
||
{ lastSeenAt: string; daysAgo: number } | null
|
||
> = {};
|
||
for (const [type, slot] of Object.entries(body.lastSeenByType)) {
|
||
if (slot === null) {
|
||
enriched[type] = null;
|
||
continue;
|
||
}
|
||
const lastMs = new Date(slot.lastSeenAt).getTime();
|
||
const daysAgo = Math.floor((nowMs - lastMs) / (24 * 60 * 60 * 1000));
|
||
enriched[type] = { lastSeenAt: slot.lastSeenAt, daysAgo };
|
||
}
|
||
return { ...body, lastSeenByType: enriched };
|
||
}
|
||
|
||
/**
|
||
* v1.4.34 IW-G — the heavy default-slice body, lifted out of the route
|
||
* handler so `cached()` can wrap it. Returns the raw JSON payload; the
|
||
* route handler attaches `Cache-Control` headers afterward. The split
|
||
* matches the v1.4.33 snapshot LRU's `buildCoachSnapshot` →
|
||
* `buildCoachSnapshotImpl` shape.
|
||
*/
|
||
async function buildAnalyticsResponse(user: AuthedUser) {
|
||
// v1.4.37.1 hotfix — fire-and-forget the rollup-fresh check.
|
||
// Awaiting it on the read path can stall the Node event loop for
|
||
// 30–60 s on a power-user account whose iOS step samples keep the
|
||
// 90-day DAY window slightly stale, which starves /api/health,
|
||
// /api/version, and concurrent iOS calls. The downstream coverage
|
||
// probe falls back to live SQL when a type is uncovered, so
|
||
// correctness is preserved; the worst the user sees is data from
|
||
// up to ~60 s ago on the very first request after a fresh
|
||
// measurement lands, and the next request returns the up-to-date
|
||
// value once the background refresh completes. `ensureUserRollupsFresh`
|
||
// wraps its own try/catch, so the void call cannot reject.
|
||
void ensureUserRollupsFresh(user.id);
|
||
|
||
// v1.4.37 W2 — single per-type coverage probe shared by the
|
||
// bp_in_target / healthScore / correlations branches below. The
|
||
// probe is one indexed query against `measurement_rollups`; the
|
||
// three downstream branches each reuse the resulting `coverage`
|
||
// map to decide between the rollup-fast-path and the live
|
||
// fallback. Probing once instead of per-branch keeps the fan-out
|
||
// cost flat across the three branches.
|
||
const coverage = await probeRollupCoverage(user.id);
|
||
|
||
// 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;
|
||
|
||
// v1.4.34 IW-B — capture the most-recent `measuredAt` per type so the
|
||
// dashboard tile strip can render an "Letzter Wert vor Xd" caption on
|
||
// tiles whose latest reading is older than a week. The series read
|
||
// already orders ascending (`fetchMeasurementSeriesChunked`'s stable
|
||
// `(measuredAt, id)` order); the last entry is the freshest sample.
|
||
//
|
||
// v1.4.38 — the cached body now carries only `lastSeenAt` (ISO);
|
||
// `daysAgo` is derived in the GET wrapper per call. Storing the
|
||
// computed delta inside the cache let the value stale across day
|
||
// boundaries (60s TTL straddling midnight could surface a "vor 3d"
|
||
// caption that should read "vor 4d"). The ISO timestamp does not
|
||
// age, so re-deriving on read costs nothing and pins the caption
|
||
// to the real wall-clock at every request.
|
||
const lastSeenByType: Record<string, { lastSeenAt: string } | null> = {};
|
||
|
||
let totalRowsReadForAggregate = 0;
|
||
const measurementsByType = await Promise.all(
|
||
types.map((type) =>
|
||
fetchMeasurementSeriesChunked(user.id, type, {
|
||
includeSleepStage: true,
|
||
}).then((measurements) => {
|
||
totalRowsReadForAggregate += measurements.length;
|
||
// v1.4.34 IW-B — record the freshest `measuredAt` for this type
|
||
// (or null when the user has never logged this metric). The
|
||
// helper returns rows sorted ascending so `.at(-1)` is the
|
||
// most-recent point without an extra pass.
|
||
const latest = measurements.at(-1);
|
||
if (latest) {
|
||
lastSeenByType[type] = {
|
||
lastSeenAt: latest.measuredAt.toISOString(),
|
||
};
|
||
} else {
|
||
lastSeenByType[type] = null;
|
||
}
|
||
// 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;
|
||
datapoints = pickCumulativeDaySum(sleepRows, (d) =>
|
||
userDayKey(d, userTz),
|
||
);
|
||
} else if (isCumulativeDaySumType(type)) {
|
||
// v1.4.36 W4c — broaden the SLEEP_DURATION day-bucket-sum
|
||
// pattern to every cumulative metric (steps, active energy,
|
||
// walking + running distance, flights climbed, time in
|
||
// daylight). Apple Health writes minute-level slices for
|
||
// these series; the dashboard tile was reading the
|
||
// *latest slice* via `summary.latest` instead of the day's
|
||
// total, which Marc reported as "Steps tile shows
|
||
// last-measurement-not-day-sum". After the per-day sum
|
||
// collapse, `summary.latest` is the most-recent day's
|
||
// total — exactly what the tile expects.
|
||
//
|
||
// Map cumulative MeasurementType → SourcePriorityMetricKey
|
||
// for the canonical-row picker. TIME_IN_DAYLIGHT has no
|
||
// dedicated priority ladder (no clinical-grade competitor
|
||
// to Apple Health for daylight minutes today) so it falls
|
||
// through `pickCanonicalSourceRows`'s "no ladder"
|
||
// pass-through branch — we ALWAYS bucket-and-sum it
|
||
// regardless of source.
|
||
const metricKey = cumulativeMetricKey(type);
|
||
const canonicalRows = metricKey
|
||
? pickCanonicalSourceRows(
|
||
measurements,
|
||
metricKey,
|
||
sourcePriorityJson,
|
||
(d) => userDayKey(d, userTz),
|
||
).canonicalRows
|
||
: measurements;
|
||
datapoints = pickCumulativeDaySum(canonicalRows, (d) =>
|
||
userDayKey(d, userTz),
|
||
);
|
||
} 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;
|
||
/**
|
||
* v1.4.38 — prior-week BP-in-target pct so the Health-Score
|
||
* week-over-week delta reflects BP movement. Pre-v1.4.38 the
|
||
* Health-Score helper pinned both windows to the same value, so the
|
||
* BP pillar always zeroed out of the delta. Computed by running a
|
||
* second `computeBpInTargetFastPath` against `now - 7d` and reading
|
||
* the resulting `last30Days.pct`.
|
||
*/
|
||
let bpInTargetPctPriorWeek: number | null = null;
|
||
const bpTargets = getBpTargets(user.dateOfBirth);
|
||
if (bpTargets) {
|
||
const now = new Date();
|
||
// v1.4.37 W2 — probe-gated dispatcher replaces the inline chunked
|
||
// read. When BOTH BP types have DAY-bucket coverage the helper
|
||
// pairs the per-day MEAN SYS + MEAN DIA from `measurement_rollups`
|
||
// and counts in-target days against the five reporting windows,
|
||
// skipping the heavy 365-day chunked walk against `measurements`.
|
||
// Falls back to the legacy `computeBpInTargetWindows` over a
|
||
// chunked read when coverage is partial so a brand-new account
|
||
// (no buckets yet) still sees per-event numbers. The helper emits
|
||
// a `path: "rollup" | "live"` annotate so prod logs prove which
|
||
// branch fired. See `bp-in-target-fast-path.ts` for the
|
||
// documented per-day-mean approximation.
|
||
//
|
||
// v1.4.38 — two parallel runs: one anchored at `now` for the
|
||
// standard windows, one anchored at `now - 7d` so the Health-Score
|
||
// helper has a real prior-week BP pct to feed into its previous-
|
||
// window snapshot. The two probes share the coverage map and the
|
||
// same rollup / live branch decision; the extra call is one extra
|
||
// pair of `readRollupBuckets` reads on the rollup path and is a
|
||
// no-op when the user has no BP rows at all.
|
||
const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||
const [windows, windowsPriorWeek] = await Promise.all([
|
||
computeBpInTargetFastPath({
|
||
userId: user.id,
|
||
targets: bpTargets,
|
||
now,
|
||
coverage,
|
||
// v1.4.38 W-A — cross-tz runtime guard. The helper falls back
|
||
// to the live SQL path when the user is more than 3 hours
|
||
// from UTC, where the rollup table's UTC-midnight day-key
|
||
// would slip a calendar day relative to the live aggregator's
|
||
// window cuts.
|
||
userTz,
|
||
}),
|
||
computeBpInTargetFastPath({
|
||
userId: user.id,
|
||
targets: bpTargets,
|
||
now: sevenDaysAgo,
|
||
coverage,
|
||
userTz,
|
||
}),
|
||
]);
|
||
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;
|
||
bpInTargetPctPriorWeek = windowsPriorWeek.last30Days?.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.
|
||
// v1.4.37 W2 — probe-gated helper. The 28-day scan window (down
|
||
// from 30 to keep the cold critical path tight while still
|
||
// satisfying the n >= 20 surface gate) reads SYS / PULSE / WEIGHT
|
||
// per-day means from `measurement_rollups` when the user has full
|
||
// coverage; mood and medication-intake reads always stay live (no
|
||
// rollup equivalent). The helper emits `meta.correlations.path` +
|
||
// `meta.correlations.window_days` so prod logs prove the branch
|
||
// selection and the truthful window.
|
||
const correlations = await computeCorrelationHypothesesFastPath({
|
||
userId: user.id,
|
||
userTz,
|
||
now: new Date(),
|
||
coverage,
|
||
});
|
||
|
||
// 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.
|
||
//
|
||
// 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,
|
||
// v1.4.38 — feed the prior-week BP pct into the Health-Score
|
||
// helper's previous-window snapshot so the week-over-week delta
|
||
// reflects BP movement instead of zeroing out of the BP pillar.
|
||
// Computed alongside the current windows via a second
|
||
// `computeBpInTargetFastPath` run anchored at `now - 7d`.
|
||
bpInTargetPctPriorWeek,
|
||
heightCm: user.heightCm ?? null,
|
||
now: new Date(),
|
||
coverage,
|
||
});
|
||
|
||
return {
|
||
summaries: results,
|
||
bmi,
|
||
bpInTargetPct,
|
||
bpInTargetPct7d,
|
||
bpInTargetPct30d,
|
||
bpInTargetPctAllTime,
|
||
bpInTargetPctPriorMonth,
|
||
bpInTargetPctPriorYear,
|
||
glucoseByContext,
|
||
correlations,
|
||
healthScore,
|
||
sleepStages,
|
||
// v1.4.34 IW-B — per-type freshness map drives the dashboard's
|
||
// staleness caption on each `<TrendCard>`. Additive: clients that
|
||
// don't read the field stay unchanged.
|
||
lastSeenByType,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 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,
|
||
};
|
||
}
|
||
|
||
// v1.4.37 W2 — `computeCorrelationHypotheses` body relocated to
|
||
// `src/lib/analytics/correlations-fast-path.ts` so the probe-gated
|
||
// rollup / live dispatcher can be unit-tested independently of the
|
||
// route. The new helper also tightens the scan window to 28 days and
|
||
// emits a sentinel on `meta.correlations.window_days` / `degraded`.
|
||
|
||
/**
|
||
* 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.37 W2 — `WEEKDAY_FORMATTER_CACHE`, `getWeekdayFormatter`,
|
||
// `dateFromDayKey`, `ISO_WEEKDAY`, and `isoWeekdayInTz` relocated
|
||
// alongside the correlation runner in
|
||
// `src/lib/analytics/correlations-fast-path.ts`. They were only ever
|
||
// used by the weight-weekday + day-key helpers inside the old inline
|
||
// correlation builder.
|
||
|
||
|
||
// 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`.
|