Files
HealthLog/src/lib/targets/build-response.ts
T
Marc-André Bombeck 9c51dd0d30 perf(analytics): read the latest value per type with one probe per type
The latest canonical reading per type ran as `DISTINCT ON (type)` over
every live row of the account in five places (summaries slice warm and
cold path, comprehensive aggregator warm and cold path, targets). With no
skip scan in Postgres 16 that sorted the whole account to keep one row
per type. `readLatestCanonical` drives the read from the type list
instead: one index probe for each type's latest instant, then a closed
one-day window ranked by the source ladder. The closed window also keeps
the plan under `jit_above_cost`. The targets read reuses `readLatestEver`
with a floor.

The summaries slice was built inside three cache cells (the tile-strip
route, the thick analytics body, the dashboard snapshot). It now has one
cell, read through `readSummariesSlice`; composing callers read it
fresh-only so a refresh after a write cannot cache the pre-write slice
again, and the snapshot folds the water tile into a copy instead of the
cached object.
2026-10-20 03:35:03 +02:00

512 lines
16 KiB
TypeScript

/**
* `GET /api/insights/targets` response builder.
*
* Owns the bounded repository fan-out and public response composition. Pure
* section builders live beside this module so timezone, classification, and
* ordering rules can be tested without the route or database shell. The route
* remains auth → `cachedSwr(buildTargetsResponse)` → `apiSuccess`.
*
* Every per-day bucket key resolves against the user's display timezone
* (`userDayKey`) so streaks, "last met goal", and the consistency strip
* are read in the user's own calendar.
*/
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import {
COURSES_COMPLIANCE_SELECT,
SCHEDULE_COMPLIANCE_SELECT,
} from "@/lib/analytics/compliance";
import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
import pLimit from "p-limit";
import type { MeasurementType } from "@/generated/prisma/client";
import {
resolveBpTargetOverride,
resolveWeightTargetOverride,
type ThresholdOverridesJson,
} from "@/lib/analytics/effective-range";
import { resolveGlucoseUnit } from "@/lib/glucose";
import { toProfileSex } from "@/lib/profile/sex";
import {
ensureUserMoodRollupsFresh,
readMoodDayRollups,
} from "@/lib/rollups/mood-rollups";
import { readRestingPulseProxy } from "@/lib/analytics/resting-pulse-read";
import { readSevenDayMeans } from "@/lib/analytics/summaries-slice";
import { readLatestEver } from "@/lib/dashboard/summary-reads";
import {
summarizeSleepNights,
type SleepStageRow,
} from "@/lib/analytics/sleep-night";
import { buildGlucoseTargets } from "./glucose-builder";
import { readGlucoseTargetSummaries } from "./glucose-read";
import { buildMedicationTarget } from "./medication-builder";
import { buildMoodTargets } from "./mood-builder";
import { buildSleepTarget } from "./sleep-builder";
import { buildTargetPageSummary } from "./summary-builder";
import { buildVitalTargets } from "./vitals-builder";
import type { TargetItem, TargetMoodEntry, TargetValueByType } from "./types";
import {
resolveTrendVerdict,
trendSentimentOfTrend,
type TrendVerdictContext,
} from "./trend-verdict";
import type { User } from "@/generated/prisma/client";
import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking";
function getAge(dateOfBirth: Date): number {
const today = new Date();
let age = today.getFullYear() - dateOfBirth.getFullYear();
const monthDiff = today.getMonth() - dateOfBirth.getMonth();
if (
monthDiff < 0 ||
(monthDiff === 0 && today.getDate() < dateOfBirth.getDate())
) {
age--;
}
return age;
}
/**
* The authenticated-user shape the builder reads directly. The profile
* fields (height / dob / gender / glucose unit / thresholds) are re-read
* from the DB inside the builder, so the handler only threads id + tz.
*/
export type AuthedUser = Pick<User, "id" | "timezone">;
/**
* `/api/insights/targets` body. Wrapped in `cachedSwr` by the route so the
* cold multi-query walk is paid once per 60 s window; writes hard-evict the
* bucket so user actions reflect on the next read.
*/
export async function buildTargetsResponse(user: AuthedUser) {
const userId = user.id;
const timezone = user.timezone ?? DEFAULT_TIMEZONE;
const queryNow = new Date();
const types: MeasurementType[] = [
"WEIGHT",
"BLOOD_PRESSURE_SYS",
"BLOOD_PRESSURE_DIA",
"PULSE",
"RESTING_HEART_RATE",
"BODY_FAT",
"ACTIVITY_STEPS",
];
// Heart rate is the one vital a device streams. Its thirty days reach the
// resting-pulse card as one proxy point per day, folded in SQL below; every
// other vital is written a few times a day and is read as rows.
const recentTypes = types.filter((type) => type !== "PULSE");
const thirtyDaysAgo = new Date(queryNow.getTime() - 30 * 24 * 60 * 60 * 1000);
const oneYearAgo = new Date(queryNow.getTime() - 365 * 24 * 60 * 60 * 1000);
const moodSince = new Date(
queryNow.getTime() - 5 * 365 * 24 * 60 * 60 * 1000,
);
void ensureUserMoodRollupsFresh(userId);
// All independent reads stay in one bounded fan-out. The medication event
// read is the sole dependent query and remains chained to its medication
// list inside one concurrency slot.
const limit = pLimit(4);
const [
dbUser,
recentMeasurements,
latestEverByType,
sleepStageRows,
medicationBundle,
moodRollups,
latestMoodEntry,
glucoseSummaries,
restingPulseProxy,
sevenDayMeans,
] = await Promise.all([
limit(() =>
prisma.user.findUnique({
where: { id: userId },
select: {
heightCm: true,
dateOfBirth: true,
gender: true,
glucoseUnit: true,
hasDiabetes: true,
thresholdsJson: true,
sourcePriorityJson: true,
},
}),
),
limit(() =>
prisma.measurement.findMany({
where: {
userId,
type: { in: recentTypes },
measuredAt: { gte: thirtyDaysAgo },
deletedAt: null,
},
orderBy: { measuredAt: "desc" },
select: { type: true, value: true, measuredAt: true },
}),
),
limit(() => readLatestEver(prisma, userId, types, oneYearAgo)),
limit(() =>
prisma.measurement.findMany({
where: {
userId,
type: "SLEEP_DURATION",
measuredAt: { gte: thirtyDaysAgo },
deletedAt: null,
},
orderBy: { measuredAt: "asc" },
select: {
value: true,
measuredAt: true,
sleepStage: true,
source: true,
deviceType: true,
},
}),
),
limit(async () => {
const activeMedications = await prisma.medication.findMany({
where: {
userId,
active: true,
asNeeded: false,
...TRACKED_INTAKE_WHERE,
},
include: {
schedules: { select: SCHEDULE_COMPLIANCE_SELECT },
scheduleRevisions: { orderBy: { validFrom: "asc" } },
pauseEras: { select: { pausedAt: true, resumedAt: true } },
// v1.40 (#1024) — the courses, so a gap between two expects nothing.
courses: COURSES_COMPLIANCE_SELECT,
},
orderBy: { name: "asc" },
});
if (activeMedications.length === 0) {
return { activeMedications, intakeEvents: [] };
}
const intakeEvents = await prisma.medicationIntakeEvent.findMany({
where: {
userId,
deletedAt: null,
medicationId: {
in: activeMedications.map((medication) => medication.id),
},
scheduledFor: { gte: thirtyDaysAgo },
},
orderBy: { scheduledFor: "desc" },
select: {
medicationId: true,
takenAt: true,
skipped: true,
scheduledFor: true,
},
});
return { activeMedications, intakeEvents };
}),
limit(() => readMoodDayRollups(userId, moodSince)),
limit(() =>
prisma.moodEntry.findFirst({
where: { userId, deletedAt: null },
orderBy: { moodLoggedAt: "desc" },
select: { score: true, moodLoggedAt: true },
}),
),
// A sensor reporting every five minutes writes 105 000 glucose readings
// a year. The cards need the newest per meal context and the last thirty
// days per local day, so both fold in SQL.
limit(() =>
readGlucoseTargetSummaries({
userId,
since: oneYearAgo,
recentSince: thirtyDaysAgo,
timeZone: timezone,
}),
),
limit(() =>
readRestingPulseProxy({
userId,
since: thirtyDaysAgo,
timeZone: timezone,
}),
),
// The verdict is placed with the value the dashboard tile places it
// with: the seven-day mean, computed by the summary slice's own query,
// so a target and its tile never disagree on which way a change counts.
limit(() => readSevenDayMeans(userId)),
]);
// Rollup coverage fallback stays an explicit repository read here rather
// than hiding persistence inside the pure mood section builder.
let recentRawMood: TargetMoodEntry[] | null = null;
if (moodRollups.length === 0 && latestMoodEntry !== null) {
const thirtyDaysAgoMood = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
recentRawMood = await prisma.moodEntry.findMany({
where: {
userId,
deletedAt: null,
moodLoggedAt: { gte: thirtyDaysAgoMood },
},
orderBy: { moodLoggedAt: "asc" },
select: { score: true, moodLoggedAt: true },
});
}
const now = new Date();
const age = dbUser?.dateOfBirth ? getAge(new Date(dbUser.dateOfBirth)) : null;
const gender = toProfileSex(dbUser?.gender);
const heightCm = dbUser?.heightCm ?? null;
const latestByType: TargetValueByType = {};
const average30ByType: TargetValueByType = {};
for (const type of types) {
latestByType[type] =
latestEverByType.find((row) => row.type === type)?.value ?? null;
}
for (const type of recentTypes) {
const recent = recentMeasurements.filter(
(measurement) => measurement.type === type,
);
average30ByType[type] =
recent.length > 0
? Math.round(
(recent.reduce((sum, measurement) => sum + measurement.value, 0) /
recent.length) *
10,
) / 10
: null;
}
const vitalSection = buildVitalTargets({
recentMeasurements,
restingPulseProxy,
latestByType,
average30ByType,
heightCm,
age,
gender,
timezone,
now,
// v1.34 — the weight card grades against the user's OWN target when they
// set one, so the reference panel and the weight chart above it name the
// same band.
weightTargetOverride: resolveWeightTargetOverride(dbUser?.thresholdsJson),
// The blood-pressure card likewise names the user's own band when they
// set one. The insights page draws its chart zones from this same range,
// so the chart and the card below it cannot disagree.
bpTargetOverride: resolveBpTargetOverride(
{
heightCm,
dateOfBirth: dbUser?.dateOfBirth ?? null,
gender: dbUser?.gender ?? null,
},
dbUser?.thresholdsJson,
),
});
const sleepTarget = buildSleepTarget({
sleepStageRows,
timezone,
sourcePriorityJson: dbUser?.sourcePriorityJson ?? null,
now,
});
const pulseIndex = vitalSection.targets.findIndex(
(target) => target.type === "PULSE",
);
const targets: TargetItem[] = [
...vitalSection.targets.slice(0, pulseIndex + 1),
sleepTarget,
...vitalSection.targets.slice(pulseIndex + 1),
];
const medicationTarget = buildMedicationTarget({
activeMedications: medicationBundle.activeMedications,
intakeEvents: medicationBundle.intakeEvents,
timezone,
now,
});
if (medicationTarget) targets.push(medicationTarget);
targets.push(
...buildMoodTargets({
moodRollups,
recentRawMood,
latestMoodEntry,
timezone,
now,
}),
);
const glucoseUnit = resolveGlucoseUnit(dbUser?.glucoseUnit ?? null);
targets.push(
...buildGlucoseTargets({
summaries: glucoseSummaries,
profile: {
heightCm,
dateOfBirth: dbUser?.dateOfBirth ?? null,
gender: dbUser?.gender ?? null,
glucoseUnit: dbUser?.glucoseUnit ?? null,
hasDiabetes: dbUser?.hasDiabetes ?? false,
thresholdsJson: (dbUser?.thresholdsJson ??
null) as ThresholdOverridesJson | null,
},
timezone,
now,
}),
);
// v1.43 (ios #116 item 3) — every item carries which way its change
// counts, judged with the same rule the dashboard tiles use.
const verdictTargets = withTargetVerdicts(
targets,
{
profile: {
heightCm,
dateOfBirth: dbUser?.dateOfBirth ?? null,
gender: dbUser?.gender ?? null,
},
thresholdsJson: dbUser?.thresholdsJson ?? null,
},
verdictSummaries(sevenDayMeans, latestByType, {
// Sleep the way the dashboard summarises it: per night, from the
// same stage rows and source ladder.
SLEEP_DURATION: summarizeSleepNights(
sleepStageRows as SleepStageRow[],
timezone,
dbUser?.sourcePriorityJson ?? null,
).summary,
}),
);
targets.splice(0, targets.length, ...verdictTargets);
const pageSummary = buildTargetPageSummary(targets);
annotate({
action: { name: "insights.targets" },
meta: {
targetCount: targets.length,
targetsMetThisWeek: pageSummary.targetsMetThisWeek,
streakHighlightMetric: pageSummary.streakHighlight?.metric ?? null,
},
});
return {
targets,
pageSummary,
bpDiastolic: {
current: latestByType.BLOOD_PRESSURE_DIA ?? null,
average30: average30ByType.BLOOD_PRESSURE_DIA ?? null,
range: vitalSection.bpRange
? {
min: vitalSection.bpRange.diaLow,
max: vitalSection.bpRange.diaHigh,
}
: null,
},
profile: {
heightCm,
age,
gender,
glucoseUnit,
},
};
}
/**
* The dashboard summary each target item's verdict is placed with: the key
* its dashboard tile reads its direction under. Blood pressure is judged by
* its systolic side, BMI by the weight it is derived from, and the resting
* pulse by the resting heart rate where the account records one (the tile
* shows that series then).
*/
function summaryKeysFor(type: string): string[] {
switch (type) {
case "BLOOD_PRESSURE":
return ["BLOOD_PRESSURE_SYS"];
case "BMI":
return ["WEIGHT"];
case "PULSE":
return ["RESTING_HEART_RATE", "PULSE"];
default:
return [type];
}
}
type VerdictSummaries = Readonly<
Record<string, { avg7: number | null; latest: number | null } | undefined>
>;
/**
* The dashboard's `{ avg7, latest }` per type: the slice's seven-day mean,
* and the newest reading where there is none in the last seven days.
*/
function verdictSummaries(
sevenDayMeans: Readonly<Record<string, number | null>>,
latestByType: TargetValueByType,
folded: Readonly<
Record<string, { avg7: number | null; latest: number | null }>
>,
): VerdictSummaries {
const out: Record<string, { avg7: number | null; latest: number | null }> =
{};
const types = new Set([
...Object.keys(sevenDayMeans),
...Object.keys(latestByType),
]);
for (const type of types) {
out[type] = {
avg7: sevenDayMeans[type] ?? null,
latest: latestByType[type as keyof TargetValueByType] ?? null,
};
}
return { ...out, ...folded };
}
/**
* The value a target's verdict is placed with, in the stored unit: the same
* basis as the dashboard tile (`resolveSummaryVerdicts`), the seven-day mean
* else the latest reading. Only when the account has no summary for the type
* does the item's own value stand in (latest, else thirty-day average; sleep
* is served in hours and stored in minutes).
*/
export function targetVerdictReference(
item: Pick<TargetItem, "type" | "current" | "average30">,
summaries: VerdictSummaries,
weightItem: Pick<TargetItem, "current" | "average30"> | undefined,
): number | null {
for (const key of summaryKeysFor(item.type)) {
const summary = summaries[key];
const value = summary ? (summary.avg7 ?? summary.latest) : null;
if (value !== null && value !== undefined) return value;
}
const own =
item.type === "BMI"
? weightItem
? (weightItem.current ?? weightItem.average30)
: null
: (item.current ?? item.average30);
if (own === null) return null;
return item.type === "SLEEP_DURATION" ? own * 60 : own;
}
/**
* Lay the verdict over each target item, placed with the dashboard's basis
* (`targetVerdictReference`).
*/
export function withTargetVerdicts(
targets: readonly TargetItem[],
ctx: Omit<TrendVerdictContext, "referenceValue">,
summaries: VerdictSummaries,
): TargetItem[] {
const weight = targets.find((item) => item.type === "WEIGHT");
return targets.map((item) => {
const direction = resolveTrendVerdict(item.type, {
...ctx,
referenceValue: targetVerdictReference(item, summaries, weight),
});
return {
...item,
direction,
trendSentiment: trendSentimentOfTrend(item.trend, direction),
};
});
}