feat(insights): intraday pulse shape and elevated-at-rest tension window

Add an on-demand intraday layer to the pulse insight: 10-minute mean
heart rate for the viewed day, folded from raw samples via the read-swap
pattern (never persisted as 10-minute rollups for all history). Draw the
personal resting baseline and softly shade any sustained
elevated-at-rest stretch.

The tension signal is not raw heart rate — it is heart rate held above
the personal resting baseline while movement is low (the elevated-at-rest
window), so a workout or a walk never reads as tension. Detection is
deliberately conservative: baseline maturity, per-bucket sample density,
a mandatory step-based low-movement gate, and a minimum sustained
duration must all hold before anything is reported. Silence over
speculation.

Surface the same signal as a calm, non-diagnostic tension_window item in
the daily digest — at most once per day, honest-absent otherwise, gated
on the insights module and yielding the rail to time-sensitive actions.
Cautious copy across all six locales; framed as "possible tension", never
a clinical stress verdict.
This commit is contained in:
Marc-André Bombeck committed 2026-07-17 08:16:32 +02:00
1 parent 1427125b7e
commit a33527da57
17 files changed
+1290 -6

No files matched your search

@@ -112,6 +112,12 @@ const NOT_COACH_OWNED_ROUTES: ReadonlyArray<string> = [
"src/app/api/insights/health-status/route.ts",
"src/app/api/insights/breathing-screening/route.ts",
"src/app/api/insights/labs-changes/route.ts",
// S11 — the intraday pulse / tension read. Deterministic compute over the
// day's raw PULSE / steps / workouts (10-minute mean shape + the cautious
// elevated-at-rest window); it carries no assistant prose and gates on the
// `insights` module, not the Coach surface. Same posture as health-status /
// labs-changes directly above — disabling Coach must not wedge this read.
"src/app/api/insights/pulse/intraday/route.ts",
// v1.29.x (S7) — the FENCED multi-document coach chat + its attach/detach
// mutations. These live under /insights/chat for URL grouping but are
// DOCUMENT-surface routes: they gate on the `inboundDocuments` module
@@ -0,0 +1,63 @@
/**
* GET /api/insights/pulse/intraday?date=YYYY-MM-DD
*
* The on-demand read for the intraday pulse / tension layer (S11). Returns one
* local day's 10-minute mean heart-rate shape plus, when every confidence gate
* holds, a single cautious elevated-at-rest ("tension") window. Computed from
* raw samples via the read-swap pattern — NOT persisted as 10-min rollups for
* all history.
*
* Cookie OR Bearer auth via `requireAuth()`; `userId` is narrowed from the
* resolved session — never a body field. Gated on the `insights` module. The
* `date` defaults to the user's local today; anything but a `YYYY-MM-DD`
* literal is a 422 via `returnAllZodIssues`.
*/
import { z } from "zod";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { NO_STORE_BUT_BFCACHE } from "@/lib/http/cache-headers";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import { userDayKey } from "@/lib/tz/format";
import { annotate } from "@/lib/logging/context";
import { loadIntradayPulse } from "@/lib/analytics/intraday-pulse-io";
export const dynamic = "force-dynamic";
const querySchema = z.object({
date: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.optional(),
});
export const GET = apiHandler(async (req: Request) => {
const { user } = await requireAuth();
const m = await requireModuleEnabled(user.id, "insights");
if (!m.enabled) return m.response;
const url = new URL(req.url);
const parsed = querySchema.safeParse({
date: url.searchParams.get("date") ?? undefined,
});
if (!parsed.success) return returnAllZodIssues(parsed.error);
const timezone = await resolveUserTimezone(user.id);
const dateKey = parsed.data.date ?? userDayKey(new Date(), timezone);
const result = await loadIntradayPulse(user.id, timezone, dateKey);
annotate({
action: { name: "insights.pulse.intraday" },
meta: {
buckets: result.series.length,
baseline_source: result.baselineSource,
tension: result.tension !== null,
},
});
const response = apiSuccess(result);
response.headers.set("Cache-Control", NO_STORE_BUT_BFCACHE);
return response;
});