Merge branch 'wave/v125-delight' into release/v1.25.0

This commit is contained in:
Marc-André Bombeck committed 2026-06-28 14:41:41 +02:00
commit ccbf4e07ae
28 files changed
+1877 -8

No files matched your search

@@ -92,6 +92,14 @@ const NOT_COACH_OWNED_ROUTES: ReadonlyArray<string> = [
// wedge the user's ability to reorder the insights tile strip.
"src/app/api/insights/layout/route.ts",
"src/app/api/insights/targets/route.ts",
// v1.25 — read-only awareness cards for the overview. Each is pure compute
// over the rollup / lab tier (baseline-drift, sleep-breathing screening,
// last-panel lab deltas) and carries no assistant prose; they gate on the
// `insights` module, not on the Coach surface. Disabling Coach must not
// wedge these factual reads.
"src/app/api/insights/health-status/route.ts",
"src/app/api/insights/breathing-screening/route.ts",
"src/app/api/insights/labs-changes/route.ts",
];
const COACH_GATE_NEEDLE = 'requireAssistantSurface("coach")';
@@ -0,0 +1,73 @@
/**
* v1.25 — breathing-disturbance screening read.
*
* GET /api/insights/breathing-screening
*
* Reads the last ~30 days of per-night sleep-breathing-disturbance index
* (`BREATHING_DISTURBANCES`) and device-flagged breathing-disturbance / apnea
* events (`BREATHING_DISTURBANCE_EVENT`) and summarises them into a calm
* awareness read (nights, recent mean, trend, event count, the device's own
* classification). SCREENING SIGNAL ONLY — never a HealthLog diagnosis; the
* card states this explicitly.
*
* Mirrors `/api/insights/derived`: `apiHandler`, `requireAuth`, the `insights`
* module gate, the shared analytics-read budget. `userId` is narrowed from the
* session.
*/
import { apiError, apiSuccess } from "@/lib/api-response";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { prisma } from "@/lib/db";
import { summariseBreathing } from "@/lib/insights/breathing-screening";
export const dynamic = "force-dynamic";
const WINDOW_DAYS = 30;
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
const m = await requireModuleEnabled(user.id, "insights");
if (!m.enabled) return m.response;
const rl = await checkAnalyticsReadRateLimit(user.id);
if (!rl.allowed) {
return apiError("Too many analytics requests. Please retry later.", 429);
}
const now = new Date();
const since = new Date(now.getTime() - WINDOW_DAYS * 24 * 60 * 60 * 1000);
const rows = await prisma.measurement.findMany({
where: {
userId: user.id,
type: { in: ["BREATHING_DISTURBANCES", "BREATHING_DISTURBANCE_EVENT"] },
measuredAt: { gte: since },
},
select: { type: true, value: true, measuredAt: true },
orderBy: { measuredAt: "asc" },
});
const indexRows = rows
.filter((r) => r.type === "BREATHING_DISTURBANCES")
.map((r) => ({ value: r.value, measuredAt: r.measuredAt }));
const eventRows = rows
.filter((r) => r.type === "BREATHING_DISTURBANCE_EVENT")
.map((r) => ({ value: r.value, measuredAt: r.measuredAt }));
const summary = summariseBreathing(indexRows, eventRows);
annotate({
action: { name: "insights.breathing-screening.read" },
meta: {
present: summary.present,
nights: summary.nights,
events: summary.eventCount,
classification: summary.classification ?? "none",
},
});
return apiSuccess({ ...summary, generatedAt: now.toISOString() });
});
@@ -0,0 +1,67 @@
/**
* v1.25 — health-status / baseline-drift read.
*
* GET /api/insights/health-status
*
* Surfaces what is drifting from the user's own normal, drawn off two existing
* read-only engines — the personal-band deviations from the coincident-
* deviation flag (`coincident-deviation.ts`) and the dated, sustained level
* shifts from the changepoint detector (`changepoint.ts`). Pure compute over
* the rollup tier: no provider call, no fabricated value, never a diagnosis.
*
* Mirrors `/api/insights/derived`: `apiHandler` wrapper, `requireAuth`, the
* `insights` module gate, the shared analytics-read budget. `userId` is always
* narrowed from the session. The labels are mapped client-side from the
* MeasurementType, so the wire carries the type tokens only.
*/
import { apiError, apiSuccess } from "@/lib/api-response";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { prisma } from "@/lib/db";
import { loadBaselineProfile } from "@/lib/insights/derived";
import { computeCoincidentDeviation } from "@/lib/insights/derived/coincident-deviation";
import { buildChangepointSignals } from "@/lib/insights/derived/changepoint";
import { summariseHealthStatus } from "@/lib/insights/health-status";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
const m = await requireModuleEnabled(user.id, "insights");
if (!m.enabled) return m.response;
const rl = await checkAnalyticsReadRateLimit(user.id);
if (!rl.allowed) {
return apiError("Too many analytics requests. Please retry later.", 429);
}
const now = new Date();
const profile = await loadBaselineProfile(prisma, user.id);
const coincident = await computeCoincidentDeviation(user.id, profile, {
tz: user.timezone,
now,
});
const shifts = await buildChangepointSignals(user.id, now);
const vitals = coincident.status === "ok" ? coincident.value.vitals : [];
const summary = summariseHealthStatus(vitals, shifts);
annotate({
action: { name: "insights.health-status.read" },
meta: {
deviations: summary.deviations.length,
shifts: summary.shifts.length,
},
});
return apiSuccess({
present: summary.present,
deviations: summary.deviations,
shifts: summary.shifts,
generatedAt: now.toISOString(),
});
});
@@ -0,0 +1,67 @@
/**
* v1.25 — "what changed since your last panel" read.
*
* GET /api/insights/labs-changes
*
* Groups the user's live numeric lab results by panel date, pairs the two
* most-recent panels, and reports the per-analyte delta + reference-band
* standing (`summariseLabChanges`). Absent when there are fewer than two
* panels or no analyte is shared. Neutral framing only — a delta is not a
* diagnosis.
*
* Mirrors `/api/insights/derived`: `apiHandler`, `requireAuth`, the `insights`
* module gate, the shared analytics-read budget. `userId` is narrowed from the
* session; soft-deleted rows (`deletedAt`) are filtered out.
*/
import { apiError, apiSuccess } from "@/lib/api-response";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { prisma } from "@/lib/db";
import { summariseLabChanges } from "@/lib/insights/labs-changes";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
const m = await requireModuleEnabled(user.id, "insights");
if (!m.enabled) return m.response;
const rl = await checkAnalyticsReadRateLimit(user.id);
if (!rl.allowed) {
return apiError("Too many analytics requests. Please retry later.", 429);
}
const rows = await prisma.labResult.findMany({
where: { userId: user.id, deletedAt: null, value: { not: null } },
select: {
analyte: true,
unit: true,
value: true,
referenceLow: true,
referenceHigh: true,
takenAt: true,
},
orderBy: { takenAt: "desc" },
});
const summary = summariseLabChanges(
rows.map((r) => ({
analyte: r.analyte,
unit: r.unit,
value: r.value as number,
referenceLow: r.referenceLow,
referenceHigh: r.referenceHigh,
takenAt: r.takenAt,
})),
);
annotate({
action: { name: "insights.labs-changes.read" },
meta: { present: summary.present, changes: summary.changes.length },
});
return apiSuccess({ ...summary, generatedAt: new Date().toISOString() });
});
+30
View File
@@ -127,6 +127,33 @@ const RhythmEventsCard = dynamic(
})),
{ ssr: false },
);
// v1.25 — baseline-drift card. Un-mounts when nothing is drifting; owns its own
// read-only `/api/insights/health-status` query.
const HealthStatusCard = dynamic(
() =>
import("@/components/insights/health-status-card").then((mod) => ({
default: mod.HealthStatusCard,
})),
{ ssr: false },
);
// v1.25 — sleep-breathing screening card. Un-mounts on no data; screening
// signal only, never a diagnosis.
const BreathingScreeningCard = dynamic(
() =>
import("@/components/insights/breathing-screening-card").then((mod) => ({
default: mod.BreathingScreeningCard,
})),
{ ssr: false },
);
// v1.25 — "what changed since your last lab panel" card. Un-mounts with fewer
// than two panels or no shared analyte.
const LabsChangesCard = dynamic(
() =>
import("@/components/insights/labs-changes-card").then((mod) => ({
default: mod.LabsChangesCard,
})),
{ ssr: false },
);
// v1.10.3 — "Today's signal" headline card. Promotes COINCIDENT_DEVIATION from
// a buried below-the-fold tile to the top-of-overview daily read (the
// always-present Apple/WHOOP/Oura pattern). Deferred behind `next/dynamic`; it
@@ -472,6 +499,9 @@ export default function InsightsPage() {
) : null,
signals: <CoincidentDeviationCard enabled={isAuthenticated} />,
"rhythm-events": <RhythmEventsCard enabled={isAuthenticated} />,
"health-status": <HealthStatusCard enabled={isAuthenticated} />,
breathing: <BreathingScreeningCard enabled={isAuthenticated} />,
"labs-changes": <LabsChangesCard enabled={isAuthenticated} />,
};
const orderedSectionIds = orderedVisibleSectionIds(layout);
@@ -0,0 +1,137 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { Wind } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { useAuth } from "@/hooks/use-auth";
import { cn } from "@/lib/utils";
import { apiGet } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import { SectionHeading } from "@/components/insights/section-heading";
import type {
BreathingClassification,
BreathingTrend,
} from "@/lib/insights/breathing-screening";
/**
* v1.25 — sleep-breathing screening card.
*
* A calm read off the read-only `/api/insights/breathing-screening` route: the
* device's own classification, the recent index trend, and the device-flagged
* event count. It un-mounts when there is no data (`present === false`). Every
* render carries the mandatory screening-only disclaimer — this is a SCREENING
* SIGNAL, not a diagnosis. Neutral tone, no chart, no red.
*/
interface BreathingScreeningResponse {
present: boolean;
nights: number;
recentMeanIndex: number | null;
trend: BreathingTrend;
eventCount: number;
classification: BreathingClassification;
generatedAt: string;
}
interface BreathingScreeningCardProps {
enabled?: boolean;
className?: string;
}
export function BreathingScreeningCard({
enabled = true,
className,
}: BreathingScreeningCardProps) {
const { t } = useTranslations();
const { isAuthenticated } = useAuth();
const { data } = useQuery({
queryKey: queryKeys.insightsBreathingScreening(),
queryFn: () =>
apiGet<BreathingScreeningResponse>("/api/insights/breathing-screening"),
enabled: enabled && isAuthenticated,
staleTime: 60_000,
});
if (!data || data.present === false) return null;
const classificationLabel =
data.classification === "elevated"
? t("insights.breathingScreening.classificationElevated")
: data.classification === "not-elevated"
? t("insights.breathingScreening.classificationNotElevated")
: null;
const trendLabel =
data.trend === "up"
? t("insights.breathingScreening.trendUp")
: data.trend === "down"
? t("insights.breathingScreening.trendDown")
: data.trend === "stable"
? t("insights.breathingScreening.trendStable")
: null;
return (
<section
data-slot="breathing-screening-section"
aria-label={t("insights.breathingScreening.sectionTitle")}
className={cn("space-y-3", className)}
>
<SectionHeading
icon={Wind}
title={t("insights.breathingScreening.sectionTitle")}
subtitle={t("insights.breathingScreening.subtitle")}
/>
<div
data-slot="breathing-screening-card"
className="bg-card flex w-full min-w-0 flex-col gap-2 rounded-xl border p-4 md:p-6"
>
{classificationLabel ? (
<p
className="text-foreground text-sm font-medium"
data-slot="breathing-classification"
>
{classificationLabel}
</p>
) : null}
<p className="text-muted-foreground text-xs leading-snug">
{t("insights.breathingScreening.nights", { count: data.nights })}
{data.recentMeanIndex != null
? ` · ${t("insights.breathingScreening.indexLine", {
value: data.recentMeanIndex,
})}`
: ""}
</p>
{trendLabel ? (
<p
className="text-muted-foreground text-xs leading-snug"
data-slot="breathing-trend"
>
{trendLabel}
</p>
) : null}
{data.eventCount > 0 ? (
<p
className="text-muted-foreground text-xs leading-snug"
data-slot="breathing-events"
>
{t("insights.breathingScreening.eventsFlagged", {
count: data.eventCount,
})}
</p>
) : null}
<p
className="text-muted-foreground mt-1 text-xs leading-snug"
data-slot="breathing-disclaimer"
>
{t("insights.breathingScreening.disclaimer")}
</p>
</div>
</section>
);
}
@@ -0,0 +1,152 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { Activity, ArrowDownRight, ArrowUpRight } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { useAuth } from "@/hooks/use-auth";
import { cn } from "@/lib/utils";
import { apiGet } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import { SectionHeading } from "@/components/insights/section-heading";
import { MEASUREMENT_TYPE_LABEL_KEYS } from "@/components/measurements/measurement-list-meta";
import type {
HealthStatusDeviation,
HealthStatusShift,
} from "@/lib/insights/health-status";
/**
* v1.25 — baseline-drift card.
*
* A calm read of what is drifting from the user's own normal, drawn off the
* read-only `/api/insights/health-status` route (which folds the personal-band
* deviations from the coincident engine with the dated changepoint shifts). The
* card un-mounts entirely when nothing is drifting (`present === false`) rather
* than surfacing an empty header. Awareness framing only — at most a neutral
* tone, never a red alert, never a diagnosis.
*/
interface HealthStatusResponse {
present: boolean;
deviations: HealthStatusDeviation[];
shifts: HealthStatusShift[];
generatedAt: string;
}
interface HealthStatusCardProps {
enabled?: boolean;
className?: string;
}
export function HealthStatusCard({
enabled = true,
className,
}: HealthStatusCardProps) {
const { t } = useTranslations();
const { isAuthenticated } = useAuth();
const { data } = useQuery({
queryKey: queryKeys.insightsHealthStatus(),
queryFn: () => apiGet<HealthStatusResponse>("/api/insights/health-status"),
enabled: enabled && isAuthenticated,
staleTime: 60_000,
});
if (!data || data.present === false) return null;
const vitalName = (type: string): string => {
const key = MEASUREMENT_TYPE_LABEL_KEYS[type];
return key ? t(key) : type;
};
return (
<section
data-slot="health-status-section"
aria-label={t("insights.healthStatus.sectionTitle")}
className={cn("space-y-3", className)}
>
<SectionHeading
icon={Activity}
title={t("insights.healthStatus.sectionTitle")}
subtitle={t("insights.healthStatus.subtitle")}
/>
<div
data-slot="health-status-card"
className="bg-card flex w-full min-w-0 flex-col gap-3 rounded-xl border p-4 md:p-6"
>
{data.deviations.map((d) => (
<div
key={`dev-${d.type}`}
className="flex items-start gap-2"
data-slot="health-status-deviation"
>
{d.direction === "above" ? (
<ArrowUpRight
className="text-muted-foreground mt-0.5 h-4 w-4 shrink-0"
aria-hidden="true"
/>
) : (
<ArrowDownRight
className="text-muted-foreground mt-0.5 h-4 w-4 shrink-0"
aria-hidden="true"
/>
)}
<div className="min-w-0 space-y-0.5">
<p className="text-foreground text-sm">
{t(
d.direction === "above"
? "insights.healthStatus.deviationAbove"
: "insights.healthStatus.deviationBelow",
{ vital: vitalName(d.type) },
)}
</p>
<p className="text-muted-foreground text-xs leading-snug">
{t("insights.healthStatus.rangeContext", {
value: d.value,
low: d.low,
high: d.high,
})}
</p>
</div>
</div>
))}
{data.shifts.map((s) => (
<div
key={`shift-${s.metric}`}
className="flex items-start gap-2"
data-slot="health-status-shift"
>
{s.direction === "up" ? (
<ArrowUpRight
className="text-muted-foreground mt-0.5 h-4 w-4 shrink-0"
aria-hidden="true"
/>
) : (
<ArrowDownRight
className="text-muted-foreground mt-0.5 h-4 w-4 shrink-0"
aria-hidden="true"
/>
)}
<div className="min-w-0 space-y-0.5">
<p className="text-foreground text-sm">
{t(
s.direction === "up"
? "insights.healthStatus.shiftUp"
: "insights.healthStatus.shiftDown",
{ vital: vitalName(s.metric) },
)}
</p>
<p className="text-muted-foreground text-xs leading-snug">
{t("insights.healthStatus.shiftContext", {
before: s.beforeMean,
after: s.afterMean,
})}
</p>
</div>
</div>
))}
</div>
</section>
);
}
@@ -63,6 +63,9 @@ const SECTION_TITLE_KEYS: Record<InsightsSectionId, string> = {
"cycle-summary": "cycle.insightsSummary.title",
signals: "insights.derived.coincident.cardTitle",
"rhythm-events": "insights.rhythmEvents.sectionTitle",
"health-status": "insights.healthStatus.sectionTitle",
breathing: "insights.breathingScreening.sectionTitle",
"labs-changes": "insights.labsChanges.sectionTitle",
};
interface InsightsEditModeProps {
@@ -0,0 +1,114 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { FlaskConical } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { useAuth } from "@/hooks/use-auth";
import { cn } from "@/lib/utils";
import { apiGet } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import { SectionHeading } from "@/components/insights/section-heading";
import type { LabChange } from "@/lib/insights/labs-changes";
/**
* v1.25 — "what changed since your last panel" card.
*
* A neutral read off the read-only `/api/insights/labs-changes` route: for each
* analyte present in both the latest and the previous panel, the latest value,
* the signed delta, and where the latest value sits against its reference band.
* It un-mounts when there are fewer than two panels or no shared analyte
* (`present === false`). Never a diagnosis — neutral framing only.
*/
interface LabsChangesResponse {
present: boolean;
latestDate: string | null;
previousDate: string | null;
changes: LabChange[];
generatedAt: string;
}
interface LabsChangesCardProps {
enabled?: boolean;
className?: string;
}
function statusLabelKey(status: LabChange["status"]): string | null {
switch (status) {
case "in-range":
return "insights.labsChanges.statusInRange";
case "below":
return "insights.labsChanges.statusBelow";
case "above":
return "insights.labsChanges.statusAbove";
default:
return null;
}
}
export function LabsChangesCard({
enabled = true,
className,
}: LabsChangesCardProps) {
const { t } = useTranslations();
const { isAuthenticated } = useAuth();
const { data } = useQuery({
queryKey: queryKeys.insightsLabsChanges(),
queryFn: () => apiGet<LabsChangesResponse>("/api/insights/labs-changes"),
enabled: enabled && isAuthenticated,
staleTime: 60_000,
});
if (!data || data.present === false) return null;
return (
<section
data-slot="labs-changes-section"
aria-label={t("insights.labsChanges.sectionTitle")}
className={cn("space-y-3", className)}
>
<SectionHeading
icon={FlaskConical}
title={t("insights.labsChanges.sectionTitle")}
subtitle={t("insights.labsChanges.subtitle")}
/>
<div
data-slot="labs-changes-card"
className="bg-card flex w-full min-w-0 flex-col divide-y rounded-xl border"
>
{data.changes.map((c) => {
const labelKey = statusLabelKey(c.status);
const signedDelta =
c.direction === "flat"
? t("insights.labsChanges.directionFlat")
: `${c.delta > 0 ? "+" : ""}${c.delta} ${c.unit}`.trim();
return (
<div
key={c.analyte}
className="flex items-center justify-between gap-3 px-4 py-3"
data-slot="labs-change-row"
>
<div className="min-w-0">
<p className="text-foreground truncate text-sm font-medium">
{c.analyte}
</p>
<p className="text-muted-foreground text-xs">
{`${c.latest} ${c.unit}`.trim()}
{labelKey ? ` · ${t(labelKey)}` : ""}
</p>
</div>
<p
className="text-muted-foreground shrink-0 text-xs tabular-nums"
data-slot="labs-change-delta"
>
{signedDelta}
</p>
</div>
);
})}
</div>
</section>
);
}
+4 -1
View File
@@ -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(),
);
+3
View File
@@ -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);
});
});
+122
View File
@@ -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,
};
}
+84
View File
@@ -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,
};
}
+131
View File
@@ -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 };
}
+2
View File
@@ -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,
+177
View File
@@ -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,
},
},
},
};
+25
View File
@@ -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,
};