mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 22:50:51 +00:00
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:
1 parent
1d080ee166
commit
62140a4aee
38 files changed
+2437
-183
No files matched your search
@@ -328,6 +328,14 @@ export const CHART_OVERLAY_KEYS = [
|
||||
"painNrs",
|
||||
"waistCircumference",
|
||||
"waistToHeight",
|
||||
// v1.42 — the score pages' history charts. Each remembers only its range
|
||||
// tab (the charts mount no overlay dropdown), one slot per score page.
|
||||
"scoreHealth",
|
||||
"scoreReadiness",
|
||||
"scoreRecovery",
|
||||
"scoreSleep",
|
||||
"scoreStress",
|
||||
"scoreStrain",
|
||||
] as const;
|
||||
export type ChartOverlayKey = (typeof CHART_OVERLAY_KEYS)[number];
|
||||
|
||||
|
||||
@@ -151,7 +151,7 @@ export type DayScoreKey = (typeof DAY_SCORE_KEYS)[number];
|
||||
|
||||
/** The page each score opens on. */
|
||||
export const DAY_SCORE_HREF: Readonly<Record<DayScoreKey, string>> = {
|
||||
healthScore: "/insights",
|
||||
healthScore: "/insights/health-score",
|
||||
readiness: "/insights/scores/readiness",
|
||||
recovery: "/insights/scores/recovery",
|
||||
sleepScore: "/insights/scores/sleep",
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
healthScoreRecord: { findMany: vi.fn() },
|
||||
measurement: { findMany: vi.fn() },
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/insights/derived/sleep-score", () => ({
|
||||
computeSleepScoreHistory: vi.fn(),
|
||||
}));
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { computeSleepScoreHistory } from "@/lib/insights/derived/sleep-score";
|
||||
import { readScoreHistory } from "../score-history";
|
||||
|
||||
/**
|
||||
* v1.42 — the score-history reader: the window, the seams in the health
|
||||
* score's line, and the usual range behind the newest point. The rows are
|
||||
* stubbed; the integration suite reads them from Postgres.
|
||||
*/
|
||||
|
||||
const records = prisma.healthScoreRecord.findMany as ReturnType<typeof vi.fn>;
|
||||
const measurements = prisma.measurement.findMany as ReturnType<typeof vi.fn>;
|
||||
const sleep = computeSleepScoreHistory as ReturnType<typeof vi.fn>;
|
||||
|
||||
const NOW = new Date("2026-06-30T10:00:00Z");
|
||||
const TZ = "UTC";
|
||||
const day = (offset: number) =>
|
||||
new Date(Date.parse("2026-06-30T12:00:00Z") + offset * 86_400_000)
|
||||
.toISOString()
|
||||
.slice(0, 10);
|
||||
|
||||
function args(
|
||||
score: "HEALTH_SCORE" | "READINESS" | "SLEEP_SCORE",
|
||||
days: number,
|
||||
) {
|
||||
return {
|
||||
userId: "u1",
|
||||
score,
|
||||
days,
|
||||
now: NOW,
|
||||
tz: TZ,
|
||||
profile: vi.fn().mockResolvedValue({ ageYears: 40, sex: null }),
|
||||
priorityJson: vi.fn().mockResolvedValue(null),
|
||||
};
|
||||
}
|
||||
|
||||
function row(
|
||||
offset: number,
|
||||
composite: number,
|
||||
configVersion: number | null = 1,
|
||||
scoreVersion = 1,
|
||||
) {
|
||||
return { dayKey: day(offset), composite, configVersion, scoreVersion };
|
||||
}
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe("readScoreHistory — health score", () => {
|
||||
it("reads thirty days back on a short window and keeps only the window's days", async () => {
|
||||
records.mockResolvedValue(
|
||||
Array.from({ length: 31 }, (_, i) => row(i - 30, 70 + (i % 3))),
|
||||
);
|
||||
const out = await readScoreHistory(args("HEALTH_SCORE", 7));
|
||||
const where = records.mock.calls[0]![0].where;
|
||||
expect(where.dayKey).toEqual({ gte: day(-30), lte: day(0) });
|
||||
expect(out.points.map((p) => p.day)).toEqual(
|
||||
[-6, -5, -4, -3, -2, -1, 0].map(day),
|
||||
);
|
||||
expect(out.band?.n).toBe(30);
|
||||
});
|
||||
|
||||
it("reads the whole window when it is longer than thirty days", async () => {
|
||||
records.mockResolvedValue([]);
|
||||
await readScoreHistory(args("HEALTH_SCORE", 90));
|
||||
expect(records.mock.calls[0]![0].where.dayKey.gte).toBe(day(-89));
|
||||
});
|
||||
|
||||
it("flags a seam on a recipe change, an algorithm change and an unknown recipe", async () => {
|
||||
records.mockResolvedValue([
|
||||
row(-5, 60, 1),
|
||||
row(-4, 61, 1),
|
||||
row(-3, 70, 2),
|
||||
row(-2, 71, 2, 2),
|
||||
row(-1, 72, null, 2),
|
||||
row(0, 73, null, 2),
|
||||
]);
|
||||
const out = await readScoreHistory(args("HEALTH_SCORE", 30));
|
||||
expect(out.points.map((p) => p.seamBreak)).toEqual([
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
]);
|
||||
});
|
||||
|
||||
it("never opens the window on a seam", async () => {
|
||||
records.mockResolvedValue([row(-8, 50, 1), row(-6, 80, 2), row(-5, 80, 2)]);
|
||||
const out = await readScoreHistory(args("HEALTH_SCORE", 7));
|
||||
expect(out.points[0]).toEqual({
|
||||
day: day(-6),
|
||||
value: 80,
|
||||
seamBreak: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("forms the usual range from the newest point's side of the last seam", async () => {
|
||||
records.mockResolvedValue([
|
||||
...Array.from({ length: 10 }, (_, i) => row(i - 20, 30, 1)),
|
||||
...Array.from({ length: 10 }, (_, i) => row(i - 9, 80 + (i % 2), 2)),
|
||||
]);
|
||||
const out = await readScoreHistory(args("HEALTH_SCORE", 30));
|
||||
expect(out.band).not.toBeNull();
|
||||
expect(out.band!.n).toBe(9);
|
||||
expect(out.band!.lo).toBeGreaterThanOrEqual(79);
|
||||
});
|
||||
|
||||
it("gives no range with fewer than seven days behind the newest point", async () => {
|
||||
records.mockResolvedValue(
|
||||
Array.from({ length: 6 }, (_, i) => row(i - 5, 70)),
|
||||
);
|
||||
expect((await readScoreHistory(args("HEALTH_SCORE", 30))).band).toBeNull();
|
||||
});
|
||||
|
||||
it("gives no range for an empty window, even with older days on record", async () => {
|
||||
records.mockResolvedValue(
|
||||
Array.from({ length: 20 }, (_, i) => row(i - 29, 70)),
|
||||
);
|
||||
const out = await readScoreHistory(args("HEALTH_SCORE", 7));
|
||||
expect(out).toEqual({
|
||||
score: "HEALTH_SCORE",
|
||||
days: 7,
|
||||
points: [],
|
||||
band: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not load the sleep inputs for another score", async () => {
|
||||
records.mockResolvedValue([]);
|
||||
const a = args("HEALTH_SCORE", 7);
|
||||
await readScoreHistory(a);
|
||||
expect(a.profile).not.toHaveBeenCalled();
|
||||
expect(a.priorityJson).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("readScoreHistory — readiness", () => {
|
||||
it("reads only the nightly blend and files it on its wake day", async () => {
|
||||
measurements.mockResolvedValue([
|
||||
{ value: 64.6, measuredAt: new Date(`${day(-3)}T12:00:00Z`) },
|
||||
// A same-night re-score: the later row wins.
|
||||
{ value: 70, measuredAt: new Date(`${day(-2)}T12:00:00Z`) },
|
||||
{ value: 71.2, measuredAt: new Date(`${day(-2)}T18:00:00Z`) },
|
||||
]);
|
||||
const out = await readScoreHistory(args("READINESS", 7));
|
||||
const where = measurements.mock.calls[0]![0].where;
|
||||
expect(where).toMatchObject({
|
||||
type: "RECOVERY_SCORE",
|
||||
source: "COMPUTED",
|
||||
deletedAt: null,
|
||||
});
|
||||
expect(out.points).toEqual([
|
||||
{ day: day(-2), value: 65, seamBreak: false },
|
||||
{ day: day(-1), value: 71, seamBreak: false },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("readScoreHistory — sleep score", () => {
|
||||
it("rounds each night and keeps the nights inside the window", async () => {
|
||||
sleep.mockResolvedValue([
|
||||
{ night: day(-40), score: 50 },
|
||||
{ night: day(-1), score: 77.4 },
|
||||
{ night: day(0), score: 80.5 },
|
||||
]);
|
||||
const a = args("SLEEP_SCORE", 7);
|
||||
const out = await readScoreHistory(a);
|
||||
expect(sleep.mock.calls[0]![2]).toMatchObject({
|
||||
fromDay: day(-30),
|
||||
now: NOW,
|
||||
tz: TZ,
|
||||
priorityJson: null,
|
||||
});
|
||||
expect(out.points).toEqual([
|
||||
{ day: day(-1), value: 77, seamBreak: false },
|
||||
{ day: day(0), value: 81, seamBreak: false },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
measurement: { findMany: vi.fn() },
|
||||
user: { findUnique: vi.fn().mockResolvedValue(null) },
|
||||
},
|
||||
}));
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
blendSleepSubScores,
|
||||
circularMeanMinutes,
|
||||
computeSleepScore,
|
||||
computeSleepScoreHistory,
|
||||
scoreComposition,
|
||||
scoreConsistency,
|
||||
scoreEfficiency,
|
||||
scoreNightsAgainstTrailingWindow,
|
||||
scoreSufficiency,
|
||||
scoreTiming,
|
||||
sleepNeedMinutes,
|
||||
type NightSummary,
|
||||
} from "../sleep-score";
|
||||
|
||||
/**
|
||||
* v1.42 — the sleep score's history: every night scored the way the
|
||||
* headline scores its latest night, against the trailing window that ends
|
||||
* on it.
|
||||
*/
|
||||
|
||||
const findMany = prisma.measurement.findMany as ReturnType<typeof vi.fn>;
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
function summary(night: string, midpoint: number, asleep = 420): NightSummary {
|
||||
return {
|
||||
night,
|
||||
asleepMinutes: asleep,
|
||||
awakeMinutes: 20,
|
||||
remMinutes: 90,
|
||||
deepMinutes: 60,
|
||||
inBedMinutes: asleep + 30,
|
||||
hasStageBreakdown: true,
|
||||
midpoint,
|
||||
};
|
||||
}
|
||||
|
||||
const NEED = sleepNeedMinutes(40);
|
||||
|
||||
describe("scoreNightsAgainstTrailingWindow", () => {
|
||||
it("scores each night with the window that ends on it", () => {
|
||||
const nights = [
|
||||
summary("2026-06-01", 180),
|
||||
summary("2026-06-02", 200),
|
||||
summary("2026-06-03", 240, 300),
|
||||
];
|
||||
const out = scoreNightsAgainstTrailingWindow(nights, NEED, "2026-06-01");
|
||||
expect(out.map((n) => n.night)).toEqual([
|
||||
"2026-06-01",
|
||||
"2026-06-02",
|
||||
"2026-06-03",
|
||||
]);
|
||||
// The third night by hand: its own sub-scores, the window's yardstick.
|
||||
const mids = [180, 200, 240];
|
||||
const expected = blendSleepSubScores({
|
||||
sufficiency: scoreSufficiency(300, NEED),
|
||||
efficiency: scoreEfficiency(300, 330),
|
||||
consistency: scoreConsistency(mids),
|
||||
timing: scoreTiming(240, circularMeanMinutes(mids), 3),
|
||||
composition: scoreComposition(90, 60, 300, true),
|
||||
}).score;
|
||||
expect(out[2]!.score).toBe(expected);
|
||||
// The first night has only itself behind it: no timing, no consistency.
|
||||
const first = blendSleepSubScores({
|
||||
sufficiency: scoreSufficiency(420, NEED),
|
||||
efficiency: scoreEfficiency(420, 450),
|
||||
consistency: scoreConsistency([180]),
|
||||
timing: scoreTiming(180, 180, 1),
|
||||
composition: scoreComposition(90, 60, 420, true),
|
||||
}).score;
|
||||
expect(out[0]!.score).toBe(first);
|
||||
});
|
||||
|
||||
it("returns only nights from fromDay, using the earlier ones as the window", () => {
|
||||
const nights = [
|
||||
summary("2026-05-20", 100),
|
||||
summary("2026-05-21", 400),
|
||||
summary("2026-06-01", 180),
|
||||
];
|
||||
const withWindow = scoreNightsAgainstTrailingWindow(
|
||||
nights,
|
||||
NEED,
|
||||
"2026-06-01",
|
||||
);
|
||||
const alone = scoreNightsAgainstTrailingWindow(
|
||||
[nights[2]!],
|
||||
NEED,
|
||||
"2026-06-01",
|
||||
);
|
||||
expect(withWindow.map((n) => n.night)).toEqual(["2026-06-01"]);
|
||||
expect(withWindow[0]!.score).not.toBe(alone[0]!.score);
|
||||
});
|
||||
|
||||
it("drops a night that fell out of the window", () => {
|
||||
const old = summary("2026-04-01", 600);
|
||||
const recent = summary("2026-06-01", 180);
|
||||
const out = scoreNightsAgainstTrailingWindow(
|
||||
[old, recent],
|
||||
NEED,
|
||||
"2026-06-01",
|
||||
);
|
||||
const alone = scoreNightsAgainstTrailingWindow(
|
||||
[recent],
|
||||
NEED,
|
||||
"2026-06-01",
|
||||
);
|
||||
expect(out[0]!.score).toBe(alone[0]!.score);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeSleepScoreHistory", () => {
|
||||
it("ends on the score the headline shows for the latest night", async () => {
|
||||
const rows = ["2026-05-30", "2026-05-31", "2026-06-01", "2026-06-02"].map(
|
||||
(day, i) => ({
|
||||
value: 400 + i * 15,
|
||||
measuredAt: new Date(`${day}T06:00:00Z`),
|
||||
sleepStage: "ASLEEP",
|
||||
source: "APPLE_HEALTH",
|
||||
deviceType: null,
|
||||
}),
|
||||
);
|
||||
findMany.mockResolvedValue(rows);
|
||||
const now = new Date("2026-06-02T10:00:00Z");
|
||||
const profile = { ageYears: 40, sex: "MALE" as const };
|
||||
const history = await computeSleepScoreHistory("u1", profile, {
|
||||
fromDay: "2026-05-27",
|
||||
now,
|
||||
tz: "UTC",
|
||||
priorityJson: null,
|
||||
});
|
||||
expect(history.map((n) => n.night)).toEqual([
|
||||
"2026-05-30",
|
||||
"2026-05-31",
|
||||
"2026-06-01",
|
||||
"2026-06-02",
|
||||
]);
|
||||
const headline = await computeSleepScore("u1", profile, {
|
||||
now,
|
||||
tz: "UTC",
|
||||
priorityJson: null,
|
||||
});
|
||||
expect(headline.status).toBe("ok");
|
||||
if (headline.status === "ok") {
|
||||
expect(history.at(-1)!.score).toBe(headline.value.score);
|
||||
}
|
||||
});
|
||||
|
||||
it("reads from the window before fromDay", async () => {
|
||||
findMany.mockResolvedValue([]);
|
||||
await computeSleepScoreHistory(
|
||||
"u1",
|
||||
{ ageYears: 40, sex: null },
|
||||
{
|
||||
fromDay: "2026-06-01",
|
||||
now: new Date("2026-06-02T10:00:00Z"),
|
||||
tz: "UTC",
|
||||
priorityJson: null,
|
||||
},
|
||||
);
|
||||
const where = findMany.mock.calls[0]![0].where;
|
||||
expect(where.type).toBe("SLEEP_DURATION");
|
||||
expect(where.measuredAt.gte.toISOString()).toBe("2026-05-01T00:00:00.000Z");
|
||||
});
|
||||
});
|
||||
@@ -40,6 +40,8 @@ import type {
|
||||
import { prisma } from "@/lib/db";
|
||||
import { wallClockInTz } from "@/lib/tz/wall-clock";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
import { shiftDateKey } from "@/lib/tz/format";
|
||||
import { startOfLocalDayKey } from "@/lib/tz/local-day";
|
||||
import {
|
||||
reconstructSleepNights,
|
||||
type SleepStageRow,
|
||||
@@ -608,3 +610,97 @@ export async function computeSleepScore(
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ── history ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Each night's Sleep Score as the headline would have shown it the morning
|
||||
* after: the night's own sufficiency, efficiency and composition, with
|
||||
* consistency and timing read against the nights of the trailing window that
|
||||
* ends on it. That is the yardstick the headline uses for its latest night,
|
||||
* applied to every night in turn, so the newest point of a history equals the
|
||||
* score on the card and a past point equals what the card said that day.
|
||||
*
|
||||
* `nights` must be ascending by wake day and scorable (asleep minutes > 0).
|
||||
* Only nights on or after `fromDay` are returned; earlier ones serve as the
|
||||
* window behind the first returned night. Pure.
|
||||
*/
|
||||
export function scoreNightsAgainstTrailingWindow(
|
||||
nights: readonly NightSummary[],
|
||||
needMinutes: number,
|
||||
fromDay: string,
|
||||
windowDays: number = DEFAULT_WINDOW_DAYS,
|
||||
): Array<{ night: string; score: number }> {
|
||||
const out: Array<{ night: string; score: number }> = [];
|
||||
let start = 0;
|
||||
for (let i = 0; i < nights.length; i += 1) {
|
||||
const night = nights[i]!;
|
||||
const windowStart = shiftDateKey(night.night, -(windowDays - 1));
|
||||
while (start < i && nights[start]!.night < windowStart) start += 1;
|
||||
if (night.night < fromDay) continue;
|
||||
const midpoints = nights
|
||||
.slice(start, i + 1)
|
||||
.map((n) => n.midpoint)
|
||||
.filter((m): m is number => m != null);
|
||||
const habitualMidpoint = circularMeanMinutes(midpoints);
|
||||
out.push({
|
||||
night: night.night,
|
||||
score: blendSleepSubScores({
|
||||
sufficiency: scoreSufficiency(night.asleepMinutes, needMinutes),
|
||||
efficiency: scoreEfficiency(night.asleepMinutes, night.inBedMinutes),
|
||||
consistency: scoreConsistency(midpoints),
|
||||
timing: scoreTiming(night.midpoint, habitualMidpoint, midpoints.length),
|
||||
composition: scoreComposition(
|
||||
night.remMinutes,
|
||||
night.deepMinutes,
|
||||
night.asleepMinutes,
|
||||
night.hasStageBreakdown,
|
||||
),
|
||||
}).score,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Sleep Score of every night from `fromDay` (a wake-day key) to now, one
|
||||
* value per night, oldest first. Reads the sleep rows once, from a window
|
||||
* before `fromDay` so the first night has its yardstick behind it.
|
||||
*/
|
||||
export async function computeSleepScoreHistory(
|
||||
userId: string,
|
||||
profile: BaselineProfile,
|
||||
opts: { fromDay: string; now: Date; tz: string; priorityJson: unknown },
|
||||
): Promise<Array<{ night: string; score: number }>> {
|
||||
// The wake day of a night can sit a day after the instant its first row
|
||||
// starts, so the read opens one day earlier than the window strictly needs.
|
||||
const since = startOfLocalDayKey(
|
||||
shiftDateKey(opts.fromDay, -(DEFAULT_WINDOW_DAYS + 1)),
|
||||
opts.tz,
|
||||
);
|
||||
const rows = (await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
type: "SLEEP_DURATION" satisfies MeasurementType,
|
||||
deletedAt: null,
|
||||
measuredAt: { gte: since, lte: opts.now },
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: {
|
||||
value: true,
|
||||
measuredAt: true,
|
||||
sleepStage: true,
|
||||
source: true,
|
||||
deviceType: true,
|
||||
},
|
||||
})) as SleepRow[];
|
||||
if (rows.length === 0) return [];
|
||||
const nights = reconstructNights(rows, opts.tz, opts.priorityJson)
|
||||
.filter((n) => n.asleepMinutes > 0)
|
||||
.sort((a, b) => (a.night < b.night ? -1 : a.night > b.night ? 1 : 0));
|
||||
return scoreNightsAgainstTrailingWindow(
|
||||
nights,
|
||||
sleepNeedMinutes(profile.ageYears),
|
||||
opts.fromDay,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* The closed set of scores `GET /api/insights/score-history` serves, and the
|
||||
* widest window it accepts. Pure constants, apart from the reader, so the
|
||||
* OpenAPI registry and the client can import them without the database.
|
||||
*/
|
||||
export const SCORE_HISTORY_IDS = [
|
||||
"HEALTH_SCORE",
|
||||
"READINESS",
|
||||
"SLEEP_SCORE",
|
||||
] as const;
|
||||
export type ScoreHistoryId = (typeof SCORE_HISTORY_IDS)[number];
|
||||
|
||||
/**
|
||||
* The widest window a caller may ask for: the chart's "All" tab, the same
|
||||
* ten years the metric charts fetch for it. Every score here is one value
|
||||
* per day, so the response is bounded by the window, not by row density.
|
||||
*/
|
||||
export const SCORE_HISTORY_MAX_DAYS = 3650;
|
||||
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* A score's daily course over a chosen window, for the score pages' history
|
||||
* chart: the health score, readiness and the sleep score.
|
||||
*
|
||||
* Each is read from where the day view reads the same score for one day
|
||||
* (`src/lib/day/scores.ts`), so a point on the chart and the score tile of
|
||||
* the day it opens never disagree:
|
||||
*
|
||||
* - Health score: the `HealthScoreRecord` row of each local day, the score
|
||||
* as it was shown that day. Nothing recomputes it.
|
||||
* - Readiness: the `COMPUTED` `RECOVERY_SCORE` rows the nightly job wrote,
|
||||
* filed on the wake day of the night they describe (`wakeDayKeyOf`).
|
||||
* - Sleep score: computed per night against the trailing window that ends
|
||||
* on it, the yardstick the headline uses for its latest night.
|
||||
*
|
||||
* The health score's line breaks where the recipe changed (Eurostat's `b`
|
||||
* flag on the first day under a new recipe): the numbers either side are
|
||||
* averages of different things, so the chart draws two segments and never
|
||||
* one line through the seam. The algorithm version is part of the recipe
|
||||
* here, for the same reason.
|
||||
*
|
||||
* The usual range is the person's own, the rule the day view's score tiles
|
||||
* use (`scoreBand`): median and scaled MAD over the 30 days before the
|
||||
* newest point, with at least seven of them, and only from the newest
|
||||
* point's side of a seam.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { scoreBand } from "@/lib/day/scores";
|
||||
import { BAND_WINDOW_DAYS } from "@/lib/day/values";
|
||||
import type { BaselineProfile } from "@/lib/insights/derived/baseline";
|
||||
import { wakeDayKeyOf } from "@/lib/insights/derived/recovery-resolve";
|
||||
import { computeSleepScoreHistory } from "@/lib/insights/derived/sleep-score";
|
||||
import { shiftDateKey, userDayKey } from "@/lib/tz/format";
|
||||
import { startOfLocalDayKey } from "@/lib/tz/local-day";
|
||||
|
||||
import type { ScoreHistoryId } from "./score-history-ids";
|
||||
|
||||
const MS_PER_DAY = 86_400_000;
|
||||
const SCORE_SCALE_MAX = 100;
|
||||
|
||||
export interface ScoreHistoryPoint {
|
||||
/** The local calendar day the value describes, `YYYY-MM-DD`. */
|
||||
day: string;
|
||||
/** 0 to 100, whole points. */
|
||||
value: number;
|
||||
/** True on the first day scored under a different recipe than the day before. */
|
||||
seamBreak: boolean;
|
||||
}
|
||||
|
||||
export interface ScoreHistory {
|
||||
score: ScoreHistoryId;
|
||||
days: number;
|
||||
points: ScoreHistoryPoint[];
|
||||
band: { lo: number; hi: number; n: number } | null;
|
||||
}
|
||||
|
||||
export interface ScoreHistoryArgs {
|
||||
userId: string;
|
||||
score: ScoreHistoryId;
|
||||
days: number;
|
||||
now: Date;
|
||||
tz: string;
|
||||
/** Read only for the sleep score. */
|
||||
profile: () => Promise<BaselineProfile>;
|
||||
priorityJson: () => Promise<unknown>;
|
||||
}
|
||||
|
||||
interface DayValue {
|
||||
day: string;
|
||||
value: number;
|
||||
seamBreak: boolean;
|
||||
}
|
||||
|
||||
async function healthScoreDays(
|
||||
userId: string,
|
||||
from: string,
|
||||
to: string,
|
||||
): Promise<DayValue[]> {
|
||||
const rows = await prisma.healthScoreRecord.findMany({
|
||||
where: { userId, dayKey: { gte: from, lte: to } },
|
||||
orderBy: { dayKey: "asc" },
|
||||
select: {
|
||||
dayKey: true,
|
||||
composite: true,
|
||||
configVersion: true,
|
||||
scoreVersion: true,
|
||||
},
|
||||
});
|
||||
return rows.map((row, index) => {
|
||||
const previous = index > 0 ? rows[index - 1] : null;
|
||||
// A row that does not say which recipe produced it cannot support the
|
||||
// claim that it shares one with its neighbour, so an unknown version on
|
||||
// either side is a seam too.
|
||||
const seamBreak =
|
||||
previous !== null &&
|
||||
(previous.configVersion === null ||
|
||||
row.configVersion === null ||
|
||||
previous.configVersion !== row.configVersion ||
|
||||
previous.scoreVersion !== row.scoreVersion);
|
||||
return { day: row.dayKey, value: row.composite, seamBreak };
|
||||
});
|
||||
}
|
||||
|
||||
async function readinessDays(
|
||||
userId: string,
|
||||
from: string,
|
||||
to: string,
|
||||
tz: string,
|
||||
): Promise<DayValue[]> {
|
||||
// The row is stamped on the day that ended, a day before the wake day it
|
||||
// is filed under, so the read opens one day early.
|
||||
const rows = await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
type: "RECOVERY_SCORE",
|
||||
source: "COMPUTED",
|
||||
deletedAt: null,
|
||||
measuredAt: {
|
||||
gte: new Date(startOfLocalDayKey(from, tz).getTime() - MS_PER_DAY),
|
||||
},
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: { value: true, measuredAt: true },
|
||||
});
|
||||
const byDay = new Map<string, number>();
|
||||
for (const row of rows) {
|
||||
// A same-night re-score is the later row: ascending order lets it win.
|
||||
byDay.set(wakeDayKeyOf(row.measuredAt, "COMPUTED", tz), row.value);
|
||||
}
|
||||
return [...byDay]
|
||||
.filter(([day]) => day >= from && day <= to)
|
||||
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
||||
.map(([day, value]) => ({
|
||||
day,
|
||||
value: Math.round(value),
|
||||
seamBreak: false,
|
||||
}));
|
||||
}
|
||||
|
||||
async function sleepScoreDays(
|
||||
args: ScoreHistoryArgs,
|
||||
from: string,
|
||||
to: string,
|
||||
): Promise<DayValue[]> {
|
||||
const nights = await computeSleepScoreHistory(
|
||||
args.userId,
|
||||
await args.profile(),
|
||||
{
|
||||
fromDay: from,
|
||||
now: args.now,
|
||||
tz: args.tz,
|
||||
priorityJson: await args.priorityJson(),
|
||||
},
|
||||
);
|
||||
return nights
|
||||
.filter((night) => night.night <= to)
|
||||
.map((night) => ({
|
||||
day: night.night,
|
||||
value: Math.round(night.score),
|
||||
seamBreak: false,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* The usual range behind the newest point: the values of the 30 days before
|
||||
* it, from its own side of the last seam.
|
||||
*/
|
||||
function usualBand(values: readonly DayValue[]): ScoreHistory["band"] {
|
||||
const newest = values[values.length - 1];
|
||||
if (!newest) return null;
|
||||
let segmentStart = 0;
|
||||
for (let i = values.length - 1; i > 0; i -= 1) {
|
||||
if (values[i]!.seamBreak) {
|
||||
segmentStart = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const windowFrom = shiftDateKey(newest.day, -BAND_WINDOW_DAYS);
|
||||
const prior = values
|
||||
.slice(segmentStart, -1)
|
||||
.filter((v) => v.day >= windowFrom)
|
||||
.map((v) => v.value);
|
||||
return scoreBand(prior, SCORE_SCALE_MAX);
|
||||
}
|
||||
|
||||
export async function readScoreHistory(
|
||||
args: ScoreHistoryArgs,
|
||||
): Promise<ScoreHistory> {
|
||||
const today = userDayKey(args.now, args.tz);
|
||||
const from = shiftDateKey(today, -(args.days - 1));
|
||||
// Read far enough back that the newest day has its 30 days behind it even
|
||||
// on a seven-day window.
|
||||
const readFrom = shiftDateKey(
|
||||
today,
|
||||
-Math.max(args.days - 1, BAND_WINDOW_DAYS),
|
||||
);
|
||||
|
||||
const values =
|
||||
args.score === "HEALTH_SCORE"
|
||||
? await healthScoreDays(args.userId, readFrom, today)
|
||||
: args.score === "READINESS"
|
||||
? await readinessDays(args.userId, readFrom, today, args.tz)
|
||||
: await sleepScoreDays(args, readFrom, today);
|
||||
|
||||
const points = values.filter((v) => v.day >= from);
|
||||
return {
|
||||
score: args.score,
|
||||
days: args.days,
|
||||
// The first point drawn opens no seam of its own: a beginning is not a
|
||||
// break.
|
||||
points: points.map((point, index) =>
|
||||
index === 0 ? { ...point, seamBreak: false } : point,
|
||||
),
|
||||
band: points.length > 0 ? usualBand(values) : null,
|
||||
};
|
||||
}
|
||||
@@ -66,6 +66,8 @@ import {
|
||||
glp1TimelineResponse,
|
||||
intradayPulseQuery,
|
||||
intradayPulseResponse,
|
||||
scoreHistoryQuery,
|
||||
scoreHistoryResponse,
|
||||
} from "./schemas";
|
||||
import { recommendationFeedbackRequestSchema } from "@/lib/validations/recommendation-feedback";
|
||||
|
||||
@@ -829,6 +831,32 @@ export const insightsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/insights/score-history": {
|
||||
get: {
|
||||
tags: ["Insights"],
|
||||
summary: "A score's daily history",
|
||||
description:
|
||||
"v1.42 — the daily course of the health score, readiness or the sleep score over a trailing window, one point per local day, for the history chart on the score's page. Each point is the value the day view shows for that day: the stored health-score day, the nightly readiness blend filed on its wake day, the sleep score of the night that ended that morning. The health score's points carry `seamBreak` where the recipe changed, and the chart draws no line across it. `band` is the usual range behind the newest point. Readiness is gated on the `recovery` module and the sleep score on `sleep` (403 `module.disabled`); the health score is core. Pure reads, no AI. Delegable at MANAGE level over the whole record. Additive. Cookie or Bearer auth.",
|
||||
requestParams: {
|
||||
query: scoreHistoryQuery,
|
||||
},
|
||||
responses: {
|
||||
...recordRefusal(),
|
||||
"200": {
|
||||
description: "The score's daily points and usual range.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
scoreHistoryResponse,
|
||||
"ScoreHistoryResponseEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/insights/glp1-plateau": {
|
||||
get: {
|
||||
tags: ["Insights"],
|
||||
|
||||
@@ -21,6 +21,10 @@ import { ANALYTICS_RANGES } from "@/lib/analytics/range-delta";
|
||||
import { providerChainPutSchema } from "@/lib/validations/ai-provider";
|
||||
import { PERIOD_DAYS } from "@/lib/insights/narrative/period-narrative";
|
||||
import { aiCapabilityState } from "../profile";
|
||||
import {
|
||||
SCORE_HISTORY_IDS,
|
||||
SCORE_HISTORY_MAX_DAYS,
|
||||
} from "@/lib/insights/score-history-ids";
|
||||
|
||||
/** The retrospective periods the narrative route accepts, read off the engine. */
|
||||
const NARRATIVE_PERIOD_VALUES = Object.keys(PERIOD_DAYS);
|
||||
@@ -2957,3 +2961,61 @@ export const intradayPulseResponse = z
|
||||
description:
|
||||
"One local day's intraday heart-rate shape, plus at most one cautious elevated-at-rest window. Computed from raw samples through the read-swap pattern rather than persisted as ten-minute rollups for all history. Awareness only, never a diagnosis.",
|
||||
});
|
||||
|
||||
export const scoreHistoryQuery = z
|
||||
.object({
|
||||
score: z
|
||||
.enum(SCORE_HISTORY_IDS)
|
||||
.describe(
|
||||
"The score to chart. `HEALTH_SCORE` reads the stored day scores, `READINESS` the nightly readiness blend, `SLEEP_SCORE` the per-night sleep score. The nightly recovery, stress and strain scores are stored measurements and chart through the measurement series instead.",
|
||||
),
|
||||
days: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(SCORE_HISTORY_MAX_DAYS)
|
||||
.describe(
|
||||
`Trailing window in local calendar days ending today (1–${SCORE_HISTORY_MAX_DAYS}). The web chart sends 7, 30, 90, or ${SCORE_HISTORY_MAX_DAYS} for its "All" tab. Out of range is a 422, never a silent clamp.`,
|
||||
),
|
||||
})
|
||||
.meta({ id: "ScoreHistoryQuery" });
|
||||
|
||||
export const scoreHistoryResponse = z
|
||||
.object({
|
||||
score: z.enum(SCORE_HISTORY_IDS),
|
||||
days: z.number().int().describe("The window the request asked for."),
|
||||
points: z
|
||||
.array(
|
||||
z.object({
|
||||
day: z
|
||||
.string()
|
||||
.describe(
|
||||
"The local calendar day the value describes, `YYYY-MM-DD`; the day the day view opens for it.",
|
||||
),
|
||||
value: z.number().int().min(0).max(100),
|
||||
seamBreak: z
|
||||
.boolean()
|
||||
.describe(
|
||||
"True on the first day scored under a different recipe (what counts, or the algorithm version) than the day before. Draw no line from the previous point to this one: the values either side are averages of different things. Only the health score has seams; never true on the first point.",
|
||||
),
|
||||
}),
|
||||
)
|
||||
.describe(
|
||||
"One point per day that has a value, oldest first. A day without one is absent, never zero.",
|
||||
),
|
||||
band: z
|
||||
.object({
|
||||
lo: z.number().int(),
|
||||
hi: z.number().int(),
|
||||
n: z.number().int().describe("Days the range was formed from."),
|
||||
})
|
||||
.nullable()
|
||||
.describe(
|
||||
"The person's usual range behind the newest point: median and scaled MAD of the values in the 30 days before it, from its own side of the last seam. Null with fewer than seven such days. Describes, does not grade.",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
id: "ScoreHistoryResponse",
|
||||
description:
|
||||
"One score's daily course over a trailing window, for a history chart with a day per point.",
|
||||
});
|
||||
@@ -120,6 +120,13 @@ export const insightsKeys = {
|
||||
*/
|
||||
insightsDerivedBatch: (tokens: readonly string[]) =>
|
||||
["insights", "derived", "batch", [...tokens].sort().join(",")] as const,
|
||||
/**
|
||||
* v1.42 — one score's daily history (`/api/insights/score-history`), per
|
||||
* score and window. Under the `["insights"]` prefix so the measurement-write
|
||||
* invalidation fan-out reaches it with the derived reads.
|
||||
*/
|
||||
insightsScoreHistory: (score: string, days: number) =>
|
||||
["insights", "score-history", score, days] as const,
|
||||
/**
|
||||
* v1.10.0 — FDR-controlled correlation discovery
|
||||
* (`/api/insights/correlations`). Read-only descriptive surface; the
|
||||
|
||||
Reference in new issue
Block a user