mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
Merge branch 'wave/v125-delight' into release/v1.25.0
This commit is contained in:
commit
ccbf4e07ae
28 files changed
+1877
-8
No files matched your search
@@ -20,7 +20,7 @@ import {
|
||||
} from "@/lib/insights-layout";
|
||||
|
||||
describe("insights-layout v2 — section id universe", () => {
|
||||
it("carries exactly the eight default sections in render order", () => {
|
||||
it("carries exactly the default sections in render order", () => {
|
||||
expect([...INSIGHTS_SECTION_IDS]).toEqual([
|
||||
"wellness-scores",
|
||||
"daily-briefing",
|
||||
@@ -30,6 +30,9 @@ describe("insights-layout v2 — section id universe", () => {
|
||||
"cycle-summary",
|
||||
"signals",
|
||||
"rhythm-events",
|
||||
"health-status",
|
||||
"breathing",
|
||||
"labs-changes",
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -101,7 +101,7 @@ const EXPECTED_TYPES = [
|
||||
] as const;
|
||||
|
||||
describe("measurementTypeEnum coverage", () => {
|
||||
it("exposes the 66 canonical measurement types", () => {
|
||||
it("exposes the 67 canonical measurement types", () => {
|
||||
expect([...measurementTypeEnum.options].sort()).toEqual(
|
||||
[...EXPECTED_TYPES].sort(),
|
||||
);
|
||||
|
||||
@@ -113,6 +113,9 @@ export const INSIGHTS_SECTION_IDS = [
|
||||
"cycle-summary",
|
||||
"signals",
|
||||
"rhythm-events",
|
||||
"health-status",
|
||||
"breathing",
|
||||
"labs-changes",
|
||||
] as const;
|
||||
|
||||
export type InsightsSectionId = (typeof INSIGHTS_SECTION_IDS)[number];
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import {
|
||||
summariseBreathing,
|
||||
type BreathingRow,
|
||||
} from "@/lib/insights/breathing-screening";
|
||||
|
||||
function night(daysAgo: number, value: number): BreathingRow {
|
||||
return {
|
||||
value,
|
||||
measuredAt: new Date(Date.now() - daysAgo * 24 * 60 * 60 * 1000),
|
||||
};
|
||||
}
|
||||
|
||||
describe("summariseBreathing", () => {
|
||||
it("is absent with no rows at all", () => {
|
||||
const s = summariseBreathing([], []);
|
||||
expect(s.present).toBe(false);
|
||||
expect(s.nights).toBe(0);
|
||||
expect(s.classification).toBeNull();
|
||||
expect(s.trend).toBeNull();
|
||||
});
|
||||
|
||||
it("classifies not-elevated with index nights and no events", () => {
|
||||
const s = summariseBreathing([night(2, 3), night(1, 4)], []);
|
||||
expect(s.present).toBe(true);
|
||||
expect(s.nights).toBe(2);
|
||||
expect(s.recentMeanIndex).toBe(3.5);
|
||||
expect(s.classification).toBe("not-elevated");
|
||||
});
|
||||
|
||||
it("classifies elevated when the device flagged events", () => {
|
||||
const s = summariseBreathing([night(1, 5)], [night(1, 1)]);
|
||||
expect(s.present).toBe(true);
|
||||
expect(s.eventCount).toBe(1);
|
||||
expect(s.classification).toBe("elevated");
|
||||
});
|
||||
|
||||
it("computes an upward trend over enough nights", () => {
|
||||
const rows = [
|
||||
night(8, 2),
|
||||
night(7, 2),
|
||||
night(6, 2),
|
||||
night(5, 8),
|
||||
night(4, 8),
|
||||
night(3, 8),
|
||||
night(2, 8),
|
||||
night(1, 8),
|
||||
];
|
||||
const s = summariseBreathing(rows, []);
|
||||
expect(s.trend).toBe("up");
|
||||
});
|
||||
|
||||
it("leaves the trend null with too few nights", () => {
|
||||
const s = summariseBreathing([night(2, 4), night(1, 5)], []);
|
||||
expect(s.trend).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { summariseHealthStatus } from "@/lib/insights/health-status";
|
||||
import type { VitalDeviation } from "@/lib/insights/derived/coincident-deviation";
|
||||
import type { ChangepointSignal } from "@/lib/insights/derived/changepoint";
|
||||
|
||||
function vital(
|
||||
outside: boolean,
|
||||
direction: VitalDeviation["direction"],
|
||||
): VitalDeviation {
|
||||
return {
|
||||
type: "RESTING_HEART_RATE",
|
||||
value: 70,
|
||||
center: 60,
|
||||
low: 55,
|
||||
high: 65,
|
||||
outside,
|
||||
direction,
|
||||
};
|
||||
}
|
||||
|
||||
function shift(): ChangepointSignal {
|
||||
return {
|
||||
metric: "WEIGHT",
|
||||
breakDate: "2026-06-10",
|
||||
beforeMean: 80,
|
||||
afterMean: 82,
|
||||
direction: "up",
|
||||
magnitude: 1.6,
|
||||
};
|
||||
}
|
||||
|
||||
describe("summariseHealthStatus", () => {
|
||||
it("is absent when nothing is outside and no shift fired", () => {
|
||||
const s = summariseHealthStatus([vital(false, "in")], []);
|
||||
expect(s.present).toBe(false);
|
||||
expect(s.deviations).toHaveLength(0);
|
||||
expect(s.shifts).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("surfaces an out-of-band deviation", () => {
|
||||
const s = summariseHealthStatus([vital(true, "above")], []);
|
||||
expect(s.present).toBe(true);
|
||||
expect(s.deviations).toHaveLength(1);
|
||||
expect(s.deviations[0]).toMatchObject({
|
||||
type: "RESTING_HEART_RATE",
|
||||
direction: "above",
|
||||
});
|
||||
});
|
||||
|
||||
it("surfaces a changepoint shift even with no deviation", () => {
|
||||
const s = summariseHealthStatus([vital(false, "in")], [shift()]);
|
||||
expect(s.present).toBe(true);
|
||||
expect(s.shifts).toHaveLength(1);
|
||||
expect(s.shifts[0]).toMatchObject({ metric: "WEIGHT", direction: "up" });
|
||||
});
|
||||
|
||||
it("drops a vital flagged outside but with an 'in' direction", () => {
|
||||
const s = summariseHealthStatus([vital(true, "in")], []);
|
||||
expect(s.present).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import {
|
||||
summariseLabChanges,
|
||||
type LabChangeRow,
|
||||
} from "@/lib/insights/labs-changes";
|
||||
|
||||
function row(
|
||||
analyte: string,
|
||||
value: number,
|
||||
day: string,
|
||||
opts: { unit?: string; low?: number | null; high?: number | null } = {},
|
||||
): LabChangeRow {
|
||||
return {
|
||||
analyte,
|
||||
unit: opts.unit ?? "mg/dL",
|
||||
value,
|
||||
referenceLow: opts.low ?? null,
|
||||
referenceHigh: opts.high ?? null,
|
||||
takenAt: new Date(`${day}T08:00:00.000Z`),
|
||||
};
|
||||
}
|
||||
|
||||
describe("summariseLabChanges", () => {
|
||||
it("is absent with no rows", () => {
|
||||
expect(summariseLabChanges([])).toMatchObject({ present: false });
|
||||
});
|
||||
|
||||
it("is absent with a single panel", () => {
|
||||
const s = summariseLabChanges([row("LDL", 130, "2026-06-01")]);
|
||||
expect(s.present).toBe(false);
|
||||
});
|
||||
|
||||
it("is absent when no analyte is shared across panels", () => {
|
||||
const s = summariseLabChanges([
|
||||
row("LDL", 130, "2026-06-01"),
|
||||
row("HbA1c", 5.4, "2026-05-01"),
|
||||
]);
|
||||
expect(s.present).toBe(false);
|
||||
});
|
||||
|
||||
it("pairs the two most-recent panels for a shared analyte", () => {
|
||||
const s = summariseLabChanges([
|
||||
row("LDL", 120, "2026-06-01", { high: 116 }),
|
||||
row("LDL", 140, "2026-05-01"),
|
||||
row("LDL", 150, "2026-04-01"),
|
||||
]);
|
||||
expect(s.present).toBe(true);
|
||||
expect(s.latestDate).toBe("2026-06-01");
|
||||
expect(s.previousDate).toBe("2026-05-01");
|
||||
expect(s.changes).toHaveLength(1);
|
||||
const c = s.changes[0];
|
||||
expect(c.latest).toBe(120);
|
||||
expect(c.previous).toBe(140);
|
||||
expect(c.delta).toBe(-20);
|
||||
expect(c.direction).toBe("down");
|
||||
expect(c.status).toBe("above");
|
||||
});
|
||||
|
||||
it("skips qualitative (non-finite) values", () => {
|
||||
const s = summariseLabChanges([
|
||||
row("LDL", Number.NaN, "2026-06-01"),
|
||||
row("LDL", 140, "2026-05-01"),
|
||||
]);
|
||||
expect(s.present).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* v1.25 — breathing-disturbance screening shaping.
|
||||
*
|
||||
* Reads the per-night sleep-breathing-disturbance index
|
||||
* (`BREATHING_DISTURBANCES`, lower-better) and the device-flagged
|
||||
* breathing-disturbance / possible-apnea events (`BREATHING_DISTURBANCE_EVENT`)
|
||||
* a sync writes, and folds them into a calm awareness summary: how many nights
|
||||
* are covered, the recent mean index, the short-window trend, and the device's
|
||||
* own classification.
|
||||
*
|
||||
* This is a SCREENING SIGNAL ONLY — never a HealthLog diagnosis. The card built
|
||||
* on top of this states that explicitly. The classification mirrors the device:
|
||||
* a night the device flagged with one or more elevated-breathing events reads
|
||||
* as "elevated"; with index nights and no flagged event it reads as
|
||||
* "not-elevated". HealthLog invents no numeric threshold of its own.
|
||||
*
|
||||
* The math lives here, free of Prisma, so the present / absent / trend states
|
||||
* are unit-testable in isolation.
|
||||
*/
|
||||
|
||||
/** A single per-night reading (index) or a device-flagged event row. */
|
||||
export interface BreathingRow {
|
||||
value: number;
|
||||
measuredAt: Date;
|
||||
}
|
||||
|
||||
export type BreathingTrend = "up" | "down" | "stable" | null;
|
||||
export type BreathingClassification = "not-elevated" | "elevated" | null;
|
||||
|
||||
export interface BreathingScreeningSummary {
|
||||
/** True when there is any index night or any flagged event to surface. */
|
||||
present: boolean;
|
||||
/** Number of nights with a per-night index reading. */
|
||||
nights: number;
|
||||
/** Mean of the index readings (lower-better), rounded to 1dp; null when none. */
|
||||
recentMeanIndex: number | null;
|
||||
/**
|
||||
* Direction of the recent index vs the prior window. "up" is worse (more
|
||||
* disturbance). Null when there are too few nights to compare.
|
||||
*/
|
||||
trend: BreathingTrend;
|
||||
/** Count of device-flagged breathing-disturbance / apnea events. */
|
||||
eventCount: number;
|
||||
/**
|
||||
* The device's own classification: "elevated" when it flagged events,
|
||||
* "not-elevated" when there are index nights and no flagged event, null when
|
||||
* there is no data at all.
|
||||
*/
|
||||
classification: BreathingClassification;
|
||||
}
|
||||
|
||||
/** Window (most-recent nights) the trend compares against the prior block. */
|
||||
const TREND_WINDOW_NIGHTS = 7;
|
||||
/** Need at least this many nights total before a trend is meaningful. */
|
||||
const MIN_TREND_NIGHTS = 6;
|
||||
/** Relative band within which the recent mean reads as "stable". */
|
||||
const STABLE_BAND = 0.1;
|
||||
|
||||
function mean(xs: readonly number[]): number {
|
||||
if (xs.length === 0) return 0;
|
||||
let sum = 0;
|
||||
for (const x of xs) sum += x;
|
||||
return sum / xs.length;
|
||||
}
|
||||
|
||||
const round1 = (n: number): number => Math.round(n * 10) / 10;
|
||||
|
||||
/**
|
||||
* Summarise the breathing-screening signal. `indexRows` are the per-night
|
||||
* index readings; `eventRows` are the device-flagged events. Order-independent
|
||||
* — the helper sorts by `measuredAt` for the trend.
|
||||
*/
|
||||
export function summariseBreathing(
|
||||
indexRows: readonly BreathingRow[],
|
||||
eventRows: readonly BreathingRow[],
|
||||
): BreathingScreeningSummary {
|
||||
const sorted = [...indexRows]
|
||||
.filter((r) => Number.isFinite(r.value))
|
||||
.sort((a, b) => a.measuredAt.getTime() - b.measuredAt.getTime());
|
||||
|
||||
const nights = sorted.length;
|
||||
const eventCount = eventRows.length;
|
||||
|
||||
if (nights === 0 && eventCount === 0) {
|
||||
return {
|
||||
present: false,
|
||||
nights: 0,
|
||||
recentMeanIndex: null,
|
||||
trend: null,
|
||||
eventCount: 0,
|
||||
classification: null,
|
||||
};
|
||||
}
|
||||
|
||||
const values = sorted.map((r) => r.value);
|
||||
const recentMeanIndex = nights > 0 ? round1(mean(values)) : null;
|
||||
|
||||
let trend: BreathingTrend = null;
|
||||
if (nights >= MIN_TREND_NIGHTS) {
|
||||
const recent = values.slice(-TREND_WINDOW_NIGHTS);
|
||||
const prior = values.slice(0, -TREND_WINDOW_NIGHTS);
|
||||
if (recent.length > 0 && prior.length > 0) {
|
||||
const recentMean = mean(recent);
|
||||
const priorMean = mean(prior);
|
||||
const band = Math.abs(priorMean) * STABLE_BAND;
|
||||
const delta = recentMean - priorMean;
|
||||
trend = Math.abs(delta) <= band ? "stable" : delta > 0 ? "up" : "down";
|
||||
}
|
||||
}
|
||||
|
||||
const classification: BreathingClassification =
|
||||
eventCount > 0 ? "elevated" : nights > 0 ? "not-elevated" : null;
|
||||
|
||||
return {
|
||||
present: true,
|
||||
nights,
|
||||
recentMeanIndex,
|
||||
trend,
|
||||
eventCount,
|
||||
classification,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* v1.25 — health-status / baseline-drift shaping.
|
||||
*
|
||||
* Surfaces what is drifting from the user's own normal by combining two
|
||||
* existing read-only engines:
|
||||
* - the personal-band deviations from the coincident-deviation flag
|
||||
* (`coincident-deviation.ts`, which fans `VITALS_BASELINE` across vitals),
|
||||
* keeping only the vitals that sit OUTSIDE their band today, and
|
||||
* - the dated, sustained level shifts from the changepoint detector
|
||||
* (`changepoint.ts`).
|
||||
*
|
||||
* This module owns only the pure reshaping — taking the engine outputs and
|
||||
* folding them into the flat response the card reads — so the present / absent
|
||||
* states are unit-testable without Prisma. It never labels a cause and never
|
||||
* diagnoses; it lists what changed and frames it as awareness only.
|
||||
*/
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import type { VitalDeviation } from "@/lib/insights/derived/coincident-deviation";
|
||||
import type { ChangepointSignal } from "@/lib/insights/derived/changepoint";
|
||||
|
||||
/** One vital sitting outside its personal band today. */
|
||||
export interface HealthStatusDeviation {
|
||||
type: MeasurementType;
|
||||
/** Today's value. */
|
||||
value: number;
|
||||
/** Band center (median). */
|
||||
center: number;
|
||||
low: number;
|
||||
high: number;
|
||||
/** Which side of the band the value falls on. */
|
||||
direction: "above" | "below";
|
||||
}
|
||||
|
||||
/** One dated, sustained level shift from the changepoint detector. */
|
||||
export interface HealthStatusShift {
|
||||
metric: MeasurementType;
|
||||
/** YYYY-MM-DD of the first day of the new level. */
|
||||
breakDate: string;
|
||||
beforeMean: number;
|
||||
afterMean: number;
|
||||
direction: "up" | "down";
|
||||
}
|
||||
|
||||
export interface HealthStatusSummary {
|
||||
/** True when there is at least one deviation or one shift to surface. */
|
||||
present: boolean;
|
||||
deviations: HealthStatusDeviation[];
|
||||
shifts: HealthStatusShift[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold the band deviations + level shifts into the response shape. `vitals` is
|
||||
* the full banded set from the coincident engine (any standing); only the
|
||||
* out-of-band ones become deviations. `shifts` is the changepoint output as-is.
|
||||
*/
|
||||
export function summariseHealthStatus(
|
||||
vitals: readonly VitalDeviation[],
|
||||
shifts: readonly ChangepointSignal[],
|
||||
): HealthStatusSummary {
|
||||
const deviations: HealthStatusDeviation[] = vitals
|
||||
.filter((v) => v.outside && v.direction !== "in")
|
||||
.map((v) => ({
|
||||
type: v.type,
|
||||
value: v.value,
|
||||
center: v.center,
|
||||
low: v.low,
|
||||
high: v.high,
|
||||
direction: v.direction === "above" ? "above" : "below",
|
||||
}));
|
||||
|
||||
const mappedShifts: HealthStatusShift[] = shifts.map((s) => ({
|
||||
metric: s.metric,
|
||||
breakDate: s.breakDate,
|
||||
beforeMean: s.beforeMean,
|
||||
afterMean: s.afterMean,
|
||||
direction: s.direction,
|
||||
}));
|
||||
|
||||
return {
|
||||
present: deviations.length + mappedShifts.length > 0,
|
||||
deviations,
|
||||
shifts: mappedShifts,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* v1.25 — "what changed since your last panel" shaping.
|
||||
*
|
||||
* Groups the user's numeric lab results by panel DATE (the calendar day of
|
||||
* `takenAt`, UTC), finds the two most-recent dates, and for every analyte that
|
||||
* appears in BOTH the latest and the previous panel reports the signed delta,
|
||||
* the direction, and where the latest value sits against its reference band
|
||||
* (`classifyReferenceRange`). Qualitative (valueText-only) results carry no
|
||||
* numeric value, so they are skipped — a delta is meaningless for them.
|
||||
*
|
||||
* Pure + Prisma-free so the present / absent states are unit-testable: it is
|
||||
* absent when there are fewer than two panel dates or no analyte is shared.
|
||||
* Neutral framing only — a delta is not a diagnosis.
|
||||
*/
|
||||
import {
|
||||
classifyReferenceRange,
|
||||
type ReferenceRangeStatus,
|
||||
} from "@/lib/labs/reference-range";
|
||||
|
||||
/** A single numeric lab reading. */
|
||||
export interface LabChangeRow {
|
||||
analyte: string;
|
||||
unit: string;
|
||||
value: number;
|
||||
referenceLow: number | null;
|
||||
referenceHigh: number | null;
|
||||
takenAt: Date;
|
||||
}
|
||||
|
||||
export interface LabChange {
|
||||
analyte: string;
|
||||
unit: string;
|
||||
latest: number;
|
||||
previous: number;
|
||||
/** Signed latest − previous, rounded to 2dp. */
|
||||
delta: number;
|
||||
direction: "up" | "down" | "flat";
|
||||
status: ReferenceRangeStatus;
|
||||
}
|
||||
|
||||
export interface LabChangesSummary {
|
||||
present: boolean;
|
||||
/** YYYY-MM-DD of the most-recent panel, or null when absent. */
|
||||
latestDate: string | null;
|
||||
/** YYYY-MM-DD of the prior panel, or null when absent. */
|
||||
previousDate: string | null;
|
||||
changes: LabChange[];
|
||||
}
|
||||
|
||||
const ABSENT: LabChangesSummary = {
|
||||
present: false,
|
||||
latestDate: null,
|
||||
previousDate: null,
|
||||
changes: [],
|
||||
};
|
||||
|
||||
/** UTC calendar-day key (YYYY-MM-DD) for a sample instant. */
|
||||
function dayKey(d: Date): string {
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/** Normalised analyte name for cross-panel matching (free-text tolerant). */
|
||||
function analyteKey(analyte: string): string {
|
||||
return analyte.trim().toLowerCase();
|
||||
}
|
||||
|
||||
const round2 = (n: number): number => Math.round(n * 100) / 100;
|
||||
|
||||
/**
|
||||
* Summarise the change between the two most-recent numeric lab panels. `rows`
|
||||
* should already be the user's live (non-deleted) numeric results; this helper
|
||||
* does the grouping + pairing.
|
||||
*/
|
||||
export function summariseLabChanges(
|
||||
rows: readonly LabChangeRow[],
|
||||
): LabChangesSummary {
|
||||
const numeric = rows.filter((r) => Number.isFinite(r.value));
|
||||
if (numeric.length === 0) return ABSENT;
|
||||
|
||||
// Group by panel day, keeping the latest reading per analyte within a day.
|
||||
const byDay = new Map<string, Map<string, LabChangeRow>>();
|
||||
for (const row of numeric) {
|
||||
const day = dayKey(row.takenAt);
|
||||
let analytes = byDay.get(day);
|
||||
if (!analytes) {
|
||||
analytes = new Map();
|
||||
byDay.set(day, analytes);
|
||||
}
|
||||
const key = analyteKey(row.analyte);
|
||||
const existing = analytes.get(key);
|
||||
if (!existing || row.takenAt.getTime() >= existing.takenAt.getTime()) {
|
||||
analytes.set(key, row);
|
||||
}
|
||||
}
|
||||
|
||||
const days = [...byDay.keys()].sort((a, b) => (a < b ? 1 : a > b ? -1 : 0));
|
||||
if (days.length < 2) return ABSENT;
|
||||
|
||||
const latestDate = days[0];
|
||||
const previousDate = days[1];
|
||||
const latest = byDay.get(latestDate)!;
|
||||
const previous = byDay.get(previousDate)!;
|
||||
|
||||
const changes: LabChange[] = [];
|
||||
for (const [key, latestRow] of latest) {
|
||||
const previousRow = previous.get(key);
|
||||
if (!previousRow) continue;
|
||||
const delta = round2(latestRow.value - previousRow.value);
|
||||
const direction: LabChange["direction"] =
|
||||
delta > 0 ? "up" : delta < 0 ? "down" : "flat";
|
||||
changes.push({
|
||||
analyte: latestRow.analyte,
|
||||
unit: latestRow.unit,
|
||||
latest: latestRow.value,
|
||||
previous: previousRow.value,
|
||||
delta,
|
||||
direction,
|
||||
status: classifyReferenceRange(
|
||||
latestRow.value,
|
||||
latestRow.referenceLow,
|
||||
latestRow.referenceHigh,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if (changes.length === 0) return ABSENT;
|
||||
|
||||
changes.sort((a, b) => a.analyte.localeCompare(b.analyte));
|
||||
|
||||
return { present: true, latestDate, previousDate, changes };
|
||||
}
|
||||
@@ -39,6 +39,7 @@ import { healthRecordPaths } from "./health-record";
|
||||
import { illnessPaths } from "./illness";
|
||||
import { importPaths } from "./import";
|
||||
import { insightsPaths } from "./insights";
|
||||
import { insightsSignalPaths } from "./insights-signals";
|
||||
import { labsPaths } from "./labs";
|
||||
import { ocrPaths } from "./ocr";
|
||||
import { measurementPaths } from "./measurements";
|
||||
@@ -72,6 +73,7 @@ export const openApiPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
...coachFeedbackPaths,
|
||||
...adminDiagnosticPaths,
|
||||
...insightsPaths,
|
||||
...insightsSignalPaths,
|
||||
...moodPaths,
|
||||
...settingsPaths,
|
||||
...consentPaths,
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* OpenAPI route module — v1.25 read-only insight signals.
|
||||
*
|
||||
* Three calm awareness reads built on existing engines:
|
||||
* - GET /api/insights/health-status baseline-drift (bands + changepoints)
|
||||
* - GET /api/insights/breathing-screening sleep-breathing screening signal
|
||||
* - GET /api/insights/labs-changes "what changed since your last panel"
|
||||
*
|
||||
* Each is pure compute over the rollup / lab tier — no provider call. Labels
|
||||
* are resolved client-side from the type tokens, so the wire carries tokens
|
||||
* only. Part of the OpenAPI route table; aggregated in `./index.ts`.
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
import type { ZodOpenApiObject } from "zod-openapi";
|
||||
|
||||
import { dataEnvelope, stdResponses } from "./shared";
|
||||
|
||||
const healthStatusResponse = z
|
||||
.object({
|
||||
present: z
|
||||
.boolean()
|
||||
.describe("True when at least one deviation or shift is surfaced."),
|
||||
deviations: z.array(
|
||||
z.object({
|
||||
type: z.string().describe("MeasurementType token."),
|
||||
value: z.number().describe("Today's value."),
|
||||
center: z.number().describe("Personal band center (median)."),
|
||||
low: z.number(),
|
||||
high: z.number(),
|
||||
direction: z.enum(["above", "below"]),
|
||||
}),
|
||||
),
|
||||
shifts: z.array(
|
||||
z.object({
|
||||
metric: z.string().describe("MeasurementType token."),
|
||||
breakDate: z
|
||||
.string()
|
||||
.describe("YYYY-MM-DD of the new level's first day."),
|
||||
beforeMean: z.number(),
|
||||
afterMean: z.number(),
|
||||
direction: z.enum(["up", "down"]),
|
||||
}),
|
||||
),
|
||||
generatedAt: z.string().describe("ISO-8601 instant the read was computed."),
|
||||
})
|
||||
.meta({
|
||||
id: "InsightsHealthStatus",
|
||||
description:
|
||||
"Vitals drifting from the user's personal normal — band deviations plus dated, sustained level shifts. Awareness only, never a diagnosis.",
|
||||
});
|
||||
|
||||
const breathingScreeningResponse = z
|
||||
.object({
|
||||
present: z.boolean(),
|
||||
nights: z.number().int().describe("Nights with a per-night index reading."),
|
||||
recentMeanIndex: z
|
||||
.number()
|
||||
.nullable()
|
||||
.describe("Mean of the index readings (lower-better); null when none."),
|
||||
trend: z
|
||||
.enum(["up", "down", "stable"])
|
||||
.nullable()
|
||||
.describe("Recent index vs the prior window; null when too few nights."),
|
||||
eventCount: z
|
||||
.number()
|
||||
.int()
|
||||
.describe("Device-flagged breathing-disturbance / apnea events."),
|
||||
classification: z
|
||||
.enum(["not-elevated", "elevated"])
|
||||
.nullable()
|
||||
.describe("The device's own classification; null when no data."),
|
||||
generatedAt: z.string(),
|
||||
})
|
||||
.meta({
|
||||
id: "InsightsBreathingScreening",
|
||||
description:
|
||||
"Sleep-breathing-disturbance screening signal — a screening signal only, never a diagnosis.",
|
||||
});
|
||||
|
||||
const labsChangesResponse = z
|
||||
.object({
|
||||
present: z.boolean(),
|
||||
latestDate: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe("YYYY-MM-DD of the most-recent panel."),
|
||||
previousDate: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe("YYYY-MM-DD of the prior panel."),
|
||||
changes: z.array(
|
||||
z.object({
|
||||
analyte: z.string(),
|
||||
unit: z.string(),
|
||||
latest: z.number(),
|
||||
previous: z.number(),
|
||||
delta: z.number().describe("Signed latest − previous."),
|
||||
direction: z.enum(["up", "down", "flat"]),
|
||||
status: z.enum(["in-range", "below", "above", "unknown"]),
|
||||
}),
|
||||
),
|
||||
generatedAt: z.string(),
|
||||
})
|
||||
.meta({
|
||||
id: "InsightsLabsChanges",
|
||||
description:
|
||||
"Per-analyte change between the two most-recent numeric lab panels. Neutral framing, never a diagnosis.",
|
||||
});
|
||||
|
||||
export const insightsSignalPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"/api/insights/health-status": {
|
||||
get: {
|
||||
tags: ["Insights"],
|
||||
summary: "Baseline-drift health status",
|
||||
description:
|
||||
"Surfaces vitals drifting from the user's personal normal — out-of-band deviations from the personal-baseline engine plus dated, sustained level shifts from the changepoint detector. Pure compute over the rollup tier; awareness only, never a diagnosis. Requires the insights module. Cookie or Bearer auth.",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The drift summary.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
healthStatusResponse,
|
||||
"InsightsHealthStatusEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/insights/breathing-screening": {
|
||||
get: {
|
||||
tags: ["Insights"],
|
||||
summary: "Sleep-breathing screening signal",
|
||||
description:
|
||||
"Summarises the last ~30 nights of the per-night sleep-breathing-disturbance index plus device-flagged events into a calm awareness read. A screening signal only — never a HealthLog diagnosis. Requires the insights module. Cookie or Bearer auth.",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The breathing-screening summary.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
breathingScreeningResponse,
|
||||
"InsightsBreathingScreeningEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/insights/labs-changes": {
|
||||
get: {
|
||||
tags: ["Insights"],
|
||||
summary: "What changed since your last lab panel",
|
||||
description:
|
||||
"Pairs the two most-recent numeric lab panels and reports each shared analyte's signed delta plus its reference-band standing. Absent when there are fewer than two panels or no analyte is shared. Neutral framing, never a diagnosis. Cookie or Bearer auth.",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The lab-change summary.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
labsChangesResponse,
|
||||
"InsightsLabsChangesEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -137,4 +137,29 @@ export const insightsKeys = {
|
||||
*/
|
||||
insightsCoachRead: (metric: string) =>
|
||||
["insights", "coach-read", metric] as const,
|
||||
|
||||
/**
|
||||
* v1.25 — baseline-drift read (`/api/insights/health-status`). Combines the
|
||||
* personal-band deviations + the dated changepoint shifts into one awareness
|
||||
* card. Pure compute over the rollup tier; the `["insights"]` prefix keeps it
|
||||
* in the standard invalidation fan-out (a measurement write busts it).
|
||||
*/
|
||||
insightsHealthStatus: () => ["insights", "health-status"] as const,
|
||||
|
||||
/**
|
||||
* v1.25 — breathing-disturbance screening read
|
||||
* (`/api/insights/breathing-screening`). Last ~30 nights of the per-night
|
||||
* index + device-flagged events. The `["insights"]` prefix carries the
|
||||
* invalidation fan-out from an Apple Health sleep batch write.
|
||||
*/
|
||||
insightsBreathingScreening: () =>
|
||||
["insights", "breathing-screening"] as const,
|
||||
|
||||
/**
|
||||
* v1.25 — "what changed since your last panel" read
|
||||
* (`/api/insights/labs-changes`). The two most-recent numeric lab panels'
|
||||
* shared-analyte deltas. The `["insights"]` prefix keeps it in the standard
|
||||
* invalidation fan-out.
|
||||
*/
|
||||
insightsLabsChanges: () => ["insights", "labs-changes"] as const,
|
||||
};
|
||||
Reference in new issue
Block a user