mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
Bound the comprehensive briefing's feature read to a 400-day window instead of walking the full measurement history on every generation. All-time min/max/avg are sourced from one grouped aggregation over the whole history, so the allTime* fields stay honest while the unbounded row materialisation is gone; the oversize downgrade ladder remains as the safety net. Fold illness-episode and cycle state into the briefing prompt, reusing the server-authoritative builders the Coach already assembles. Both are module-gated and cost nothing for users without them, so a mid-illness or mid-cycle user finally gets context-aware prose.
1452 lines
48 KiB
TypeScript
1452 lines
48 KiB
TypeScript
/**
|
||
* Feature extraction for OpenAI insights.
|
||
* Extracts aggregated health metrics from the database.
|
||
* No raw timestamps or exact values are sent in aggregated mode.
|
||
*/
|
||
import { prisma } from "@/lib/db";
|
||
import { summarize, trendSlope } from "@/lib/analytics/trends";
|
||
import type { DataPoint } from "@/lib/analytics/trends";
|
||
import {
|
||
buildComplianceMedicationContext,
|
||
calculateCompliance,
|
||
lastNonSkippedTakenAt,
|
||
SCHEDULE_COMPLIANCE_SELECT,
|
||
} from "@/lib/analytics/compliance";
|
||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||
import {
|
||
reconstructSleepNights,
|
||
type SleepStageRow,
|
||
} from "@/lib/analytics/sleep-night";
|
||
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
|
||
import { getBpTargets } from "@/lib/analytics/bp-targets";
|
||
import { isBpReadingInTarget } from "@/lib/analytics/bp-in-target";
|
||
import {
|
||
pairByTimestamp,
|
||
pearsonCorrelation,
|
||
type CorrelationResult,
|
||
} from "@/lib/analytics/correlations";
|
||
import { getMedicationCategories } from "@/lib/medication-category";
|
||
import { readRollupBuckets } from "@/lib/rollups/measurement-rollups";
|
||
import {
|
||
ensureUserMoodRollupsFresh,
|
||
readMoodDayRollups,
|
||
} from "@/lib/rollups/mood-rollups";
|
||
import type {
|
||
MeasurementType,
|
||
RollupGranularity,
|
||
} from "@/generated/prisma/client";
|
||
|
||
interface DataCoverage {
|
||
count: number;
|
||
spanDays: number;
|
||
avgDaysBetween: number | null;
|
||
oldestDaysAgo: number;
|
||
newestDaysAgo: number;
|
||
}
|
||
|
||
export interface AggregatedFeatures {
|
||
weight?: {
|
||
latest: number;
|
||
avg7: number | null;
|
||
avg30: number | null;
|
||
avg90: number | null;
|
||
allTimeAvg: number | null;
|
||
allTimeMin: number | null;
|
||
allTimeMax: number | null;
|
||
slope30: number | null;
|
||
outlierCount: number;
|
||
bmi: number | null;
|
||
coverage: DataCoverage;
|
||
};
|
||
bloodPressure?: {
|
||
avgSys30: number | null;
|
||
avgDia30: number | null;
|
||
avgSys90: number | null;
|
||
avgDia90: number | null;
|
||
allTimeAvgSys: number | null;
|
||
allTimeAvgDia: number | null;
|
||
allTimeMinSys: number | null;
|
||
allTimeMaxSys: number | null;
|
||
allTimeMinDia: number | null;
|
||
allTimeMaxDia: number | null;
|
||
slopeSys30: number | null;
|
||
slopeDia30: number | null;
|
||
sdSys30: number | null;
|
||
sdDia30: number | null;
|
||
pulsePressure30: number | null;
|
||
pctInTarget: number | null;
|
||
coverage: DataCoverage;
|
||
};
|
||
pulse?: {
|
||
avg7: number | null;
|
||
avg30: number | null;
|
||
avg90: number | null;
|
||
allTimeAvg: number | null;
|
||
allTimeMin: number | null;
|
||
allTimeMax: number | null;
|
||
slope30: number | null;
|
||
anomalyCount: number;
|
||
coverage: DataCoverage;
|
||
};
|
||
bodyFat?: {
|
||
latest: number | null;
|
||
avg30: number | null;
|
||
slope30: number | null;
|
||
coverage: DataCoverage;
|
||
};
|
||
mood?: {
|
||
scale: string;
|
||
avg7: number | null;
|
||
avg30: number | null;
|
||
latest: number | null;
|
||
trend30: "improving" | "declining" | "stable" | null;
|
||
totalEntries: number;
|
||
coverage: DataCoverage;
|
||
};
|
||
sleep?: {
|
||
avg7: number | null;
|
||
avg30: number | null;
|
||
latest: number | null;
|
||
coverage: DataCoverage;
|
||
};
|
||
activity?: {
|
||
avg7: number | null;
|
||
avg30: number | null;
|
||
latest: number | null;
|
||
coverage: DataCoverage;
|
||
};
|
||
ratePressureProduct?: {
|
||
rpp7: number | null;
|
||
rpp30: number | null;
|
||
risk: "normal" | "elevated" | null;
|
||
};
|
||
bodyCompositionDivergence?: {
|
||
weightStable: boolean;
|
||
bodyFatRising: boolean;
|
||
flag: boolean;
|
||
};
|
||
moodAdherenceRisk?: boolean;
|
||
seasonalVariation?: {
|
||
winterAvgSys: number | null;
|
||
summerAvgSys: number | null;
|
||
delta: number | null;
|
||
significance: "normal" | "elevated" | null;
|
||
};
|
||
correlations?: {
|
||
weightVsSystolic: CorrelationResult | null;
|
||
weightVsDiastolic: CorrelationResult | null;
|
||
pulseVsSystolic: CorrelationResult | null;
|
||
moodVsPulse: CorrelationResult | null;
|
||
moodVsSystolic: CorrelationResult | null;
|
||
moodVsWeight: CorrelationResult | null;
|
||
sleepVsPulse: CorrelationResult | null;
|
||
sleepVsSystolic: CorrelationResult | null;
|
||
};
|
||
historicalComparison?: {
|
||
weight?: {
|
||
current7dAvg: number | null;
|
||
previous30dAvg: number | null;
|
||
change: number | null;
|
||
};
|
||
systolic?: {
|
||
current7dAvg: number | null;
|
||
previous30dAvg: number | null;
|
||
change: number | null;
|
||
};
|
||
diastolic?: {
|
||
current7dAvg: number | null;
|
||
previous30dAvg: number | null;
|
||
change: number | null;
|
||
};
|
||
pulse?: {
|
||
current7dAvg: number | null;
|
||
previous30dAvg: number | null;
|
||
change: number | null;
|
||
};
|
||
};
|
||
medications?: Array<{
|
||
name: string;
|
||
dose: string;
|
||
category: string;
|
||
compliance7: number;
|
||
compliance30: number;
|
||
compliance90: number;
|
||
streak: number;
|
||
missedLast7: number;
|
||
}>;
|
||
context: {
|
||
heightCm: number | null;
|
||
hasBpTargets: boolean;
|
||
totalMeasurements: number;
|
||
dataSpanDays: number;
|
||
oldestMeasurementDaysAgo: number | null;
|
||
newestMeasurementDaysAgo: number | null;
|
||
ageYears: number | null;
|
||
gender: string | null;
|
||
};
|
||
/**
|
||
* v1.18.7 — "Signals of the day": the present-focused read the daily
|
||
* briefing leads with. Each entry compares the freshest reading against
|
||
* the user's own trailing-7d / 30d windows, flags an emerging slope, and
|
||
* surfaces a recent anomaly — ranked by clinical priority, ≤3 entries.
|
||
*
|
||
* Recomputed every day: the freshest reading, the 7/30d windows, and the
|
||
* recency move daily, so a flat 30/90d mean no longer pins the briefing.
|
||
* The block lands inside the compacted features payload, so it also seeds
|
||
* the content-hash gate (`hashInsightSnapshot`) — a fresh daily signal
|
||
* forces the briefing to regenerate. See `computeSignalsOfDay`.
|
||
*/
|
||
signalsOfDay?: SignalOfDay[];
|
||
}
|
||
|
||
/**
|
||
* v1.18.7 — one present-focused signal feeding the daily briefing. Every
|
||
* numeric field is pre-computed so the model states it rather than
|
||
* re-deriving the comparison (which small LLMs do unreliably).
|
||
*/
|
||
export interface SignalOfDay {
|
||
/** Briefing `sourceMetric` discriminator the UI pins an icon + route on. */
|
||
metric:
|
||
| "bp"
|
||
| "weight"
|
||
| "pulse"
|
||
| "mood"
|
||
| "sleep"
|
||
| "resting_hr"
|
||
| "glucose";
|
||
/** Natural-language label (no enum leak into prose). */
|
||
label: string;
|
||
/** Unit string when the metric carries one. */
|
||
unit?: string;
|
||
/** Freshest reading (the "now" value the briefing leads with). */
|
||
latest: number;
|
||
/** Days since the freshest reading. */
|
||
latestDaysAgo: number;
|
||
/** Trailing-7d mean. */
|
||
avg7: number | null;
|
||
/** Trailing-30d mean. */
|
||
avg30: number | null;
|
||
/** Signed `latest − avg7`, pre-computed. */
|
||
deltaVs7: number | null;
|
||
/** Signed `latest − avg30`, pre-computed. */
|
||
deltaVs30: number | null;
|
||
/** Normal-swing SD over the trailing-30d window. */
|
||
spread30: number | null;
|
||
/** `|latest − avg30| > spread30` — the significance verdict as a boolean. */
|
||
outsideNormalSwing: boolean;
|
||
/** Emerging direction over the trailing 30 days (slope sign). */
|
||
emergingTrend: "rising" | "falling" | "flat" | null;
|
||
/** A peak / trough inside the last 14 days, when one stands out. */
|
||
recentAnomaly: {
|
||
kind: "peak" | "trough";
|
||
value: number;
|
||
anomalyDaysAgo: number;
|
||
} | null;
|
||
}
|
||
|
||
/**
|
||
* One bucketed series. `granularity` decides the bucket width (DAY for
|
||
* the trailing 90-day window, WEEK for 90→365 days, MONTH for the
|
||
* 365→1825-day deep history). `bucketStart` is an ISO-8601 UTC
|
||
* timestamp anchored to the start of the bucket — Postgres
|
||
* `date_trunc()` semantics, same as the rollup populator.
|
||
*
|
||
* Replaces v1.4.35.x `rawMeasurements` (which appended every
|
||
* measurement row and blew the 10 MB Codex prompt ceiling on power
|
||
* users with multi-year imports — 25.9 MB observed on the maintainer's account
|
||
* with 311 775 rows). Reading from `measurement_rollups` instead caps
|
||
* the payload at O(types × buckets) regardless of underlying row
|
||
* count.
|
||
*/
|
||
export interface BucketedSeries {
|
||
type: string;
|
||
granularity: RollupGranularity;
|
||
buckets: Array<{
|
||
bucketStart: string;
|
||
mean: number;
|
||
count: number;
|
||
}>;
|
||
}
|
||
|
||
export interface RawFeatures extends AggregatedFeatures {
|
||
bucketedMeasurements: BucketedSeries[];
|
||
}
|
||
|
||
/**
|
||
* v1.4.36 W3 T1 — payload size ceiling. The Codex provider rejects
|
||
* any prompt-string above 10 MB; the features payload is roughly half
|
||
* the eventual prompt (the JSON dump plus the system + comparison
|
||
* blocks), so a 5 MB feature payload sits comfortably below the
|
||
* ceiling even after locale-prefix expansion. Throwing here lets the
|
||
* route handler downgrade to the non-raw payload + annotate so we
|
||
* catch the regression instead of blowing the upstream call.
|
||
*/
|
||
export const FEATURES_MAX_BYTES = 5 * 1024 * 1024;
|
||
|
||
/**
|
||
* v1.18.11 P1 — default bounded read window for the comprehensive briefing.
|
||
*
|
||
* The briefing's prose only ever cites 7 / 30 / 90-day windows plus all-time
|
||
* extremes; the unbounded `measurement.findMany` it used to run materialised
|
||
* the entire history (tens to hundreds of thousands of rows for multi-year
|
||
* Withings / Apple imports) into JS on every generation, only to trim the
|
||
* payload after the fact through the 5 MB downgrade ladder. 400 days captures
|
||
* full history for the overwhelming majority of accounts while keeping the
|
||
* seasonal-BP contrast (winter vs summer, needs >180 d) intact; the genuinely
|
||
* long-horizon all-time min/max/avg figures are sourced separately from a
|
||
* single grouped aggregation over the whole history (see
|
||
* `readAllTimeExtremes`), so bounding the bulk read does not silently relabel a
|
||
* 400-day extreme as "all-time". Mirrors the Coach's 90-day bound
|
||
* (`coach/snapshot.ts`), just wider because the briefing narrates longer trends.
|
||
*/
|
||
export const BRIEFING_FEATURE_WINDOW_DAYS = 400;
|
||
|
||
/** All-time aggregate (full history) for one measurement type. */
|
||
interface AllTimeExtremes {
|
||
mean: number | null;
|
||
min: number | null;
|
||
max: number | null;
|
||
}
|
||
|
||
/**
|
||
* v1.18.11 P1 — full-history min / max / mean per measurement type via ONE
|
||
* grouped SQL aggregation, with NO row materialisation in JS. Used to fill the
|
||
* `allTime*` feature fields honestly when the bulk feature read is bounded to a
|
||
* recent window: the windowed `summarize()` covers trends + recent windows, and
|
||
* this covers the long-horizon extremes the prompt labels "allTime".
|
||
*
|
||
* Only the four types that expose `allTime*` fields are aggregated (weight,
|
||
* systolic, diastolic, pulse). Returns a map keyed by `MeasurementType`; a type
|
||
* with no rows is simply absent.
|
||
*/
|
||
async function readAllTimeExtremes(
|
||
userId: string,
|
||
types: readonly MeasurementType[],
|
||
): Promise<Map<MeasurementType, AllTimeExtremes>> {
|
||
const rows = await prisma.measurement.groupBy({
|
||
by: ["type"],
|
||
where: { userId, deletedAt: null, type: { in: [...types] } },
|
||
_avg: { value: true },
|
||
_min: { value: true },
|
||
_max: { value: true },
|
||
});
|
||
const out = new Map<MeasurementType, AllTimeExtremes>();
|
||
for (const r of rows) {
|
||
out.set(r.type, {
|
||
mean: r._avg.value ?? null,
|
||
min: r._min.value ?? null,
|
||
max: r._max.value ?? null,
|
||
});
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/** Round an all-time mean to 1 decimal, matching the windowed `summarize` mean. */
|
||
function roundMean(v: number | null): number | null {
|
||
return v === null ? null : Math.round(v * 10) / 10;
|
||
}
|
||
|
||
export class FeaturesPayloadTooLargeError extends Error {
|
||
readonly code = "FEATURES_PAYLOAD_TOO_LARGE";
|
||
readonly sizeBytes: number;
|
||
readonly limitBytes: number;
|
||
|
||
constructor(sizeBytes: number, limitBytes: number) {
|
||
super(
|
||
`Features payload too large: ${sizeBytes} bytes exceeds ${limitBytes} byte cap`,
|
||
);
|
||
this.name = "FeaturesPayloadTooLargeError";
|
||
this.sizeBytes = sizeBytes;
|
||
this.limitBytes = limitBytes;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* v1.4.36 W3 T1 — bucket-window definitions for the
|
||
* `bucketedMeasurements` payload. Mirrors the rollup populator's
|
||
* granularity ladder so the read-side picks up whatever the persistent
|
||
* table holds without a recompute round-trip.
|
||
*
|
||
* The 90 / 365 / 1825-day windows are non-overlapping: each row of
|
||
* `measurement_rollups` lives at exactly one granularity, and the
|
||
* downstream model reads the union of the three series per type.
|
||
* Total volume per type for a heavy power user (5 years of daily
|
||
* data): 90 DAY + 39 WEEK + 50 MONTH = 179 buckets × ~40 bytes JSON
|
||
* each ≈ 7 KB. Eight metric types lands at ~56 KB — well under the
|
||
* 5 MB cap above, vs 25.9 MB for the v1.4.35 rawMeasurements shape.
|
||
*/
|
||
const BUCKET_WINDOWS: Array<{
|
||
granularity: RollupGranularity;
|
||
fromDays: number;
|
||
toDays: number;
|
||
}> = [
|
||
{ granularity: "DAY", fromDays: 0, toDays: 90 },
|
||
{ granularity: "WEEK", fromDays: 90, toDays: 365 },
|
||
{ granularity: "MONTH", fromDays: 365, toDays: 1825 },
|
||
];
|
||
|
||
/**
|
||
* Types the bucketed payload covers. Mirrors the aggregate branches
|
||
* above so the model never sees a bucket for a metric whose aggregate
|
||
* block was suppressed. New `MeasurementType` enum values flow in by
|
||
* adding one row; the rollup populator already covers every type.
|
||
*/
|
||
const BUCKETED_TYPES: MeasurementType[] = [
|
||
"WEIGHT",
|
||
"BLOOD_PRESSURE_SYS",
|
||
"BLOOD_PRESSURE_DIA",
|
||
"PULSE",
|
||
"BODY_FAT",
|
||
"SLEEP_DURATION",
|
||
"ACTIVITY_STEPS",
|
||
];
|
||
|
||
function stdDev(values: number[]): number | null {
|
||
if (values.length < 2) return null;
|
||
const mean = values.reduce((s, v) => s + v, 0) / values.length;
|
||
const variance =
|
||
values.reduce((s, v) => s + (v - mean) ** 2, 0) / (values.length - 1);
|
||
return Math.round(Math.sqrt(variance) * 10) / 10;
|
||
}
|
||
|
||
function toDataPoints(
|
||
records: Array<{ value: number; measuredAt: Date }>,
|
||
): DataPoint[] {
|
||
return records.map((r) => ({ date: r.measuredAt, value: r.value }));
|
||
}
|
||
|
||
function computeCoverage(
|
||
records: Array<{ measuredAt: Date }>,
|
||
now: number,
|
||
): DataCoverage {
|
||
if (records.length === 0) {
|
||
return {
|
||
count: 0,
|
||
spanDays: 0,
|
||
avgDaysBetween: null,
|
||
oldestDaysAgo: 0,
|
||
newestDaysAgo: 0,
|
||
};
|
||
}
|
||
const oldest = records[0].measuredAt.getTime();
|
||
const newest = records[records.length - 1].measuredAt.getTime();
|
||
const spanDays = Math.round((newest - oldest) / (24 * 60 * 60 * 1000));
|
||
const avgDaysBetween =
|
||
records.length > 1
|
||
? Math.round((spanDays / (records.length - 1)) * 10) / 10
|
||
: null;
|
||
return {
|
||
count: records.length,
|
||
spanDays,
|
||
avgDaysBetween,
|
||
oldestDaysAgo: Math.round((now - oldest) / (24 * 60 * 60 * 1000)),
|
||
newestDaysAgo: Math.round((now - newest) / (24 * 60 * 60 * 1000)),
|
||
};
|
||
}
|
||
|
||
/** Compute average of values within a time window (days ago from now). */
|
||
function avgInWindow(
|
||
records: Array<{ value: number; measuredAt: Date }>,
|
||
now: number,
|
||
fromDaysAgo: number,
|
||
toDaysAgo: number = 0,
|
||
): number | null {
|
||
const fromMs = now - fromDaysAgo * 24 * 60 * 60 * 1000;
|
||
const toMs = now - toDaysAgo * 24 * 60 * 60 * 1000;
|
||
const filtered = records.filter((r) => {
|
||
const t = r.measuredAt.getTime();
|
||
return t >= fromMs && t <= toMs;
|
||
});
|
||
if (filtered.length === 0) return null;
|
||
const sum = filtered.reduce((s, r) => s + r.value, 0);
|
||
return Math.round((sum / filtered.length) * 100) / 100;
|
||
}
|
||
|
||
/** Compute historical comparison: current 7d avg vs previous 30d avg (days 7-37). */
|
||
function computeHistoricalComparison(
|
||
records: Array<{ value: number; measuredAt: Date }>,
|
||
now: number,
|
||
): {
|
||
current7dAvg: number | null;
|
||
previous30dAvg: number | null;
|
||
change: number | null;
|
||
} {
|
||
const current7dAvg = avgInWindow(records, now, 7, 0);
|
||
const previous30dAvg = avgInWindow(records, now, 37, 7);
|
||
const change =
|
||
current7dAvg !== null && previous30dAvg !== null
|
||
? Math.round((current7dAvg - previous30dAvg) * 100) / 100
|
||
: null;
|
||
return { current7dAvg, previous30dAvg, change };
|
||
}
|
||
|
||
/**
|
||
* v1.18.7 — clinical priority order for the signals block. BP / glucose
|
||
* lead, then resting HR / pulse, then weight, then mood.
|
||
* Lower index = higher priority; the briefing surfaces the top ≤3.
|
||
*/
|
||
const SIGNAL_PRIORITY: SignalOfDay["metric"][] = [
|
||
"bp",
|
||
"glucose",
|
||
"resting_hr",
|
||
"pulse",
|
||
"weight",
|
||
"mood",
|
||
];
|
||
|
||
/** Round helper local to the signals builder. */
|
||
function r2(v: number): number {
|
||
return Math.round(v * 100) / 100;
|
||
}
|
||
|
||
/**
|
||
* Build one signal from a single metric's measurement records. Returns null
|
||
* when there is no fresh reading or fewer than three points in 30 days — a
|
||
* sparse metric cannot carry an honest "today vs your normal" read.
|
||
*/
|
||
function buildSignal(
|
||
metric: SignalOfDay["metric"],
|
||
label: string,
|
||
records: Array<{ value: number; measuredAt: Date }>,
|
||
now: number,
|
||
unit?: string,
|
||
): SignalOfDay | null {
|
||
if (records.length === 0) return null;
|
||
const newest = records[records.length - 1];
|
||
const latestDaysAgo = Math.round(
|
||
(now - newest.measuredAt.getTime()) / (24 * 60 * 60 * 1000),
|
||
);
|
||
// A reading older than two weeks is not a "signal of the day".
|
||
if (latestDaysAgo > 14) return null;
|
||
|
||
const win30 = records.filter(
|
||
(rec) => rec.measuredAt.getTime() >= now - 30 * 24 * 60 * 60 * 1000,
|
||
);
|
||
if (win30.length < 3) return null;
|
||
|
||
const avg7 = avgInWindow(records, now, 7);
|
||
const avg30 = avgInWindow(records, now, 30);
|
||
const spread30 = stdDev(win30.map((rec) => rec.value));
|
||
const deltaVs7 = avg7 !== null ? r2(newest.value - avg7) : null;
|
||
const deltaVs30 = avg30 !== null ? r2(newest.value - avg30) : null;
|
||
const outsideNormalSwing =
|
||
avg30 !== null && spread30 !== null && spread30 > 0
|
||
? Math.abs(newest.value - avg30) > spread30
|
||
: false;
|
||
|
||
const slope = trendSlope(toDataPoints(records), 30, now);
|
||
const emergingTrend: SignalOfDay["emergingTrend"] = slope
|
||
? slope.direction === "up"
|
||
? "rising"
|
||
: slope.direction === "down"
|
||
? "falling"
|
||
: "flat"
|
||
: null;
|
||
|
||
// Recent anomaly: an extreme inside the last 14 days vs the 30d mean ± 2 SD.
|
||
let recentAnomaly: SignalOfDay["recentAnomaly"] = null;
|
||
// Track the RAW extreme magnitude — comparing against the already-r2()
|
||
// rounded stored value can drop a genuinely larger anomaly.
|
||
let bestAbs = 0;
|
||
if (avg30 !== null && spread30 !== null && spread30 > 0) {
|
||
const recent = records.filter(
|
||
(rec) => rec.measuredAt.getTime() >= now - 14 * 24 * 60 * 60 * 1000,
|
||
);
|
||
for (const rec of recent) {
|
||
const sd = (rec.value - avg30) / spread30;
|
||
if (Math.abs(sd) >= 2) {
|
||
const abs = Math.abs(rec.value - avg30);
|
||
if (recentAnomaly === null || abs > bestAbs) {
|
||
bestAbs = abs;
|
||
recentAnomaly = {
|
||
kind: (sd > 0 ? "peak" : "trough") as "peak" | "trough",
|
||
value: r2(rec.value),
|
||
anomalyDaysAgo: Math.round(
|
||
(now - rec.measuredAt.getTime()) / (24 * 60 * 60 * 1000),
|
||
),
|
||
};
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return {
|
||
metric,
|
||
label,
|
||
...(unit ? { unit } : {}),
|
||
latest: r2(newest.value),
|
||
latestDaysAgo,
|
||
avg7,
|
||
avg30,
|
||
deltaVs7,
|
||
deltaVs30,
|
||
spread30,
|
||
outsideNormalSwing,
|
||
emergingTrend,
|
||
recentAnomaly,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* v1.18.7 — assemble the present-focused "Signals of the day" block from the
|
||
* in-memory measurement set (no extra DB round-trip). Computes today-vs-7d/30d
|
||
* deltas, an emerging slope, and a recent anomaly per salient metric, then
|
||
* returns the top ≤3 ranked by clinical priority. Salient signals
|
||
* (outside-normal-swing or a recent anomaly) bubble above quiet ones inside
|
||
* each priority tier so the briefing leads with what actually moved.
|
||
*/
|
||
function computeSignalsOfDay(
|
||
byType: (type: string) => Array<{ value: number; measuredAt: Date }>,
|
||
now: number,
|
||
): SignalOfDay[] {
|
||
const candidates: SignalOfDay[] = [];
|
||
const push = (s: SignalOfDay | null) => {
|
||
if (s) candidates.push(s);
|
||
};
|
||
|
||
// Systolic carries the BP signal (the headline number clinicians read first).
|
||
push(
|
||
buildSignal(
|
||
"bp",
|
||
"blood pressure (systolic)",
|
||
byType("BLOOD_PRESSURE_SYS"),
|
||
now,
|
||
"mmHg",
|
||
),
|
||
);
|
||
// Glucose uses the canonical stored value (the model reads the snapshot,
|
||
// not display units); absent data simply produces no signal.
|
||
push(buildSignal("glucose", "blood glucose", byType("BLOOD_GLUCOSE"), now));
|
||
push(
|
||
buildSignal(
|
||
"resting_hr",
|
||
"resting heart rate",
|
||
byType("RESTING_HEART_RATE"),
|
||
now,
|
||
"bpm",
|
||
),
|
||
);
|
||
push(buildSignal("pulse", "pulse", byType("PULSE"), now, "bpm"));
|
||
push(buildSignal("weight", "weight", byType("WEIGHT"), now, "kg"));
|
||
// Sleep is stored one row per stage per night, so a raw "latest" point
|
||
// would mis-sum; the sleep aggregates carry that signal already. Steps
|
||
// ingest as many intraday `stats:`-prefixed samples, so the newest raw row
|
||
// is a partial-day fragment, not a daily total — excluded like sleep.
|
||
|
||
const priorityIndex = (m: SignalOfDay["metric"]) => {
|
||
const idx = SIGNAL_PRIORITY.indexOf(m);
|
||
return idx === -1 ? SIGNAL_PRIORITY.length : idx;
|
||
};
|
||
const salience = (s: SignalOfDay) =>
|
||
(s.outsideNormalSwing ? 2 : 0) + (s.recentAnomaly ? 1 : 0);
|
||
|
||
return candidates
|
||
.sort((a, b) => {
|
||
const sal = salience(b) - salience(a);
|
||
if (sal !== 0) return sal;
|
||
return priorityIndex(a.metric) - priorityIndex(b.metric);
|
||
})
|
||
.slice(0, 3);
|
||
}
|
||
|
||
export async function extractFeatures(
|
||
userId: string,
|
||
includeRaw: boolean,
|
||
options: { sinceDays?: number } = {},
|
||
): Promise<AggregatedFeatures | RawFeatures> {
|
||
const user = await prisma.user.findUnique({
|
||
where: { id: userId },
|
||
select: {
|
||
heightCm: true,
|
||
dateOfBirth: true,
|
||
gender: true,
|
||
},
|
||
});
|
||
|
||
const now = Date.now();
|
||
|
||
// Fetch measurements. Default = ALL (full temporal context for the
|
||
// dashboard / insights generator). Callers that only consume the
|
||
// ≤90-day windows (Coach snapshot per turn) pass `sinceDays: 90` so
|
||
// the per-turn I/O stays bounded — `findMany({ where: { userId } })`
|
||
// is unbounded by user-history size and gets paid once per Coach turn
|
||
// for power users with multi-year Withings imports.
|
||
const sinceDays = options.sinceDays;
|
||
const sinceCutoff =
|
||
typeof sinceDays === "number" && sinceDays > 0
|
||
? new Date(now - sinceDays * 24 * 60 * 60 * 1000)
|
||
: null;
|
||
const measurements = await prisma.measurement.findMany({
|
||
where: sinceCutoff
|
||
? { userId, measuredAt: { gte: sinceCutoff }, deletedAt: null }
|
||
: { userId, deletedAt: null },
|
||
orderBy: { measuredAt: "asc" },
|
||
});
|
||
|
||
// v1.18.11 P1 — when the bulk read is bounded to a recent window, the
|
||
// windowed `summarize()` no longer covers the full history, so the `allTime*`
|
||
// fields would silently become "last `sinceDays` days" extremes. Source the
|
||
// genuine full-history min / max / mean from one grouped aggregation (no row
|
||
// materialisation) so the labels stay honest. Unbounded reads (sinceCutoff
|
||
// null) already see the whole history and skip the extra query.
|
||
const allTimeExtremes = sinceCutoff
|
||
? await readAllTimeExtremes(userId, [
|
||
"WEIGHT",
|
||
"BLOOD_PRESSURE_SYS",
|
||
"BLOOD_PRESSURE_DIA",
|
||
"PULSE",
|
||
])
|
||
: null;
|
||
|
||
const byType = (type: string) => measurements.filter((m) => m.type === type);
|
||
|
||
const bpTargets = getBpTargets(user?.dateOfBirth ?? null);
|
||
|
||
// Compute overall data span
|
||
const oldestMeasurement =
|
||
measurements.length > 0 ? measurements[0].measuredAt : null;
|
||
const newestMeasurement =
|
||
measurements.length > 0
|
||
? measurements[measurements.length - 1].measuredAt
|
||
: null;
|
||
const overallSpanDays =
|
||
oldestMeasurement && newestMeasurement
|
||
? Math.round(
|
||
(newestMeasurement.getTime() - oldestMeasurement.getTime()) /
|
||
(24 * 60 * 60 * 1000),
|
||
)
|
||
: 0;
|
||
|
||
// Compute age
|
||
let ageYears: number | null = null;
|
||
if (user?.dateOfBirth) {
|
||
const dob = user.dateOfBirth;
|
||
const today = new Date();
|
||
ageYears = today.getFullYear() - dob.getFullYear();
|
||
const monthDiff = today.getMonth() - dob.getMonth();
|
||
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < dob.getDate())) {
|
||
ageYears--;
|
||
}
|
||
}
|
||
|
||
const features: AggregatedFeatures = {
|
||
context: {
|
||
heightCm: user?.heightCm ?? null,
|
||
hasBpTargets: !!bpTargets,
|
||
totalMeasurements: measurements.length,
|
||
dataSpanDays: overallSpanDays,
|
||
oldestMeasurementDaysAgo: oldestMeasurement
|
||
? Math.round(
|
||
(now - oldestMeasurement.getTime()) / (24 * 60 * 60 * 1000),
|
||
)
|
||
: null,
|
||
newestMeasurementDaysAgo: newestMeasurement
|
||
? Math.round(
|
||
(now - newestMeasurement.getTime()) / (24 * 60 * 60 * 1000),
|
||
)
|
||
: null,
|
||
ageYears,
|
||
gender: user?.gender ?? null,
|
||
},
|
||
};
|
||
|
||
// Weight
|
||
const weightData = byType("WEIGHT");
|
||
if (weightData.length > 0) {
|
||
const summary = summarize(toDataPoints(weightData));
|
||
const bmi =
|
||
user?.heightCm && summary.latest
|
||
? parseFloat((summary.latest / (user.heightCm / 100) ** 2).toFixed(1))
|
||
: null;
|
||
|
||
features.weight = {
|
||
latest: summary.latest!,
|
||
avg7: summary.avg7,
|
||
avg30: summary.avg30,
|
||
avg90: avgInWindow(weightData, now, 90),
|
||
// v1.18.11 P1 — full-history extremes from the grouped aggregation when
|
||
// the bulk read is windowed; the in-window summary otherwise.
|
||
allTimeAvg: allTimeExtremes
|
||
? roundMean(allTimeExtremes.get("WEIGHT")?.mean ?? null)
|
||
: summary.count > 0
|
||
? summary.mean
|
||
: null,
|
||
allTimeMin: allTimeExtremes
|
||
? (allTimeExtremes.get("WEIGHT")?.min ?? null)
|
||
: summary.count > 0
|
||
? summary.min
|
||
: null,
|
||
allTimeMax: allTimeExtremes
|
||
? (allTimeExtremes.get("WEIGHT")?.max ?? null)
|
||
: summary.count > 0
|
||
? summary.max
|
||
: null,
|
||
slope30: summary.slope30?.slope ?? null,
|
||
outlierCount: summary.anomalyCount,
|
||
bmi,
|
||
coverage: computeCoverage(weightData, now),
|
||
};
|
||
}
|
||
|
||
// Blood Pressure
|
||
const sysData = byType("BLOOD_PRESSURE_SYS");
|
||
const diaData = byType("BLOOD_PRESSURE_DIA");
|
||
if (sysData.length > 0 || diaData.length > 0) {
|
||
const sysSummary =
|
||
sysData.length > 0 ? summarize(toDataPoints(sysData)) : null;
|
||
const diaSummary =
|
||
diaData.length > 0 ? summarize(toDataPoints(diaData)) : null;
|
||
|
||
let pctInTarget: number | null = null;
|
||
if (bpTargets) {
|
||
const sysByTime = new Map(
|
||
sysData.map((m) => [m.measuredAt.getTime(), m.value]),
|
||
);
|
||
let inTargetCount = 0;
|
||
let pairedCount = 0;
|
||
for (const dia of diaData) {
|
||
const sysVal = sysByTime.get(dia.measuredAt.getTime());
|
||
if (sysVal === undefined) continue;
|
||
pairedCount++;
|
||
// v1.4.16 A2 — one-sided ceiling semantics with hypotension
|
||
// floor. See lib/analytics/bp-in-target.ts.
|
||
if (isBpReadingInTarget(sysVal, dia.value, bpTargets)) {
|
||
inTargetCount++;
|
||
}
|
||
}
|
||
pctInTarget =
|
||
pairedCount > 0
|
||
? Math.round((inTargetCount / pairedCount) * 100)
|
||
: null;
|
||
}
|
||
|
||
features.bloodPressure = {
|
||
avgSys30: sysSummary?.avg30 ?? null,
|
||
avgDia30: diaSummary?.avg30 ?? null,
|
||
avgSys90: sysData.length > 0 ? avgInWindow(sysData, now, 90) : null,
|
||
avgDia90: diaData.length > 0 ? avgInWindow(diaData, now, 90) : null,
|
||
// v1.18.11 P1 — full-history extremes when the bulk read is windowed.
|
||
allTimeAvgSys: allTimeExtremes
|
||
? roundMean(allTimeExtremes.get("BLOOD_PRESSURE_SYS")?.mean ?? null)
|
||
: sysSummary?.count
|
||
? sysSummary.mean
|
||
: null,
|
||
allTimeAvgDia: allTimeExtremes
|
||
? roundMean(allTimeExtremes.get("BLOOD_PRESSURE_DIA")?.mean ?? null)
|
||
: diaSummary?.count
|
||
? diaSummary.mean
|
||
: null,
|
||
allTimeMinSys: allTimeExtremes
|
||
? (allTimeExtremes.get("BLOOD_PRESSURE_SYS")?.min ?? null)
|
||
: sysSummary?.count
|
||
? sysSummary.min
|
||
: null,
|
||
allTimeMaxSys: allTimeExtremes
|
||
? (allTimeExtremes.get("BLOOD_PRESSURE_SYS")?.max ?? null)
|
||
: sysSummary?.count
|
||
? sysSummary.max
|
||
: null,
|
||
allTimeMinDia: allTimeExtremes
|
||
? (allTimeExtremes.get("BLOOD_PRESSURE_DIA")?.min ?? null)
|
||
: diaSummary?.count
|
||
? diaSummary.min
|
||
: null,
|
||
allTimeMaxDia: allTimeExtremes
|
||
? (allTimeExtremes.get("BLOOD_PRESSURE_DIA")?.max ?? null)
|
||
: diaSummary?.count
|
||
? diaSummary.max
|
||
: null,
|
||
slopeSys30: sysSummary?.slope30?.slope ?? null,
|
||
slopeDia30: diaSummary?.slope30?.slope ?? null,
|
||
sdSys30: (() => {
|
||
const fromMs = now - 30 * 24 * 60 * 60 * 1000;
|
||
const vals = sysData
|
||
.filter((m) => m.measuredAt.getTime() >= fromMs)
|
||
.map((m) => m.value);
|
||
return stdDev(vals);
|
||
})(),
|
||
sdDia30: (() => {
|
||
const fromMs = now - 30 * 24 * 60 * 60 * 1000;
|
||
const vals = diaData
|
||
.filter((m) => m.measuredAt.getTime() >= fromMs)
|
||
.map((m) => m.value);
|
||
return stdDev(vals);
|
||
})(),
|
||
pulsePressure30: (() => {
|
||
const avgSys = sysSummary?.avg30 ?? null;
|
||
const avgDia = diaSummary?.avg30 ?? null;
|
||
if (avgSys === null || avgDia === null) return null;
|
||
return Math.round((avgSys - avgDia) * 10) / 10;
|
||
})(),
|
||
pctInTarget,
|
||
coverage: computeCoverage(
|
||
[...sysData, ...diaData].sort(
|
||
(a, b) => a.measuredAt.getTime() - b.measuredAt.getTime(),
|
||
),
|
||
now,
|
||
),
|
||
};
|
||
}
|
||
|
||
// Pulse
|
||
const pulseData = byType("PULSE");
|
||
if (pulseData.length > 0) {
|
||
const summary = summarize(toDataPoints(pulseData));
|
||
features.pulse = {
|
||
avg7: summary.avg7,
|
||
avg30: summary.avg30,
|
||
avg90: avgInWindow(pulseData, now, 90),
|
||
// v1.18.11 P1 — full-history extremes when the bulk read is windowed.
|
||
allTimeAvg: allTimeExtremes
|
||
? roundMean(allTimeExtremes.get("PULSE")?.mean ?? null)
|
||
: summary.count > 0
|
||
? summary.mean
|
||
: null,
|
||
allTimeMin: allTimeExtremes
|
||
? (allTimeExtremes.get("PULSE")?.min ?? null)
|
||
: summary.count > 0
|
||
? summary.min
|
||
: null,
|
||
allTimeMax: allTimeExtremes
|
||
? (allTimeExtremes.get("PULSE")?.max ?? null)
|
||
: summary.count > 0
|
||
? summary.max
|
||
: null,
|
||
slope30: summary.slope30?.slope ?? null,
|
||
anomalyCount: summary.anomalyCount,
|
||
coverage: computeCoverage(pulseData, now),
|
||
};
|
||
}
|
||
|
||
// Body Fat
|
||
const fatData = byType("BODY_FAT");
|
||
if (fatData.length > 0) {
|
||
const summary = summarize(toDataPoints(fatData));
|
||
features.bodyFat = {
|
||
latest: summary.latest,
|
||
avg30: summary.avg30,
|
||
slope30: summary.slope30?.slope ?? null,
|
||
coverage: computeCoverage(fatData, now),
|
||
};
|
||
}
|
||
|
||
// Sleep Duration
|
||
//
|
||
// SLEEP_DURATION is stored ONE ROW PER STAGE per night, so summarising the
|
||
// raw stage rows would average individual stages (and double-count a bare
|
||
// ASLEEP aggregate against its granular CORE/DEEP/REM twin). Route the
|
||
// feature block through the per-night dedup reconstruction — the same helper
|
||
// the dashboard / series / status path use — so `avg7` / `avg30` / `latest`
|
||
// are per-night TIME-ASLEEP totals in minutes, never stage averages.
|
||
const sleepData = byType("SLEEP_DURATION");
|
||
if (sleepData.length > 0) {
|
||
const [sleepTz, sleepPriority] = await Promise.all([
|
||
resolveUserTimezone(userId),
|
||
loadUserSourcePriority(userId),
|
||
]);
|
||
const sleepNights = reconstructSleepNights(
|
||
sleepData as unknown as SleepStageRow[],
|
||
sleepTz,
|
||
sleepPriority,
|
||
).filter((n) => n.asleepMinutes > 0);
|
||
const summary = summarize(
|
||
sleepNights.map((n) => ({ date: n.measuredAt, value: n.asleepMinutes })),
|
||
);
|
||
features.sleep = {
|
||
avg7: summary.avg7,
|
||
avg30: summary.avg30,
|
||
latest: summary.latest,
|
||
coverage: computeCoverage(
|
||
sleepNights.map((n) => ({ measuredAt: n.measuredAt })),
|
||
now,
|
||
),
|
||
};
|
||
}
|
||
|
||
// Activity Steps
|
||
const activityData = byType("ACTIVITY_STEPS");
|
||
if (activityData.length > 0) {
|
||
const summary = summarize(toDataPoints(activityData));
|
||
features.activity = {
|
||
avg7: summary.avg7,
|
||
avg30: summary.avg30,
|
||
latest: summary.latest,
|
||
coverage: computeCoverage(activityData, now),
|
||
};
|
||
}
|
||
|
||
// Mood
|
||
//
|
||
// v1.4.40 — swap the unbounded `prisma.moodEntry.findMany` for the
|
||
// persistent mood-rollup DAY tier (audit Critical Finding #2). The
|
||
// feature block downstream consumes:
|
||
// - `avg7` / `avg30` over recent rollup means
|
||
// - `trend30` from first-half vs second-half of last 30 days
|
||
// - `latest` = newest individual entry score (one bounded row)
|
||
// - `totalEntries` = sum of rollup `count` across all DAY rows
|
||
// - `oldest` / `newest` = first and last rollup `bucketStart`
|
||
// - `moodPoints` for cross-metric correlations (one per-day mean
|
||
// DataPoint — same resolution as the legacy raw-score series for
|
||
// single-entry-per-day power users, slightly different on
|
||
// multi-entry days, which is the rollup-tier semantic the
|
||
// v1.4.39 `/api/mood/analytics` already shipped)
|
||
//
|
||
// Coverage-fallback: when raw entries exist but the rollup tier is
|
||
// still empty (legacy account before boot-time backfill caught up),
|
||
// we fall back to a bounded 1-year raw walk and fire the warm-up so
|
||
// the next request lands on the rollup tier. Same posture as
|
||
// `/api/mood/analytics`.
|
||
void ensureUserMoodRollupsFresh(userId);
|
||
// Five-year window mirrors the mood-rollup writer default; covers
|
||
// every realistic user history span without an unbounded scan.
|
||
const moodSince = new Date(Date.now() - 5 * 365 * 24 * 60 * 60 * 1000);
|
||
const moodRollupDayRows = await readMoodDayRollups(userId, moodSince);
|
||
|
||
type MoodDayPoint = {
|
||
measuredAt: Date;
|
||
value: number;
|
||
count: number;
|
||
};
|
||
let moodDailyPoints: MoodDayPoint[] = [];
|
||
let moodLatestScore: number | null = null;
|
||
let moodTotalEntries = 0;
|
||
|
||
if (moodRollupDayRows.length > 0) {
|
||
moodDailyPoints = moodRollupDayRows.map((r) => ({
|
||
measuredAt: r.bucketStart,
|
||
value: r.mean,
|
||
count: r.count,
|
||
}));
|
||
moodTotalEntries = moodRollupDayRows.reduce((s, r) => s + r.count, 0);
|
||
// Latest score = newest individual entry (one bounded row).
|
||
const latestEntry = await prisma.moodEntry.findFirst({
|
||
// v1.7.0 sync — exclude tombstoned rows.
|
||
where: { userId, deletedAt: null },
|
||
orderBy: { moodLoggedAt: "desc" },
|
||
select: { score: true },
|
||
});
|
||
moodLatestScore = latestEntry?.score ?? null;
|
||
} else {
|
||
// Coverage-fallback. Bounded 1-year walk (instead of the legacy
|
||
// unbounded `findMany`) so even a cache miss is capped.
|
||
const oneYearAgo = new Date(Date.now() - 365 * 24 * 60 * 60 * 1000);
|
||
const moodEntriesRaw = await prisma.moodEntry.findMany({
|
||
// v1.7.0 sync — exclude tombstoned rows.
|
||
where: { userId, deletedAt: null, moodLoggedAt: { gte: oneYearAgo } },
|
||
orderBy: { moodLoggedAt: "asc" },
|
||
select: { score: true, moodLoggedAt: true, date: true },
|
||
});
|
||
if (moodEntriesRaw.length > 0) {
|
||
// Bucket per `date` (TZ-anchored YYYY-MM-DD label, same key the
|
||
// rollup tier would emit for Berlin-anchored single-entry days)
|
||
// so the fallback shape matches the rollup-tier shape.
|
||
const byDay = new Map<string, { sum: number; count: number; ts: Date }>();
|
||
for (const e of moodEntriesRaw) {
|
||
const k = e.date;
|
||
const cur = byDay.get(k) ?? { sum: 0, count: 0, ts: e.moodLoggedAt };
|
||
cur.sum += e.score;
|
||
cur.count += 1;
|
||
// Pin the bucket's measuredAt to the latest entry's timestamp
|
||
// — close enough for the trailing-window filters below.
|
||
if (e.moodLoggedAt > cur.ts) cur.ts = e.moodLoggedAt;
|
||
byDay.set(k, cur);
|
||
}
|
||
moodDailyPoints = Array.from(byDay.values())
|
||
.map((b) => ({
|
||
measuredAt: b.ts,
|
||
value: b.sum / b.count,
|
||
count: b.count,
|
||
}))
|
||
.sort((a, b) => a.measuredAt.getTime() - b.measuredAt.getTime());
|
||
moodTotalEntries = moodEntriesRaw.length;
|
||
moodLatestScore = moodEntriesRaw[moodEntriesRaw.length - 1].score;
|
||
}
|
||
}
|
||
|
||
if (moodDailyPoints.length > 0) {
|
||
const moodNow = Date.now();
|
||
const last7 = moodDailyPoints.filter(
|
||
(e) => moodNow - e.measuredAt.getTime() < 7 * 24 * 60 * 60 * 1000,
|
||
);
|
||
const last30 = moodDailyPoints.filter(
|
||
(e) => moodNow - e.measuredAt.getTime() < 30 * 24 * 60 * 60 * 1000,
|
||
);
|
||
|
||
const avg = (points: MoodDayPoint[]) =>
|
||
points.length > 0
|
||
? Math.round(
|
||
(points.reduce((s, e) => s + e.value, 0) / points.length) * 100,
|
||
) / 100
|
||
: null;
|
||
|
||
// Compute 30-day trend: compare first half vs second half of last 30 days
|
||
let trend30: "improving" | "declining" | "stable" | null = null;
|
||
if (last30.length >= 4) {
|
||
const firstHalf = last30.filter(
|
||
(e) => moodNow - e.measuredAt.getTime() >= 15 * 24 * 60 * 60 * 1000,
|
||
);
|
||
const secondHalf = last30.filter(
|
||
(e) => moodNow - e.measuredAt.getTime() < 15 * 24 * 60 * 60 * 1000,
|
||
);
|
||
if (firstHalf.length >= 2 && secondHalf.length >= 2) {
|
||
const avgFirst = avg(firstHalf)!;
|
||
const avgSecond = avg(secondHalf)!;
|
||
const diff = avgSecond - avgFirst;
|
||
if (diff > 0.3) trend30 = "improving";
|
||
else if (diff < -0.3) trend30 = "declining";
|
||
else trend30 = "stable";
|
||
}
|
||
}
|
||
|
||
const oldest = moodDailyPoints[0].measuredAt;
|
||
const newest = moodDailyPoints[moodDailyPoints.length - 1].measuredAt;
|
||
const spanDays = Math.round(
|
||
(newest.getTime() - oldest.getTime()) / (24 * 60 * 60 * 1000),
|
||
);
|
||
const avgDaysBetween =
|
||
moodTotalEntries > 1
|
||
? Math.round((spanDays / (moodTotalEntries - 1)) * 10) / 10
|
||
: null;
|
||
|
||
features.mood = {
|
||
scale: "1=LAUSIG, 2=SCHLECHT, 3=OKAY, 4=GUT, 5=SUPER_GUT",
|
||
avg7: avg(last7),
|
||
avg30: avg(last30),
|
||
latest: moodLatestScore,
|
||
trend30,
|
||
totalEntries: moodTotalEntries,
|
||
coverage: {
|
||
count: moodTotalEntries,
|
||
spanDays,
|
||
avgDaysBetween,
|
||
oldestDaysAgo: Math.round(
|
||
(moodNow - oldest.getTime()) / (24 * 60 * 60 * 1000),
|
||
),
|
||
newestDaysAgo: Math.round(
|
||
(moodNow - newest.getTime()) / (24 * 60 * 60 * 1000),
|
||
),
|
||
},
|
||
};
|
||
}
|
||
|
||
// Cross-metric correlations
|
||
const weightPoints = toDataPoints(weightData);
|
||
const sysPoints = toDataPoints(sysData);
|
||
const diaPoints = toDataPoints(diaData);
|
||
const pulsePoints = toDataPoints(pulseData);
|
||
const moodPoints: DataPoint[] = moodDailyPoints.map((e) => ({
|
||
date: e.measuredAt,
|
||
value: e.value,
|
||
}));
|
||
|
||
const computeCorr = (a: DataPoint[], b: DataPoint[]) =>
|
||
pearsonCorrelation(pairByTimestamp(a, b));
|
||
|
||
const sleepPoints = toDataPoints(sleepData);
|
||
|
||
features.correlations = {
|
||
weightVsSystolic: computeCorr(weightPoints, sysPoints),
|
||
weightVsDiastolic: computeCorr(weightPoints, diaPoints),
|
||
pulseVsSystolic: computeCorr(pulsePoints, sysPoints),
|
||
moodVsPulse: computeCorr(moodPoints, pulsePoints),
|
||
moodVsSystolic: computeCorr(moodPoints, sysPoints),
|
||
moodVsWeight: computeCorr(moodPoints, weightPoints),
|
||
sleepVsPulse:
|
||
sleepData.length > 0 ? computeCorr(sleepPoints, pulsePoints) : null,
|
||
sleepVsSystolic:
|
||
sleepData.length > 0 ? computeCorr(sleepPoints, sysPoints) : null,
|
||
};
|
||
|
||
// Rate-Pressure Product (RPP) — myocardial oxygen demand indicator
|
||
if (features.pulse && features.bloodPressure) {
|
||
const rpp7 =
|
||
features.pulse.avg7 !== null && features.bloodPressure.avgSys30 !== null
|
||
? Math.round(
|
||
features.pulse.avg7 *
|
||
(avgInWindow(sysData, now, 7) ?? features.bloodPressure.avgSys30),
|
||
)
|
||
: null;
|
||
const rpp30 =
|
||
features.pulse.avg30 !== null && features.bloodPressure.avgSys30 !== null
|
||
? Math.round(features.pulse.avg30 * features.bloodPressure.avgSys30)
|
||
: null;
|
||
const rppRef = rpp30 ?? rpp7;
|
||
features.ratePressureProduct = {
|
||
rpp7,
|
||
rpp30,
|
||
risk: rppRef !== null ? (rppRef > 12000 ? "elevated" : "normal") : null,
|
||
};
|
||
}
|
||
|
||
// Body Composition Divergence
|
||
if (features.weight && features.bodyFat) {
|
||
const weightStable =
|
||
features.weight.slope30 !== null &&
|
||
Math.abs(features.weight.slope30) < 0.01;
|
||
const bodyFatRising =
|
||
features.bodyFat.slope30 !== null && features.bodyFat.slope30 > 0;
|
||
features.bodyCompositionDivergence = {
|
||
weightStable,
|
||
bodyFatRising,
|
||
flag: weightStable && bodyFatRising,
|
||
};
|
||
}
|
||
|
||
// Mood-Adherence Risk Flag
|
||
if (
|
||
features.mood &&
|
||
features.medications &&
|
||
features.medications.length > 0
|
||
) {
|
||
features.moodAdherenceRisk =
|
||
features.mood.avg7 !== null &&
|
||
features.mood.avg7 <= 2.5 &&
|
||
features.mood.trend30 === "declining";
|
||
}
|
||
|
||
// Seasonal BP Variation (only if > 180 days of data)
|
||
if (features.context.dataSpanDays > 180 && sysData.length > 0) {
|
||
const winterMonths = [11, 0, 1]; // Dec, Jan, Feb (0-indexed)
|
||
const summerMonths = [5, 6, 7]; // Jun, Jul, Aug
|
||
const winterVals = sysData
|
||
.filter((m) => winterMonths.includes(m.measuredAt.getMonth()))
|
||
.map((m) => m.value);
|
||
const summerVals = sysData
|
||
.filter((m) => summerMonths.includes(m.measuredAt.getMonth()))
|
||
.map((m) => m.value);
|
||
const winterAvg =
|
||
winterVals.length > 0
|
||
? Math.round(
|
||
(winterVals.reduce((s, v) => s + v, 0) / winterVals.length) * 10,
|
||
) / 10
|
||
: null;
|
||
const summerAvg =
|
||
summerVals.length > 0
|
||
? Math.round(
|
||
(summerVals.reduce((s, v) => s + v, 0) / summerVals.length) * 10,
|
||
) / 10
|
||
: null;
|
||
const delta =
|
||
winterAvg !== null && summerAvg !== null
|
||
? Math.round((winterAvg - summerAvg) * 10) / 10
|
||
: null;
|
||
features.seasonalVariation = {
|
||
winterAvgSys: winterAvg,
|
||
summerAvgSys: summerAvg,
|
||
delta,
|
||
significance:
|
||
delta !== null ? (Math.abs(delta) > 5 ? "elevated" : "normal") : null,
|
||
};
|
||
}
|
||
|
||
// Historical comparison: current 7d avg vs previous 30d avg (days 7-37)
|
||
features.historicalComparison = {};
|
||
if (weightData.length > 0) {
|
||
features.historicalComparison.weight = computeHistoricalComparison(
|
||
weightData,
|
||
now,
|
||
);
|
||
}
|
||
if (sysData.length > 0) {
|
||
features.historicalComparison.systolic = computeHistoricalComparison(
|
||
sysData,
|
||
now,
|
||
);
|
||
}
|
||
if (diaData.length > 0) {
|
||
features.historicalComparison.diastolic = computeHistoricalComparison(
|
||
diaData,
|
||
now,
|
||
);
|
||
}
|
||
if (pulseData.length > 0) {
|
||
features.historicalComparison.pulse = computeHistoricalComparison(
|
||
pulseData,
|
||
now,
|
||
);
|
||
}
|
||
|
||
// Medications
|
||
const medications = await prisma.medication.findMany({
|
||
// v1.16.11 — as-needed (PRN) medications never surface a compliance
|
||
// rate (no expected doses), so the insight features exclude them.
|
||
where: { userId, active: true, asNeeded: false },
|
||
// v1.15.20 — schedules through the shared compliance select so the
|
||
// configured per-dose windows reach this surface like every other.
|
||
include: {
|
||
schedules: { select: SCHEDULE_COMPLIANCE_SELECT },
|
||
// v1.16.3 — archived schedule eras for era-aware compliance.
|
||
scheduleRevisions: { orderBy: { validFrom: "asc" } },
|
||
},
|
||
});
|
||
|
||
if (medications.length > 0) {
|
||
const categoryMap = await getMedicationCategories(
|
||
medications.map((med) => med.id),
|
||
);
|
||
|
||
// Single batched fetch + in-memory grouping replaces the per-medication
|
||
// findMany loop. Same shape as the v1.3.0 fix to /api/insights/comprehensive
|
||
// (the previous N+1 the v3 audit closed). 90 days is the longest window
|
||
// calculateCompliance uses below, so we don't need the full intake history.
|
||
const ninetyDaysAgo = new Date(Date.now() - 90 * 86_400_000);
|
||
const allEvents = await prisma.medicationIntakeEvent.findMany({
|
||
where: {
|
||
userId,
|
||
// v1.7.0 sync — exclude tombstoned rows.
|
||
deletedAt: null,
|
||
medicationId: { in: medications.map((med) => med.id) },
|
||
scheduledFor: { gte: ninetyDaysAgo },
|
||
},
|
||
orderBy: { scheduledFor: "desc" },
|
||
select: {
|
||
medicationId: true,
|
||
takenAt: true,
|
||
skipped: true,
|
||
scheduledFor: true,
|
||
},
|
||
});
|
||
|
||
const eventsByMed = new Map<
|
||
string,
|
||
{ takenAt: Date | null; skipped: boolean; scheduledFor: Date }[]
|
||
>();
|
||
for (const e of allEvents) {
|
||
const list = eventsByMed.get(e.medicationId) ?? [];
|
||
list.push({
|
||
takenAt: e.takenAt,
|
||
skipped: e.skipped,
|
||
scheduledFor: e.scheduledFor,
|
||
});
|
||
eventsByMed.set(e.medicationId, list);
|
||
}
|
||
|
||
// v1.7.0 SB-SCHED-2 — resolve the user timezone once so every
|
||
// per-med compliance call can route its denominator through the
|
||
// canonical engine (RRULE / rolling / one-shot / PRN / cyclic).
|
||
const userTz = await resolveUserTimezone(userId);
|
||
|
||
features.medications = medications.map((med) => {
|
||
const mapped = eventsByMed.get(med.id) ?? [];
|
||
const medicationContext = buildComplianceMedicationContext(
|
||
med,
|
||
lastNonSkippedTakenAt(mapped),
|
||
userTz,
|
||
);
|
||
const c7 = calculateCompliance(mapped, med.schedules, 7, med.createdAt, {
|
||
medicationContext,
|
||
});
|
||
const c30 = calculateCompliance(
|
||
mapped,
|
||
med.schedules,
|
||
30,
|
||
med.createdAt,
|
||
{
|
||
medicationContext,
|
||
},
|
||
);
|
||
const c90 = calculateCompliance(
|
||
mapped,
|
||
med.schedules,
|
||
90,
|
||
med.createdAt,
|
||
{
|
||
medicationContext,
|
||
},
|
||
);
|
||
|
||
return {
|
||
name: med.name,
|
||
dose: med.dose,
|
||
category: categoryMap[med.id] ?? "OTHER",
|
||
compliance7: c7.rate,
|
||
compliance30: c30.rate,
|
||
compliance90: c90.rate,
|
||
streak: c30.streak,
|
||
missedLast7: c7.missed,
|
||
};
|
||
});
|
||
}
|
||
|
||
// v1.18.7 — present-focused "Signals of the day". Computed from the
|
||
// in-memory measurement set (no extra DB round-trip). Lands inside the
|
||
// compacted features payload, so it feeds both the briefing prompt AND the
|
||
// content-hash gate — a fresh daily signal forces the briefing to refresh.
|
||
const signalsOfDay = computeSignalsOfDay(byType, now);
|
||
if (signalsOfDay.length > 0) {
|
||
features.signalsOfDay = signalsOfDay;
|
||
}
|
||
|
||
// v1.4.36 W3 T1 — "raw" mode no longer dumps every measurement row
|
||
// into the prompt. Instead we attach DAY / WEEK / MONTH bucket means
|
||
// from `measurement_rollups` so the model gets the same temporal
|
||
// shape (granular near-term, coarser deep history) at O(buckets)
|
||
// instead of O(rows). The v1.4.35 shape blew past Codex's 10 MB
|
||
// string limit on power users (~25.9 MB observed); the bucketed
|
||
// shape lands at ~50–500 KB per account.
|
||
if (includeRaw) {
|
||
const bucketedMeasurements = await readBucketedSeries(userId, now);
|
||
const rawFeatures: RawFeatures = {
|
||
...features,
|
||
bucketedMeasurements,
|
||
};
|
||
enforceSizeGuard(rawFeatures);
|
||
return rawFeatures;
|
||
}
|
||
|
||
enforceSizeGuard(features);
|
||
return features;
|
||
}
|
||
|
||
/**
|
||
* Read every BUCKETED_TYPES × BUCKET_WINDOWS combination from the
|
||
* persistent rollup table and project to the wire shape. Empty
|
||
* (type, granularity) combinations are dropped so the payload never
|
||
* carries a labelled-but-empty series.
|
||
*/
|
||
async function readBucketedSeries(
|
||
userId: string,
|
||
now: number,
|
||
): Promise<BucketedSeries[]> {
|
||
const series: BucketedSeries[] = [];
|
||
for (const type of BUCKETED_TYPES) {
|
||
for (const window of BUCKET_WINDOWS) {
|
||
const from = new Date(now - window.toDays * 24 * 60 * 60 * 1000);
|
||
const to = new Date(now - window.fromDays * 24 * 60 * 60 * 1000);
|
||
const rows = await readRollupBuckets(
|
||
userId,
|
||
type,
|
||
window.granularity,
|
||
from,
|
||
to,
|
||
);
|
||
if (rows.length === 0) continue;
|
||
series.push({
|
||
type,
|
||
granularity: window.granularity,
|
||
buckets: rows.map((r) => ({
|
||
bucketStart: r.bucketStart.toISOString(),
|
||
mean: Math.round(r.mean * 100) / 100,
|
||
count: r.count,
|
||
})),
|
||
});
|
||
}
|
||
}
|
||
return series;
|
||
}
|
||
|
||
/**
|
||
* Hard ceiling on the serialised payload. Throws
|
||
* `FeaturesPayloadTooLargeError` (tagged with `code` so the route
|
||
* handler can pattern-match) when the JSON dump crosses
|
||
* `FEATURES_MAX_BYTES`. The route handler downgrades to the non-raw
|
||
* shape and annotates so the regression is observable; the model
|
||
* call still succeeds.
|
||
*/
|
||
function enforceSizeGuard(payload: AggregatedFeatures | RawFeatures): void {
|
||
const sizeBytes = Buffer.byteLength(JSON.stringify(payload), "utf8");
|
||
if (sizeBytes > FEATURES_MAX_BYTES) {
|
||
throw new FeaturesPayloadTooLargeError(sizeBytes, FEATURES_MAX_BYTES);
|
||
}
|
||
}
|