feat(insights): follow every score over time with one range selector

The health score had no history anywhere, readiness and the sleep score
showed a fixed sparkline, and only the stored nightly scores offered the
range tabs. Every score page now charts its course with the same
7 / 30 / 90 / All tabs, remembered per page, and each point opens its day.

- GET /api/insights/score-history?score=&days= serves the daily course of
  the health score (its stored day rows), readiness (the nightly blend,
  filed on its wake day) and the sleep score (each night against the
  trailing window ending on it), so a point equals what the day view shows
  for that day. MANAGE record read, module-gated for readiness and sleep,
  days 1 to 3650, additive.
- The health score's line breaks where its recipe changed (configuration
  or algorithm version); the usual range is formed from the newest side
  of the last seam.
- The range tabs move into one shared component used by every chart;
  recovery, stress and strain keep their measurement chart, now with the
  range remembered and no overlay dropdown.
- New page /insights/health-score, linked from the score panel and from
  the day view's score tile.
This commit is contained in:
Marc-André Bombeck committed 2026-10-09 16:45:39 +02:00
1 parent 1d080ee166
commit 62140a4aee
38 files changed
+2437 -183

No files matched your search

@@ -210,6 +210,8 @@ export const DATA_ROUTES: Readonly<Record<string, string>> = {
"src/app/api/insights/ecg/[id]/route.ts": "One device recording's waveform.",
"src/app/api/insights/rhythm-events/route.ts":
"The device's own flagged events, verbatim.",
"src/app/api/insights/score-history/route.ts":
"A score's stored or computed daily values; no model text.",
"src/app/api/insights/cards/route.ts":
"Rule alerts from the threshold engine; `provider` is `rules`.",
"src/app/api/insights/correlations/route.ts":
@@ -67,6 +67,10 @@ const DELIBERATELY_OPEN: Record<string, { count: number; reason: string }> = {
reason: "hand-built query object",
},
"insights/derived/route.ts": { count: 1, reason: "hand-built query object" },
"insights/score-history/route.ts": {
count: 1,
reason: "hand-built query object",
},
"insights/metric-status/route.ts": {
count: 1,
reason: "hand-built query object",
@@ -0,0 +1,98 @@
/**
* `GET /api/insights/score-history?score=<id>&days=<n>` — one score's daily
* course over the trailing `days`, for the history chart on its page.
*
* Serves the three scores that have no stored measurement series of their
* own to chart: the health score (its `HealthScoreRecord` rows), readiness
* (the nightly blend the recovery job persists) and the sleep score
* (computed per night). The nightly recovery, stress and strain scores are
* stored readings and chart through the measurement series route like any
* other metric. What each point means, and why the health score's line can
* break, is written at `readScoreHistory`.
*
* `days` is the chart's range tab: 7, 30, 90, or the "All" tab's ten years.
* Any whole number in between is accepted, so a client is not tied to these
* tabs; out of range is a 422, never a silent clamp.
*/
import { NextRequest } from "next/server";
import { z } from "zod/v4";
import { apiError, apiSuccess, returnAllZodIssues } from "@/lib/api-response";
import { apiHandler, requireRecordAuth } 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 { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
import { resolveStoredTimezone } from "@/lib/tz/resolver";
import { readScoreHistory } from "@/lib/insights/score-history";
import {
SCORE_HISTORY_IDS,
SCORE_HISTORY_MAX_DAYS,
} from "@/lib/insights/score-history-ids";
import { DERIVED_MODULE } from "../derived/route";
export const dynamic = "force-dynamic";
const scoreHistoryQuerySchema = z.object({
score: z.enum(SCORE_HISTORY_IDS),
days: z.coerce.number().int().min(1).max(SCORE_HISTORY_MAX_DAYS),
});
export const GET = apiHandler(async (request: NextRequest) => {
// The same standing as the score itself: the derived scores and the other
// insight reads are a MANAGE-level read over the whole record.
const { user } = await requireRecordAuth("manage", "record");
const rl = await checkAnalyticsReadRateLimit(user.id);
if (!rl.allowed) {
return apiError("Too many analytics requests. Please retry later.", 429);
}
const parsed = scoreHistoryQuerySchema.safeParse({
score: request.nextUrl.searchParams.get("score"),
days: request.nextUrl.searchParams.get("days"),
});
if (!parsed.success) {
annotate({
action: { name: "insights.score-history.invalid-query" },
meta: { issue_count: parsed.error.issues.length },
});
return returnAllZodIssues(parsed.error, 422);
}
const { score, days } = parsed.data;
// Readiness is the recovery module's, the sleep score the sleep module's,
// through the same map the derived routes gate on. The health score is
// core and carries no gate.
const moduleKey =
score === "HEALTH_SCORE" ? undefined : DERIVED_MODULE[score];
if (moduleKey) {
const gate = await requireModuleEnabled(user.id, moduleKey);
if (!gate.enabled) return gate.response;
}
const history = await readScoreHistory({
userId: user.id,
score,
days,
now: new Date(),
tz: await resolveStoredTimezone(user.timezone),
profile: () => loadBaselineProfile(prisma, user.id),
priorityJson: () => loadUserSourcePriority(user.id),
});
annotate({
action: { name: "insights.score-history" },
meta: {
score,
days,
points: history.points.length,
seams: history.points.filter((p) => p.seamBreak).length,
has_band: history.band !== null,
},
});
return apiSuccess(history);
});