mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
feat(insights): wire labs, preventive care, workouts and glucose into the briefing
The daily briefing's always-on feature payload carried only the core vitals, mood, sleep, steps and medication compliance, leaving four captured domains invisible to it. Add four blocks to the aggregated features: - Labs: recent flagged biomarkers (out-of-range or trending vs the prior reading). Retired markers are excluded; qualitative results stay neutral with no fabricated verdict. - Preventive care: due and overdue items off the server-authoritative next-due date, so an overdue screening can prompt an early nudge. - Workouts: a provider-agnostic count, duration and distance aggregate over the trailing windows (the briefing previously saw activity only as step counts). - Glucose: the present-and-trend block the other vitals carry, where glucose previously reached the briefing only as a single signal-of-the-day row. The three extra reads are scoped to the wide briefing window; the tight snapshot window that already carries its own context skips them. Every block is omitted when its domain is empty rather than emitting zeros, and user-entered labels are sanitised before they reach the prompt.
This commit is contained in:
1 parent
138211b46a
commit
2f22d66ef7
2 files changed
+586
-2
No files matched your search
@@ -3,12 +3,16 @@ import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findUnique: vi.fn() },
|
||||
measurement: { findMany: vi.fn() },
|
||||
measurement: { findMany: vi.fn(), groupBy: vi.fn() },
|
||||
measurementRollup: { findMany: vi.fn() },
|
||||
moodEntry: { findMany: vi.fn(), findFirst: vi.fn() },
|
||||
moodEntryRollup: { findMany: vi.fn(), findFirst: vi.fn() },
|
||||
medication: { findMany: vi.fn() },
|
||||
medicationIntakeEvent: { findMany: vi.fn() },
|
||||
// v1.22 — cross-signal integration blocks.
|
||||
labResult: { findMany: vi.fn() },
|
||||
measurementReminder: { findMany: vi.fn() },
|
||||
workout: { findMany: vi.fn() },
|
||||
$queryRawUnsafe: vi.fn(),
|
||||
},
|
||||
}));
|
||||
@@ -41,7 +45,10 @@ import {
|
||||
|
||||
const prismaMock = prisma as unknown as {
|
||||
user: { findUnique: ReturnType<typeof vi.fn> };
|
||||
measurement: { findMany: ReturnType<typeof vi.fn> };
|
||||
measurement: {
|
||||
findMany: ReturnType<typeof vi.fn>;
|
||||
groupBy: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
measurementRollup: { findMany: ReturnType<typeof vi.fn> };
|
||||
moodEntry: {
|
||||
findMany: ReturnType<typeof vi.fn>;
|
||||
@@ -53,6 +60,9 @@ const prismaMock = prisma as unknown as {
|
||||
};
|
||||
medication: { findMany: ReturnType<typeof vi.fn> };
|
||||
medicationIntakeEvent: { findMany: ReturnType<typeof vi.fn> };
|
||||
labResult: { findMany: ReturnType<typeof vi.fn> };
|
||||
measurementReminder: { findMany: ReturnType<typeof vi.fn> };
|
||||
workout: { findMany: ReturnType<typeof vi.fn> };
|
||||
};
|
||||
|
||||
const dayMs = 24 * 60 * 60 * 1000;
|
||||
@@ -79,6 +89,7 @@ beforeEach(() => {
|
||||
gender: "MALE",
|
||||
});
|
||||
prismaMock.measurement.findMany.mockResolvedValue([]);
|
||||
prismaMock.measurement.groupBy.mockResolvedValue([]);
|
||||
prismaMock.measurementRollup.findMany.mockResolvedValue([]);
|
||||
prismaMock.moodEntry.findMany.mockResolvedValue([]);
|
||||
prismaMock.moodEntry.findFirst.mockResolvedValue(null);
|
||||
@@ -86,6 +97,9 @@ beforeEach(() => {
|
||||
prismaMock.moodEntryRollup.findFirst.mockResolvedValue(null);
|
||||
prismaMock.medication.findMany.mockResolvedValue([]);
|
||||
prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([]);
|
||||
prismaMock.labResult.findMany.mockResolvedValue([]);
|
||||
prismaMock.measurementReminder.findMany.mockResolvedValue([]);
|
||||
prismaMock.workout.findMany.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
describe("extractFeatures — v1.4.36 W3 bucketed payload", () => {
|
||||
@@ -187,3 +201,199 @@ describe("extractFeatures — v1.4.36 W3 bucketed payload", () => {
|
||||
expect(FEATURES_MAX_BYTES).toBe(5 * 1024 * 1024);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── v1.22 — cross-signal integration blocks ──────────────────────────────
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
function measurementRow(type: string, value: number, daysAgo: number) {
|
||||
return {
|
||||
type,
|
||||
value,
|
||||
measuredAt: new Date(Date.now() - daysAgo * DAY_MS),
|
||||
sleepStage: null,
|
||||
source: "MANUAL",
|
||||
deviceType: null,
|
||||
};
|
||||
}
|
||||
|
||||
function labRow(opts: {
|
||||
analyte: string;
|
||||
value: number | null;
|
||||
referenceLow: number | null;
|
||||
referenceHigh: number | null;
|
||||
daysAgo: number;
|
||||
hidden?: boolean;
|
||||
valueText?: string | null;
|
||||
}) {
|
||||
return {
|
||||
analyte: opts.analyte,
|
||||
panel: null,
|
||||
unit: "mg/dL",
|
||||
value: opts.value,
|
||||
valueText: opts.valueText ?? null,
|
||||
referenceLow: opts.referenceLow,
|
||||
referenceHigh: opts.referenceHigh,
|
||||
takenAt: new Date(Date.now() - opts.daysAgo * DAY_MS),
|
||||
biomarkerId: opts.hidden === undefined ? null : "bm-" + opts.analyte,
|
||||
biomarker:
|
||||
opts.hidden === undefined
|
||||
? null
|
||||
: {
|
||||
id: "bm-" + opts.analyte,
|
||||
name: opts.analyte,
|
||||
unit: "mg/dL",
|
||||
lowerBound: opts.referenceLow,
|
||||
upperBound: opts.referenceHigh,
|
||||
panel: null,
|
||||
hidden: opts.hidden,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("extractFeatures — v1.22 glucose aggregate block", () => {
|
||||
it("emits a glucose block from BLOOD_GLUCOSE readings", async () => {
|
||||
prismaMock.measurement.findMany.mockResolvedValue([
|
||||
measurementRow("BLOOD_GLUCOSE", 95, 1),
|
||||
measurementRow("BLOOD_GLUCOSE", 110, 10),
|
||||
measurementRow("BLOOD_GLUCOSE", 105, 40),
|
||||
]);
|
||||
const f = await extractFeatures("user-1", false);
|
||||
expect(f.glucose).toBeDefined();
|
||||
expect(f.glucose?.latest).not.toBeNull();
|
||||
expect(f.glucose?.coverage.count).toBe(3);
|
||||
});
|
||||
|
||||
it("omits the glucose block when there are no readings", async () => {
|
||||
const f = await extractFeatures("user-1", false);
|
||||
expect(f.glucose).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractFeatures — v1.22 labs briefing block", () => {
|
||||
it("surfaces an abnormal (out-of-range) biomarker", async () => {
|
||||
prismaMock.labResult.findMany.mockResolvedValue([
|
||||
labRow({
|
||||
analyte: "LDL",
|
||||
value: 200,
|
||||
referenceLow: 0,
|
||||
referenceHigh: 130,
|
||||
daysAgo: 5,
|
||||
}),
|
||||
]);
|
||||
const f = await extractFeatures("user-1", false);
|
||||
expect(f.labs).toBeDefined();
|
||||
expect(f.labs?.flagged[0]?.analyte).toBe("LDL");
|
||||
expect(f.labs?.flagged[0]?.rangeStatus).toBe("above");
|
||||
});
|
||||
|
||||
it("excludes a HIDDEN biomarker even when it is abnormal", async () => {
|
||||
prismaMock.labResult.findMany.mockResolvedValue([
|
||||
labRow({
|
||||
analyte: "Ferritin",
|
||||
value: 500,
|
||||
referenceLow: 30,
|
||||
referenceHigh: 300,
|
||||
daysAgo: 3,
|
||||
hidden: true,
|
||||
}),
|
||||
]);
|
||||
const f = await extractFeatures("user-1", false);
|
||||
// The only marker is hidden + abnormal → block omitted entirely.
|
||||
expect(f.labs).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not surface an in-range, non-trending marker", async () => {
|
||||
prismaMock.labResult.findMany.mockResolvedValue([
|
||||
labRow({
|
||||
analyte: "TSH",
|
||||
value: 2,
|
||||
referenceLow: 0.4,
|
||||
referenceHigh: 4,
|
||||
daysAgo: 4,
|
||||
}),
|
||||
]);
|
||||
const f = await extractFeatures("user-1", false);
|
||||
expect(f.labs).toBeUndefined();
|
||||
});
|
||||
|
||||
it("surfaces an in-range but trending marker (rising vs prior reading)", async () => {
|
||||
prismaMock.labResult.findMany.mockResolvedValue([
|
||||
// Newest first (route orders desc); both in range, but rising.
|
||||
labRow({
|
||||
analyte: "Glucose",
|
||||
value: 99,
|
||||
referenceLow: 70,
|
||||
referenceHigh: 100,
|
||||
daysAgo: 2,
|
||||
}),
|
||||
labRow({
|
||||
analyte: "Glucose",
|
||||
value: 80,
|
||||
referenceLow: 70,
|
||||
referenceHigh: 100,
|
||||
daysAgo: 60,
|
||||
}),
|
||||
]);
|
||||
const f = await extractFeatures("user-1", false);
|
||||
expect(f.labs?.flagged[0]?.trend).toBe("rising");
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractFeatures — v1.22 preventive-care block", () => {
|
||||
it("buckets reminders into overdue and due", async () => {
|
||||
prismaMock.measurementReminder.findMany.mockResolvedValue([
|
||||
{ label: "Blutbild", nextDueAt: new Date(Date.now() - 5 * DAY_MS) },
|
||||
{ label: "Augenarzt", nextDueAt: new Date(Date.now() + 7 * DAY_MS) },
|
||||
]);
|
||||
const f = await extractFeatures("user-1", false);
|
||||
expect(f.preventiveCare?.overdue[0]?.label).toBe("Blutbild");
|
||||
expect(f.preventiveCare?.overdue[0]?.daysOverdue).toBeGreaterThanOrEqual(4);
|
||||
expect(f.preventiveCare?.due[0]?.label).toBe("Augenarzt");
|
||||
});
|
||||
|
||||
it("omits the block when nothing is due or overdue", async () => {
|
||||
const f = await extractFeatures("user-1", false);
|
||||
expect(f.preventiveCare).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractFeatures — v1.22 workouts block", () => {
|
||||
it("aggregates counts + distance over the windows", async () => {
|
||||
prismaMock.workout.findMany.mockResolvedValue([
|
||||
{
|
||||
sportType: "running",
|
||||
startedAt: new Date(Date.now() - 1 * DAY_MS),
|
||||
durationSec: 1800,
|
||||
totalDistanceM: 5000,
|
||||
},
|
||||
{
|
||||
sportType: "cycling",
|
||||
startedAt: new Date(Date.now() - 20 * DAY_MS),
|
||||
durationSec: 3600,
|
||||
totalDistanceM: 20000,
|
||||
},
|
||||
]);
|
||||
const f = await extractFeatures("user-1", false);
|
||||
expect(f.workouts?.last7.count).toBe(1);
|
||||
expect(f.workouts?.last30.count).toBe(2);
|
||||
expect(f.workouts?.last7.totalDistanceKm).toBe(5);
|
||||
expect(f.workouts?.latest?.sportType).toBe("running");
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractFeatures — v1.22 integration blocks are briefing-scoped", () => {
|
||||
it("skips the extra reads on the tight Coach-snapshot window (sinceDays: 90)", async () => {
|
||||
await extractFeatures("user-1", false, { sinceDays: 90 });
|
||||
expect(prismaMock.labResult.findMany).not.toHaveBeenCalled();
|
||||
expect(prismaMock.measurementReminder.findMany).not.toHaveBeenCalled();
|
||||
expect(prismaMock.workout.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("runs the extra reads on the wide briefing window (sinceDays: 400)", async () => {
|
||||
await extractFeatures("user-1", false, { sinceDays: 400 });
|
||||
expect(prismaMock.labResult.findMany).toHaveBeenCalled();
|
||||
expect(prismaMock.measurementReminder.findMany).toHaveBeenCalled();
|
||||
expect(prismaMock.workout.findMany).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -26,6 +26,8 @@ import {
|
||||
type CorrelationResult,
|
||||
} from "@/lib/analytics/correlations";
|
||||
import { getMedicationCategories } from "@/lib/medication-category";
|
||||
import { classifyReferenceRange } from "@/lib/labs/reference-range";
|
||||
import { resolveLabFields } from "@/lib/labs/serialise";
|
||||
import { readRollupBuckets } from "@/lib/rollups/measurement-rollups";
|
||||
import {
|
||||
ensureUserMoodRollupsFresh,
|
||||
@@ -197,6 +199,77 @@ export interface AggregatedFeatures {
|
||||
* forces the briefing to regenerate. See `computeSignalsOfDay`.
|
||||
*/
|
||||
signalsOfDay?: SignalOfDay[];
|
||||
/**
|
||||
* v1.22 — glucose aggregate block. Glucose previously reached the briefing
|
||||
* only as a single `signalOfDay` row; this is the present-and-trend block the
|
||||
* other vitals already carry (7 / 30 / 90-day means + a 30-day slope), from
|
||||
* the canonical stored value. Omitted when the user has no glucose readings.
|
||||
*/
|
||||
glucose?: {
|
||||
avg7: number | null;
|
||||
avg30: number | null;
|
||||
avg90: number | null;
|
||||
latest: number | null;
|
||||
latestDaysAgo: number | null;
|
||||
slope30: number | null;
|
||||
coverage: DataCoverage;
|
||||
};
|
||||
/**
|
||||
* v1.22 — recent FLAGGED biomarkers (abnormal or trending). Closes the
|
||||
* biggest siloed domain: labs reached the Coach snapshot but never the
|
||||
* briefing. Hidden biomarkers (the W3 catalog `hidden` flag) are excluded;
|
||||
* qualitative rows carry a neutral `unknown` status (no fabricated verdict).
|
||||
* Only abnormal / trending markers surface (the briefing-relevant ones) and
|
||||
* the list is bounded. Omitted when nothing is flagged.
|
||||
*/
|
||||
labs?: {
|
||||
flagged: Array<{
|
||||
analyte: string;
|
||||
value: number | null;
|
||||
valueText: string | null;
|
||||
unit: string;
|
||||
rangeStatus: "in-range" | "below" | "above" | "unknown";
|
||||
/** Direction vs the immediately prior reading; null without a prior. */
|
||||
trend: "rising" | "falling" | "flat" | null;
|
||||
takenAt: string;
|
||||
daysAgo: number;
|
||||
}>;
|
||||
/** Count of flagged markers (may exceed the bounded `flagged` length). */
|
||||
flaggedCount: number;
|
||||
};
|
||||
/**
|
||||
* v1.22 — preventive-care (Vorsorge) read-side: due + overdue items. The
|
||||
* early-nudge use case ("your screening is overdue"). Previously fully siloed
|
||||
* — captured + scheduled but read by no AI surface. Labels are sanitised
|
||||
* (user free-text). Omitted when nothing is due or overdue.
|
||||
*/
|
||||
preventiveCare?: {
|
||||
overdue: Array<{ label: string; daysOverdue: number }>;
|
||||
due: Array<{ label: string; daysUntil: number }>;
|
||||
};
|
||||
/**
|
||||
* v1.22 — workout aggregate. The briefing previously saw activity only as
|
||||
* ACTIVITY_STEPS; the `Workout` table never reached it. Provider-agnostic
|
||||
* counts + load over the trailing windows. Omitted when no workouts logged.
|
||||
*/
|
||||
workouts?: {
|
||||
last7: {
|
||||
count: number;
|
||||
totalDurationMin: number;
|
||||
totalDistanceKm: number | null;
|
||||
};
|
||||
last30: {
|
||||
count: number;
|
||||
totalDurationMin: number;
|
||||
totalDistanceKm: number | null;
|
||||
};
|
||||
latest: {
|
||||
sportType: string;
|
||||
daysAgo: number;
|
||||
durationMin: number;
|
||||
distanceKm: number | null;
|
||||
} | null;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -649,6 +722,267 @@ function computeSignalsOfDay(
|
||||
.slice(0, 3);
|
||||
}
|
||||
|
||||
// ─── v1.22 — cross-signal integration blocks (briefing-scoped) ────────────
|
||||
|
||||
/**
|
||||
* v1.22 — the integration blocks (labs / preventive-care / workouts) carry
|
||||
* extra DB reads. They are BRIEFING-scoped: the daily briefing reads a wide
|
||||
* window (`BRIEFING_FEATURE_WINDOW_DAYS`) and narrates long trends, while the
|
||||
* Coach snapshot path calls `extractFeatures` with a tight ~90-day window and
|
||||
* already carries its own labs / illness / workout context. Gating on the read
|
||||
* window keeps these extra reads off every Coach turn without a new caller flag:
|
||||
* the briefing window (400) clears it, the Coach window (90) does not.
|
||||
*/
|
||||
const INTEGRATION_BLOCK_MIN_WINDOW_DAYS = 180;
|
||||
|
||||
/** Days a preventive-care item must be due within to surface as "due soon". */
|
||||
const PREVENTIVE_DUE_HORIZON_DAYS = 21;
|
||||
|
||||
/** Cap on items surfaced per preventive-care bucket. */
|
||||
const PREVENTIVE_MAX_PER_BUCKET = 5;
|
||||
|
||||
/** Cap on flagged biomarkers surfaced to the briefing. */
|
||||
const LABS_MAX_FLAGGED = 8;
|
||||
|
||||
/** Only lab readings within this many months are considered "recent". */
|
||||
const LABS_LOOKBACK_MONTHS = 12;
|
||||
|
||||
/** Trailing window (days) for the workout aggregate. */
|
||||
const WORKOUT_WINDOW_DAYS = 90;
|
||||
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Strip control chars + collapse whitespace, then bound the length, before a
|
||||
* user-supplied label can reach the briefing prompt. Mirrors the labs /
|
||||
* illness snapshot label handling — a self-scoped prompt-injection surface.
|
||||
*/
|
||||
function sanitizeLabel(text: string, max = 80): string {
|
||||
let out = "";
|
||||
for (const ch of text) {
|
||||
const code = ch.codePointAt(0) ?? 0;
|
||||
out += code < 0x20 || (code >= 0x7f && code <= 0x9f) ? " " : ch;
|
||||
}
|
||||
return out.replace(/\s+/g, " ").trim().slice(0, max);
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.22 — recent FLAGGED biomarkers (abnormal or trending) for the briefing.
|
||||
* Most-recent reading per biomarker over the lookback window; hidden markers
|
||||
* excluded; qualitative rows neutral. Only abnormal (below/above) OR trending
|
||||
* markers surface, bounded. Returns `undefined` when nothing is flagged so the
|
||||
* block is omitted rather than emitting an empty shape.
|
||||
*/
|
||||
async function readLabsBriefingBlock(
|
||||
userId: string,
|
||||
now: number,
|
||||
): Promise<AggregatedFeatures["labs"] | undefined> {
|
||||
const nowDate = new Date(now);
|
||||
const cutoff = new Date(nowDate);
|
||||
cutoff.setMonth(cutoff.getMonth() - LABS_LOOKBACK_MONTHS);
|
||||
|
||||
const rows = await prisma.labResult.findMany({
|
||||
where: { userId, deletedAt: null, takenAt: { gte: cutoff, lte: nowDate } },
|
||||
orderBy: { takenAt: "desc" },
|
||||
take: LABS_MAX_FLAGGED * 16,
|
||||
select: {
|
||||
analyte: true,
|
||||
panel: true,
|
||||
unit: true,
|
||||
value: true,
|
||||
valueText: true,
|
||||
referenceLow: true,
|
||||
referenceHigh: true,
|
||||
takenAt: true,
|
||||
biomarkerId: true,
|
||||
biomarker: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
unit: true,
|
||||
lowerBound: true,
|
||||
upperBound: true,
|
||||
panel: true,
|
||||
hidden: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (rows.length === 0) return undefined;
|
||||
|
||||
// Group rows per biomarker identity (linked id, else lower-cased analyte),
|
||||
// newest-first, so we can read the latest reading + the immediately prior one
|
||||
// for a trend. Hidden markers are dropped entirely.
|
||||
const byMarker = new Map<string, typeof rows>();
|
||||
for (const row of rows) {
|
||||
if (row.biomarker?.hidden) continue;
|
||||
const resolved = resolveLabFields(row, row.biomarker);
|
||||
const key = row.biomarkerId ?? `analyte:${resolved.analyte.toLowerCase()}`;
|
||||
const list = byMarker.get(key) ?? [];
|
||||
list.push(row);
|
||||
byMarker.set(key, list);
|
||||
}
|
||||
|
||||
const flagged: NonNullable<AggregatedFeatures["labs"]>["flagged"] = [];
|
||||
for (const list of byMarker.values()) {
|
||||
const latest = list[0];
|
||||
const resolved = resolveLabFields(latest, latest.biomarker);
|
||||
const rangeStatus =
|
||||
latest.value === null
|
||||
? ("unknown" as const)
|
||||
: classifyReferenceRange(
|
||||
latest.value,
|
||||
resolved.referenceLow,
|
||||
resolved.referenceHigh,
|
||||
);
|
||||
|
||||
// Trend = latest numeric reading vs the immediately prior numeric reading.
|
||||
let trend: "rising" | "falling" | "flat" | null = null;
|
||||
if (latest.value !== null) {
|
||||
const prior = list.find((r, i) => i > 0 && r.value !== null);
|
||||
if (prior?.value != null) {
|
||||
const delta = latest.value - prior.value;
|
||||
const eps = Math.max(Math.abs(prior.value) * 0.02, 1e-9);
|
||||
trend = delta > eps ? "rising" : delta < -eps ? "falling" : "flat";
|
||||
}
|
||||
}
|
||||
|
||||
const isAbnormal = rangeStatus === "below" || rangeStatus === "above";
|
||||
const isTrending = trend === "rising" || trend === "falling";
|
||||
if (!isAbnormal && !isTrending) continue;
|
||||
|
||||
flagged.push({
|
||||
analyte: resolved.analyte,
|
||||
value: latest.value,
|
||||
valueText: latest.valueText ? sanitizeLabel(latest.valueText, 60) : null,
|
||||
unit: resolved.unit,
|
||||
rangeStatus,
|
||||
trend,
|
||||
takenAt: latest.takenAt.toISOString(),
|
||||
daysAgo: Math.round((now - latest.takenAt.getTime()) / MS_PER_DAY),
|
||||
});
|
||||
}
|
||||
|
||||
if (flagged.length === 0) return undefined;
|
||||
// Abnormal markers lead, then most-recent first.
|
||||
flagged.sort((a, b) => {
|
||||
const abn = (s: typeof a.rangeStatus) =>
|
||||
s === "below" || s === "above" ? 0 : 1;
|
||||
const d = abn(a.rangeStatus) - abn(b.rangeStatus);
|
||||
return d !== 0 ? d : a.daysAgo - b.daysAgo;
|
||||
});
|
||||
return {
|
||||
flagged: flagged.slice(0, LABS_MAX_FLAGGED),
|
||||
flaggedCount: flagged.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.22 — preventive-care (Vorsorge) due + overdue read-side. Reads the
|
||||
* user's enabled, live reminders and buckets by the server-authoritative
|
||||
* `nextDueAt`. Returns `undefined` when nothing is due or overdue.
|
||||
*/
|
||||
async function readPreventiveCareBlock(
|
||||
userId: string,
|
||||
now: number,
|
||||
): Promise<AggregatedFeatures["preventiveCare"] | undefined> {
|
||||
const horizon = new Date(now + PREVENTIVE_DUE_HORIZON_DAYS * MS_PER_DAY);
|
||||
const rows = await prisma.measurementReminder.findMany({
|
||||
where: {
|
||||
userId,
|
||||
deletedAt: null,
|
||||
enabled: true,
|
||||
nextDueAt: { not: null, lte: horizon },
|
||||
},
|
||||
orderBy: { nextDueAt: "asc" },
|
||||
take: (PREVENTIVE_MAX_PER_BUCKET + 1) * 4,
|
||||
select: { label: true, nextDueAt: true },
|
||||
});
|
||||
if (rows.length === 0) return undefined;
|
||||
|
||||
const overdue: NonNullable<AggregatedFeatures["preventiveCare"]>["overdue"] =
|
||||
[];
|
||||
const due: NonNullable<AggregatedFeatures["preventiveCare"]>["due"] = [];
|
||||
for (const r of rows) {
|
||||
if (!r.nextDueAt) continue;
|
||||
const label = sanitizeLabel(r.label);
|
||||
if (!label) continue;
|
||||
const diffMs = r.nextDueAt.getTime() - now;
|
||||
if (diffMs < 0) {
|
||||
overdue.push({ label, daysOverdue: Math.round(-diffMs / MS_PER_DAY) });
|
||||
} else {
|
||||
due.push({ label, daysUntil: Math.round(diffMs / MS_PER_DAY) });
|
||||
}
|
||||
}
|
||||
if (overdue.length === 0 && due.length === 0) return undefined;
|
||||
return {
|
||||
overdue: overdue.slice(0, PREVENTIVE_MAX_PER_BUCKET),
|
||||
due: due.slice(0, PREVENTIVE_MAX_PER_BUCKET),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.22 — workout aggregate over the trailing window. Provider-agnostic:
|
||||
* counts + summed duration + summed distance (km, when any source reported it)
|
||||
* over 7 / 30 days, plus the latest workout. Returns `undefined` when no
|
||||
* workouts fall in the window.
|
||||
*/
|
||||
async function readWorkoutsBlock(
|
||||
userId: string,
|
||||
now: number,
|
||||
): Promise<AggregatedFeatures["workouts"] | undefined> {
|
||||
const since = new Date(now - WORKOUT_WINDOW_DAYS * MS_PER_DAY);
|
||||
const rows = await prisma.workout.findMany({
|
||||
where: { userId, startedAt: { gte: since } },
|
||||
orderBy: { startedAt: "desc" },
|
||||
take: 2000,
|
||||
select: {
|
||||
sportType: true,
|
||||
startedAt: true,
|
||||
durationSec: true,
|
||||
totalDistanceM: true,
|
||||
},
|
||||
});
|
||||
if (rows.length === 0) return undefined;
|
||||
|
||||
const tally = (windowDays: number) => {
|
||||
const cutoff = now - windowDays * MS_PER_DAY;
|
||||
let count = 0;
|
||||
let durationSec = 0;
|
||||
let distanceM = 0;
|
||||
let anyDistance = false;
|
||||
for (const w of rows) {
|
||||
if (w.startedAt.getTime() < cutoff) continue;
|
||||
count += 1;
|
||||
durationSec += w.durationSec;
|
||||
if (w.totalDistanceM != null) {
|
||||
distanceM += w.totalDistanceM;
|
||||
anyDistance = true;
|
||||
}
|
||||
}
|
||||
return {
|
||||
count,
|
||||
totalDurationMin: Math.round(durationSec / 60),
|
||||
totalDistanceKm: anyDistance
|
||||
? Math.round((distanceM / 1000) * 10) / 10
|
||||
: null,
|
||||
};
|
||||
};
|
||||
|
||||
const newest = rows[0];
|
||||
const latest = {
|
||||
sportType: sanitizeLabel(newest.sportType, 40),
|
||||
daysAgo: Math.round((now - newest.startedAt.getTime()) / MS_PER_DAY),
|
||||
durationMin: Math.round(newest.durationSec / 60),
|
||||
distanceKm:
|
||||
newest.totalDistanceM != null
|
||||
? Math.round((newest.totalDistanceM / 1000) * 10) / 10
|
||||
: null,
|
||||
};
|
||||
|
||||
return { last7: tally(7), last30: tally(30), latest };
|
||||
}
|
||||
|
||||
export async function extractFeatures(
|
||||
userId: string,
|
||||
includeRaw: boolean,
|
||||
@@ -1389,6 +1723,46 @@ export async function extractFeatures(
|
||||
features.signalsOfDay = signalsOfDay;
|
||||
}
|
||||
|
||||
// v1.22 — glucose aggregate block. Computed from the already-fetched
|
||||
// measurement set (no extra DB read), so it is always cheap to emit. Glucose
|
||||
// previously reached the briefing only as one `signalOfDay` row.
|
||||
const glucoseData = byType("BLOOD_GLUCOSE");
|
||||
if (glucoseData.length > 0) {
|
||||
const summary = summarize(toDataPoints(glucoseData));
|
||||
const newest = glucoseData[glucoseData.length - 1];
|
||||
features.glucose = {
|
||||
avg7: summary.avg7,
|
||||
avg30: summary.avg30,
|
||||
avg90: avgInWindow(glucoseData, now, 90),
|
||||
latest: summary.latest,
|
||||
latestDaysAgo: Math.round(
|
||||
(now - newest.measuredAt.getTime()) / (24 * 60 * 60 * 1000),
|
||||
),
|
||||
slope30: summary.slope30?.slope ?? null,
|
||||
coverage: computeCoverage(glucoseData, now),
|
||||
};
|
||||
}
|
||||
|
||||
// v1.22 — cross-signal integration blocks (labs / preventive-care /
|
||||
// workouts). Briefing-scoped: only the wide briefing window pays the extra
|
||||
// reads; the tight Coach-snapshot window (which carries its own labs / illness
|
||||
// / workout context) skips them. Each block is omitted when its domain is
|
||||
// empty (no fabricated zeros). Fetched in parallel — independent reads.
|
||||
const includeIntegrations =
|
||||
sinceCutoff === null ||
|
||||
(typeof sinceDays === "number" &&
|
||||
sinceDays >= INTEGRATION_BLOCK_MIN_WINDOW_DAYS);
|
||||
if (includeIntegrations) {
|
||||
const [labs, preventiveCare, workouts] = await Promise.all([
|
||||
readLabsBriefingBlock(userId, now),
|
||||
readPreventiveCareBlock(userId, now),
|
||||
readWorkoutsBlock(userId, now),
|
||||
]);
|
||||
if (labs) features.labs = labs;
|
||||
if (preventiveCare) features.preventiveCare = preventiveCare;
|
||||
if (workouts) features.workouts = workouts;
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
Reference in new issue
Block a user