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
@@ -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() });
|
||||
});
|
||||
Reference in new issue
Block a user