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() });
});