Files
HealthLog/src/app/api/insights/cards/route.ts
T
Marc-André Bombeck 7c5ff89def feat(lint): refuse UTC day keys outside the time zone module
`instant.toISOString().slice(0, 10)` and parsing a key back as UTC
midnight are the idioms behind every day-boundary fix this year: the
evening west of UTC and the small hours east of it land on the
neighbouring day. A new rule, healthlog/no-utc-day-key, refuses them in
src/ outside src/lib/tz and tests, and refuses a disable directive for
itself that is not next-line or gives no reason.

The time zone module now names every legitimate form: userDayKey for a
person's day, dateOnlyKey / dateOnlyAtNoonUtc / dayKeyAsUtcMidnight for
date-only values and day labels, daysBetweenDateKeys / weekdayOfDateKey /
isCalendarDateKey / statedDateKey for key arithmetic and parsing.

Of the 133 sites the rule found, 84 are gone. Most moved onto a named
helper with no change in behaviour; these changed behaviour:

- cycle calendar, insights, Coach snapshot and prediction refresh start
  their lookbacks at the first instant of the user's day
- a model-transcribed date like "June 10, 2026" no longer lands a day
  early in a process east of UTC (documents and lab scans)
- a custom report range covers exactly the picked days in the profile
  zone instead of starting the evening before west of UTC
- date-of-birth pickers cap at the user's today, not the UTC one
- an allergy onset date is stored at noon UTC like other date-only values
- the report's cycle summary, the Coach's usual BP range and the intake
  projector's rollup recompute use the user's day
- vaccination dates render as dates instead of the previous evening

49 remain, each with its reason on the line above: 39 UTC by design (15
file name stamps, rollup and object keys, the UTC-day score engines) and
10 marked baseline. A guard test requires a classified reason on every
directive and holds the baseline to a ceiling that only goes down.
2026-10-01 09:59:57 +02:00

283 lines
9.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* GET /api/insights/cards — iOS adapter over `/api/insights/comprehensive`.
*
* Reuses the same data sources (measurements, mood, intake events) and the
* shared `generateAlerts()` rule engine, then re-shapes each `HealthAlert`
* to the iOS Insight model (id, title, summary, severity, recommendations,
* provider).
*
* Every card is a rule alert: deterministic text from the threshold engine,
* never model output. So the route carries no AI gate and no `insights`
* module gate (the AI analysis opt-out), `summary` is never null, and
* `provider` is `"rules"`. It used to echo the account's chosen AI provider
* (or `"claude"`), which claimed a model wrote text that none did.
*/
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
import { summarize, type DataPoint } from "@/lib/analytics/trends";
import { probeRollupCoverage } from "@/lib/rollups/measurement-coverage";
import { readDayMeanSeries } from "@/lib/insights/derived/baseline";
import { getBpTargets } from "@/lib/analytics/bp-targets";
import { isBpReadingInTarget } from "@/lib/analytics/bp-in-target";
import {
generateAlerts,
type HealthAlert,
} from "@/lib/analytics/classifications";
import { pairByTimestamp } from "@/lib/analytics/correlations";
import {
buildComplianceMedicationContext,
calculateCompliance,
lastNonSkippedTakenAt,
SCHEDULE_COMPLIANCE_SELECT,
} from "@/lib/analytics/compliance";
import type { MeasurementType } from "@/generated/prisma/client";
import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { dayKeyAsUtcMidnight } from "@/lib/tz/date-only";
type Severity = "alert" | "caution" | "info" | "good";
function toSeverity(level: HealthAlert["level"]): Severity {
switch (level) {
case "danger":
return "alert";
case "warning":
return "caution";
case "success":
return "good";
default:
return "info";
}
}
interface InsightRecommendation {
id: string;
label: string;
actionURL: string | null;
}
interface InsightCard {
id: string;
title: string;
summary: string;
body: string | null;
severity: Severity;
recommendations: InsightRecommendation[];
generatedAt: string;
provider: string;
}
export const dynamic = "force-dynamic";
export const GET = apiHandler(async () => {
// v1.37.0 — MANAGE-level read: computed over the whole record, with no
// provider anywhere on the path.
const { user } = await requireRecordAuth("manage", "record");
annotate({ action: { name: "insights.cards" } });
const NINETY_DAY_WINDOW = 90;
const ninetyDaysAgo = new Date(Date.now() - NINETY_DAY_WINDOW * 86_400_000);
const [dbUser, manualMeasurements, medications, rollupCoverage] =
await Promise.all([
prisma.user.findUnique({
where: { id: user.id },
select: { heightCm: true, dateOfBirth: true },
}),
// WEIGHT + BP are manual entries (low volume, and BP needs exact-
// timestamp pairing below for `bpPctInTarget`), so a bounded raw read
// stays cheap. PULSE is excluded here — a continuously-synced wearable
// account can carry 100k+ raw rows in the window; it reads through the
// rollup tier below instead.
prisma.measurement.findMany({
where: {
userId: user.id,
type: { in: ["WEIGHT", "BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"] },
measuredAt: { gte: ninetyDaysAgo },
deletedAt: null,
},
orderBy: { measuredAt: "asc" },
select: { type: true, value: true, measuredAt: true },
}),
prisma.medication.findMany({
// v1.16.11 — as-needed (PRN) medications never surface a compliance
// rate (no expected doses).
where: {
userId: user.id,
active: true,
asNeeded: false,
...TRACKED_INTAKE_WHERE,
},
include: {
// Schedules through the shared compliance select so the configured
// per-dose windows reach the engine like every other surface.
schedules: { select: SCHEDULE_COMPLIANCE_SELECT },
// v1.16.3 — archived schedule eras for era-aware expected counts.
scheduleRevisions: { orderBy: { validFrom: "asc" } },
// v1.25 H-MED1 — pause eras so paused days drop out of the denominator.
pauseEras: { select: { pausedAt: true, resumedAt: true } },
},
}),
probeRollupCoverage(user.id),
]);
// v1.28 perf — PULSE reads the DAY-rollup tier (read-swap; falls back to a
// bounded per-day-grouped live read on a coverage miss) instead of pulling
// every raw sample into JS. `pulseAvg30` / `pulseAnomalyCount` derive from
// the resulting day-mean series, so a dense wearable account's anomaly
// count reflects daily outliers rather than every noisy raw sample.
const { points: pulseDayMeans } = await readDayMeanSeries(
user.id,
"PULSE",
NINETY_DAY_WINDOW,
new Date(),
rollupCoverage,
);
const pulseData: DataPoint[] = pulseDayMeans.map((p) => ({
date: dayKeyAsUtcMidnight(p.day),
value: p.mean,
}));
const byType = (t: MeasurementType): DataPoint[] =>
manualMeasurements
.filter((m) => m.type === t)
.map((m) => ({ date: m.measuredAt, value: m.value }));
const summaries: Partial<
Record<MeasurementType, ReturnType<typeof summarize>>
> = {};
for (const t of [
"WEIGHT",
"BLOOD_PRESSURE_SYS",
"BLOOD_PRESSURE_DIA",
] as MeasurementType[]) {
const data = byType(t);
if (data.length > 0) summaries[t] = summarize(data);
}
if (pulseData.length > 0) summaries.PULSE = summarize(pulseData);
let bmi: number | null = null;
if (dbUser?.heightCm && summaries.WEIGHT?.latest) {
const heightM = dbUser.heightCm / 100;
bmi = Math.round((summaries.WEIGHT.latest / (heightM * heightM)) * 10) / 10;
}
let bpPctInTarget: number | null = null;
const bpTargets = getBpTargets(dbUser?.dateOfBirth ?? null);
if (bpTargets) {
const sysData = byType("BLOOD_PRESSURE_SYS");
const diaData = byType("BLOOD_PRESSURE_DIA");
const pairs = pairByTimestamp(sysData, diaData, 5 * 60_000);
if (pairs.length > 0) {
// v1.4.16 A2 — one-sided ceiling semantics with hypotension
// floor. See lib/analytics/bp-in-target.ts.
const inTarget = pairs.filter((p) =>
isBpReadingInTarget(p.a, p.b, bpTargets),
).length;
bpPctInTarget = Math.round((inTarget / pairs.length) * 100);
}
}
// Medication compliance for alert input — routed through the canonical
// cadence-aware engine (`calculateCompliance`), the same single source of
// truth the comprehensive insights, dashboard pillar, Coach snapshot and
// doctor report use. The naive `schedules.length × days` denominator was a
// #214 regression that falsely flagged weekly injectables and paused meds
// as low-adherence on this user-facing alert path.
const medicationCompliance: Array<{
name: string;
compliance7: number;
compliance30: number;
}> = [];
// Single batched query — bucket by medicationId in JS (fix N+1).
const activeMeds = medications.filter((m) => m.schedules.length > 0);
if (activeMeds.length > 0) {
const allEvents = await prisma.medicationIntakeEvent.findMany({
where: {
userId: user.id,
// v1.7.0 sync — exclude tombstoned rows.
deletedAt: null,
medicationId: { in: activeMeds.map((m) => m.id) },
// 90-day window: the rolling-cadence gap-walk re-anchors on prior
// intakes outside the 30-day rate window.
scheduledFor: { gte: ninetyDaysAgo },
},
orderBy: { scheduledFor: "desc" },
select: {
medicationId: true,
takenAt: true,
skipped: true,
scheduledFor: true,
},
});
const eventsByMed = new Map<string, typeof allEvents>();
for (const e of allEvents) {
const arr = eventsByMed.get(e.medicationId) ?? [];
arr.push(e);
eventsByMed.set(e.medicationId, arr);
}
const tz = user.timezone || DEFAULT_TIMEZONE;
for (const med of activeMeds) {
const events = eventsByMed.get(med.id) ?? [];
const mapped = events.map((e) => ({
takenAt: e.takenAt,
skipped: e.skipped,
scheduledFor: e.scheduledFor,
}));
// v1.7.0 SB-SCHED-2 — engine-routed denominator.
const medicationContext = buildComplianceMedicationContext(
med,
lastNonSkippedTakenAt(mapped),
tz,
);
const c7 = calculateCompliance(mapped, med.schedules, 7, med.createdAt, {
medicationContext,
});
const c30 = calculateCompliance(
mapped,
med.schedules,
30,
med.createdAt,
{
medicationContext,
},
);
medicationCompliance.push({
name: med.name,
compliance7: c7.rate,
compliance30: c30.rate,
});
}
}
const alerts = generateAlerts({
bmi,
bpAvgSys: summaries.BLOOD_PRESSURE_SYS?.avg30 ?? null,
bpAvgDia: summaries.BLOOD_PRESSURE_DIA?.avg30 ?? null,
bpPctInTarget,
weightSlope30: summaries.WEIGHT?.slope30?.slope ?? null,
pulseAvg30: summaries.PULSE?.avg30 ?? null,
pulseAnomalyCount: summaries.PULSE?.anomalyCount ?? 0,
medications: medicationCompliance,
});
// The rule engine wrote these, not a model.
const provider = "rules";
const generatedAt = new Date().toISOString();
const cards: InsightCard[] = alerts.map((alert, idx) => ({
id: `alert-${idx + 1}`,
title: alert.title,
summary: alert.message,
body: null,
severity: toSeverity(alert.level),
recommendations: [],
generatedAt,
provider,
}));
return apiSuccess(cards);
});