Files
HealthLog/src/lib/insights/features.ts
T
Marc-André BombeckandClaude Opus 4.7 249607d0b2 perf(coach): bound buildCoachSnapshot to last 90 days
Every POST /api/insights/chat called extractFeatures(userId, false),
which ran prisma.measurement.findMany({ where: { userId } }) with no
date filter — fetching every measurement the user had ever recorded.
For power users with multi-year Withings imports that's tens of
thousands of rows on every Coach turn.

Add an optional sinceDays bound to extractFeatures (default unchanged
= full history; analytics dashboard + insights/generate keep their
existing behaviour) and have the Coach snapshot pass sinceDays: 90.
The Coach already only narrates the last7 / last30 / last90 windows,
so the 90-day floor preserves every metric the snapshot surfaces.

Fixes Senior-HIGH-3.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 18:02:43 +02:00

784 lines
24 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 } from "@/lib/analytics/trends";
import type { DataPoint } from "@/lib/analytics/trends";
import { calculateCompliance } from "@/lib/analytics/compliance";
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";
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;
};
}
export interface RawFeatures extends AggregatedFeatures {
rawMeasurements: Array<{
type: string;
value: number;
dayOffset: number; // days ago (anonymized — no exact date)
}>;
}
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 };
}
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 } }
: { userId },
orderBy: { measuredAt: "asc" },
});
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),
allTimeAvg: summary.count > 0 ? summary.mean : null,
allTimeMin: summary.count > 0 ? summary.min : null,
allTimeMax: 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,
allTimeAvgSys: sysSummary?.count ? sysSummary.mean : null,
allTimeAvgDia: diaSummary?.count ? diaSummary.mean : null,
allTimeMinSys: sysSummary?.count ? sysSummary.min : null,
allTimeMaxSys: sysSummary?.count ? sysSummary.max : null,
allTimeMinDia: diaSummary?.count ? diaSummary.min : null,
allTimeMaxDia: 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),
allTimeAvg: summary.count > 0 ? summary.mean : null,
allTimeMin: summary.count > 0 ? summary.min : null,
allTimeMax: 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
const sleepData = byType("SLEEP_DURATION");
if (sleepData.length > 0) {
const summary = summarize(toDataPoints(sleepData));
features.sleep = {
avg7: summary.avg7,
avg30: summary.avg30,
latest: summary.latest,
coverage: computeCoverage(sleepData, 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
const moodEntries = await prisma.moodEntry.findMany({
where: { userId },
orderBy: { moodLoggedAt: "asc" },
});
if (moodEntries.length > 0) {
const moodNow = Date.now();
const last7 = moodEntries.filter(
(e) => moodNow - e.moodLoggedAt.getTime() < 7 * 24 * 60 * 60 * 1000,
);
const last30 = moodEntries.filter(
(e) => moodNow - e.moodLoggedAt.getTime() < 30 * 24 * 60 * 60 * 1000,
);
const avg = (entries: typeof moodEntries) =>
entries.length > 0
? Math.round(
(entries.reduce((s, e) => s + e.score, 0) / entries.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.moodLoggedAt.getTime() >= 15 * 24 * 60 * 60 * 1000,
);
const secondHalf = last30.filter(
(e) => moodNow - e.moodLoggedAt.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 = moodEntries[0].moodLoggedAt;
const newest = moodEntries[moodEntries.length - 1].moodLoggedAt;
const spanDays = Math.round(
(newest.getTime() - oldest.getTime()) / (24 * 60 * 60 * 1000),
);
const avgDaysBetween =
moodEntries.length > 1
? Math.round((spanDays / (moodEntries.length - 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: moodEntries[moodEntries.length - 1].score,
trend30,
totalEntries: moodEntries.length,
coverage: {
count: moodEntries.length,
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[] = moodEntries.map((e) => ({
date: e.moodLoggedAt,
value: e.score,
}));
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({
where: { userId, active: true },
include: { schedules: true },
});
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,
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);
}
features.medications = medications.map((med) => {
const mapped = eventsByMed.get(med.id) ?? [];
const c7 = calculateCompliance(mapped, med.schedules, 7, med.createdAt);
const c30 = calculateCompliance(mapped, med.schedules, 30, med.createdAt);
const c90 = calculateCompliance(mapped, med.schedules, 90, med.createdAt);
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,
};
});
}
// Raw mode: add anonymized raw data points
if (includeRaw) {
const rawFeatures: RawFeatures = {
...features,
rawMeasurements: measurements.map((m) => ({
type: m.type,
value: m.value,
dayOffset: Math.round(
(now - m.measuredAt.getTime()) / (24 * 60 * 60 * 1000),
),
})),
};
return rawFeatures;
}
return features;
}