From 67e2563c6322c4dd240ac4fc6694ef21c56221af Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Tue, 20 Oct 2026 09:15:31 +0200
Subject: [PATCH 01/13] fix(insights): keep deleted mood entries and readings
out of status snapshots
Four per-metric status snapshots read mood context without the tombstone
filter, and the blood-pressure snapshot read measurements the same way, so
a deleted entry kept shaping the text written about it. The breathing
screen and the HRV widget probe had the same gap.
One reader now serves the shared mood context, and a guard derives the
soft-deleting models from the schema and fails on any unfiltered read in
the analysis, insight and model-input directories.
---
src/__tests__/tombstone-read-guard.test.ts | 175 ++++++++++++++++++
src/app/api/dashboard/widgets/route.ts | 6 +-
.../api/insights/breathing-screening/route.ts | 1 +
src/lib/insights/blood-pressure-status.ts | 11 +-
src/lib/insights/general-status.ts | 10 +-
src/lib/insights/mood-status.ts | 1 +
src/lib/insights/pulse-status.ts | 10 +-
src/lib/insights/weight-status.ts | 10 +-
src/lib/mood/__tests__/recent-scores.test.ts | 36 ++++
src/lib/mood/recent-scores.ts | 27 +++
10 files changed, 254 insertions(+), 33 deletions(-)
create mode 100644 src/__tests__/tombstone-read-guard.test.ts
create mode 100644 src/lib/mood/__tests__/recent-scores.test.ts
create mode 100644 src/lib/mood/recent-scores.ts
diff --git a/src/__tests__/tombstone-read-guard.test.ts b/src/__tests__/tombstone-read-guard.test.ts
new file mode 100644
index 000000000..58895e9a0
--- /dev/null
+++ b/src/__tests__/tombstone-read-guard.test.ts
@@ -0,0 +1,175 @@
+/**
+ * Structural guard: a read that feeds analysis or a model prompt never sees a
+ * deleted row.
+ *
+ * Several record types delete softly: the row stays with `deletedAt` set so
+ * sync can propagate the delete and undo can bring it back. Four per-metric
+ * status snapshots read mood entries with `where: { userId }` alone, so an
+ * entry the person had deleted kept shaping the text written about them, and
+ * the blood-pressure snapshot did the same with measurements.
+ *
+ * The models are derived from the schema (every model with a `deletedAt`
+ * column), not listed by hand, so a model that gains soft delete is covered
+ * the day it does. The scope is every directory whose output is analysis,
+ * a score, an insight, or model input. A read passes when its argument names
+ * `deletedAt`, or when it filters through a local object that does. The
+ * entries below read tombstones on purpose and say why. Comments are
+ * stripped first; the planted cases prove the matcher fires.
+ */
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+import { describe, expect, it } from "vitest";
+
+import { walkSourceFiles } from "./helpers/source-files";
+
+const SRC = join(process.cwd(), "src");
+
+const SCOPES = [
+ "lib/insights/",
+ "lib/analytics/",
+ "lib/ai/",
+ "lib/rollups/",
+ "lib/doctor-report/",
+ "lib/day/",
+ "lib/timeline/",
+ "lib/targets/",
+ "lib/gamification/",
+ "lib/mood/",
+ "app/api/insights/",
+ "app/api/analytics/",
+ "app/api/dashboard/",
+];
+
+/** `model@file` → why this read includes deleted rows. */
+const ALLOWED: Record = {
+ "measurement@lib/rollups/measurement-rollups.ts":
+ "Freshness probe on updatedAt: a delete bumps it and must invalidate the rollup.",
+ "moodEntry@lib/rollups/mood-rollups.ts":
+ "Freshness probe on updatedAt: a delete bumps it and must invalidate the rollup.",
+ "coachFact@lib/ai/coach/memory/trail-read.ts":
+ "Withholds medication texts from the recall trail; a deleted fact still withholds, which errs toward saying less.",
+ "coachFact@lib/ai/coach/memory/remember.ts":
+ "Answer-once check on the message that proposed the fact, inside its lock.",
+};
+
+function stripComments(text: string): string {
+ return text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
+}
+
+function tombstonedModels(): string[] {
+ const schema = readFileSync(
+ join(process.cwd(), "prisma/schema.prisma"),
+ "utf8",
+ );
+ const models = new Set();
+ let current: string | null = null;
+ for (const line of schema.split("\n")) {
+ const head = /^model\s+(\w+)\s*\{/.exec(line);
+ if (head) current = head[1];
+ else if (/^\}/.test(line)) current = null;
+ else if (current && /^\s+deletedAt\s+DateTime\?/.test(line)) {
+ models.add(current[0].toLowerCase() + current.slice(1));
+ }
+ }
+ return [...models].sort();
+}
+
+/** The local object a read filters through carries `deletedAt`. */
+function filtersThroughLiveObject(
+ code: string,
+ arg: string,
+ at: number,
+): boolean {
+ const names = [...arg.matchAll(/(?:where:\s*|\.\.\.)(\w+)/g)].map(
+ (m) => m[1],
+ );
+ if (/(?:^|[{,])\s*where\s*[,}]/.test(arg)) names.push("where");
+ return names.some((name) => {
+ // The nearest declaration above the read is the one it uses.
+ const decl = new RegExp(
+ `(?:const|let)\\s+${name}\\b[^=]*=\\s*\\{([\\s\\S]{0,600}?)\\};`,
+ "g",
+ );
+ const before = [...code.slice(0, at).matchAll(decl)].at(-1);
+ return before !== undefined && /\bdeletedAt\b/.test(before[1]);
+ });
+}
+
+export function unfilteredReads(
+ code: string,
+ models: string[],
+): { model: string; arg: string }[] {
+ const re = new RegExp(
+ `\\.(${models.join("|")})\\s*\\.\\s*(findMany|findFirst|count|groupBy|aggregate)\\s*\\(`,
+ "g",
+ );
+ const out: { model: string; arg: string }[] = [];
+ for (const m of code.matchAll(re)) {
+ let i = m.index + m[0].length;
+ let depth = 1;
+ while (depth > 0 && i < code.length) {
+ if (code[i] === "(") depth += 1;
+ else if (code[i] === ")") depth -= 1;
+ i += 1;
+ }
+ const arg = code.slice(m.index + m[0].length, i - 1);
+ if (/\bdeletedAt\b/.test(arg)) continue;
+ if (filtersThroughLiveObject(code, arg, m.index)) continue;
+ out.push({ model: m[1], arg: arg.replace(/\s+/g, " ").slice(0, 120) });
+ }
+ return out;
+}
+
+describe("analysis reads skip deleted rows", () => {
+ const models = tombstonedModels();
+
+ it("derives the soft-deleting models from the schema", () => {
+ expect(models.length).toBeGreaterThanOrEqual(20);
+ expect(models).toContain("moodEntry");
+ expect(models).toContain("measurement");
+ });
+
+ it("the matcher fires on an unfiltered read and passes a filtered one", () => {
+ const planted = `const rows = await prisma.moodEntry
+ .findMany({ where: { userId }, take: 90 });`;
+ expect(unfilteredReads(planted, models)).toHaveLength(1);
+ expect(
+ unfilteredReads(
+ "await prisma.moodEntry.findMany({ where: { userId, deletedAt: null } });",
+ models,
+ ),
+ ).toEqual([]);
+ const viaLocal = `const where = { userId, deletedAt: null };
+ await prisma.measurement.count({ where });`;
+ expect(unfilteredReads(viaLocal, models)).toEqual([]);
+ const viaLocalWithout = `const where = { userId };
+ await prisma.measurement.count({ where });`;
+ expect(unfilteredReads(viaLocalWithout, models)).toHaveLength(1);
+ });
+
+ it("no read in scope includes deleted rows outside the list", () => {
+ const files = walkSourceFiles(SRC, { floor: 3000 }).filter(
+ (file) =>
+ SCOPES.some((scope) => file.startsWith(scope)) &&
+ !file.includes("__tests__/") &&
+ !/\.test\.tsx?$/.test(file),
+ );
+ expect(files.length).toBeGreaterThan(400);
+
+ let reads = 0;
+ const readRe = new RegExp(`\\.(${models.join("|")})\\s*\\.\\s*find`, "g");
+ const offenders: string[] = [];
+ for (const file of files) {
+ const code = stripComments(readFileSync(join(SRC, file), "utf8"));
+ reads += [...code.matchAll(readRe)].length;
+ for (const hit of unfilteredReads(code, models)) {
+ const key = `${hit.model}@${file}`;
+ if (!(key in ALLOWED)) offenders.push(`${key}: ${hit.arg}`);
+ }
+ }
+ // The scope reads these models in well over a hundred places; a sweep
+ // that finds a handful read the wrong tree.
+ expect(reads).toBeGreaterThan(100);
+ expect(offenders).toEqual([]);
+ });
+});
diff --git a/src/app/api/dashboard/widgets/route.ts b/src/app/api/dashboard/widgets/route.ts
index 3d4bd5a0e..470c6362a 100644
--- a/src/app/api/dashboard/widgets/route.ts
+++ b/src/app/api/dashboard/widgets/route.ts
@@ -245,7 +245,11 @@ export const GET = apiHandler(async () => {
let hasSdnn = true;
if (!recoveryEnabled) {
const sdnnRow = await prisma.measurement.findFirst({
- where: { userId: user.id, type: "HEART_RATE_VARIABILITY" },
+ where: {
+ userId: user.id,
+ type: "HEART_RATE_VARIABILITY",
+ deletedAt: null,
+ },
select: { id: true },
});
hasSdnn = sdnnRow !== null;
diff --git a/src/app/api/insights/breathing-screening/route.ts b/src/app/api/insights/breathing-screening/route.ts
index 1a0cf88c6..b3eddcdb2 100644
--- a/src/app/api/insights/breathing-screening/route.ts
+++ b/src/app/api/insights/breathing-screening/route.ts
@@ -48,6 +48,7 @@ export const GET = apiHandler(async () => {
const rows = await prisma.measurement.findMany({
where: {
userId: user.id,
+ deletedAt: null,
type: { in: ["BREATHING_DISTURBANCES", "BREATHING_DISTURBANCE_EVENT"] },
measuredAt: { gte: since },
},
diff --git a/src/lib/insights/blood-pressure-status.ts b/src/lib/insights/blood-pressure-status.ts
index 463073a7e..2063bbed6 100644
--- a/src/lib/insights/blood-pressure-status.ts
+++ b/src/lib/insights/blood-pressure-status.ts
@@ -1,4 +1,5 @@
import { prisma } from "@/lib/db";
+import { readRecentMoodScores } from "@/lib/mood/recent-scores";
import {
getBloodPressureSystemPrompt,
getBloodPressureUserPrompt,
@@ -238,6 +239,7 @@ export async function prepareBloodPressureStatusForUser(
.findMany({
where: {
userId,
+ deletedAt: null,
type: {
in: ["WEIGHT", "BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"],
},
@@ -457,14 +459,7 @@ export async function prepareBloodPressureStatusForUser(
// Fetch mood context (optional — for enrichment only). v1.4.28
// Cap at 90 entries (~3 months) so power users don't pull
// unbounded rows before the bucket-series budget runs.
- const moodEntries = await prisma.moodEntry
- .findMany({
- where: { userId },
- orderBy: { moodLoggedAt: "desc" },
- take: 90,
- select: { date: true, score: true, moodLoggedAt: true },
- })
- .then((rows) => rows.reverse());
+ const moodEntries = await readRecentMoodScores(userId, 90);
const moodSeries = applyPayloadBudget(
moodEntries.map((entry) => ({
diff --git a/src/lib/insights/general-status.ts b/src/lib/insights/general-status.ts
index 7e5230b54..0c69dfd90 100644
--- a/src/lib/insights/general-status.ts
+++ b/src/lib/insights/general-status.ts
@@ -1,4 +1,5 @@
import { prisma } from "@/lib/db";
+import { readRecentMoodScores } from "@/lib/mood/recent-scores";
import {
getGeneralStatusSystemPrompt,
getGeneralStatusUserPrompt,
@@ -309,14 +310,7 @@ export async function prepareGeneralStatusForUser(
// Fetch mood context (optional — for enrichment only). v1.4.28
// Cap at 90 entries.
- const moodEntries = await prisma.moodEntry
- .findMany({
- where: { userId },
- orderBy: { moodLoggedAt: "desc" },
- take: 90,
- select: { date: true, score: true, moodLoggedAt: true },
- })
- .then((rows) => rows.reverse());
+ const moodEntries = await readRecentMoodScores(userId, 90);
const moodRecords = moodEntries.map((entry) => ({
measuredAt: entry.moodLoggedAt,
diff --git a/src/lib/insights/mood-status.ts b/src/lib/insights/mood-status.ts
index 7cc878483..e19388e09 100644
--- a/src/lib/insights/mood-status.ts
+++ b/src/lib/insights/mood-status.ts
@@ -241,6 +241,7 @@ export async function prepareMoodStatusForUser(
.findMany({
where: {
userId,
+ deletedAt: null,
},
orderBy: { date: "desc" },
take: 90,
diff --git a/src/lib/insights/pulse-status.ts b/src/lib/insights/pulse-status.ts
index 9262f3fba..2cd31d862 100644
--- a/src/lib/insights/pulse-status.ts
+++ b/src/lib/insights/pulse-status.ts
@@ -1,4 +1,5 @@
import { prisma } from "@/lib/db";
+import { readRecentMoodScores } from "@/lib/mood/recent-scores";
import {
getPulseSystemPrompt,
getPulseUserPrompt,
@@ -252,14 +253,7 @@ export async function preparePulseStatusForUser(
// Fetch mood context (optional — for enrichment only). Cap at 90
// entries (~3 months); the bucket-series budget then summarises
// further. v1.4.28 FB-D2 — prevent unbounded reads for power users.
- const moodEntries = await prisma.moodEntry
- .findMany({
- where: { userId },
- orderBy: { moodLoggedAt: "desc" },
- take: 90,
- select: { date: true, score: true, moodLoggedAt: true },
- })
- .then((rows) => rows.reverse());
+ const moodEntries = await readRecentMoodScores(userId, 90);
const moodPoints = moodEntries.map((entry) => ({
measuredAt: entry.moodLoggedAt,
diff --git a/src/lib/insights/weight-status.ts b/src/lib/insights/weight-status.ts
index 053ba36e4..c5ec126e2 100644
--- a/src/lib/insights/weight-status.ts
+++ b/src/lib/insights/weight-status.ts
@@ -1,4 +1,5 @@
import { prisma } from "@/lib/db";
+import { readRecentMoodScores } from "@/lib/mood/recent-scores";
import {
getWeightSystemPrompt,
getWeightUserPrompt,
@@ -356,14 +357,7 @@ export async function prepareWeightStatusForUser(
// Fetch mood context (optional — for enrichment only). v1.4.28
// Cap at 90 entries.
- const moodEntries = await prisma.moodEntry
- .findMany({
- where: { userId },
- orderBy: { moodLoggedAt: "desc" },
- take: 90,
- select: { date: true, score: true, moodLoggedAt: true },
- })
- .then((rows) => rows.reverse());
+ const moodEntries = await readRecentMoodScores(userId, 90);
const moodSeries = applyPayloadBudget(
moodEntries.map((entry) => ({
diff --git a/src/lib/mood/__tests__/recent-scores.test.ts b/src/lib/mood/__tests__/recent-scores.test.ts
new file mode 100644
index 000000000..9cd2080a3
--- /dev/null
+++ b/src/lib/mood/__tests__/recent-scores.test.ts
@@ -0,0 +1,36 @@
+import { describe, expect, it, vi } from "vitest";
+
+const { moodEntry } = vi.hoisted(() => ({
+ moodEntry: { findMany: vi.fn() },
+}));
+
+vi.mock("@/lib/db", () => ({ prisma: { moodEntry } }));
+
+import { readRecentMoodScores } from "../recent-scores";
+
+describe("readRecentMoodScores", () => {
+ it("reads live entries only, newest first, and hands them back oldest first", async () => {
+ const newer = {
+ date: "2026-10-02",
+ score: 4,
+ moodLoggedAt: new Date("2026-10-02T08:00:00Z"),
+ };
+ const older = {
+ date: "2026-10-01",
+ score: 2,
+ moodLoggedAt: new Date("2026-10-01T08:00:00Z"),
+ };
+ moodEntry.findMany.mockResolvedValueOnce([newer, older]);
+
+ const rows = await readRecentMoodScores("u1", 30);
+
+ expect(moodEntry.findMany).toHaveBeenCalledWith(
+ expect.objectContaining({
+ where: { userId: "u1", deletedAt: null },
+ orderBy: { moodLoggedAt: "desc" },
+ take: 30,
+ }),
+ );
+ expect(rows).toEqual([older, newer]);
+ });
+});
diff --git a/src/lib/mood/recent-scores.ts b/src/lib/mood/recent-scores.ts
new file mode 100644
index 000000000..13c757506
--- /dev/null
+++ b/src/lib/mood/recent-scores.ts
@@ -0,0 +1,27 @@
+/**
+ * The recent-mood context read shared by the per-metric status snapshots
+ * (pulse, weight, blood pressure, general). A deleted entry is a tombstone
+ * kept for sync and undo; it must never reach a model prompt or a summary,
+ * so the filter lives here rather than at each call site.
+ */
+import { prisma } from "@/lib/db";
+
+export interface RecentMoodScore {
+ date: string;
+ score: number;
+ moodLoggedAt: Date;
+}
+
+/** Most recent `take` live entries, returned oldest first. */
+export async function readRecentMoodScores(
+ userId: string,
+ take = 90,
+): Promise {
+ const rows = await prisma.moodEntry.findMany({
+ where: { userId, deletedAt: null },
+ orderBy: { moodLoggedAt: "desc" },
+ take,
+ select: { date: true, score: true, moodLoggedAt: true },
+ });
+ return rows.reverse();
+}
From 3004f55875b799ce124f6b26fdf9c08f159c5333 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Tue, 20 Oct 2026 09:16:43 +0200
Subject: [PATCH 02/13] fix(correlations): keep a chronic condition's logged
days in the symptom channel
Excluding chronic episodes from the zero-fill also dropped them from the
read, so their day-logs never reached the series and an account with only
a chronic condition had no symptom channel at all. The read now takes
every in-window episode; only the ones that can trigger Rest Mode carry a
zero-filled span, and every logged day counts.
---
.../__tests__/symptom-series-chronic.test.ts | 54 ++++++++++++++++---
.../insights/correlation-channel-series.ts | 27 +++++-----
.../insights/correlation-series-builders.ts | 22 ++++----
3 files changed, 72 insertions(+), 31 deletions(-)
diff --git a/src/lib/insights/__tests__/symptom-series-chronic.test.ts b/src/lib/insights/__tests__/symptom-series-chronic.test.ts
index c949b3380..a6a31e0d1 100644
--- a/src/lib/insights/__tests__/symptom-series-chronic.test.ts
+++ b/src/lib/insights/__tests__/symptom-series-chronic.test.ts
@@ -1,11 +1,14 @@
/**
- * A chronic condition does not zero-fill the symptom-severity series.
+ * A chronic condition does not zero-fill the symptom-severity series, but its
+ * logged days stay in it.
*
* The series fills healthy days with 0 across an episode's span. A chronic
* condition has no end, so its span covered the whole window and the engine
* correlated a near-constant run of zeros. Only episodes that can put the
- * account into Rest Mode carry a span; the fake store below honours the
- * lifecycle filter the way Postgres would.
+ * account into Rest Mode carry a span. The first cut of that fix dropped the
+ * chronic episode from the read altogether, which also dropped every day the
+ * person had logged for it: a chronic-only account had no channel at all.
+ * The fake store honours the episode filter the way Postgres would.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
@@ -15,7 +18,11 @@ type Episode = {
onsetAt: Date;
resolvedAt: Date | null;
};
-const store: { episodes: Episode[] } = { episodes: [] };
+type DayLog = { episodeId: string; date: string; functionalImpact: number };
+const store: { episodes: Episode[]; dayLogs: DayLog[] } = {
+ episodes: [],
+ dayLogs: [],
+};
vi.mock("@/lib/db", () => ({
prisma: {
@@ -29,7 +36,18 @@ vi.mock("@/lib/db", () => ({
),
),
},
- illnessDayLog: { findMany: vi.fn(async () => []) },
+ illnessDayLog: {
+ findMany: vi.fn(
+ async (args: { where: { episodeId: { in: string[] } } }) =>
+ store.dayLogs
+ .filter((d) => args.where.episodeId.in.includes(d.episodeId))
+ .map((d) => ({
+ date: d.date,
+ functionalImpact: d.functionalImpact,
+ symptomLinks: [],
+ })),
+ ),
+ },
},
}));
@@ -38,12 +56,16 @@ import { fetchSymptomSeries } from "../correlation-channel-series";
const DAY = 24 * 60 * 60 * 1000;
const since = new Date(Date.now() - 60 * DAY);
+const dayKeyAgo = (days: number) =>
+ new Date(Date.now() - days * DAY).toISOString().slice(0, 10);
+
beforeEach(() => {
store.episodes = [];
+ store.dayLogs = [];
});
describe("fetchSymptomSeries", () => {
- it("has no series for a chronic condition alone", async () => {
+ it("has no zero-filled series for a chronic condition without logs", async () => {
store.episodes = [
{
id: "chronic",
@@ -75,4 +97,24 @@ describe("fetchSymptomSeries", () => {
expect(series.points.length).toBeGreaterThanOrEqual(6);
expect(series.points.length).toBeLessThanOrEqual(8);
});
+
+ it("keeps the logged days of a chronic condition, without zero-fill", async () => {
+ store.episodes = [
+ {
+ id: "chronic",
+ lifecycle: "CHRONIC_ONGOING",
+ onsetAt: new Date(Date.now() - 900 * DAY),
+ resolvedAt: null,
+ },
+ ];
+ store.dayLogs = [
+ { episodeId: "chronic", date: dayKeyAgo(10), functionalImpact: 3 },
+ { episodeId: "chronic", date: dayKeyAgo(5), functionalImpact: 1 },
+ ];
+ const series = await fetchSymptomSeries("user-1", "UTC", since);
+ expect(series.points).toEqual([
+ { day: dayKeyAgo(10), value: 3 },
+ { day: dayKeyAgo(5), value: 1 },
+ ]);
+ });
});
diff --git a/src/lib/insights/correlation-channel-series.ts b/src/lib/insights/correlation-channel-series.ts
index 3e038c63c..7c8382edf 100644
--- a/src/lib/insights/correlation-channel-series.ts
+++ b/src/lib/insights/correlation-channel-series.ts
@@ -20,7 +20,7 @@
* fabricating a constant.
*/
import { prisma } from "@/lib/db";
-import { REST_MODE_EPISODE_WHERE } from "@/lib/illness/rest-mode";
+import { triggersRestMode } from "@/lib/illness/rest-mode";
import {
buildComplianceMedicationContext,
COURSES_COMPLIANCE_SELECT,
@@ -150,11 +150,13 @@ export async function fetchComplianceSeries(
}
/**
- * v1.21.0 (FDREXTEND) — build the user's SYMPTOM_SEVERITY daily series in the
- * `outcome` role (callers that need the behaviour role re-tag the returned
- * series — the points are role-invariant). Reads every in-window illness episode
- * that is not a chronic condition + its day-logs; the builder zero-fills healthy
- * days ONLY across those spans, so a user with no such episode yields an empty
+ * Build the user's SYMPTOM_SEVERITY daily series in the `outcome` role
+ * (callers that need the behaviour role re-tag the returned series — the
+ * points are role-invariant). Reads every in-window illness episode and its
+ * day-logs. Only episodes that can put the account into Rest Mode zero-fill
+ * their span: a chronic condition has no end, so its span would fill every
+ * day of the window and hand the engine a near-constant series. Its logged
+ * days still count, as real readings. No episode and no log yields an empty
* series that degrades to absent.
*/
export async function fetchSymptomSeries(
@@ -166,17 +168,13 @@ export async function fetchSymptomSeries(
const episodes = await prisma.illnessEpisode.findMany({
// An episode overlaps the window when it onset before `now` and either is
// ongoing or resolved at/after the window start.
- // A chronic condition has no end, so its span would zero-fill every day
- // of the window and hand the engine a near-constant series. Only the
- // episodes that can put the account into Rest Mode carry a span.
where: {
userId,
deletedAt: null,
onsetAt: { lte: now },
OR: [{ resolvedAt: null }, { resolvedAt: { gte: since } }],
- ...REST_MODE_EPISODE_WHERE,
},
- select: { id: true, onsetAt: true, resolvedAt: true },
+ select: { id: true, onsetAt: true, resolvedAt: true, lifecycle: true },
});
if (episodes.length === 0) {
return { key: SYMPTOM_SEVERITY_CHANNEL_KEY, role: "outcome", points: [] };
@@ -215,10 +213,9 @@ export async function fetchSymptomSeries(
dayLogs.push({ day: row.date, impact: maxSeverity });
}
- const spans: SymptomEpisodeSpan[] = episodes.map((e) => ({
- onsetAt: e.onsetAt,
- resolvedAt: e.resolvedAt,
- }));
+ const spans: SymptomEpisodeSpan[] = episodes
+ .filter(triggersRestMode)
+ .map((e) => ({ onsetAt: e.onsetAt, resolvedAt: e.resolvedAt }));
return buildSymptomSeverityDailySeries({
dayLogs,
diff --git a/src/lib/insights/correlation-series-builders.ts b/src/lib/insights/correlation-series-builders.ts
index 7da33628e..a34095560 100644
--- a/src/lib/insights/correlation-series-builders.ts
+++ b/src/lib/insights/correlation-series-builders.ts
@@ -120,9 +120,11 @@ function dayKey(at: Date, tz: string): string {
/**
* Build the daily SYMPTOM_SEVERITY series (rides both roles; this returns it in
* the requested `role`). Healthy days are zero-filled — but ONLY across the
- * span of the user's real episodes intersected with `[windowStart, windowEnd]`,
- * so a user with no episodes yields an EMPTY series (the channel degrades to
- * absent, never a spurious all-zero constant).
+ * span of the episodes passed in, intersected with `[windowStart, windowEnd]`.
+ * Logged days always count, inside a span or not, so an episode that carries
+ * no span (a chronic condition) still contributes its real readings. No span
+ * and no log yields an EMPTY series (the channel degrades to absent, never a
+ * spurious all-zero constant).
*
* Algorithm:
* 1. Compute the set of in-window calendar days covered by ANY episode span
@@ -134,8 +136,8 @@ function dayKey(at: Date, tz: string): string {
* on a day → the MAX, the most-symptomatic reading).
*
* The result is a dense-within-episode-spans series with genuine 0↔>0 variance
- * when the user had symptomatic days, and NOTHING at all when they had no
- * episodes. Pure.
+ * when the user had symptomatic days, and NOTHING at all when there is neither
+ * a span nor a logged day. Pure.
*/
export function buildSymptomSeverityDailySeries(args: {
dayLogs: SymptomDayLogRow[];
@@ -171,11 +173,6 @@ export function buildSymptomSeverityDailySeries(args: {
}
}
- // If no episode put a single day on the board, the channel is absent.
- if (byDay.size === 0) {
- return { key: SYMPTOM_SEVERITY_CHANNEL_KEY, role, points: [] };
- }
-
// Step 3 — overlay logged impacts (MAX per day). A logged day outside every
// episode span is still honoured (it is, by definition, a symptomatic day the
// user recorded), so it joins the series even if the span walk missed it.
@@ -186,6 +183,11 @@ export function buildSymptomSeverityDailySeries(args: {
byDay.set(log.day, prev == null ? log.impact : Math.max(prev, log.impact));
}
+ // No span and no logged day: the channel is absent.
+ if (byDay.size === 0) {
+ return { key: SYMPTOM_SEVERITY_CHANNEL_KEY, role, points: [] };
+ }
+
const points: DailySeriesPoint[] = [...byDay.entries()]
.map(([day, value]) => ({ day, value }))
.sort((a, b) => (a.day < b.day ? -1 : a.day > b.day ? 1 : 0));
From 63bbf0931c4d212e0372bf05da8cd692feb6f1db Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Tue, 20 Oct 2026 09:28:40 +0200
Subject: [PATCH 03/13] fix(sleep): one day total for every sleep reader, and
the latest night is a night
A day's sleep is the main night plus its naps (daySleepMinutes). The
series, the sleep score and the composition already read it that way; the
sleep list, the CSV export and the correlation sleep channel read the main
night alone, so a day of naps listed as 0 h and an average over the list
counted it as a night without sleep. The list now carries the day total in
value and the main night alone in the new mainAsleepMinutes field; the nap
caption reads as included.
GET /api/sleep/night without a date answered the latest day holding any
sleep, so a nap after the last night left the hypnogram with no night to
draw. It now walks back to the latest night with a main sleep; nap days
stay reachable by date.
The SQL night page bounded a night by its asleep sessions only, so an
in-bed-only session of the same night fell outside a chunked read that the
one-night read counted. The span now covers every session of the night.
---
docs/api/openapi.yaml | 73 +++++++++--
messages/de.json | 2 +-
messages/en.json | 2 +-
messages/es.json | 2 +-
messages/fr.json | 2 +-
messages/it.json | 2 +-
messages/ko.json | 2 +-
messages/nl.json | 2 +-
messages/pl.json | 2 +-
.../api/sleep/night/__tests__/route.test.ts | 18 ++-
src/app/api/sleep/night/route.ts | 10 +-
.../measurements/measurement-list.tsx | 6 +-
src/lib/analytics/sleep-night.ts | 18 +++
src/lib/export.ts | 33 ++---
.../insights/correlation-channel-series.ts | 9 +-
src/lib/measurements/list/sleep-list.ts | 19 +--
src/lib/measurements/list/sleep-nights.ts | 80 ++++++------
src/lib/openapi/routes/measurements.ts | 27 +++-
.../measurements-sleep-list.test.ts | 8 +-
.../sleep-night-sql-parity.test.ts | 116 +++++++++++++++++-
20 files changed, 339 insertions(+), 94 deletions(-)
diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index 3230be4c9..89de3e04e 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -4207,11 +4207,13 @@ paths:
With `groupBy=day` on a cumulative type the rows are one sum per local day, and `limit` / `offset` page over
those days; every returned day is complete, which is why `valueMin` / `valueMax` (a filter on single readings)
are refused with 422 beside `groupBy=day`. `type=SLEEP_DURATION` returns one row per night, paged the same way
- over nights; every night of the page has a row, and a day whose only sleep is daytime naps carries `value` 0
- with its naps in `napCount` / `napAsleepMinutes`. Its `dayKey` is the same day `/api/sleep/night?date=` answers
- for. On both, `meta.total` counts the days or nights in the filtered window. A day-sum page takes `limit` as
- asked (up to the list's 5000); a sleep page holds at most 1000 nights, and `meta.limit` echoes the value
- applied.
+ over nights; every night of the page has a row, and `value` is the day's time asleep, the main night plus its
+ naps (the same total `/api/measurements/series?kind=sleep` and the sleep score read), with the main night alone
+ in `mainAsleepMinutes` and the naps in `napCount` / `napAsleepMinutes`; a day whose only sleep is daytime naps
+ carries the naps' minutes and `mainAsleepMinutes: null`. Its `dayKey` is the same day `/api/sleep/night?date=`
+ answers for. On both, `meta.total` counts the days or nights in the filtered window. A day-sum page takes
+ `limit` as asked (up to the list's 5000); a sleep page holds at most 1000 nights, and `meta.limit` echoes the
+ value applied.
parameters:
- in: query
name: type
@@ -4732,16 +4734,18 @@ paths:
source's stage segments (each with an absolute start/end), the per-stage breakdown, asleep/in-bed/awake totals,
the mid-sleep awakenings count, and same-wake-day naps surfaced separately. Session-clustered, keyed by the
local wake day, collapsed to one source via the sleep priority ladder so two sources never overlay. `date`
- omitted returns the most recent night holding sleep, however old. Any past date is answered, with no window cut.
- A read-only view over existing per-stage SLEEP_DURATION rows — no schema, no new measurement type."
+ omitted returns the most recent night with a main sleep, however old; later days of naps alone are stepped over
+ and stay reachable by `date`. An account that has only ever napped gets its latest day of naps. Any past date is
+ answered, with no window cut. A read-only view over existing per-stage SLEEP_DURATION rows — no schema, no new
+ measurement type."
parameters:
- in: query
name: date
schema:
- description: Wake-day key (YYYY-MM-DD). Omit for the most recent night.
+ description: Wake-day key (YYYY-MM-DD). Omit for the most recent night with a main sleep.
type: string
pattern: ^\d{4}-\d{2}-\d{2}$
- description: Wake-day key (YYYY-MM-DD). Omit for the most recent night.
+ description: Wake-day key (YYYY-MM-DD). Omit for the most recent night with a main sleep.
responses:
"200":
description: Reconstructed night.
@@ -46284,6 +46288,23 @@ components:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
+ mainAsleepMinutes:
+ description: The main night's own time asleep in minutes; null on a day of naps alone. On a sleep row `value` is the
+ day's total, the main night plus its naps.
+ anyOf:
+ - type: integer
+ minimum: 0
+ maximum: 9007199254740991
+ - type: "null"
+ napCount:
+ description: Naps on the same wake day.
+ type: integer
+ minimum: 0
+ maximum: 9007199254740991
+ napAsleepMinutes:
+ description: Time asleep across those naps, in minutes.
+ type: number
+ minimum: 0
required:
- id
- type
@@ -47108,6 +47129,23 @@ components:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
+ mainAsleepMinutes:
+ description: The main night's own time asleep in minutes; null on a day of naps alone. On a sleep row `value` is the
+ day's total, the main night plus its naps.
+ anyOf:
+ - type: integer
+ minimum: 0
+ maximum: 9007199254740991
+ - type: "null"
+ napCount:
+ description: Naps on the same wake day.
+ type: integer
+ minimum: 0
+ maximum: 9007199254740991
+ napAsleepMinutes:
+ description: Time asleep across those naps, in minutes.
+ type: number
+ minimum: 0
required:
- id
- type
@@ -47289,6 +47327,23 @@ components:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
+ mainAsleepMinutes:
+ description: The main night's own time asleep in minutes; null on a day of naps alone. On a sleep row `value` is the
+ day's total, the main night plus its naps.
+ anyOf:
+ - type: integer
+ minimum: 0
+ maximum: 9007199254740991
+ - type: "null"
+ napCount:
+ description: Naps on the same wake day.
+ type: integer
+ minimum: 0
+ maximum: 9007199254740991
+ napAsleepMinutes:
+ description: Time asleep across those naps, in minutes.
+ type: number
+ minimum: 0
status:
type: string
enum:
diff --git a/messages/de.json b/messages/de.json
index b2667a7de..b2f45cd89 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -845,7 +845,7 @@
"nextPage": "Nächste Seite",
"dailyTotalCaption": "({count} Einzelwerte)",
"openMetricInsights": "{type}-Auswertung öffnen",
- "sleepNapCaption": "+{count} Nickerchen",
+ "sleepNapCaption": "inkl. {count} Nickerchen",
"sleepAwakeningsCaption": "{count} Wachphasen",
"expandDay": "Einzelwerte dieses Tages anzeigen",
"collapseDay": "Einzelwerte dieses Tages ausblenden",
diff --git a/messages/en.json b/messages/en.json
index 3e30108aa..b24c7614c 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -845,7 +845,7 @@
"nextPage": "Next page",
"dailyTotalCaption": "({count} samples)",
"openMetricInsights": "Open {type} insights",
- "sleepNapCaption": "+{count} nap(s)",
+ "sleepNapCaption": "incl. {count} nap(s)",
"sleepAwakeningsCaption": "{count} awakening(s)",
"expandDay": "Show samples for this day",
"collapseDay": "Hide samples for this day",
diff --git a/messages/es.json b/messages/es.json
index a7e92f85e..ad0911981 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -845,7 +845,7 @@
"nextPage": "Página siguiente",
"dailyTotalCaption": "({count} muestras)",
"openMetricInsights": "Abrir análisis de {type}",
- "sleepNapCaption": "+{count} siesta(s)",
+ "sleepNapCaption": "incl. {count} siesta(s)",
"sleepAwakeningsCaption": "{count} despertar(es)",
"expandDay": "Mostrar las muestras de este día",
"collapseDay": "Ocultar las muestras de este día",
diff --git a/messages/fr.json b/messages/fr.json
index 2626bfb40..c646bb00d 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -845,7 +845,7 @@
"nextPage": "Page suivante",
"dailyTotalCaption": "({count} échantillons)",
"openMetricInsights": "Ouvrir l'analyse {type}",
- "sleepNapCaption": "+{count} sieste(s)",
+ "sleepNapCaption": "dont {count} sieste(s)",
"sleepAwakeningsCaption": "{count} réveil(s)",
"expandDay": "Afficher les échantillons de ce jour",
"collapseDay": "Masquer les échantillons de ce jour",
diff --git a/messages/it.json b/messages/it.json
index 9f6082888..ed4265878 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -845,7 +845,7 @@
"nextPage": "Pagina successiva",
"dailyTotalCaption": "({count} campioni)",
"openMetricInsights": "Apri l'analisi {type}",
- "sleepNapCaption": "+{count} pisolino/i",
+ "sleepNapCaption": "incl. {count} pisolino/i",
"sleepAwakeningsCaption": "{count} risveglio/i",
"expandDay": "Mostra i campioni di questa giornata",
"collapseDay": "Nascondi i campioni di questa giornata",
diff --git a/messages/ko.json b/messages/ko.json
index 291d55291..a4a77cdba 100644
--- a/messages/ko.json
+++ b/messages/ko.json
@@ -845,7 +845,7 @@
"nextPage": "다음 쪽",
"dailyTotalCaption": "({count}개 샘플)",
"openMetricInsights": "{type} 인사이트 열기",
- "sleepNapCaption": "+낮잠 {count}회",
+ "sleepNapCaption": "낮잠 {count}회 포함",
"sleepAwakeningsCaption": "깬 횟수 {count}회",
"expandDay": "이 날의 샘플 보기",
"collapseDay": "이 날의 샘플 숨기기",
diff --git a/messages/nl.json b/messages/nl.json
index 21d713639..5ea71537a 100644
--- a/messages/nl.json
+++ b/messages/nl.json
@@ -845,7 +845,7 @@
"nextPage": "Volgende pagina",
"dailyTotalCaption": "({count} metingen)",
"openMetricInsights": "Inzichten voor {type} openen",
- "sleepNapCaption": "+{count} dutje(s)",
+ "sleepNapCaption": "incl. {count} dutje(s)",
"sleepAwakeningsCaption": "{count} keer wakker",
"expandDay": "Metingen van deze dag tonen",
"collapseDay": "Metingen van deze dag verbergen",
diff --git a/messages/pl.json b/messages/pl.json
index 4735fd572..b41dfa858 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -845,7 +845,7 @@
"nextPage": "Następna strona",
"dailyTotalCaption": "({count} pomiarów)",
"openMetricInsights": "Otwórz analizę {type}",
- "sleepNapCaption": "+{count} drzemka/i",
+ "sleepNapCaption": "w tym {count} drzemka/i",
"sleepAwakeningsCaption": "{count} przebudzenia",
"expandDay": "Pokaż pomiary z tego dnia",
"collapseDay": "Ukryj pomiary z tego dnia",
diff --git a/src/app/api/sleep/night/__tests__/route.test.ts b/src/app/api/sleep/night/__tests__/route.test.ts
index c5e1f8f34..483f888ee 100644
--- a/src/app/api/sleep/night/__tests__/route.test.ts
+++ b/src/app/api/sleep/night/__tests__/route.test.ts
@@ -28,6 +28,17 @@ vi.mock("@/lib/modules/gate", () => ({
requireModuleEnabled: vi.fn(async () => ({ enabled: true })),
}));
+// The latest-night walk pages nights in SQL; its own behaviour is pinned by
+// the SQL parity integration suite. Here it is stubbed to a fixed day so the
+// route's "no date" arm can be read.
+vi.mock("@/lib/measurements/list/sleep-nights", async (importOriginal) => {
+ const actual =
+ await importOriginal<
+ typeof import("@/lib/measurements/list/sleep-nights")
+ >();
+ return { ...actual, latestMainSleepNight: vi.fn(async () => null) };
+});
+
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
@@ -49,6 +60,7 @@ vi.mock("next/headers", () => ({
import { GET } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
+import { latestMainSleepNight } from "@/lib/measurements/list/sleep-nights";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -133,15 +145,13 @@ describe("GET /api/sleep/night", () => {
expect(Number.isInteger(body.data.main.awakeMinutes)).toBe(true);
});
- it("defaults to the most recent scorable night when date omitted", async () => {
+ it("defaults to the latest main night when date omitted", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
stage("2026-06-02T03:00:00.000Z", "CORE", 200),
stage("2026-06-04T03:00:00.000Z", "CORE", 300),
] as never);
- vi.mocked(prisma.measurement.findFirst).mockResolvedValue({
- measuredAt: new Date("2026-06-04T03:00:00.000Z"),
- } as never);
+ vi.mocked(latestMainSleepNight).mockResolvedValueOnce("2026-06-04");
const res = await GET(req(""));
const body = await res.json();
expect(body.data.night).toBe("2026-06-04");
diff --git a/src/app/api/sleep/night/route.ts b/src/app/api/sleep/night/route.ts
index 8c3f4a01d..0d909d975 100644
--- a/src/app/api/sleep/night/route.ts
+++ b/src/app/api/sleep/night/route.ts
@@ -17,7 +17,9 @@
* (WHOOP) — the client renders the hypnogram but labels it an approximate
* layout. A real-series source (Apple Health / Withings / Fitbit) is false.
*
- * `date` omitted → the most recent night that holds sleep, however old.
+ * `date` omitted → the most recent night with a main sleep, however old; a
+ * run of nap-only days since is stepped over (each stays reachable by its
+ * `date`). An account that has only ever napped gets its latest nap day.
*
* Only the stage rows within a day and a half of the night are read, as the
* list's drill-down reads them (`readNightSessions`), so any past night is
@@ -39,7 +41,7 @@ import {
type SleepSession,
} from "@/lib/analytics/sleep-night";
import {
- latestSleepNight,
+ latestMainSleepNight,
readNightSessions,
} from "@/lib/measurements/list/sleep-nights";
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
@@ -131,7 +133,9 @@ export const GET = apiHandler(async (request: NextRequest) => {
]);
const targetDay =
- date ?? (await latestSleepNight(user.id, tz, priorityJson)) ?? undefined;
+ date ??
+ (await latestMainSleepNight(user.id, tz, priorityJson)) ??
+ undefined;
const { main, naps } = targetDay
? pickMainNightAndNaps(
(await readNightSessions(user.id, targetDay, tz, priorityJson))
diff --git a/src/components/measurements/measurement-list.tsx b/src/components/measurements/measurement-list.tsx
index 044567114..15c01cef7 100644
--- a/src/components/measurements/measurement-list.tsx
+++ b/src/components/measurements/measurement-list.tsx
@@ -1989,9 +1989,9 @@ function DayDrillDown({
}
/**
- * v1.11.5 — sub-caption for a per-night sleep row: surfaces the nap count
- * (naps are separate from the main night per the nap convention) and the
- * night's mid-sleep awakenings when present.
+ * Sub-caption for a per-night sleep row: the row's value is the day's sleep,
+ * main night plus naps, so the caption says how many naps it includes, and
+ * the night's mid-sleep awakenings when present.
*/
function SleepNightCaption({ m }: { m: Measurement }) {
const { t } = useTranslations();
diff --git a/src/lib/analytics/sleep-night.ts b/src/lib/analytics/sleep-night.ts
index 732424e68..71d82ee7a 100644
--- a/src/lib/analytics/sleep-night.ts
+++ b/src/lib/analytics/sleep-night.ts
@@ -1282,6 +1282,24 @@ export function separateNightsAndNaps(
return days.sort((a, b) => a.night.localeCompare(b.night));
}
+/**
+ * A wake day's sleep total, the one definition every reader states: the main
+ * night plus every nap of the day. It equals `SleepNight.asleepMinutes` for
+ * the same rows (both sum each session's canonical-writer asleep minutes),
+ * which is what the series, the sleep score and the composition read; the
+ * main night alone stays available as `main.asleepMinutes` /
+ * `SleepNight.mainSession`. A day of naps alone totals its naps, not 0.
+ */
+export function daySleepMinutes(day: {
+ main: SleepSession | null;
+ naps: readonly SleepSession[];
+}): number {
+ return (
+ (day.main?.asleepMinutes ?? 0) +
+ day.naps.reduce((sum, nap) => sum + nap.asleepMinutes, 0)
+ );
+}
+
export interface SleepNightSummary {
/**
* A `DataSummary` whose every value is a per-night TIME-ASLEEP total
diff --git a/src/lib/export.ts b/src/lib/export.ts
index f2ad30b28..80718d53a 100644
--- a/src/lib/export.ts
+++ b/src/lib/export.ts
@@ -17,6 +17,7 @@
import { formatInUserTz } from "./tz/format";
import {
reconstructSleepSessions,
+ daySleepMinutes,
pickMainNightAndNaps,
type SleepStageRow,
} from "./analytics/sleep-night";
@@ -142,9 +143,9 @@ interface ExportMeasurement {
*
* v1.11.5 — SLEEP_DURATION is stored one row per STAGE per night. A flat
* export emitted a wall of per-stage minutes with no night attribution.
- * By default sleep rows are now COLLAPSED to one record per night (the
- * main session's TIME ASLEEP per the nap convention) carrying the stage
- * breakdown in `notes` so the single uniform CSV reads as one night, not
+ * By default sleep rows are now COLLAPSED to one record per wake day (the
+ * day's TIME ASLEEP, main night plus naps, `daySleepMinutes`) carrying the
+ * main night's stage breakdown and the naps in `notes` so the single uniform CSV reads as one night, not
* many stages. Pass `granularity: "raw"` to keep the per-stage rows for
* power users. Storage is untouched either way.
*/
@@ -224,11 +225,14 @@ export function formatMeasurementsForExport(
const nightRecords: Array<{ at: Date; rec: ExportableRecord }> = [];
for (const daySessions of byDay.values()) {
const { main, naps } = pickMainNightAndNaps(daySessions);
- if (!main) continue;
- const stageParts = (Object.entries(main.stages) as Array<[string, number]>)
- .filter(([, mins]) => mins > 0)
- .map(([stage, mins]) => `${stage}=${Math.round(mins)}m`)
- .join(" ");
+ const lead = main ?? naps.at(-1);
+ if (!lead) continue;
+ const stageParts = main
+ ? (Object.entries(main.stages) as Array<[string, number]>)
+ .filter(([, mins]) => mins > 0)
+ .map(([stage, mins]) => `${stage}=${Math.round(mins)}m`)
+ .join(" ")
+ : "";
const napNote =
naps.length > 0
? ` naps=${naps.length}(${Math.round(
@@ -236,16 +240,17 @@ export function formatMeasurementsForExport(
)}m)`
: "";
const awakeningNote =
- main.awakenings > 0 ? ` awakenings=${main.awakenings}` : "";
+ main && main.awakenings > 0 ? ` awakenings=${main.awakenings}` : "";
nightRecords.push({
- at: main.end,
+ at: lead.end,
rec: {
type: "SLEEP_DURATION",
- // Headline value = the main night's TIME ASLEEP in minutes.
- value: Math.round(main.asleepMinutes),
+ // Headline value = the day's TIME ASLEEP in minutes, the main night
+ // plus its naps (`daySleepMinutes`), as the list and the series read.
+ value: Math.round(daySleepMinutes({ main, naps })),
unit: "minutes",
- measuredAt: formatTimestamp(main.end, userTz),
- source: main.source ?? "",
+ measuredAt: formatTimestamp(lead.end, userTz),
+ source: lead.source ?? "",
notes: `${stageParts}${napNote}${awakeningNote}`.trim(),
glucoseContext: "",
},
diff --git a/src/lib/insights/correlation-channel-series.ts b/src/lib/insights/correlation-channel-series.ts
index 7c8382edf..47ea68dbb 100644
--- a/src/lib/insights/correlation-channel-series.ts
+++ b/src/lib/insights/correlation-channel-series.ts
@@ -58,6 +58,7 @@ import { metricKeyForType } from "@/lib/measurements/cumulative-day-sum";
import { pickCanonicalSourceRows } from "@/lib/analytics/source-priority";
import {
reconstructSleepSessions,
+ daySleepMinutes,
pickMainNightAndNaps,
type SleepStageRow,
} from "@/lib/analytics/sleep-night";
@@ -831,11 +832,13 @@ function buildSleepDailySeries(
byNight.set(s.night, list);
}
+ // A day's sleep is the main night plus its naps (`daySleepMinutes`), the
+ // total the series route and the sleep score read.
const points: DailySeriesPoint[] = [];
for (const [night, nightSessions] of byNight) {
- const { main } = pickMainNightAndNaps(nightSessions);
- if (!main) continue;
- points.push({ day: night, value: main.asleepMinutes });
+ const day = pickMainNightAndNaps(nightSessions);
+ if (!day.main && day.naps.length === 0) continue;
+ points.push({ day: night, value: daySleepMinutes(day) });
}
return points.sort((a, b) => (a.day < b.day ? -1 : 1));
}
diff --git a/src/lib/measurements/list/sleep-list.ts b/src/lib/measurements/list/sleep-list.ts
index 32b205de8..288849d35 100644
--- a/src/lib/measurements/list/sleep-list.ts
+++ b/src/lib/measurements/list/sleep-list.ts
@@ -9,17 +9,20 @@
* Two modes:
* - `dayKey` set → drill-down: the night's stage SEGMENTS (the canonical
* source's per-stage spans), read exactly as the night view reads them.
- * - no `dayKey` → one synthetic row per night, value = the main sleep's TIME
- * ASLEEP in minutes (the canonical unit), with the per-night stage
- * breakdown, nap count and awakenings in the row so the UI can caption the
- * night without a second fetch.
+ * - no `dayKey` → one synthetic row per night, value = the day's TIME ASLEEP
+ * in minutes (the canonical unit): the main night plus that day's naps,
+ * `daySleepMinutes`, the same total the series and the sleep score read.
+ * The main night alone rides as `mainAsleepMinutes`, beside its stage
+ * breakdown, nap count and awakenings, so the UI can caption the night
+ * without a second fetch.
*
* The nights are paged in Postgres (`sleepNightPage`) and only the stage rows
* of the page's nights are read. Every page night gets a row, so a page is as
* long as its limit and `meta.total` counts exactly the rows a full walk sees.
* A day whose only sleep is daytime naps has no main night: its row carries
- * 0 minutes asleep and its naps, as `/api/sleep/night` answers the same day
- * with `main: null` and the naps.
+ * the naps' minutes as its value and `mainAsleepMinutes: null`, as
+ * `/api/sleep/night?date=` answers the same day with `main: null` and the
+ * naps.
*/
import { annotate } from "@/lib/logging/context";
import { apiSuccess } from "@/lib/api-response";
@@ -27,6 +30,7 @@ import { getUnitForType } from "@/lib/validations/measurement";
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
import { resolveStoredTimezone } from "@/lib/tz/resolver";
import {
+ daySleepMinutes,
pickMainNightAndNaps,
reconstructSleepSessions,
type SleepSession,
@@ -131,7 +135,8 @@ export async function sleepListResponse(
// Synthetic id mirrors the series route's `sleep:` shape.
id: `sleep:${night}`,
type: "SLEEP_DURATION",
- value: main ? Math.round(main.asleepMinutes) : 0,
+ value: Math.round(daySleepMinutes({ main, naps })),
+ mainAsleepMinutes: main ? Math.round(main.asleepMinutes) : null,
unit: dayUnit,
source: main?.source ?? naps[0]?.source ?? "MANUAL",
measuredAt: (main?.end ?? lastNap?.end ?? spanEnd).toISOString(),
diff --git a/src/lib/measurements/list/sleep-nights.ts b/src/lib/measurements/list/sleep-nights.ts
index ee6f3608a..156f9c308 100644
--- a/src/lib/measurements/list/sleep-nights.ts
+++ b/src/lib/measurements/list/sleep-nights.ts
@@ -15,6 +15,7 @@ import {
SESSION_GAP_MS,
reconstructSleepNights,
reconstructSleepSessions,
+ separateNightsAndNaps,
type SleepNight,
type SleepSession,
} from "@/lib/analytics/sleep-night";
@@ -117,38 +118,44 @@ export async function readNightSessions(
return { sessions, rows };
}
+/** How many nights one step of the latest-night walk reads. */
+const LATEST_WALK_STEP = 14;
+
/**
- * The wake day of the most recent night that holds sleep, or null. Found
- * from the latest asleep row: the night holding it is the newest one, and
- * its sessions lie within the one-night reach of that row.
+ * The wake day of the most recent night with a main sleep, or, on an account
+ * that has only ever recorded naps, the most recent day of naps; null with no
+ * sleep at all. A day of naps alone is a real day of sleep, but it is not
+ * "last night": answering it would leave a hypnogram with no night to draw.
+ * Postgres names the nights newest first and the walk reconstructs them a
+ * step at a time until one holds a main sleep, so a run of nap-only days
+ * however long is stepped over and the heap holds a step's rows.
*/
-export async function latestSleepNight(
+export async function latestMainSleepNight(
userId: string,
tz: string,
priorityJson: unknown,
): Promise {
- const latest = await prisma.measurement.findFirst({
- where: {
- userId,
- type: "SLEEP_DURATION",
- deletedAt: null,
- value: { gt: 0 },
- OR: [{ sleepStage: null }, { sleepStage: { in: [...ASLEEP_STAGES] } }],
- },
- orderBy: { measuredAt: "desc" },
- select: { measuredAt: true },
- });
- if (!latest) return null;
- const at = latest.measuredAt.getTime();
- const rows = await readSleepRows(
- userId,
- new Date(at - NIGHT_READ_REACH_MS),
- new Date(at + NIGHT_READ_REACH_MS),
- );
- const scorable = reconstructSleepSessions(rows, tz, priorityJson).filter(
- (s) => s.asleepMinutes > 0,
- );
- return scorable.at(-1)?.night ?? null;
+ let latestNapDay: string | null = null;
+ for (let offset = 0; ; offset += LATEST_WALK_STEP) {
+ const page = await sleepNightPage(userId, tz, {
+ sortDir: "desc",
+ limit: LATEST_WALK_STEP,
+ offset,
+ });
+ if (page.nights.length === 0) return latestNapDay;
+ const keys = new Set(page.nights.map((n) => n.night));
+ const rows = await readNightSpanRows(userId, page.nights);
+ const days = separateNightsAndNaps(
+ reconstructSleepSessions(rows, tz, priorityJson).filter((s) =>
+ keys.has(s.night),
+ ),
+ );
+ for (let i = days.length - 1; i >= 0; i -= 1) {
+ if (days[i].main) return days[i].night;
+ latestNapDay ??= days[i].night;
+ }
+ if (page.nights.length < LATEST_WALK_STEP) return latestNapDay;
+ }
}
/** One night of a page: its wake day and the span its sessions cover. */
@@ -164,13 +171,16 @@ export interface SleepNightSpan {
* The stage rows are cut into sessions the way `reconstructSleepSessions`
* cuts them: ordered by start (`measured_at` minus the minutes), a new
* session wherever a row starts more than `SESSION_GAP_MS` after every
- * earlier row has ended. A session counts when it holds an asleep row (an
- * `ASLEEP_STAGES` stage, or a stage-less row, with minutes), and its night is
- * the local day its last row ends on: the reconstruction's own rule, so a
- * page night and a reconstructed night name the same day. Nights are grouped,
- * sorted and cut by `limit` / `offset`; `total` counts the whole filtered set.
- * `spanStart` / `spanEnd` bound the night's counting sessions, so the caller
- * reads exactly their rows.
+ * earlier row has ended. A session's night is the local day its last row
+ * ends on: the reconstruction's own rule, so a page night and a
+ * reconstructed night name the same day. A night counts when one of its
+ * sessions holds an asleep row (an `ASLEEP_STAGES` stage, or a stage-less
+ * row, with minutes). Nights are grouped, sorted and cut by `limit` /
+ * `offset`; `total` counts the whole filtered set. `spanStart` / `spanEnd`
+ * bound every session of the night, the in-bed-only ones included, because
+ * the reconstruction adds their in-bed time to the night: a span over the
+ * asleep sessions alone left an evening's in-bed block out of a chunked read
+ * that the one-night read, reaching a day and a half, counted.
*
* Before the cut, rows are folded into hour buckets by their end: min start,
* max end, any asleep. That cannot move a session boundary. Two rows whose
@@ -218,10 +228,9 @@ export async function sleepNightPage(
) OVER (ORDER BY s, e ROWS UNBOUNDED PRECEDING) AS sid
FROM o
), sess AS (
- SELECT MIN(s) AS s, MAX(e) AS e
+ SELECT MIN(s) AS s, MAX(e) AS e, BOOL_OR(asleep) AS asleep
FROM g
GROUP BY sid
- HAVING BOOL_OR(asleep)
), nights AS (
SELECT
to_char((e AT TIME ZONE 'UTC') AT TIME ZONE ${tz}, 'YYYY-MM-DD') AS night,
@@ -229,6 +238,7 @@ export async function sleepNightPage(
MAX(e) AS span_end
FROM sess
GROUP BY 1
+ HAVING BOOL_OR(asleep)
)
`;
const { rows, total } = await pageWithWindowTotal(
diff --git a/src/lib/openapi/routes/measurements.ts b/src/lib/openapi/routes/measurements.ts
index a2e25ad6d..e93227d1f 100644
--- a/src/lib/openapi/routes/measurements.ts
+++ b/src/lib/openapi/routes/measurements.ts
@@ -328,6 +328,27 @@ export const measurementResource = z
// Synthesised-row extras — present only on the collapsed day-sum mode.
dayKey: z.string().optional(),
sampleCount: z.number().int().optional(),
+ // Per-night extras — present only on `type=SLEEP_DURATION` list rows.
+ mainAsleepMinutes: z
+ .number()
+ .int()
+ .nonnegative()
+ .nullable()
+ .optional()
+ .describe(
+ "The main night's own time asleep in minutes; null on a day of naps alone. On a sleep row `value` is the day's total, the main night plus its naps.",
+ ),
+ napCount: z
+ .number()
+ .int()
+ .nonnegative()
+ .optional()
+ .describe("Naps on the same wake day."),
+ napAsleepMinutes: z
+ .number()
+ .nonnegative()
+ .optional()
+ .describe("Time asleep across those naps, in minutes."),
})
.meta({
id: "MeasurementResource",
@@ -804,7 +825,7 @@ export const measurementPaths: NonNullable = {
summary: "List measurements",
description:
"Filter by type + date range. Response is paged via offset/limit; default limit 100.\n\n" +
- "With `groupBy=day` on a cumulative type the rows are one sum per local day, and `limit` / `offset` page over those days; every returned day is complete, which is why `valueMin` / `valueMax` (a filter on single readings) are refused with 422 beside `groupBy=day`. `type=SLEEP_DURATION` returns one row per night, paged the same way over nights; every night of the page has a row, and a day whose only sleep is daytime naps carries `value` 0 with its naps in `napCount` / `napAsleepMinutes`. Its `dayKey` is the same day `/api/sleep/night?date=` answers for. On both, `meta.total` counts the days or nights in the filtered window. A day-sum page takes `limit` as asked (up to the list's 5000); a sleep page holds at most 1000 nights, and `meta.limit` echoes the value applied.",
+ "With `groupBy=day` on a cumulative type the rows are one sum per local day, and `limit` / `offset` page over those days; every returned day is complete, which is why `valueMin` / `valueMax` (a filter on single readings) are refused with 422 beside `groupBy=day`. `type=SLEEP_DURATION` returns one row per night, paged the same way over nights; every night of the page has a row, and `value` is the day's time asleep, the main night plus its naps (the same total `/api/measurements/series?kind=sleep` and the sleep score read), with the main night alone in `mainAsleepMinutes` and the naps in `napCount` / `napAsleepMinutes`; a day whose only sleep is daytime naps carries the naps' minutes and `mainAsleepMinutes: null`. Its `dayKey` is the same day `/api/sleep/night?date=` answers for. On both, `meta.total` counts the days or nights in the filtered window. A day-sum page takes `limit` as asked (up to the list's 5000); a sleep page holds at most 1000 nights, and `meta.limit` echoes the value applied.",
requestParams: {
query: listMeasurementsSchema,
},
@@ -1118,7 +1139,7 @@ export const measurementPaths: NonNullable = {
tags: ["Measurements"],
summary: "Reconstructed sleep night (hypnogram source, v1.11.5)",
description:
- "Returns one night's reconstructed sleep session for the phase-progression (hypnogram) view: the canonical source's stage segments (each with an absolute start/end), the per-stage breakdown, asleep/in-bed/awake totals, the mid-sleep awakenings count, and same-wake-day naps surfaced separately. Session-clustered, keyed by the local wake day, collapsed to one source via the sleep priority ladder so two sources never overlay. `date` omitted returns the most recent night holding sleep, however old. Any past date is answered, with no window cut. A read-only view over existing per-stage SLEEP_DURATION rows — no schema, no new measurement type.",
+ "Returns one night's reconstructed sleep session for the phase-progression (hypnogram) view: the canonical source's stage segments (each with an absolute start/end), the per-stage breakdown, asleep/in-bed/awake totals, the mid-sleep awakenings count, and same-wake-day naps surfaced separately. Session-clustered, keyed by the local wake day, collapsed to one source via the sleep priority ladder so two sources never overlay. `date` omitted returns the most recent night with a main sleep, however old; later days of naps alone are stepped over and stay reachable by `date`. An account that has only ever napped gets its latest day of naps. Any past date is answered, with no window cut. A read-only view over existing per-stage SLEEP_DURATION rows — no schema, no new measurement type.",
requestParams: {
query: z.object({
date: z
@@ -1126,7 +1147,7 @@ export const measurementPaths: NonNullable = {
.regex(/^\d{4}-\d{2}-\d{2}$/)
.optional()
.describe(
- "Wake-day key (YYYY-MM-DD). Omit for the most recent night.",
+ "Wake-day key (YYYY-MM-DD). Omit for the most recent night with a main sleep.",
),
}),
},
diff --git a/tests/integration/measurements-sleep-list.test.ts b/tests/integration/measurements-sleep-list.test.ts
index 5db0eaf56..673347319 100644
--- a/tests/integration/measurements-sleep-list.test.ts
+++ b/tests/integration/measurements-sleep-list.test.ts
@@ -54,6 +54,7 @@ interface ListEnvelope {
sampleCount?: number;
sleepStage?: string | null;
napCount?: number;
+ mainAsleepMinutes?: number | null;
awakenings?: number;
}>;
meta: { total: number; groupBy?: string; windowFrom?: string };
@@ -231,7 +232,9 @@ describe("GET /api/measurements?type=SLEEP_DURATION", () => {
expect(firstBody.data.meta.windowFrom).toBeUndefined();
expect(firstBody.data.measurements).toHaveLength(25);
expect(firstBody.data.measurements[0].dayKey).toBe(dayKeyOf(morning));
- expect(firstBody.data.measurements[0].value).toBe(420);
+ // The day's sleep counts the nap; the main night rides beside it.
+ expect(firstBody.data.measurements[0].value).toBe(450);
+ expect(firstBody.data.measurements[0].mainAsleepMinutes).toBe(420);
expect(firstBody.data.measurements[0].napCount).toBe(1);
// The stage read covered the page's nights, not the history.
const read = spy.mock.calls
@@ -256,7 +259,8 @@ describe("GET /api/measurements?type=SLEEP_DURATION", () => {
expect(body.data.meta.total).toBe(NIGHTS);
for (const row of body.data.measurements) {
seen.push(row.dayKey!);
- expect(row.value).toBe(420);
+ expect(row.mainAsleepMinutes).toBe(420);
+ expect(row.value).toBe(row.napCount ? 450 : 420);
}
}
expect(seen).toHaveLength(NIGHTS);
diff --git a/tests/integration/sleep-night-sql-parity.test.ts b/tests/integration/sleep-night-sql-parity.test.ts
index 129ec0697..b6a11b0d7 100644
--- a/tests/integration/sleep-night-sql-parity.test.ts
+++ b/tests/integration/sleep-night-sql-parity.test.ts
@@ -191,6 +191,7 @@ async function seed(timezone: string) {
interface ListRow {
dayKey: string;
value: number;
+ mainAsleepMinutes: number | null;
napCount: number;
napAsleepMinutes: number;
}
@@ -245,9 +246,15 @@ describe("sleep nights: SQL page and JS reconstruction agree", () => {
const jsNights = [...new Set(scorable.map((s) => s.night))].sort();
expect(page.nights.map((n) => n.night)).toEqual(jsNights);
expect(page.total).toBe(jsNights.length);
- // Every reconstructed session lies inside its night's SQL span.
+ // Every reconstructed session of a page night lies inside the night's
+ // SQL span, the in-bed-only ones included: the reconstruction adds
+ // their in-bed time to the night, so a chunked read must reach them.
const span = new Map(page.nights.map((n) => [n.night, n]));
- for (const s of scorable) {
+ const sessions = reconstructSleepSessions(rows, tz, null).filter((s) =>
+ span.has(s.night),
+ );
+ expect(sessions.length).toBeGreaterThan(scorable.length);
+ for (const s of sessions) {
const n = span.get(s.night)!;
expect(s.start.getTime()).toBeGreaterThanOrEqual(
n.spanStart.getTime() - 1000,
@@ -255,6 +262,34 @@ describe("sleep nights: SQL page and JS reconstruction agree", () => {
expect(s.end.getTime()).toBeLessThanOrEqual(n.spanEnd.getTime() + 1000);
}
}, 240_000);
+
+ it(`reads the same in-bed time a night at a time and in chunks (${tz})`, async () => {
+ const user = await seed(tz);
+ const { readSleepNights, readSleepRows } =
+ await import("@/lib/measurements/list/sleep-nights");
+ const { reconstructSleepNights } =
+ await import("@/lib/analytics/sleep-night");
+ const chunked = await readSleepNights(user.id, tz, null, {
+ maxNights: 10_000,
+ });
+ const whole = new Map(
+ reconstructSleepNights(
+ await readSleepRows(
+ user.id,
+ new Date(BASE - 2 * 86_400_000),
+ new Date(BASE + (DAYS + 2) * 86_400_000),
+ ),
+ tz,
+ null,
+ ).map((n) => [n.night, n]),
+ );
+ expect(chunked.length).toBeGreaterThan(DAYS - 2);
+ for (const night of chunked) {
+ const ref = whole.get(night.night)!;
+ expect(night.inBedMinutes).toBe(ref.inBedMinutes);
+ expect(night.asleepMinutes).toBe(ref.asleepMinutes);
+ }
+ }, 240_000);
}
it("gives every page night a row the night view answers for the same day", async () => {
@@ -275,8 +310,20 @@ describe("sleep nights: SQL page and JS reconstruction agree", () => {
for (const row of rows) {
const view = await nightView(row.dayKey);
expect(view.data.night).toBe(row.dayKey);
- expect(view.data.main?.asleepMinutes ?? 0).toBe(row.value);
+ // The row's value is the day's sleep, main night plus naps; the main
+ // night alone rides beside it.
+ const napMinutes = view.data.naps.reduce(
+ (sum, n) => sum + n.asleepMinutes,
+ 0,
+ );
+ expect(row.value).toBe(
+ Math.round((view.data.main?.asleepMinutes ?? 0) + napMinutes),
+ );
+ expect(row.mainAsleepMinutes).toBe(
+ view.data.main ? Math.round(view.data.main.asleepMinutes) : null,
+ );
expect(view.data.naps).toHaveLength(row.napCount);
+ if (row.napCount > 0) expect(row.value).toBeGreaterThan(0);
if (!view.data.main) {
napOnly += 1;
expect(row.napCount).toBeGreaterThan(0);
@@ -286,6 +333,69 @@ describe("sleep nights: SQL page and JS reconstruction agree", () => {
expect(napOnly).toBe(DAYS / 10);
}, 480_000);
+ it("states the same day total in the list and the series", async () => {
+ await seed("Europe/Berlin");
+ const { GET } = await import("@/app/api/measurements/series/route");
+ const res = await GET(
+ new NextRequest(
+ "http://localhost/api/measurements/series?kind=sleep&days=3650",
+ ),
+ );
+ expect(res.status).toBe(200);
+ const series = (
+ (await res.json()) as {
+ data: { points: Array<{ id: string; value: number }> };
+ }
+ ).data.points;
+ const rows = (await list("type=SLEEP_DURATION&limit=1000&sortDir=asc")).data
+ .measurements;
+ const byDay = new Map(series.map((p) => [p.id.slice("sleep:".length), p]));
+ let napDays = 0;
+ for (const row of rows) {
+ const point = byDay.get(row.dayKey);
+ expect(point, row.dayKey).toBeDefined();
+ // Hours to two places against whole minutes: within a minute.
+ expect(Math.abs(point!.value * 60 - row.value)).toBeLessThanOrEqual(1);
+ if (row.napCount > 0) napDays += 1;
+ }
+ expect(napDays).toBeGreaterThan(0);
+ }, 240_000);
+
+ it("answers the latest main night, not a later day of naps alone", async () => {
+ const user = await seed("UTC");
+ const prisma = getPrismaClient();
+ // Two afternoon naps on the two days after the last fixture day.
+ const after = BASE + DAYS * 86_400_000;
+ await prisma.measurement.createMany({
+ data: [0, 1].map((d) => ({
+ userId: user.id,
+ type: "SLEEP_DURATION" as const,
+ value: 40,
+ unit: "minutes",
+ source: "APPLE_HEALTH" as const,
+ measuredAt: new Date(after + d * 86_400_000 + 14 * 3_600_000),
+ externalId: `late-nap-${d}`,
+ sleepStage: "CORE" as const,
+ })),
+ });
+ const { GET } = await import("@/app/api/sleep/night/route");
+ const res = await GET(new NextRequest("http://localhost/api/sleep/night"));
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as {
+ data: { night: string; main: unknown; naps: unknown[] };
+ };
+ expect(body.data.main).not.toBeNull();
+ // The answer is a fixture night, before the naps.
+ expect(body.data.night < new Date(after).toISOString().slice(0, 10)).toBe(
+ true,
+ );
+ // The nap days stay reachable by date.
+ const napDay = new Date(after + 86_400_000).toISOString().slice(0, 10);
+ const nap = await nightView(napDay);
+ expect(nap.data.main).toBeNull();
+ expect(nap.data.naps).toHaveLength(1);
+ }, 240_000);
+
it("reaches the oldest night from the series' All range", async () => {
await seed("Europe/Berlin");
const { GET } = await import("@/app/api/measurements/series/route");
From 29c5b5b83b30db3efa2f5a24ce24ee4334f52646 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Tue, 20 Oct 2026 09:29:03 +0200
Subject: [PATCH 04/13] fix(crypto): one read order for a sealed column beside
its readable one
Notes, workout tracks and the cached insights payload each read their
ciphertext and legacy column their own way: the cache read the readable
column first, and the two that read the ciphertext first disagreed on a
value that does not open. readSealedOrLegacy fixes the order (ciphertext
whenever present, the readable column only without one, never as a stand-in
for a failed open) and makes each caller state what a failed open means.
---
.../crypto/__tests__/sealed-or-legacy.test.ts | 69 +++++++++++++++++++
src/lib/crypto/note-cipher.ts | 11 +--
src/lib/crypto/sealed-or-legacy.ts | 53 ++++++++++++++
.../__tests__/cached-text-cipher.test.ts | 20 ++++--
src/lib/insights/cached-text-cipher.ts | 36 +++++-----
src/lib/workouts/route-geometry-cipher.ts | 21 ++----
6 files changed, 170 insertions(+), 40 deletions(-)
create mode 100644 src/lib/crypto/__tests__/sealed-or-legacy.test.ts
create mode 100644 src/lib/crypto/sealed-or-legacy.ts
diff --git a/src/lib/crypto/__tests__/sealed-or-legacy.test.ts b/src/lib/crypto/__tests__/sealed-or-legacy.test.ts
new file mode 100644
index 000000000..71e816a1e
--- /dev/null
+++ b/src/lib/crypto/__tests__/sealed-or-legacy.test.ts
@@ -0,0 +1,69 @@
+import { describe, expect, it, vi } from "vitest";
+
+const addWarning = vi.fn();
+vi.mock("@/lib/logging/context", () => ({ getEvent: () => ({ addWarning }) }));
+
+import { readSealedOrLegacy, warnAndDrop } from "../sealed-or-legacy";
+
+const sealed = new Uint8Array([1, 2, 3]);
+const open = (bytes: Uint8Array) => `opened:${bytes.length}`;
+const broken = () => {
+ throw new Error("bad tag");
+};
+
+describe("readSealedOrLegacy", () => {
+ it("reads the ciphertext first, even with a readable value beside it", () => {
+ expect(
+ readSealedOrLegacy({
+ sealed,
+ legacy: "readable",
+ open,
+ onUndecryptable: "throw",
+ }),
+ ).toBe("opened:3");
+ });
+
+ it("reads the readable column only when there is no ciphertext", () => {
+ for (const none of [null, undefined, new Uint8Array(0)]) {
+ expect(
+ readSealedOrLegacy({
+ sealed: none,
+ legacy: "readable",
+ open,
+ onUndecryptable: "throw",
+ }),
+ ).toBe("readable");
+ }
+ expect(
+ readSealedOrLegacy({
+ sealed: null,
+ legacy: null,
+ open,
+ onUndecryptable: "throw",
+ }),
+ ).toBeNull();
+ });
+
+ it("throws on a ciphertext that does not open when told to", () => {
+ expect(() =>
+ readSealedOrLegacy({
+ sealed,
+ legacy: "readable",
+ open: broken,
+ onUndecryptable: "throw",
+ }),
+ ).toThrow("bad tag");
+ });
+
+ it("drops with a warning, never falling back to the readable value", () => {
+ expect(
+ readSealedOrLegacy({
+ sealed,
+ legacy: "readable",
+ open: broken,
+ onUndecryptable: warnAndDrop("thing did not open"),
+ }),
+ ).toBeNull();
+ expect(addWarning).toHaveBeenCalledWith("thing did not open: bad tag");
+ });
+});
diff --git a/src/lib/crypto/note-cipher.ts b/src/lib/crypto/note-cipher.ts
index dc63001dd..04463d9e2 100644
--- a/src/lib/crypto/note-cipher.ts
+++ b/src/lib/crypto/note-cipher.ts
@@ -24,6 +24,7 @@
* when there is NO ciphertext (a legacy row not yet covered by the backfill).
*/
import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec";
+import { readSealedOrLegacy } from "@/lib/crypto/sealed-or-legacy";
/**
* Encrypt a free-text note for the `Bytes` ciphertext column. Returns `null`
@@ -51,10 +52,12 @@ export function readNote(
ciphertext: Uint8Array | null | undefined,
plaintextFallback: string | null | undefined,
): string | null {
- if (ciphertext && ciphertext.byteLength > 0) {
- return decryptFromBytes(ciphertext);
- }
- return plaintextFallback ?? null;
+ return readSealedOrLegacy({
+ sealed: ciphertext,
+ legacy: plaintextFallback,
+ open: decryptFromBytes,
+ onUndecryptable: "throw",
+ });
}
/**
diff --git a/src/lib/crypto/sealed-or-legacy.ts b/src/lib/crypto/sealed-or-legacy.ts
new file mode 100644
index 000000000..4de4cfb22
--- /dev/null
+++ b/src/lib/crypto/sealed-or-legacy.ts
@@ -0,0 +1,53 @@
+/**
+ * The one read rule for a value stored sealed beside a legacy readable column.
+ *
+ * Several columns moved from plaintext to AES-256-GCM and keep the readable
+ * column until a backfill reaches every row: notes, workout tracks, the cached
+ * insights payload. Each reader once picked its own order and its own answer
+ * to a ciphertext that does not open, and they disagreed: two read the
+ * ciphertext first, one read the readable column first, and of the two that
+ * opened the ciphertext first one threw and one returned nothing.
+ *
+ * The order is fixed here and is not a parameter. A present ciphertext is
+ * always the answer: the readable column is read only when the row has no
+ * ciphertext, never as a stand-in for one that failed to open, because that
+ * would serve whatever an older writer left in the clear in place of the value
+ * the key was meant to protect. What a failure to open means is the caller's
+ * decision and must be stated: `"throw"` for a record whose loss must surface,
+ * or a handler for a value whose absence the surface can carry, typically
+ * {@link warnAndDrop}.
+ */
+import { getEvent } from "@/lib/logging/context";
+
+export type OnUndecryptable = "throw" | ((error: unknown) => T | null);
+
+export function readSealedOrLegacy(args: {
+ sealed: Uint8Array | null | undefined;
+ legacy: T | null | undefined;
+ open: (sealed: Uint8Array) => T;
+ onUndecryptable: OnUndecryptable;
+}): T | null {
+ const { sealed, legacy, open, onUndecryptable } = args;
+ if (sealed && sealed.byteLength > 0) {
+ if (onUndecryptable === "throw") return open(sealed);
+ try {
+ return open(sealed);
+ } catch (error) {
+ return onUndecryptable(error);
+ }
+ }
+ return legacy ?? null;
+}
+
+/**
+ * A handler that records the failure as a warning on the request's event and
+ * reads the value as absent.
+ */
+export function warnAndDrop(label: string): (error: unknown) => null {
+ return (error) => {
+ getEvent()?.addWarning(
+ `${label}: ${error instanceof Error ? error.message : String(error)}`,
+ );
+ return null;
+ };
+}
diff --git a/src/lib/insights/__tests__/cached-text-cipher.test.ts b/src/lib/insights/__tests__/cached-text-cipher.test.ts
index 276347f58..8fcd71142 100644
--- a/src/lib/insights/__tests__/cached-text-cipher.test.ts
+++ b/src/lib/insights/__tests__/cached-text-cipher.test.ts
@@ -41,13 +41,25 @@ describe("cached insights payload at rest", () => {
).toBe(PAYLOAD);
});
- it("prefers a readable value over a sealed one, since only an older writer can have put it there", () => {
+ // This used to prefer the readable column, the one sealed column read
+ // that way. Every sealed column now reads the ciphertext first.
+ it("reads a sealed value before a readable one beside it", () => {
expect(
openInsightsCachedText({
- insightsCachedText: "newer, from the previous release",
- insightsCachedTextEncrypted: sealInsightsCachedText("older"),
+ insightsCachedText: "readable",
+ insightsCachedTextEncrypted: sealInsightsCachedText("sealed"),
}),
- ).toBe("newer, from the previous release");
+ ).toBe("sealed");
+ });
+
+ it("never serves the readable value in place of a sealed one that does not open", () => {
+ const foreign = encryptBytes(Buffer.from(PAYLOAD, "utf8"), "other/v1");
+ expect(
+ openInsightsCachedText({
+ insightsCachedText: "readable",
+ insightsCachedTextEncrypted: new Uint8Array(foreign),
+ }),
+ ).toBeNull();
});
it("does not open a value sealed for another purpose", () => {
diff --git a/src/lib/insights/cached-text-cipher.ts b/src/lib/insights/cached-text-cipher.ts
index d51cb65a7..c74cbfcb0 100644
--- a/src/lib/insights/cached-text-cipher.ts
+++ b/src/lib/insights/cached-text-cipher.ts
@@ -19,7 +19,7 @@ import { Buffer } from "node:buffer";
import { decryptBytes, encryptBytes } from "@/lib/crypto";
import { INSIGHTS_CACHED_TEXT_AAD } from "@/lib/crypto/encrypted-columns";
-import { getEvent } from "@/lib/logging/context";
+import { readSealedOrLegacy, warnAndDrop } from "@/lib/crypto/sealed-or-legacy";
/** The columns a reader selects to open the cache. */
export const INSIGHTS_CACHE_SELECT = {
@@ -76,10 +76,14 @@ export const INSIGHTS_CACHE_PRESENT_WHERE = {
/**
* The cached payload as text, or null when there is none.
*
- * A legacy readable value wins when one is present: from v1.43.7 on nothing
- * writes that column, so a row holding both can only come from a writer of the
- * previous release still running after the upgrade, and its value is the
- * newer one. The backfill seals it on its next pass.
+ * The ciphertext is read first, as every sealed column is read
+ * (`readSealedOrLegacy`); the readable column only when the row holds no
+ * ciphertext. A row holding both can come from a writer of the previous
+ * release still running after the upgrade; its readable value may be the
+ * newer one, and the free-text backfill seals it over the old ciphertext on
+ * its next pass. Until then the reader serves the sealed one: an older
+ * briefing for a while, never a readable value preferred over a protected
+ * one.
*
* A sealed value that does not open reads as an empty cache with a warning on
* the event. It is a cache: the next warm overwrites it under the active key,
@@ -89,17 +93,13 @@ export function openInsightsCachedText(
row: InsightsCacheColumns | null | undefined,
): string | null {
if (!row) return null;
- if (typeof row.insightsCachedText === "string") return row.insightsCachedText;
- const sealed = row.insightsCachedTextEncrypted;
- if (!sealed || sealed.byteLength === 0) return null;
- try {
- return decryptBytes(Buffer.from(sealed), INSIGHTS_CACHED_TEXT_AAD).toString(
- "utf8",
- );
- } catch (err) {
- getEvent()?.addWarning(
- `insights cache did not open: ${err instanceof Error ? err.message : String(err)}`,
- );
- return null;
- }
+ return readSealedOrLegacy({
+ sealed: row.insightsCachedTextEncrypted,
+ legacy: row.insightsCachedText,
+ open: (sealed) =>
+ decryptBytes(Buffer.from(sealed), INSIGHTS_CACHED_TEXT_AAD).toString(
+ "utf8",
+ ),
+ onUndecryptable: warnAndDrop("insights cache did not open"),
+ });
}
diff --git a/src/lib/workouts/route-geometry-cipher.ts b/src/lib/workouts/route-geometry-cipher.ts
index 80ed5f4fb..5387a663c 100644
--- a/src/lib/workouts/route-geometry-cipher.ts
+++ b/src/lib/workouts/route-geometry-cipher.ts
@@ -23,7 +23,7 @@ import { Buffer } from "node:buffer";
import { decryptBytes, encryptBytes } from "@/lib/crypto";
import { WORKOUT_ROUTE_GEOMETRY_AAD } from "@/lib/crypto/encrypted-columns";
-import { getEvent } from "@/lib/logging/context";
+import { readSealedOrLegacy, warnAndDrop } from "@/lib/crypto/sealed-or-legacy";
/** Seal a route's GeoJSON geometry for `WorkoutRoute.geometryEncrypted`. */
export function encryptRouteGeometry(
@@ -53,17 +53,10 @@ export function readRouteGeometry(row: {
geometry?: unknown;
geometryEncrypted?: Uint8Array | null;
}): unknown {
- if (row.geometryEncrypted && row.geometryEncrypted.byteLength > 0) {
- try {
- return decryptRouteGeometry(row.geometryEncrypted);
- } catch (err) {
- getEvent()?.addWarning(
- `workout route decrypt failed: ${
- err instanceof Error ? err.message : String(err)
- }`,
- );
- return null;
- }
- }
- return row.geometry ?? null;
+ return readSealedOrLegacy({
+ sealed: row.geometryEncrypted,
+ legacy: row.geometry,
+ open: decryptRouteGeometry,
+ onUndecryptable: warnAndDrop("workout route decrypt failed"),
+ });
}
From 0fe9d5d224659127bf5ad8d2bedf9b5758411aa8 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Tue, 20 Oct 2026 09:35:11 +0200
Subject: [PATCH 05/13] fix(import): round Health Connect values through
importValue
The Health Connect import stored its unit factors' binary noise as is
(grams to kilograms, millis to minutes, the day sums) and rounded the
pulse means and nutrients privately. Every measurement value now takes
importValue at the write boundary, and importValue takes a precision for
the two places that need another one. The rounding guard now covers the
import directory and the scaled Math.round form.
---
src/__tests__/provider-rounding-guard.test.ts | 35 +++++++++++++++----
src/lib/import/health-connect/import.ts | 25 ++++++++++---
.../__tests__/import-value.test.ts | 8 +++++
src/lib/integrations/import-value.ts | 27 ++++++++------
.../integration/health-connect-import.test.ts | 24 +++++++++++++
5 files changed, 97 insertions(+), 22 deletions(-)
diff --git a/src/__tests__/provider-rounding-guard.test.ts b/src/__tests__/provider-rounding-guard.test.ts
index 10fe0b655..6c4b279c2 100644
--- a/src/__tests__/provider-rounding-guard.test.ts
+++ b/src/__tests__/provider-rounding-guard.test.ts
@@ -1,11 +1,14 @@
/**
- * Structural guard: a provider client rounds an imported value through
- * `importValue` (`src/lib/integrations/import-value.ts`) and nothing else.
+ * Structural guard: a provider client or a file importer rounds an imported
+ * value through `importValue` (`src/lib/integrations/import-value.ts`) and
+ * nothing else.
*
* Each client used to keep its own `parseFloat(n.toFixed(2))`. Five copies
* of one rule drift apart, and `toFixed` mis-rounds halves on the double
- * below the decimal. A `toFixed` or a private `round2` in a provider
- * directory is the class coming back.
+ * below the decimal. The Health Connect importer had the other spelling,
+ * `Math.round(x * 1000) / 1000`, in three places and stored its unit
+ * factors' noise unrounded everywhere else. A `toFixed`, a private `round2`
+ * or a scaled `Math.round` in these directories is the class coming back.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
@@ -25,8 +28,18 @@ const PROVIDER_DIRS = [
"strava",
"whoop",
"withings",
+ "import",
];
-const ROUNDING = /\.toFixed\s*\(|\b(?:function|const|let)\s+round2\b/;
+const ROUNDING =
+ /\.toFixed\s*\(|\b(?:function|const|let)\s+round2\b|Math\.round\s*\([^;]*\*\s*(10+)\s*\)\s*\/\s*\1\b/;
+
+/** Files that round for display, not on the way in, and say so. */
+const ALLOWED: Record = {
+ "dawarich/movement.ts":
+ "Formats a stored distance to one decimal in the account's unit for the day view; nothing is stored.",
+ "import/unzip-export-xml.ts":
+ "Prints a compression ratio into an error message.",
+};
function strip(text: string): string {
return text
@@ -47,8 +60,10 @@ describe("provider clients round through importValue", () => {
});
it("finds no rounding of their own", () => {
- const offenders = files.filter((f) =>
- ROUNDING.test(strip(readFileSync(join(LIB, f), "utf8"))),
+ const offenders = files.filter(
+ (f) =>
+ !(f in ALLOWED) &&
+ ROUNDING.test(strip(readFileSync(join(LIB, f), "utf8"))),
);
expect(offenders).toEqual([]);
});
@@ -57,6 +72,12 @@ describe("provider clients round through importValue", () => {
expect(ROUNDING.test("const v = parseFloat(n.toFixed(2));")).toBe(true);
expect(ROUNDING.test("function round2(n: number) {}")).toBe(true);
expect(ROUNDING.test("const round2 = (n: number) => n;")).toBe(true);
+ expect(ROUNDING.test("const a = Math.round(e.amount * 1000) / 1000;")).toBe(
+ true,
+ );
+ expect(ROUNDING.test("value: Math.round(mean * 10) / 10,")).toBe(true);
expect(ROUNDING.test("value: importValue(n)")).toBe(false);
+ expect(ROUNDING.test("Math.round((end - start) / 1000)")).toBe(false);
+ expect(ROUNDING.test("Math.round(mmol * MGDL_PER_MMOL)")).toBe(false);
});
});
diff --git a/src/lib/import/health-connect/import.ts b/src/lib/import/health-connect/import.ts
index a36b8e20f..a35f8d481 100644
--- a/src/lib/import/health-connect/import.ts
+++ b/src/lib/import/health-connect/import.ts
@@ -44,6 +44,7 @@
* Privacy: the result carries counts only, per type and per app. No value,
* no timestamp and nothing a person typed (notes, titles) leaves this module.
*/
+import { importValue } from "@/lib/integrations/import-value";
import type {
FlowLevel,
GlucoseContext,
@@ -547,7 +548,7 @@ class ImportRun {
this.stat("PULSE").read += 1;
await sink.push({
type: "PULSE",
- value: Math.round(mean * 10) / 10,
+ value: importValue(mean, 1),
valueMin: hcNumber(best.lo),
valueMax: hcNumber(best.hi),
// The middle of the local hour, in the record's own zone.
@@ -889,7 +890,10 @@ class ImportRun {
const days: Array<{ dayKey: string; value: number }> = [];
for (const day of this.dailyGroups(sql, HC_CATEGORY.ACTIVITY)) {
this.stat(spec.type).read += 1;
- days.push({ dayKey: day.dayKey, value: day.total * spec.factor });
+ days.push({
+ dayKey: day.dayKey,
+ value: importValue(day.total * spec.factor),
+ });
if (days.length >= WRITE_BATCH) {
await this.writeDayTotals(spec.type, days.splice(0));
}
@@ -1136,7 +1140,7 @@ class ImportRun {
): Promise {
if (entries.length === 0) return;
const valid = entries.filter((e) => {
- const amount = Math.round(e.amount * 1000) / 1000;
+ const amount = importValue(e.amount, 3);
if (amount > NUTRIENT_CATALOG[e.code].plausibleDailyMax) {
this.nutrients.outOfRange += 1;
this.skip("out_of_range", 1, `nutrient:${e.code}`);
@@ -1160,7 +1164,7 @@ class ImportRun {
);
const fresh: Prisma.NutrientIntakeDayCreateManyInput[] = [];
for (const e of valid) {
- const amount = Math.round(e.amount * 1000) / 1000;
+ const amount = importValue(e.amount, 3);
const prior = existing.get(`${e.day}|${e.code}`);
if (prior === undefined) {
fresh.push({
@@ -1597,8 +1601,19 @@ class MeasurementSink {
private readonly hourlyPulse = false,
) {}
+ /**
+ * Every measurement value this import writes passes here (or through
+ * `writeDayTotals`), so this is where it takes the one import rounding:
+ * a mapper's unit factor (grams to kilograms, millis to minutes) leaves
+ * binary noise that would otherwise be stored and compared as is.
+ */
async push(row: PendingRow): Promise {
- this.rows.push(row);
+ this.rows.push({
+ ...row,
+ value: importValue(row.value),
+ valueMin: row.valueMin == null ? row.valueMin : importValue(row.valueMin),
+ valueMax: row.valueMax == null ? row.valueMax : importValue(row.valueMax),
+ });
if (this.rows.length >= WRITE_BATCH) await this.flush();
}
diff --git a/src/lib/integrations/__tests__/import-value.test.ts b/src/lib/integrations/__tests__/import-value.test.ts
index ff83ad1bf..73aa3faf1 100644
--- a/src/lib/integrations/__tests__/import-value.test.ts
+++ b/src/lib/integrations/__tests__/import-value.test.ts
@@ -26,4 +26,12 @@ describe("importValue", () => {
expect(importValue(1e-7)).toBe(0);
expect(importValue(1e22)).toBe(1e22);
});
+
+ it("rounds to another precision on request, halves away from zero", () => {
+ expect(importValue(72.34999, 1)).toBe(72.3);
+ expect(importValue(1.0005, 3)).toBe(1.001);
+ expect(importValue(-2.5, 0)).toBe(-3);
+ expect(importValue(0.30000000000000004, 3)).toBe(0.3);
+ expect(() => importValue(1, 7)).toThrow(RangeError);
+ });
});
diff --git a/src/lib/integrations/import-value.ts b/src/lib/integrations/import-value.ts
index a3de3d846..616b8126c 100644
--- a/src/lib/integrations/import-value.ts
+++ b/src/lib/integrations/import-value.ts
@@ -9,20 +9,27 @@
* also mis-rounds on halves (`(1.005).toFixed(2)` is `"1.00"`, because the
* double below 1.005 is what gets rounded).
*
- * Two decimals, rounded half away from zero on the decimal the value prints
- * as, so the result is the double nearest to a two-place decimal and prints
- * cleanly. Two places is the precision every provider client already stored;
- * this keeps it. `src/__tests__/provider-rounding-guard.test.ts` keeps
- * `toFixed` out of the provider directories.
+ * Two decimals by default, rounded half away from zero on the decimal the
+ * value prints as, so the result is the double nearest to a short decimal
+ * and prints cleanly. Two places is the precision every provider client
+ * already stored; this keeps it. A source whose values need another
+ * precision (a nutrient in grams to the milligram) passes `places` rather
+ * than rounding privately. `src/__tests__/provider-rounding-guard.test.ts`
+ * keeps `toFixed` and hand-rolled `Math.round(x * 10^n) / 10^n` out of the
+ * provider and import directories.
*/
-export function importValue(n: number): number {
+export function importValue(n: number, places = 2): number {
if (!Number.isFinite(n)) return n;
+ if (!Number.isInteger(places) || places < 0 || places > 6) {
+ throw new RangeError(`importValue: places must be 0..6, got ${places}`);
+ }
const magnitude = Math.abs(n);
const text = String(magnitude);
+ const scale = 10 ** places;
// A value printed in exponent form is below a millionth or beyond 1e21;
// shifting its text would not parse, and plain arithmetic is exact enough.
- const hundredths = text.includes("e")
- ? Math.round(magnitude * 100)
- : Math.round(Number(`${text}e2`));
- return (Math.sign(n) * hundredths) / 100 || 0;
+ const units = text.includes("e")
+ ? Math.round(magnitude * scale)
+ : Math.round(Number(`${text}e${places}`));
+ return (Math.sign(n) * units) / scale || 0;
}
diff --git a/tests/integration/health-connect-import.test.ts b/tests/integration/health-connect-import.test.ts
index 87cd33fd9..e7dceb5bd 100644
--- a/tests/integration/health-connect-import.test.ts
+++ b/tests/integration/health-connect-import.test.ts
@@ -20,6 +20,7 @@ import {
rmSync,
writeFileSync,
} from "node:fs";
+import { DatabaseSync } from "node:sqlite";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
@@ -233,6 +234,29 @@ describe("Health Connect import, mapping", () => {
expect(JSON.stringify(result)).not.toContain("Evening run");
});
+ it("stores a scaled value with the import rounding, not its unit factor's noise", async () => {
+ const prisma = getPrismaClient();
+ const user = await createUser("hc-round");
+ const { path } = fixtureDb("round", { days: 1, endUtc: today });
+ // Grams to kilograms: 80 345.678 g is 80.345678 kg before rounding.
+ const db = new DatabaseSync(path);
+ db.exec("UPDATE weight_record_table SET weight = 80345.678");
+ db.close();
+ await importHealthConnectExport({
+ prisma,
+ dbPath: path,
+ userId: user.id,
+ userTimezone: "Europe/Berlin",
+ connectedIntegrations: [],
+ });
+ const weights = await prisma.measurement.findMany({
+ where: { userId: user.id, type: "WEIGHT", source: "HEALTH_CONNECT" },
+ select: { value: true },
+ });
+ expect(weights.length).toBeGreaterThan(0);
+ for (const w of weights) expect(w.value).toBe(80.35);
+ });
+
it("writes nothing the second time the same export comes in", async () => {
const prisma = getPrismaClient();
const user = await createUser("hc-twice");
From 05503e2b82320b13bc97b418798d5a48e8753b66 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Tue, 20 Oct 2026 09:43:47 +0200
Subject: [PATCH 06/13] refactor(stats): one least-squares fit for every trend
line
Six readers fitted a line by hand, four in the determinant form that
loses digits on a large x axis, and the two charts carried the same trend
function twice. linearFit / fitFromSums in lib/stats is now the one
implementation (centred sums, so a fit from points and one composed from
stored accumulators are the same arithmetic), trendLineByTime serves both
charts with the same two-decimal output, and a guard keeps hand-rolled
slopes out of the tree.
---
...ar-fit-single-implementation-guard.test.ts | 69 ++++++++++
src/components/charts/health-chart.tsx | 41 +-----
src/components/charts/mood-chart.tsx | 41 +-----
src/lib/analytics/trends.ts | 27 ++--
src/lib/insights/derived/trajectory.ts | 20 +--
src/lib/insights/graded-series.ts | 21 +--
src/lib/rollups/measurement-read.ts | 17 +--
src/lib/stats/__tests__/linear-fit.test.ts | 44 ++++++
src/lib/stats/linear-fit.ts | 129 ++++++++++++++++++
9 files changed, 270 insertions(+), 139 deletions(-)
create mode 100644 src/__tests__/linear-fit-single-implementation-guard.test.ts
create mode 100644 src/lib/stats/__tests__/linear-fit.test.ts
create mode 100644 src/lib/stats/linear-fit.ts
diff --git a/src/__tests__/linear-fit-single-implementation-guard.test.ts b/src/__tests__/linear-fit-single-implementation-guard.test.ts
new file mode 100644
index 000000000..b1ee601f5
--- /dev/null
+++ b/src/__tests__/linear-fit-single-implementation-guard.test.ts
@@ -0,0 +1,69 @@
+/**
+ * Structural guard: a least-squares line is fitted in `lib/stats/linear-fit`.
+ *
+ * Six readers fitted one by hand, four with the determinant form
+ * `n·Σxy − Σx·Σy` and two with centred sums, and two charts carried the same
+ * function twice. The matcher flags either form of the slope outside the
+ * implementation. Comments are stripped first; the planted cases prove the
+ * matcher fires.
+ */
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+import { describe, expect, it } from "vitest";
+
+import { walkSourceFiles } from "./helpers/source-files";
+
+const SRC = join(process.cwd(), "src");
+
+const ALLOWED: Record = {
+ "lib/stats/linear-fit.ts": "The implementation.",
+};
+
+const HAND_ROLLED_SLOPE =
+ /\bn\s*\*\s*sum_?xy\s*-\s*sum_?x\s*\*\s*sum_?y\b|\bsxy\s*\/\s*sxx\b|\bsumXy\s*-\s*\(\s*sumX\s*\*\s*sumY\s*\)\s*\/\s*n\b/i;
+
+function stripComments(text: string): string {
+ return text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
+}
+
+describe("one least-squares line", () => {
+ it("the matcher fires on a hand-rolled slope", () => {
+ expect(
+ HAND_ROLLED_SLOPE.test(
+ "const slope = (n * sumXY - sumX * sumY) / denominator;",
+ ),
+ ).toBe(true);
+ expect(HAND_ROLLED_SLOPE.test("const slope = sxy / sxx;")).toBe(true);
+ expect(
+ HAND_ROLLED_SLOPE.test("const sxy = sumXy - (sumX * sumY) / n;"),
+ ).toBe(true);
+ expect(HAND_ROLLED_SLOPE.test("const fit = linearFit(xs, ys);")).toBe(
+ false,
+ );
+ });
+
+ it("no file outside the implementation fits its own", () => {
+ const files = walkSourceFiles(SRC, { floor: 3000 }).filter(
+ (file) =>
+ !file.startsWith("generated/") &&
+ !file.includes("__tests__/") &&
+ !/\.test\.tsx?$/.test(file),
+ );
+ const offenders = files.filter(
+ (file) =>
+ !(file in ALLOWED) &&
+ HAND_ROLLED_SLOPE.test(
+ stripComments(readFileSync(join(SRC, file), "utf8")),
+ ),
+ );
+ expect(offenders).toEqual([]);
+ // The implementation itself matches, so the sweep read real files.
+ expect(
+ HAND_ROLLED_SLOPE.test(
+ stripComments(
+ readFileSync(join(SRC, "lib/stats/linear-fit.ts"), "utf8"),
+ ),
+ ),
+ ).toBe(true);
+ });
+});
diff --git a/src/components/charts/health-chart.tsx b/src/components/charts/health-chart.tsx
index 640c8db11..96c31223d 100644
--- a/src/components/charts/health-chart.tsx
+++ b/src/components/charts/health-chart.tsx
@@ -89,6 +89,7 @@ import {
DEFAULT_CHART_RANGE,
rangeWindowDays,
} from "./chart-range-tabs";
+import { trendLineByTime } from "@/lib/stats/linear-fit";
// The range tabs (7 / 30 / 90 / All, a calendar-day window ending now) and
// the "All" tab's ten-year fetch span live with the shared
@@ -585,44 +586,6 @@ export function resolveAnnotationPositions(
return out;
}
-function buildTrendSeriesByTime(data: Array<{ date: Date; value: number }>): {
- points: Array<{ date: Date; value: number }>;
- slopePerDay: number;
-} | null {
- if (data.length < 2) return null;
-
- const sorted = [...data].sort((a, b) => a.date.getTime() - b.date.getTime());
- const startTime = sorted[0].date.getTime();
- const points = sorted.map((p) => ({
- x: (p.date.getTime() - startTime) / 86400000,
- y: p.value,
- }));
-
- const n = points.length;
- const sumX = points.reduce((s, p) => s + p.x, 0);
- const sumY = points.reduce((s, p) => s + p.y, 0);
- const sumXY = points.reduce((s, p) => s + p.x * p.y, 0);
- const sumXX = points.reduce((s, p) => s + p.x * p.x, 0);
-
- const denominator = n * sumXX - sumX * sumX;
- if (denominator === 0) return null;
-
- const slope = (n * sumXY - sumX * sumY) / denominator;
- const intercept = (sumY - slope * sumX) / n;
-
- return {
- points: sorted.map((p) => ({
- date: p.date,
- value:
- Math.round(
- (intercept + slope * ((p.date.getTime() - startTime) / 86400000)) *
- 100,
- ) / 100,
- })),
- slopePerDay: slope,
- };
-}
-
export function HealthChart({
types,
title,
@@ -1235,7 +1198,7 @@ export function HealthChart({
}));
if (typeData.length >= 2) {
- const trend = buildTrendSeriesByTime(typeData);
+ const trend = trendLineByTime(typeData);
if (trend) {
for (const point of trend.points) {
const pointTimestamp = point.date.getTime();
diff --git a/src/components/charts/mood-chart.tsx b/src/components/charts/mood-chart.tsx
index 2b5641cb7..0e3db438a 100644
--- a/src/components/charts/mood-chart.tsx
+++ b/src/components/charts/mood-chart.tsx
@@ -54,6 +54,7 @@ import { CHART_HEIGHT_PX } from "@/lib/charts/constants";
import { shouldFireDataReady } from "@/lib/charts/data-ready-latch";
import { moodLabelKeyForScore } from "@/lib/mood/labels";
import { apiGet } from "@/lib/api/api-fetch";
+import { trendLineByTime } from "@/lib/stats/linear-fit";
// --- Types ---
@@ -197,44 +198,6 @@ function movingAverageByPoints(
});
}
-function buildTrendLine(data: Array<{ date: Date; value: number }>): {
- points: Array<{ date: Date; value: number }>;
- slopePerDay: number;
-} | null {
- if (data.length < 2) return null;
-
- const sorted = [...data].sort((a, b) => a.date.getTime() - b.date.getTime());
- const startTime = sorted[0].date.getTime();
- const points = sorted.map((p) => ({
- x: (p.date.getTime() - startTime) / 86400000,
- y: p.value,
- }));
-
- const n = points.length;
- const sumX = points.reduce((s, p) => s + p.x, 0);
- const sumY = points.reduce((s, p) => s + p.y, 0);
- const sumXY = points.reduce((s, p) => s + p.x * p.y, 0);
- const sumXX = points.reduce((s, p) => s + p.x * p.x, 0);
-
- const denominator = n * sumXX - sumX * sumX;
- if (denominator === 0) return null;
-
- const slope = (n * sumXY - sumX * sumY) / denominator;
- const intercept = (sumY - slope * sumX) / n;
-
- return {
- points: sorted.map((p) => ({
- date: p.date,
- value:
- Math.round(
- (intercept + slope * ((p.date.getTime() - startTime) / 86400000)) *
- 100,
- ) / 100,
- })),
- slopePerDay: slope,
- };
-}
-
// --- Aggregation helpers (exported for unit tests) ---
interface MoodEntryDay {
@@ -542,7 +505,7 @@ export function MoodChart({
}));
if (scoreData.length >= 2) {
- const trend = buildTrendLine(scoreData);
+ const trend = trendLineByTime(scoreData);
if (trend) {
for (const point of trend.points) {
const pointTimestamp = point.date.getTime();
diff --git a/src/lib/analytics/trends.ts b/src/lib/analytics/trends.ts
index c137b62d3..4e3d18327 100644
--- a/src/lib/analytics/trends.ts
+++ b/src/lib/analytics/trends.ts
@@ -4,6 +4,7 @@
*/
import { READING_SPREAD, standardDeviation } from "@/lib/stats/spread";
+import { linearFit } from "@/lib/stats/linear-fit";
export interface DataPoint {
date: Date;
@@ -48,26 +49,14 @@ export function trendSlope(
y: p.value,
}));
- const n = points.length;
- const sumX = points.reduce((s, p) => s + p.x, 0);
- const sumY = points.reduce((s, p) => s + p.y, 0);
- const sumXY = points.reduce((s, p) => s + p.x * p.y, 0);
- const sumXX = points.reduce((s, p) => s + p.x * p.x, 0);
-
- const denominator = n * sumXX - sumX * sumX;
- if (denominator === 0) return null;
-
- const slope = (n * sumXY - sumX * sumY) / denominator;
- const intercept = (sumY - slope * sumX) / n;
-
- // R² calculation
- const meanY = sumY / n;
- const ssTotal = points.reduce((s, p) => s + (p.y - meanY) ** 2, 0);
- const ssResidual = points.reduce(
- (s, p) => s + (p.y - (intercept + slope * p.x)) ** 2,
- 0,
+ const fit = linearFit(
+ points.map((p) => p.x),
+ points.map((p) => p.y),
);
- const rSquared = ssTotal === 0 ? 0 : 1 - ssResidual / ssTotal;
+ if (!fit) return null;
+ const { slope } = fit;
+ // No variance in y: a flat line explains nothing, as it always read.
+ const rSquared = fit.r2 ?? 0;
const threshold = 0.01; // units per day threshold for "stable"
const direction: "up" | "down" | "stable" =
diff --git a/src/lib/insights/derived/trajectory.ts b/src/lib/insights/derived/trajectory.ts
index 259d3c952..00b60b45e 100644
--- a/src/lib/insights/derived/trajectory.ts
+++ b/src/lib/insights/derived/trajectory.ts
@@ -53,6 +53,7 @@ import {
import { readDayMeanSeries, type BaselineProfile } from "./baseline";
import type { Derived, DerivedProvenanceSource } from "./types";
import { daysBetweenDateKeys, shiftDateKey } from "@/lib/tz/format";
+import { linearFit } from "@/lib/stats/linear-fit";
/** Trailing window the fit reads (days). Matches the baseline engine. */
const DEFAULT_WINDOW_DAYS = 30;
@@ -154,22 +155,9 @@ export function fitOls(xs: number[], ys: number[]): OlsFit | null {
const n = xs.length;
if (n < 3 || ys.length !== n) return null;
- const sumX = xs.reduce((s, x) => s + x, 0);
- const sumY = ys.reduce((s, y) => s + y, 0);
- const meanX = sumX / n;
- const meanY = sumY / n;
-
- let sxx = 0;
- let sxy = 0;
- for (let i = 0; i < n; i++) {
- const dx = xs[i] - meanX;
- sxx += dx * dx;
- sxy += dx * (ys[i] - meanY);
- }
- if (sxx === 0) return null; // all x identical → no slope
-
- const slope = sxy / sxx;
- const intercept = meanY - slope * meanX;
+ const fit = linearFit(xs, ys);
+ if (!fit) return null; // all x identical → no slope
+ const { slope, intercept, meanX, meanY, sxx } = fit;
let ssRes = 0;
let ssTot = 0;
diff --git a/src/lib/insights/graded-series.ts b/src/lib/insights/graded-series.ts
index 57caf5be3..4f82da485 100644
--- a/src/lib/insights/graded-series.ts
+++ b/src/lib/insights/graded-series.ts
@@ -60,6 +60,7 @@ import {
import { readingsMean } from "@/lib/measurements/day-mean";
import { usesHourlyMeanDay } from "@/lib/measurements/day-statistic";
import { userDayKey } from "@/lib/tz/format";
+import { linearFit } from "@/lib/stats/linear-fit";
const MS_PER_DAY = 24 * 60 * 60 * 1000;
@@ -205,21 +206,11 @@ function foldInto(map: Map, key: string, value: number): void {
* over the order the readings landed.
*/
function leastSquaresSlope(values: number[]): number | null {
- const n = values.length;
- if (n < 2) return null;
- let sumX = 0;
- let sumY = 0;
- let sumXY = 0;
- let sumXX = 0;
- for (let i = 0; i < n; i++) {
- sumX += i;
- sumY += values[i];
- sumXY += i * values[i];
- sumXX += i * i;
- }
- const denom = n * sumXX - sumX * sumX;
- if (denom === 0) return null;
- return round((n * sumXY - sumX * sumY) / denom, 4);
+ const fit = linearFit(
+ values.map((_, i) => i),
+ values,
+ );
+ return fit ? round(fit.slope, 4) : null;
}
/**
diff --git a/src/lib/rollups/measurement-read.ts b/src/lib/rollups/measurement-read.ts
index 82b7c6070..ede087a84 100644
--- a/src/lib/rollups/measurement-read.ts
+++ b/src/lib/rollups/measurement-read.ts
@@ -38,6 +38,7 @@ import {
getSourceLadder,
parseSourcePriority,
} from "@/lib/validations/source-priority";
+import { fitFromSums } from "@/lib/stats/linear-fit";
/**
* v1.11.1 — load a user's source-priority blob for the rollup collapse.
@@ -217,18 +218,12 @@ export function composeRegression(
if (n < 2) return MISS;
- // Mean-centered (corrected-sum) identities. Sxx/Sxy/Syy are the determinant
- // terms divided by n — algebraically identical to `n·Σxx − Σx²` etc., but
- // they subtract `Σx²/n` from `Σxx` at matched scale, avoiding the
- // catastrophic cancellation the determinant form suffers on the ~1e10
- // epoch-day x-axis. See the function header for the full rationale.
- const sxx = sumXx - (sumX * sumX) / n;
+ // The centred fit over the accumulators (`fitFromSums`), the same
+ // arithmetic every point fit uses. No x variance: no slope, no r².
+ const fit = fitFromSums({ n, sumX, sumY, sumXx, sumXy, sumYy });
+ const slope = fit ? fit.slope : null;
+ const r2 = fit ? fit.r2 : null;
const syy = sumYy - (sumY * sumY) / n;
- const sxy = sumXy - (sumX * sumY) / n;
-
- const slope = sxx === 0 ? null : sxy / sxx;
- // REGR_R2 is null when either variance is zero (matches Postgres).
- const r2 = sxx === 0 || syy === 0 ? null : (sxy * sxy) / (sxx * syy);
// Population variance: Syy/n = Σyy/n − (Σy/n)². Clamp tiny negative values
// float rounding can produce when the variance is effectively zero.
diff --git a/src/lib/stats/__tests__/linear-fit.test.ts b/src/lib/stats/__tests__/linear-fit.test.ts
new file mode 100644
index 000000000..f9702977e
--- /dev/null
+++ b/src/lib/stats/__tests__/linear-fit.test.ts
@@ -0,0 +1,44 @@
+import { describe, expect, it } from "vitest";
+
+import { fitFromSums, linearFit, sumsOf, trendLineByTime } from "../linear-fit";
+
+describe("linearFit", () => {
+ it("fits an exact line", () => {
+ const fit = linearFit([0, 1, 2, 3], [1, 3, 5, 7])!;
+ expect(fit.slope).toBeCloseTo(2, 12);
+ expect(fit.intercept).toBeCloseTo(1, 12);
+ expect(fit.r2).toBeCloseTo(1, 12);
+ });
+
+ it("answers null without a slope, and a null r² without y variance", () => {
+ expect(linearFit([1], [1])).toBeNull();
+ expect(linearFit([2, 2, 2], [1, 2, 3])).toBeNull();
+ expect(linearFit([0, 1, 2], [5, 5, 5])!.r2).toBeNull();
+ });
+
+ it("keeps its digits on an epoch-day axis", () => {
+ const xs = [20_000, 20_001, 20_002, 20_003].map((d) => d * 86_400);
+ const fit = linearFit(xs, [10, 11, 12, 13])!;
+ expect(fit.slope * 86_400).toBeCloseTo(1, 6);
+ });
+
+ it("fits the same from points and from their sums", () => {
+ const xs = [0, 2, 3, 7, 9];
+ const ys = [4, 5, 5, 9, 11];
+ expect(fitFromSums(sumsOf(xs, ys))).toEqual(linearFit(xs, ys));
+ });
+});
+
+describe("trendLineByTime", () => {
+ it("draws the fitted value at two decimals per point, in time order", () => {
+ const day = (n: number) => new Date(Date.UTC(2026, 0, 1 + n));
+ const line = trendLineByTime([
+ { date: day(2), value: 3 },
+ { date: day(0), value: 1 },
+ { date: day(1), value: 2.5 },
+ ])!;
+ expect(line.points.map((p) => p.date)).toEqual([day(0), day(1), day(2)]);
+ expect(line.slopePerDay).toBeCloseTo(1, 12);
+ expect(line.points.map((p) => p.value)).toEqual([1.17, 2.17, 3.17]);
+ });
+});
diff --git a/src/lib/stats/linear-fit.ts b/src/lib/stats/linear-fit.ts
new file mode 100644
index 000000000..2b05f8592
--- /dev/null
+++ b/src/lib/stats/linear-fit.ts
@@ -0,0 +1,129 @@
+/**
+ * The least-squares line, in one place.
+ *
+ * Six readers fitted a line by hand: two charts, the trend summary, the
+ * graded series, the trajectory fit and the rollup regression composed from
+ * stored accumulators. Two of them used the centred sums, four the
+ * determinant form `n·Σxy − Σx·Σy`, which loses digits on a large x axis
+ * (epoch days) that the centred form keeps. Every fit now goes through
+ * {@link fitFromSums}, the centred form over the accumulators, so a fit read
+ * from points and one composed from stored sums are the same arithmetic.
+ *
+ * `src/__tests__/linear-fit-single-implementation-guard.test.ts` keeps the
+ * hand-rolled forms out of the rest of the tree.
+ */
+
+/** The sums a fit is composed from. */
+export interface FitSums {
+ n: number;
+ sumX: number;
+ sumY: number;
+ sumXx: number;
+ sumXy: number;
+ sumYy: number;
+}
+
+export interface LinearFit {
+ slope: number;
+ intercept: number;
+ /** Null when y has no variance (Postgres REGR_R2 answers null too). */
+ r2: number | null;
+ n: number;
+ meanX: number;
+ meanY: number;
+ /** Centred sums of squares and products. */
+ sxx: number;
+ sxy: number;
+ syy: number;
+}
+
+/**
+ * Fit from accumulated sums. Null below two points or when every x is the
+ * same (no slope). The centred identities `Sxx = Σxx − (Σx)²/n` subtract at
+ * matched scale, so the cancellation is bounded by the x variance, not the
+ * x magnitude.
+ */
+export function fitFromSums(sums: FitSums): LinearFit | null {
+ const { n, sumX, sumY, sumXx, sumXy, sumYy } = sums;
+ if (n < 2) return null;
+ const sxx = sumXx - (sumX * sumX) / n;
+ const sxy = sumXy - (sumX * sumY) / n;
+ const syy = sumYy - (sumY * sumY) / n;
+ if (sxx === 0) return null;
+ const slope = sxy / sxx;
+ const meanX = sumX / n;
+ const meanY = sumY / n;
+ return {
+ slope,
+ intercept: meanY - slope * meanX,
+ r2: syy === 0 ? null : (sxy * sxy) / (sxx * syy),
+ n,
+ meanX,
+ meanY,
+ sxx,
+ sxy,
+ syy,
+ };
+}
+
+/** The sums of paired values. */
+export function sumsOf(xs: readonly number[], ys: readonly number[]): FitSums {
+ if (xs.length !== ys.length) {
+ throw new RangeError("linear fit: xs and ys differ in length");
+ }
+ let sumX = 0;
+ let sumY = 0;
+ let sumXx = 0;
+ let sumXy = 0;
+ let sumYy = 0;
+ for (let i = 0; i < xs.length; i += 1) {
+ const x = xs[i];
+ const y = ys[i];
+ sumX += x;
+ sumY += y;
+ sumXx += x * x;
+ sumXy += x * y;
+ sumYy += y * y;
+ }
+ return { n: xs.length, sumX, sumY, sumXx, sumXy, sumYy };
+}
+
+/** Fit `ys` against `xs`. */
+export function linearFit(
+ xs: readonly number[],
+ ys: readonly number[],
+): LinearFit | null {
+ return fitFromSums(sumsOf(xs, ys));
+}
+
+/** One day in milliseconds, the x unit of a trend over dated points. */
+const DAY_MS = 86_400_000;
+
+/**
+ * The fitted line over dated points, x in days from the first point: the
+ * chart trend lines draw it, with each fitted value at two decimals. Null
+ * below two points or when every point shares one instant.
+ */
+export function trendLineByTime(
+ data: ReadonlyArray<{ date: Date; value: number }>,
+): {
+ points: Array<{ date: Date; value: number }>;
+ slopePerDay: number;
+} | null {
+ if (data.length < 2) return null;
+ const sorted = [...data].sort((a, b) => a.date.getTime() - b.date.getTime());
+ const startTime = sorted[0].date.getTime();
+ const xOf = (d: Date) => (d.getTime() - startTime) / DAY_MS;
+ const fit = linearFit(
+ sorted.map((p) => xOf(p.date)),
+ sorted.map((p) => p.value),
+ );
+ if (!fit) return null;
+ return {
+ points: sorted.map((p) => ({
+ date: p.date,
+ value: Math.round((fit.intercept + fit.slope * xOf(p.date)) * 100) / 100,
+ })),
+ slopePerDay: fit.slope,
+ };
+}
From f0c20151a9070af8577a25d847bd0fdd5b6bf931 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Tue, 20 Oct 2026 09:45:51 +0200
Subject: [PATCH 07/13] perf(reads): ask the latest reading per type with one
probe per type
The briefing's last-seen read and the MCP search presence probe grouped
every live row of the account to learn the newest instant or whether a
type exists at all. Both now use readLatestEver, one index lookup per
type. The latest-per-type guard also catches the Prisma spelling: a
groupBy by type with nothing but _max, or no aggregate, and no window.
---
.../latest-per-type-read-guard.test.ts | 79 +++++++++++++++++++
src/lib/daily/briefing-today-read.ts | 21 +++--
src/lib/mcp/__tests__/record-search.test.ts | 7 ++
src/lib/mcp/__tests__/search-fetch.test.ts | 7 ++
src/lib/mcp/__tests__/tools.test.ts | 24 ++++--
src/lib/mcp/tools.ts | 16 ++--
6 files changed, 126 insertions(+), 28 deletions(-)
diff --git a/src/__tests__/latest-per-type-read-guard.test.ts b/src/__tests__/latest-per-type-read-guard.test.ts
index a0e7eb796..4ebc51836 100644
--- a/src/__tests__/latest-per-type-read-guard.test.ts
+++ b/src/__tests__/latest-per-type-read-guard.test.ts
@@ -14,6 +14,12 @@
* the one cell; `computeSummariesSlice` is its builder and is called nowhere
* else in production code.
*
+ * The Prisma spelling of the same question is `measurement.groupBy` by type
+ * with nothing but `_max` (or nothing at all, a presence probe) and no time
+ * window: it groups every live row the account has to answer a question one
+ * index probe per type answers. Grouped reads that count, sum or average, or
+ * that are bounded in time, ask something else and pass.
+ *
* Comments are stripped before matching, and the planted cases prove each
* matcher finds what it claims to.
*/
@@ -37,6 +43,36 @@ const LATEST_PER_TYPE = /DISTINCT\s+ON\s*\(\s*(?:\w+\s*\.\s*)?"?type"?\s*\)/i;
const SLICE_BUILDER_CALL = /\bcomputeSummariesSlice\s*\(/;
+/** Grouped presence reads that ask something the per-type probe cannot. */
+const GROUPBY_ALLOWED: Record = {
+ "lib/measurements/import-apple-health-export.ts":
+ "Which types hold a MANUAL reading: a source-filtered presence the per-type probe does not take.",
+ "lib/jobs/insight-pregenerate.ts":
+ "Which types exist at all, with no type list to drive a per-type probe; a nightly job, not a request path.",
+};
+
+/** Each `measurement.groupBy(...)` argument that asks only latest or presence. */
+function latestOnlyGroupBys(code: string): string[] {
+ const out: string[] = [];
+ for (const m of code.matchAll(/\.measurement\s*\.\s*groupBy\s*\(/g)) {
+ let i = m.index + m[0].length;
+ let depth = 1;
+ while (depth > 0 && i < code.length) {
+ if (code[i] === "(") depth += 1;
+ else if (code[i] === ")") depth -= 1;
+ i += 1;
+ }
+ const arg = code.slice(m.index + m[0].length, i - 1);
+ const byType = /by:\s*\[\s*"type"\s*\]/.test(arg);
+ const asksMore = /\b_(?:count|sum|avg|min)\b/.test(arg);
+ const windowed = /\bmeasuredAt\s*:/.test(
+ arg.replace(/_max:\s*\{[^}]*\}/, ""),
+ );
+ if (byType && !asksMore && !windowed) out.push(arg.replace(/\s+/g, " "));
+ }
+ return out;
+}
+
function productionFiles(): string[] {
return walkSourceFiles(SRC, { floor: 3000 }).filter(
(file) =>
@@ -71,6 +107,49 @@ describe("latest-per-type reads", () => {
expect(offenders).toEqual([]);
});
+ it("the groupBy matcher finds a latest-only or presence-only read", () => {
+ expect(
+ latestOnlyGroupBys(
+ `prisma.measurement.groupBy({ by: ["type"], where: { userId }, _max: { measuredAt: true } })`,
+ ),
+ ).toHaveLength(1);
+ expect(
+ latestOnlyGroupBys(
+ `prisma.measurement\n .groupBy({ by: ["type"], where: { userId, deletedAt: null } })`,
+ ),
+ ).toHaveLength(1);
+ expect(
+ latestOnlyGroupBys(
+ `prisma.measurement.groupBy({ by: ["type"], where: { userId }, _count: { _all: true }, _max: { measuredAt: true } })`,
+ ),
+ ).toEqual([]);
+ expect(
+ latestOnlyGroupBys(
+ `prisma.measurement.groupBy({ by: ["type"], where: { userId, measuredAt: { gte: since } }, _max: { measuredAt: true } })`,
+ ),
+ ).toEqual([]);
+ });
+
+ it("no production read groups every row for the latest per type", () => {
+ const files = productionFiles();
+ const grouped = files.filter((file) =>
+ /\.measurement\s*\.\s*groupBy\s*\(/.test(
+ readFileSync(join(SRC, file), "utf8"),
+ ),
+ );
+ // The tree groups measurements in a dozen places; finding none means
+ // the walk read the wrong tree.
+ expect(grouped.length).toBeGreaterThanOrEqual(8);
+ const offenders = files
+ .filter((file) => !(file in GROUPBY_ALLOWED))
+ .flatMap((file) =>
+ latestOnlyGroupBys(
+ stripComments(readFileSync(join(SRC, file), "utf8")),
+ ).map((arg) => `${file}: ${arg.slice(0, 100)}`),
+ );
+ expect(offenders).toEqual([]);
+ });
+
it("the summaries slice is built only behind its cache cell", () => {
const callers = productionFiles().filter((file) =>
SLICE_BUILDER_CALL.test(
diff --git a/src/lib/daily/briefing-today-read.ts b/src/lib/daily/briefing-today-read.ts
index f9d74bb20..49ace8d14 100644
--- a/src/lib/daily/briefing-today-read.ts
+++ b/src/lib/daily/briefing-today-read.ts
@@ -11,6 +11,7 @@
*/
import { dailyBriefingSchema, type DailyBriefing } from "@/lib/ai/schema";
import { prisma } from "@/lib/db";
+import { readLatestEver } from "@/lib/dashboard/summary-reads";
import type { Locale } from "@/lib/i18n/config";
import { readBriefingGeneratedAt } from "@/lib/insights/briefing-generated-at";
import { userDayKey } from "@/lib/tz/format";
@@ -41,21 +42,17 @@ export async function readSignalLastSeen(
userId: string,
): Promise<(type: string) => string | null> {
try {
- const rows = await prisma.measurement.groupBy({
- by: ["type"],
- where: {
- userId,
- deletedAt: null,
- type: {
- in: Object.values(SIGNAL_READING_TYPE) as MeasurementType[],
- },
- },
- _max: { measuredAt: true },
- });
+ // One index probe per type for its latest live reading
+ // (`readLatestEver`), rather than a grouped max over every row.
+ const rows = await readLatestEver(
+ prisma,
+ userId,
+ Object.values(SIGNAL_READING_TYPE) as MeasurementType[],
+ );
const byType = new Map(
rows.map((row) => [
String(row.type),
- row._max.measuredAt?.toISOString() ?? null,
+ new Date(row.measured_at).toISOString(),
]),
);
return (type) => byType.get(type) ?? null;
diff --git a/src/lib/mcp/__tests__/record-search.test.ts b/src/lib/mcp/__tests__/record-search.test.ts
index 4d05457da..7a7f5e9e1 100644
--- a/src/lib/mcp/__tests__/record-search.test.ts
+++ b/src/lib/mcp/__tests__/record-search.test.ts
@@ -121,6 +121,13 @@ vi.mock("@/lib/db", () => ({
vaccinationRecord: table("vaccinationRecord"),
},
}));
+
+// The search presence probe is one index lookup per type in Postgres; the
+// suite states which types hold a live reading through this stub.
+vi.mock("@/lib/dashboard/summary-reads", async (importOriginal) => ({
+ ...(await importOriginal()),
+ readLatestEver: vi.fn(async () => []),
+}));
vi.mock("@/lib/links", () => ({
listTargets: vi.fn(
async (
diff --git a/src/lib/mcp/__tests__/search-fetch.test.ts b/src/lib/mcp/__tests__/search-fetch.test.ts
index d6de27350..63f09c7c3 100644
--- a/src/lib/mcp/__tests__/search-fetch.test.ts
+++ b/src/lib/mcp/__tests__/search-fetch.test.ts
@@ -42,6 +42,13 @@ vi.mock("@/lib/db", () => ({
},
}));
+// The search presence probe is one index lookup per type in Postgres; the
+// suite states which types hold a live reading through this stub.
+vi.mock("@/lib/dashboard/summary-reads", async (importOriginal) => ({
+ ...(await importOriginal()),
+ readLatestEver: vi.fn(async () => []),
+}));
+
import { MCP_TOOLS } from "../tools";
import { buildCoachDataInventory } from "@/lib/ai/coach/tools/inventory";
import { executeCoachTool } from "@/lib/ai/coach/tools/executor";
diff --git a/src/lib/mcp/__tests__/tools.test.ts b/src/lib/mcp/__tests__/tools.test.ts
index f316ccfaa..f736e0d92 100644
--- a/src/lib/mcp/__tests__/tools.test.ts
+++ b/src/lib/mcp/__tests__/tools.test.ts
@@ -60,6 +60,13 @@ vi.mock("@/lib/db", () => ({
ecgRecording: { findMany: vi.fn(async () => []) },
},
}));
+
+// The search presence probe is one index lookup per type in Postgres; the
+// suite states which types hold a live reading through this stub.
+vi.mock("@/lib/dashboard/summary-reads", async (importOriginal) => ({
+ ...(await importOriginal()),
+ readLatestEver: vi.fn(async () => []),
+}));
// v1.24 — the operational reads delegate to existing server-authoritative
// engines; stub them so the registry-wide loops never reach a real engine.
vi.mock("@/lib/medications/scheduling/next-due", () => ({
@@ -157,6 +164,7 @@ import { loadIntradayPulse } from "@/lib/analytics/intraday-pulse-io";
import { listTargetsBySource } from "@/lib/links";
import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
import type { McpAuthContext } from "../auth";
+import { readLatestEver } from "@/lib/dashboard/summary-reads";
const CTX: McpAuthContext = {
userId: "user-1",
@@ -1229,8 +1237,8 @@ describe("v1.25 clinical signals on the MCP surface", () => {
});
it("search surfaces a present clinical signal as metric:", async () => {
- vi.mocked(prisma.measurement.groupBy).mockResolvedValue([
- { type: "GRIP_STRENGTH" },
+ vi.mocked(readLatestEver).mockResolvedValue([
+ { type: "GRIP_STRENGTH", value: 1, measured_at: new Date() },
] as never);
const result = (await tool("search").run(CTX, { query: "grip" })) as {
results: Array<{ id: string; title: string; url: string }>;
@@ -1242,7 +1250,7 @@ describe("v1.25 clinical signals on the MCP surface", () => {
});
it("search omits a clinical signal with no recorded data", async () => {
- vi.mocked(prisma.measurement.groupBy).mockResolvedValue([] as never);
+ vi.mocked(readLatestEver).mockResolvedValue([] as never);
const result = (await tool("search").run(CTX, { query: "grip" })) as {
results: Array<{ id: string }>;
};
@@ -1250,7 +1258,7 @@ describe("v1.25 clinical signals on the MCP surface", () => {
});
it("fetch hydrates a clinical signal via the baseline read (not the Coach path)", async () => {
- vi.mocked(prisma.measurement.groupBy).mockResolvedValue([] as never);
+ vi.mocked(readLatestEver).mockResolvedValue([] as never);
vi.mocked(getMetricBaseline).mockResolvedValue({
present: true,
metric: "Grip strength",
@@ -1292,8 +1300,8 @@ describe("metric-status-only discovery on search/fetch (v1.30 coverage review G5
});
it("search surfaces a present metric-status-only id (undiscoverable before this wave)", async () => {
- vi.mocked(prisma.measurement.groupBy).mockResolvedValue([
- { type: "WRIST_TEMPERATURE" },
+ vi.mocked(readLatestEver).mockResolvedValue([
+ { type: "WRIST_TEMPERATURE", value: 1, measured_at: new Date() },
] as never);
const result = (await tool("search").run(CTX, { query: "wrist" })) as {
results: Array<{ id: string; title: string }>;
@@ -1303,7 +1311,7 @@ describe("metric-status-only discovery on search/fetch (v1.30 coverage review G5
});
it("fetch hydrates a metric-status-only id via the baseline read", async () => {
- vi.mocked(prisma.measurement.groupBy).mockResolvedValue([] as never);
+ vi.mocked(readLatestEver).mockResolvedValue([] as never);
vi.mocked(getMetricBaseline).mockResolvedValue({
present: true,
metric: "Wrist skin temperature",
@@ -1387,7 +1395,7 @@ describe("nutrients on search / fetch (v1.30 coverage review G1)", () => {
} as never);
vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.labResult.findMany).mockResolvedValue([] as never);
- vi.mocked(prisma.measurement.groupBy).mockResolvedValue([] as never);
+ vi.mocked(readLatestEver).mockResolvedValue([] as never);
});
it("search resolves 'water' to nutrient:water when the user has logged it", async () => {
diff --git a/src/lib/mcp/tools.ts b/src/lib/mcp/tools.ts
index d5b046a11..ee6e575f0 100644
--- a/src/lib/mcp/tools.ts
+++ b/src/lib/mcp/tools.ts
@@ -20,6 +20,7 @@
import { z } from "zod/v4";
import { prisma } from "@/lib/db";
+import { readLatestEver } from "@/lib/dashboard/summary-reads";
import { resolveMarkerForRead } from "@/lib/labs/biomarker-resolve";
import { annotate } from "@/lib/logging/context";
import { getDayArgsSchema } from "@/lib/ai/coach/tools/definitions";
@@ -584,14 +585,13 @@ function searchAndFetchTools(): McpToolDefinition[] {
// v1.41.2 — live rows only: a deleted reading is not a signal the
// record holds, and the filter lets the probe use the live-rows
// partial index instead of walking the tombstones.
- const discoverablePresent = await prisma.measurement.groupBy({
- by: ["type"],
- where: {
- userId: ctx.userId,
- type: { in: discoverableSignals.map((s) => s.measurementType) },
- deletedAt: null,
- },
- });
+ // A presence probe is one index lookup per type (`readLatestEver`
+ // drops a type with no live reading), not a grouping of every row.
+ const discoverablePresent = await readLatestEver(
+ prisma,
+ ctx.userId,
+ discoverableSignals.map((s) => s.measurementType),
+ );
const presentTypes = new Set(discoverablePresent.map((r) => r.type));
for (const sig of discoverableSignals) {
if (!presentTypes.has(sig.measurementType)) continue;
From b402c65f6abd15824d9d909847743c49b0139d08 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Tue, 20 Oct 2026 09:46:48 +0200
Subject: [PATCH 08/13] fix(demo): seal the baked insights cache and carry it
across a migration
The demo seed wrote the cached insights payload into the readable column,
and the edge migration copied only that column, so a migrated demo lost
any sealed payload. The seed now seals it the way the app does and the
migration copies insights_cached_text_encrypted beside it.
---
scripts/migrate-demo-edge01.sh | 6 ++++--
scripts/seed-demo.ts | 13 ++++++++++---
2 files changed, 14 insertions(+), 5 deletions(-)
diff --git a/scripts/migrate-demo-edge01.sh b/scripts/migrate-demo-edge01.sh
index 5c9521e0e..af4918879 100755
--- a/scripts/migrate-demo-edge01.sh
+++ b/scripts/migrate-demo-edge01.sh
@@ -329,7 +329,8 @@ readonly USERS_COLS="id, username, email, password_hash, role, created_at, updat
height_cm, date_of_birth, gender, timezone,
codex_access_token_encrypted, codex_refresh_token_encrypted, codex_token_expires_at,
codex_connected_at, codex_connection_status,
- insights_privacy_mode, insights_cached_at, insights_cached_text, insights_exclude_metrics,
+ insights_privacy_mode, insights_cached_at, insights_cached_text, insights_cached_text_encrypted,
+ insights_exclude_metrics,
global_excluded_injection_sites,
telegram_bot_token, telegram_chat_id, telegram_enabled,
withings_client_id_encrypted, withings_client_secret_encrypted,
@@ -361,7 +362,8 @@ readonly USERS_SELECT="id, username, email, password_hash,
NULL::timestamptz AS codex_token_expires_at,
NULL::timestamptz AS codex_connected_at,
'disconnected' AS codex_connection_status,
- insights_privacy_mode, insights_cached_at, insights_cached_text, insights_exclude_metrics,
+ insights_privacy_mode, insights_cached_at, insights_cached_text, insights_cached_text_encrypted,
+ insights_exclude_metrics,
global_excluded_injection_sites,
NULL::text AS telegram_bot_token,
NULL::text AS telegram_chat_id,
diff --git a/scripts/seed-demo.ts b/scripts/seed-demo.ts
index 6e13de3b0..57abb04a7 100644
--- a/scripts/seed-demo.ts
+++ b/scripts/seed-demo.ts
@@ -88,6 +88,7 @@ import {
getKeyFingerprint,
} from "../src/lib/crypto";
import { ENVIRONMENT_LOCATION_AAD } from "../src/lib/crypto/encrypted-columns";
+import { sealInsightsCachedText } from "../src/lib/insights/cached-text-cipher";
import {
DEFAULT_DASHBOARD_LAYOUT,
serializeDashboardLayout,
@@ -3343,7 +3344,8 @@ async function seed() {
const bakedProvider = "anthropic";
// ── Comprehensive insight + daily briefing ──
- // Stored on users.insights_cached_text (JSON). The GET read parses it and
+ // Stored sealed on users.insights_cached_text_encrypted (JSON). The GET
+ // read opens and parses it and
// returns it as-is; setting insights_cached_at = NOW() keeps it inside the
// 24 h freshness window for the dashboard-snapshot briefing path. The shape
// is the union of insightResultSchema (summary / findings / correlations /
@@ -3527,14 +3529,19 @@ async function seed() {
await client.query(
`UPDATE users
- SET insights_cached_text = $2,
+ SET insights_cached_text_encrypted = $2,
+ insights_cached_text = NULL,
insights_cached_at = NOW(),
insights_snapshot_hash = $3,
insights_briefing_reroll_date = $4
WHERE id = $1`,
[
userId,
- JSON.stringify(comprehensiveInsight),
+ // Sealed like every write since v1.43.7; the app never reads the
+ // readable column while a ciphertext is there.
+ Buffer.from(
+ sealInsightsCachedText(JSON.stringify(comprehensiveInsight)),
+ ),
// A stable fingerprint. The 24 h cache window keeps the row fresh on
// its own; this value just has to be present + non-null so a
// hypothetical regeneration would treat the snapshot as known. The
From bfd86391781a840273d74343be9aee73635eb6ef Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Tue, 20 Oct 2026 09:47:19 +0200
Subject: [PATCH 09/13] test(cache): prove analytics coalescing per cell, not
by the bucket total
The analytics bucket holds two cells per account since the summaries slice
got its own: the default body and the slice it composes. A cold mount
builds each once, so the bucket-wide miss counter reads 2, and the test
read that as a second builder run. It now counts builder runs per key, one
each under ten concurrent reads, and two each after a write invalidates
both.
---
tests/integration/server-cache-routes.test.ts | 73 +++++++++++++++++--
1 file changed, 68 insertions(+), 5 deletions(-)
diff --git a/tests/integration/server-cache-routes.test.ts b/tests/integration/server-cache-routes.test.ts
index 41456c4b2..6e5535fac 100644
--- a/tests/integration/server-cache-routes.test.ts
+++ b/tests/integration/server-cache-routes.test.ts
@@ -85,12 +85,56 @@ async function seedSession(username: string) {
return user;
}
+/**
+ * Count the builder runs per key on a cache. The analytics bucket holds two
+ * cells per account: the thick default body (`|default|`) and
+ * the summaries slice it composes (`|summaries`), which the tile-strip
+ * route and the snapshot read too. The bucket-wide `misses` counter therefore
+ * reads 2 on a cold mount; coalescing is proven per key, one build each.
+ */
+function countBuildsPerKey(cache: {
+ wrap: (...args: never[]) => Promise;
+}): Map {
+ const builds = new Map();
+ const original = cache.wrap.bind(cache) as unknown as (
+ key: string,
+ builder: () => Promise,
+ ttl?: number,
+ ) => Promise;
+ vi.spyOn(cache, "wrap").mockImplementation(((
+ key: string,
+ builder: () => Promise,
+ ttl?: number,
+ ) =>
+ original(
+ key,
+ () => {
+ builds.set(key, (builds.get(key) ?? 0) + 1);
+ return builder();
+ },
+ ttl,
+ )) as never);
+ return builds;
+}
+
+function buildsOf(builds: Map, userId: string) {
+ const entries = [...builds.entries()].filter(([k]) => k.startsWith(userId));
+ return {
+ defaultBody: entries
+ .filter(([k]) => k.startsWith(`${userId}|default|`))
+ .reduce((n, [, c]) => n + c, 0),
+ summaries: builds.get(`${userId}|summaries`) ?? 0,
+ keys: entries.length,
+ };
+}
+
describe("v1.4.34 IW-G — server-cache route integration", () => {
it("coalesces 10 concurrent /api/analytics reads into a single builder call", async () => {
- await seedSession("cache-stampede-user");
+ const user = await seedSession("cache-stampede-user");
const { GET } = await import("@/app/api/analytics/route");
const { caches } = await import("@/lib/cache/server-cache");
+ const builds = countBuildsPerKey(caches.analytics);
// Fire 10 concurrent reads against a cold cache. The first sets up
// the `pending` promise; the other nine join it. The cache's
@@ -110,8 +154,15 @@ describe("v1.4.34 IW-G — server-cache route integration", () => {
// microtasks — the now-warm cache (a hit). What we forbid is a
// second miss: that would mean the single-flight join failed and a
// duplicate builder fired.
+ // Per cell: the default body built once and the summaries slice it
+ // composes built once, nothing else. The bucket counter sums both cells.
+ expect(buildsOf(builds, user.id)).toEqual({
+ defaultBody: 1,
+ summaries: 1,
+ keys: 2,
+ });
const stats = caches.analytics.stats();
- expect(stats.misses).toBe(1);
+ expect(stats.misses).toBe(2);
expect(stats.stampedes + stats.hits).toBe(9);
// Subsequent serial reads inside the TTL hit the warm cache.
const warm = await (GET as unknown as (req: Request) => Promise)(
@@ -129,13 +180,19 @@ describe("v1.4.34 IW-G — server-cache route integration", () => {
const { POST: postMeasurement } =
await import("@/app/api/measurements/route");
const { caches } = await import("@/lib/cache/server-cache");
+ const builds = countBuildsPerKey(caches.analytics);
- // Prime the cache — first read is a miss; second is a hit.
+ // Prime the cache — first read builds both cells once; second is a hit.
const first = await (GET as unknown as (req: Request) => Promise)(
new Request("http://localhost/api/analytics"),
);
expect(first.status).toBe(200);
- expect(caches.analytics.stats().misses).toBe(1);
+ expect(buildsOf(builds, user.id)).toEqual({
+ defaultBody: 1,
+ summaries: 1,
+ keys: 2,
+ });
+ expect(caches.analytics.stats().misses).toBe(2);
expect(caches.analytics.stats().hits).toBe(0);
const second = await (
@@ -167,7 +224,13 @@ describe("v1.4.34 IW-G — server-cache route integration", () => {
new Request("http://localhost/api/analytics"),
);
expect(third.status).toBe(200);
- expect(caches.analytics.stats().misses).toBe(2);
+ // Both cells were evicted by the write and rebuilt once each.
+ expect(buildsOf(builds, user.id)).toEqual({
+ defaultBody: 2,
+ summaries: 2,
+ keys: 2,
+ });
+ expect(caches.analytics.stats().misses).toBe(4);
// Sanity: the body reflects the new write.
const body = (await third.json()) as {
From 5fd433301742fd7f1d78e49e5ce225de1170335c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Tue, 20 Oct 2026 09:47:20 +0200
Subject: [PATCH 10/13] fix(insights): read snapshots by time and per type, not
by a shared row count
The pulse and per-metric status snapshots read the latest 365 rows, which
for a dense pulse stream is a few hours, so their 30-day figures were
computed over part of one day; they now read at least the last 30 days.
The general overview read the newest 5000 rows across every type, so a
dense stream crowded the other types out within days; it now reads the
latest 365 of each type with one index descent per type. The sleep summary
in the analytics slice read one year of stage rows, so its all-time count
and its year-ago average fell short; it now walks the whole history in
chunks.
---
src/lib/analytics/sleep-night.ts | 13 ++-
src/lib/analytics/summaries-slice.ts | 44 +++-----
.../insights/__tests__/general-status.test.ts | 20 ++++
.../__tests__/locale-pipeline.test.ts | 20 ++++
.../prompt-soft-delete-filter.test.ts | 7 +-
.../insights/__tests__/pulse-status.test.ts | 5 +-
.../__tests__/recent-readings.test.ts | 61 +++++++++++
src/lib/insights/general-status.ts | 30 ++----
src/lib/insights/metric-status.ts | 17 +--
src/lib/insights/pulse-status.ts | 23 +---
src/lib/insights/recent-readings.ts | 102 ++++++++++++++++++
.../analytics-summaries-slice.test.ts | 31 ++++++
.../insights-recent-readings.test.ts | 64 +++++++++++
13 files changed, 348 insertions(+), 89 deletions(-)
create mode 100644 src/lib/insights/__tests__/recent-readings.test.ts
create mode 100644 src/lib/insights/recent-readings.ts
create mode 100644 tests/integration/insights-recent-readings.test.ts
diff --git a/src/lib/analytics/sleep-night.ts b/src/lib/analytics/sleep-night.ts
index 71d82ee7a..50faedcdf 100644
--- a/src/lib/analytics/sleep-night.ts
+++ b/src/lib/analytics/sleep-night.ts
@@ -1324,9 +1324,16 @@ export function summarizeSleepNights(
tz: string,
priorityJson: unknown = null,
): SleepNightSummary {
- const nights = reconstructSleepNights(rows, tz, priorityJson).filter(
- (n) => n.asleepMinutes > 0,
- );
+ return summarizeNights(reconstructSleepNights(rows, tz, priorityJson));
+}
+
+/**
+ * `summarizeSleepNights` over nights already reconstructed, for a reader
+ * that gets them from the chunked Postgres walk (`readSleepNights`) instead
+ * of holding the stage rows itself.
+ */
+export function summarizeNights(all: readonly SleepNight[]): SleepNightSummary {
+ const nights = all.filter((n) => n.asleepMinutes > 0);
const dataPoints = nights.map((n) => ({
date: n.measuredAt,
value: n.asleepMinutes,
diff --git a/src/lib/analytics/summaries-slice.ts b/src/lib/analytics/summaries-slice.ts
index f489baf44..2d43ab91f 100644
--- a/src/lib/analytics/summaries-slice.ts
+++ b/src/lib/analytics/summaries-slice.ts
@@ -103,17 +103,20 @@ import {
type RollupCoverageMap,
} from "@/lib/rollups/measurement-coverage";
import {
- summarizeSleepNights,
+ summarizeNights,
type SleepSourceDiscrepancy,
- type SleepStageRow,
} from "@/lib/analytics/sleep-night";
import { resolveUserTimezone } from "@/lib/tz/resolver";
+import { readSleepNights } from "@/lib/measurements/list/sleep-nights";
import { userDayKey } from "@/lib/tz/format";
import { localDayWindow } from "@/lib/tz/local-day";
import { canonicalRowsOfDay } from "@/lib/mood/linked-context";
const DAY_MS = 24 * 60 * 60 * 1000;
+/** No account holds this many nights; the walk is uncapped in practice. */
+const ALL_NIGHTS = 100_000;
+
/**
* v1.18.10 — upper bound on the cold-fallback `allTime` aggregate's
* scan window. Sits far beyond any real account's history (the earliest prod
@@ -459,9 +462,10 @@ async function withLatestDayValues(
* summary with one computed over per-night asleep totals (one DataPoint
* per night). The unit stays MINUTES (canonical), so existing consumers
* that already treat the summary as minutes keep working — they now
- * average nights instead of stages, which is strictly more correct. The
- * read is bounded to a year (~5 rows/night) and is skipped entirely when
- * the user has logged no sleep.
+ * average nights instead of stages, which is strictly more correct. It
+ * covers the whole history, like every other type here (a year's cut left
+ * `avg30LastYear` and the all-time count and range short), and is skipped
+ * entirely when the user has logged no sleep.
*/
async function withSleepNightTotals(
userId: string,
@@ -471,32 +475,18 @@ async function withSleepNightTotals(
// No sleep data at all → nothing to recompute (the empty summary stays).
if (!existing || existing.count === 0) return slice;
- const since = new Date(Date.now() - 365 * DAY_MS);
- const rows = (await prisma.measurement.findMany({
- where: {
- userId,
- type: "SLEEP_DURATION",
- deletedAt: null,
- measuredAt: { gte: since },
- },
- orderBy: { measuredAt: "asc" },
- // `deviceType` feeds the writer-level collapse: two HealthKit apps
- // behind one source (watch stages vs phone in-bed) must not blend.
- select: {
- value: true,
- measuredAt: true,
- sleepStage: true,
- source: true,
- deviceType: true,
- },
- })) as SleepStageRow[];
- if (rows.length === 0) return slice;
-
const [tz, priorityJson] = await Promise.all([
resolveUserTimezone(userId),
loadUserSourcePriority(userId),
]);
- const { summary, latestNight } = summarizeSleepNights(rows, tz, priorityJson);
+ // The whole history, as every other type in the slice: the walk names the
+ // nights in Postgres and reconstructs them ninety at a time, so the heap
+ // holds a chunk's rows however long the history is.
+ const nights = await readSleepNights(userId, tz, priorityJson, {
+ maxNights: ALL_NIGHTS,
+ });
+ if (nights.length === 0) return slice;
+ const { summary, latestNight } = summarizeNights(nights);
// Preserve the slope tuples the night summary doesn't compute (the
// dashboard tile reads slope30); recompute them off the night series is
// out of scope here — the per-night `summarize()` already fills slope7 /
diff --git a/src/lib/insights/__tests__/general-status.test.ts b/src/lib/insights/__tests__/general-status.test.ts
index 1c3c74ada..bd44c3aec 100644
--- a/src/lib/insights/__tests__/general-status.test.ts
+++ b/src/lib/insights/__tests__/general-status.test.ts
@@ -14,6 +14,26 @@ vi.mock("@/lib/db", () => ({
},
}));
+// The per-type latest-readings read is one LATERAL query in Postgres; here
+// it is served from the `measurement.findMany` fixtures, newest first as the
+// query orders them, so the suite keeps describing its data as rows.
+vi.mock("@/lib/insights/recent-readings", async (importOriginal) => {
+ const actual =
+ await importOriginal();
+ const { prisma: db } = await import("@/lib/db");
+ return {
+ ...actual,
+ readLatestReadingsPerType: async () =>
+ [
+ ...((await db.measurement.findMany({} as never)) as Array<{
+ type: string;
+ value: number;
+ measuredAt: Date;
+ }>),
+ ].reverse(),
+ };
+});
+
vi.mock("@/lib/insights/status-provider", () => ({
runStatusCompletion: vi.fn(),
}));
diff --git a/src/lib/insights/__tests__/locale-pipeline.test.ts b/src/lib/insights/__tests__/locale-pipeline.test.ts
index 4254c33be..5302d72e2 100644
--- a/src/lib/insights/__tests__/locale-pipeline.test.ts
+++ b/src/lib/insights/__tests__/locale-pipeline.test.ts
@@ -81,6 +81,26 @@ vi.mock("@/lib/db", () => ({
},
}));
+// The per-type latest-readings read is one LATERAL query in Postgres; here
+// it is served from the `measurement.findMany` fixtures, newest first as the
+// query orders them, so the suite keeps describing its data as rows.
+vi.mock("@/lib/insights/recent-readings", async (importOriginal) => {
+ const actual =
+ await importOriginal();
+ const { prisma: db } = await import("@/lib/db");
+ return {
+ ...actual,
+ readLatestReadingsPerType: async () =>
+ [
+ ...((await db.measurement.findMany({} as never)) as Array<{
+ type: string;
+ value: number;
+ measuredAt: Date;
+ }>),
+ ].reverse(),
+ };
+});
+
vi.mock("@/lib/insights/status-provider", () => ({
runStatusCompletion: vi.fn(),
}));
diff --git a/src/lib/insights/__tests__/prompt-soft-delete-filter.test.ts b/src/lib/insights/__tests__/prompt-soft-delete-filter.test.ts
index c26ee7d95..5e7dcf05b 100644
--- a/src/lib/insights/__tests__/prompt-soft-delete-filter.test.ts
+++ b/src/lib/insights/__tests__/prompt-soft-delete-filter.test.ts
@@ -19,9 +19,10 @@ function read(rel: string): string {
describe("prompt reads exclude tombstoned rows", () => {
it("general-status filters its measurement read", () => {
- const src = read("../general-status.ts");
- expect(src).toMatch(
- /measurement\s*\n?\s*\.findMany\(\{\s*where:\s*\{[\s\S]{0,300}?\bdeletedAt:\s*null/,
+ // The overview reads through the per-type latest-readings query.
+ expect(read("../general-status.ts")).toMatch(/readLatestReadingsPerType\(/);
+ expect(read("../recent-readings.ts")).toMatch(
+ /readLatestReadingsPerType[\s\S]*?m\."deleted_at" IS NULL/,
);
});
diff --git a/src/lib/insights/__tests__/pulse-status.test.ts b/src/lib/insights/__tests__/pulse-status.test.ts
index 6965abddc..e593ba9b8 100644
--- a/src/lib/insights/__tests__/pulse-status.test.ts
+++ b/src/lib/insights/__tests__/pulse-status.test.ts
@@ -185,8 +185,11 @@ describe("generatePulseStatusForUser — A2 resting-target in-target %", () => {
dateOfBirth: null,
gender: null,
} as never);
- // pulse-status reads PULSE first, then RESTING_HEART_RATE.
+ // pulse-status reads PULSE first, then RESTING_HEART_RATE. The latest
+ // 365 PULSE readings reach back less than 30 days here, so the PULSE
+ // read takes the 30-day window as a second query.
vi.mocked(prisma.measurement.findMany)
+ .mockResolvedValueOnce(pulseRecords as never)
.mockResolvedValueOnce(pulseRecords as never)
.mockResolvedValueOnce(restingRecords as never)
.mockResolvedValue([] as never);
diff --git a/src/lib/insights/__tests__/recent-readings.test.ts b/src/lib/insights/__tests__/recent-readings.test.ts
new file mode 100644
index 000000000..4cb16aa6d
--- /dev/null
+++ b/src/lib/insights/__tests__/recent-readings.test.ts
@@ -0,0 +1,61 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const { findMany, annotate } = vi.hoisted(() => ({
+ findMany: vi.fn(),
+ annotate: vi.fn(),
+}));
+vi.mock("@/lib/db", () => ({ prisma: { measurement: { findMany } } }));
+vi.mock("@/lib/logging/context", () => ({ annotate }));
+
+import { readRecentReadings, RECENT_READINGS } from "../recent-readings";
+
+const now = new Date("2026-10-20T12:00:00Z");
+const minutesAgo = (n: number) => new Date(now.getTime() - n * 60_000);
+
+beforeEach(() => {
+ findMany.mockReset();
+ annotate.mockReset();
+});
+
+describe("readRecentReadings", () => {
+ it("keeps the latest 365 when they reach back 30 days", async () => {
+ // One a day: 365 readings span a year.
+ const rows = Array.from({ length: RECENT_READINGS }, (_, i) => ({
+ value: 70,
+ measuredAt: minutesAgo(i * 1440),
+ }));
+ findMany.mockResolvedValueOnce(rows);
+ const out = await readRecentReadings("u1", "PULSE", now);
+ expect(findMany).toHaveBeenCalledTimes(1);
+ expect(out[0].measuredAt < out[out.length - 1].measuredAt).toBe(true);
+ });
+
+ it("reads the last 30 days by time when 365 readings are a few hours", async () => {
+ const dense = Array.from({ length: RECENT_READINGS }, (_, i) => ({
+ value: 70,
+ measuredAt: minutesAgo(i),
+ }));
+ const month = Array.from({ length: 30 * 24 }, (_, i) => ({
+ value: 70,
+ measuredAt: minutesAgo(i * 60),
+ }));
+ findMany.mockResolvedValueOnce(dense).mockResolvedValueOnce(month);
+ const out = await readRecentReadings("u1", "PULSE", now);
+ expect(findMany).toHaveBeenCalledTimes(2);
+ const window = findMany.mock.calls[1][0] as {
+ where: { measuredAt: { gte: Date }; deletedAt: null };
+ };
+ expect(window.where.deletedAt).toBeNull();
+ expect(window.where.measuredAt.gte.toISOString()).toBe(
+ "2026-09-20T12:00:00.000Z",
+ );
+ expect(out).toHaveLength(month.length);
+ expect(annotate).not.toHaveBeenCalled();
+ });
+
+ it("keeps fewer than 365 readings whole", async () => {
+ findMany.mockResolvedValueOnce([{ value: 1, measuredAt: minutesAgo(5) }]);
+ expect(await readRecentReadings("u1", "PULSE", now)).toHaveLength(1);
+ expect(findMany).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/lib/insights/general-status.ts b/src/lib/insights/general-status.ts
index 0c69dfd90..1bb525a4e 100644
--- a/src/lib/insights/general-status.ts
+++ b/src/lib/insights/general-status.ts
@@ -1,5 +1,6 @@
import { prisma } from "@/lib/db";
import { readRecentMoodScores } from "@/lib/mood/recent-scores";
+import { readLatestReadingsPerType } from "@/lib/insights/recent-readings";
import {
getGeneralStatusSystemPrompt,
getGeneralStatusUserPrompt,
@@ -53,7 +54,7 @@ import { TRACKED_INTAKE_EVENT_WHERE } from "@/lib/medications/intake-tracking";
const MEASUREMENT_TYPES = measurementTypeEnum.options;
// The general overview folds its per-type graded series in memory from a
-// single bounded read (take 5000, desc) rather than per-type rollup-tier
+// single bounded read (the latest 365 per type) rather than per-type rollup-tier
// reads: a many-metric account would otherwise fan out ~15 metrics × 3
// rollup round-trips per render. `degradeStatusSnapshotToBudget` already
// caps the multi-metric snapshot, so the bounded in-memory fold is the
@@ -169,28 +170,11 @@ export async function prepareGeneralStatusForUser(
},
});
- // v1.4.28 — cap the snapshot input. General-status pulls
- // every supported measurement type; without a cap the read scales
- // linearly with account density.
- const measurements = await prisma.measurement
- .findMany({
- where: {
- userId,
- type: {
- in: [...MEASUREMENT_TYPES],
- },
- // Soft-deleted rows must never reach the prompt snapshot.
- deletedAt: null,
- },
- orderBy: { measuredAt: "desc" },
- take: 5000,
- select: {
- type: true,
- value: true,
- measuredAt: true,
- },
- })
- .then((rows) => rows.reverse());
+ // The latest 365 readings of each type, bounded per type so a dense
+ // stream cannot crowd the others out (`readLatestReadingsPerType`).
+ const measurements = await readLatestReadingsPerType(userId, [
+ ...MEASUREMENT_TYPES,
+ ]);
const now = new Date();
diff --git a/src/lib/insights/metric-status.ts b/src/lib/insights/metric-status.ts
index 3d6168610..69c1aff79 100644
--- a/src/lib/insights/metric-status.ts
+++ b/src/lib/insights/metric-status.ts
@@ -22,6 +22,7 @@
* the card's insufficient-data state.
*/
import { prisma } from "@/lib/db";
+import { readRecentReadings } from "@/lib/insights/recent-readings";
import {
getMetricArchetypeSystemPrompt,
getMetricArchetypeUserPrompt,
@@ -293,7 +294,7 @@ export async function generateMetricStatus(args: {
let measurements: Array<{ measuredAt: Date }> = [];
if (isSleep) {
// The latest 365 nights, the sleep twin of the 365 latest readings the
- // other types read. A cut on stage rows instead (2190 of them) reached
+ // other types read (`readRecentReadings`). A cut on stage rows instead (2190 of them) reached
// back a few weeks on a stage-rich watch and began mid-night.
const nights = (
await readSleepNights(
@@ -308,18 +309,8 @@ export async function generateMetricStatus(args: {
}
measurements = nights.map((n) => ({ measuredAt: n.measuredAt }));
} else {
- const rows = await prisma.measurement
- .findMany({
- where: {
- userId: args.userId,
- type: meta.measurementType,
- deletedAt: null,
- },
- orderBy: { measuredAt: "desc" },
- take: 365,
- select: { value: true, measuredAt: true },
- })
- .then((r) => r.reverse());
+ // The latest 365 readings, never less than the last 30 days.
+ const rows = await readRecentReadings(args.userId, meta.measurementType);
for (const m of rows) {
points.push({
measuredAt: m.measuredAt,
diff --git a/src/lib/insights/pulse-status.ts b/src/lib/insights/pulse-status.ts
index 2cd31d862..3f7dfee82 100644
--- a/src/lib/insights/pulse-status.ts
+++ b/src/lib/insights/pulse-status.ts
@@ -1,5 +1,6 @@
import { prisma } from "@/lib/db";
import { readRecentMoodScores } from "@/lib/mood/recent-scores";
+import { readRecentReadings } from "@/lib/insights/recent-readings";
import {
getPulseSystemPrompt,
getPulseUserPrompt,
@@ -185,25 +186,9 @@ export async function preparePulseStatusForUser(
// estimate for non-Berlin users. `userTz` was already resolved above for
// the cache day-key.
- // v1.4.28 — cap the snapshot input. The downstream
- // `applyPayloadBudget` trims further but the unbounded findMany was
- // pulling tens of thousands of rows for Apple-Health-rich accounts
- // before the budget call even started.
- const measurements = await prisma.measurement
- .findMany({
- where: {
- userId,
- type: "PULSE",
- deletedAt: null,
- },
- orderBy: { measuredAt: "desc" },
- take: 365,
- select: {
- value: true,
- measuredAt: true,
- },
- })
- .then((rows) => rows.reverse());
+ // The latest 365 readings, and never less than the last 30 days: a dense
+ // pulse stream holds 365 readings in a few hours (`readRecentReadings`).
+ const measurements = await readRecentReadings(userId, "PULSE");
// v1.15.12 — the RESTING band is judged against the RESTING series,
// not the raw PULSE stream (Apple fills PULSE with workout HR). Pull
diff --git a/src/lib/insights/recent-readings.ts b/src/lib/insights/recent-readings.ts
new file mode 100644
index 000000000..f7761440e
--- /dev/null
+++ b/src/lib/insights/recent-readings.ts
@@ -0,0 +1,102 @@
+/**
+ * The raw readings a per-metric status snapshot reads: the latest 365, and
+ * never less than the last 30 days.
+ *
+ * The snapshots used to read `take: 365` rows. For a type read a few times a
+ * day that reaches back months; for a dense stream (a watch's pulse every
+ * few minutes, hourly folds of an imported history) it reached back hours,
+ * and the "last 30 days" figures built on it (in-target share, the resting
+ * proxy, the latest day against the one before) were computed over a sliver
+ * of one day. When the latest 365 readings do not reach back 30 days, the
+ * last 30 days are read by time instead, bounded by {@link DENSE_READ_CAP}
+ * so a pathological stream cannot take the heap; hitting the bound is
+ * annotated, never silent. The longer tail of every snapshot comes from the
+ * rollup tier, not from here.
+ */
+import type { MeasurementType } from "@/generated/prisma/client";
+import { prisma } from "@/lib/db";
+import { annotate } from "@/lib/logging/context";
+
+export const RECENT_READINGS = 365;
+export const RECENT_MIN_DAYS = 30;
+/** A reading a minute for 30 days is 43 200; this is above any real stream. */
+export const DENSE_READ_CAP = 60_000;
+
+const DAY_MS = 24 * 60 * 60 * 1000;
+
+export interface RecentReading {
+ value: number;
+ measuredAt: Date;
+}
+
+/** Oldest first. */
+export async function readRecentReadings(
+ userId: string,
+ type: MeasurementType,
+ now: Date = new Date(),
+): Promise {
+ const latest = await prisma.measurement.findMany({
+ where: { userId, type, deletedAt: null },
+ orderBy: { measuredAt: "desc" },
+ take: RECENT_READINGS,
+ select: { value: true, measuredAt: true },
+ });
+ const since = new Date(now.getTime() - RECENT_MIN_DAYS * DAY_MS);
+ const oldest = latest.at(-1);
+ if (
+ latest.length < RECENT_READINGS ||
+ !oldest ||
+ oldest.measuredAt <= since
+ ) {
+ return latest.reverse();
+ }
+ const window = await prisma.measurement.findMany({
+ where: { userId, type, deletedAt: null, measuredAt: { gte: since } },
+ orderBy: { measuredAt: "desc" },
+ take: DENSE_READ_CAP,
+ select: { value: true, measuredAt: true },
+ });
+ if (window.length === DENSE_READ_CAP) {
+ annotate({
+ action: { name: "insights.recent-readings.capped" },
+ meta: { type, cap: DENSE_READ_CAP },
+ });
+ }
+ return window.reverse();
+}
+
+/**
+ * The latest `perType` readings of every type in `types`, oldest first, for
+ * the multi-metric overview. It read the newest 5 000 rows across all types
+ * at once, so a dense stream (pulse every few minutes) filled the whole read
+ * within days and every other type fell out of the overview. One index
+ * descent per type now bounds each type on its own.
+ */
+export async function readLatestReadingsPerType(
+ userId: string,
+ types: readonly MeasurementType[],
+ perType: number = RECENT_READINGS,
+): Promise> {
+ if (types.length === 0) return [];
+ const rows = await prisma.$queryRaw<
+ Array<{ type: MeasurementType; value: number; measured_at: Date }>
+ >`
+ SELECT t."type" AS "type", x."value", x."measured_at"
+ FROM unnest(${[...types]}::text[]) AS t("type")
+ CROSS JOIN LATERAL (
+ SELECT m."value", m."measured_at"
+ FROM measurements m
+ WHERE m."user_id" = ${userId}
+ AND m."type" = t."type"::measurement_type
+ AND m."deleted_at" IS NULL
+ ORDER BY m."measured_at" DESC
+ LIMIT ${perType}
+ ) x
+ ORDER BY x."measured_at" ASC
+ `;
+ return rows.map((r) => ({
+ type: r.type,
+ value: Number(r.value),
+ measuredAt: new Date(r.measured_at),
+ }));
+}
diff --git a/tests/integration/analytics-summaries-slice.test.ts b/tests/integration/analytics-summaries-slice.test.ts
index 244c2f98a..7ff10e366 100644
--- a/tests/integration/analytics-summaries-slice.test.ts
+++ b/tests/integration/analytics-summaries-slice.test.ts
@@ -185,6 +185,37 @@ describe("GET /api/analytics?slice=summaries (C1)", () => {
expect(envelope.data.summaries.PULSE.count).toBe(0);
});
+ it("summarises sleep over the whole history, not the last year", async () => {
+ const prisma = getPrismaClient();
+ const user = await seedSession("slim-sleep-history");
+ const DAY_MS = 24 * 60 * 60 * 1000;
+ const morning = new Date();
+ morning.setUTCHours(6, 0, 0, 0);
+ // One 7 h night every third day for two years.
+ const nights = Array.from({ length: 244 }, (_, i) => ({
+ userId: user.id,
+ type: "SLEEP_DURATION" as const,
+ value: 420,
+ unit: "minutes",
+ source: "APPLE_HEALTH" as const,
+ measuredAt: new Date(morning.getTime() - i * 3 * DAY_MS),
+ externalId: `uuid-slice-night-${i}`,
+ sleepStage: "CORE" as const,
+ }));
+ await prisma.measurement.createMany({ data: nights });
+
+ const { GET } = await import("@/app/api/analytics/route");
+ const response = await (
+ GET as unknown as (req: Request) => Promise
+ )(new Request("http://localhost/api/analytics?slice=summaries"));
+ expect(response.status).toBe(200);
+ const sleep = ((await response.json()) as SlimEnvelope).data.summaries
+ .SLEEP_DURATION;
+ // Every night counts, and the window a year back has nights in it.
+ expect(sleep.count).toBe(244);
+ expect(sleep.avg30LastYear).toBe(420);
+ });
+
it("default slice still carries the thick envelope (additive contract)", async () => {
await seedSession("slim-default-still-thick");
diff --git a/tests/integration/insights-recent-readings.test.ts b/tests/integration/insights-recent-readings.test.ts
new file mode 100644
index 000000000..3fa334a9d
--- /dev/null
+++ b/tests/integration/insights-recent-readings.test.ts
@@ -0,0 +1,64 @@
+/**
+ * The overview's per-type latest-readings query against Postgres: a dense
+ * stream is bounded on its own and cannot crowd a sparse type out, deleted
+ * rows stay out, and the rows come back oldest first.
+ */
+import { beforeEach, describe, expect, it } from "vitest";
+
+import { getPrismaClient, truncateAllTables } from "./setup";
+import { readLatestReadingsPerType } from "@/lib/insights/recent-readings";
+
+beforeEach(async () => {
+ await truncateAllTables(getPrismaClient());
+});
+
+describe("readLatestReadingsPerType", () => {
+ it("bounds each type on its own", async () => {
+ const prisma = getPrismaClient();
+ const user = await prisma.user.create({
+ data: { username: "recent-per-type", email: "rpt@example.test" },
+ });
+ const now = Date.now();
+ const base = {
+ userId: user.id,
+ source: "APPLE_HEALTH" as const,
+ };
+ await prisma.measurement.createMany({
+ data: [
+ // 6 000 pulse readings a minute apart: a few days of a watch.
+ ...Array.from({ length: 6000 }, (_, i) => ({
+ ...base,
+ type: "PULSE" as const,
+ value: 60 + (i % 20),
+ unit: "bpm",
+ measuredAt: new Date(now - i * 60_000),
+ })),
+ // Weight once a week for a year, the newest one deleted.
+ ...Array.from({ length: 52 }, (_, i) => ({
+ ...base,
+ source: "MANUAL" as const,
+ type: "WEIGHT" as const,
+ value: 80,
+ unit: "kg",
+ measuredAt: new Date(now - i * 7 * 86_400_000),
+ deletedAt: i === 0 ? new Date() : null,
+ })),
+ ],
+ });
+
+ const rows = await readLatestReadingsPerType(
+ user.id,
+ ["PULSE", "WEIGHT", "BODY_FAT"],
+ 365,
+ );
+ const byType = (t: string) => rows.filter((r) => r.type === t);
+ expect(byType("PULSE")).toHaveLength(365);
+ expect(byType("WEIGHT")).toHaveLength(51);
+ expect(byType("BODY_FAT")).toHaveLength(0);
+ for (let i = 1; i < rows.length; i += 1) {
+ expect(rows[i].measuredAt >= rows[i - 1].measuredAt).toBe(true);
+ }
+ // The newest pulse reading is in, the oldest of the 6 000 is not.
+ expect(byType("PULSE").at(-1)!.measuredAt.getTime()).toBe(now);
+ });
+});
From 071d23537b98d122f2fee9b65f36438e36002894 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Tue, 20 Oct 2026 09:48:30 +0200
Subject: [PATCH 11/13] fix(insights): read the mood fallback over the rollup's
window
While the mood rollup tier is still empty the feature block falls back to
raw entries, and that fallback read one year where the rollup read five,
so the same block described a different history until the tier caught up.
---
src/lib/insights/__tests__/features.test.ts | 19 +++++++++++++++++++
src/lib/insights/features.ts | 14 ++++++--------
2 files changed, 25 insertions(+), 8 deletions(-)
diff --git a/src/lib/insights/__tests__/features.test.ts b/src/lib/insights/__tests__/features.test.ts
index 0f582b50c..82480c191 100644
--- a/src/lib/insights/__tests__/features.test.ts
+++ b/src/lib/insights/__tests__/features.test.ts
@@ -703,3 +703,22 @@ describe("extractFeatures — blood-pressure spread", () => {
expect(features.bloodPressure?.sdSys30).toBe(8.2);
});
});
+
+describe("extractFeatures — mood fallback window", () => {
+ it("reads the raw fallback over the rollup's five years, not one", async () => {
+ // Empty rollup tier: the raw fallback answers.
+ prismaMock.moodEntryRollup.findMany.mockResolvedValue([]);
+ await extractFeatures("user-mood-window", false, {
+ only: new Set(["mood"]),
+ });
+ const call = prismaMock.moodEntry.findMany.mock.calls.find(
+ ([args]) =>
+ (args as { where?: { moodLoggedAt?: unknown } })?.where?.moodLoggedAt,
+ );
+ expect(call).toBeDefined();
+ const since = (call![0] as { where: { moodLoggedAt: { gte: Date } } }).where
+ .moodLoggedAt.gte;
+ const years = (Date.now() - since.getTime()) / (365 * dayMs);
+ expect(years).toBeGreaterThan(4.9);
+ });
+});
diff --git a/src/lib/insights/features.ts b/src/lib/insights/features.ts
index a13f3b384..4c52bcd09 100644
--- a/src/lib/insights/features.ts
+++ b/src/lib/insights/features.ts
@@ -1021,7 +1021,7 @@ async function readMoodFeature(
//
// Coverage-fallback: when raw entries exist but the rollup tier is
// still empty (legacy account before boot-time backfill caught up),
- // we fall back to a bounded 1-year raw walk and fire the warm-up so
+ // we fall back to a raw walk over the same window and fire the warm-up so
// the next request lands on the rollup tier. Same posture as
// `/api/mood/analytics`.
void ensureUserMoodRollupsFresh(userId);
@@ -1062,15 +1062,13 @@ async function readMoodFeature(
});
moodLatestScore = latestEntry?.score ?? null;
} else {
- // Coverage-fallback. Bounded 1-year walk (instead of the legacy
- // unbounded `findMany`) so even a cache miss is capped.
- const oneYearAgo = laterDate(
- new Date(Date.now() - 365 * 24 * 60 * 60 * 1000),
- historyFloor,
- );
+ // Coverage-fallback over the same window the rollup read covers
+ // (`moodSince`, five years). A one-year fallback answered the same
+ // question with a different history while the tier was warming up;
+ // mood entries are a few a day, so five years stays a bounded read.
const moodEntriesRaw = await prisma.moodEntry.findMany({
// v1.7.0 sync — exclude tombstoned rows.
- where: { userId, deletedAt: null, moodLoggedAt: { gte: oneYearAgo } },
+ where: { userId, deletedAt: null, moodLoggedAt: { gte: moodSince } },
orderBy: { moodLoggedAt: "asc" },
select: { score: true, moodLoggedAt: true, date: true },
});
From be79603d53fd07b9ef5b5282f6d6d2ddf298c7e3 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Tue, 20 Oct 2026 10:01:30 +0200
Subject: [PATCH 12/13] chore(guards): give the linear-fit guard a match floor
and keep SQL out of the i18n scan
The linear-fit guard floored only its walk; it now also requires the
implementation's call sites it expects. The per-type readings query
aliased its driving relation t, which the i18n call-site scanner read as a
translation call; it is mt now, as in readLatestEver.
---
.../linear-fit-single-implementation-guard.test.ts | 8 ++++++++
src/lib/insights/recent-readings.ts | 10 ++++++----
2 files changed, 14 insertions(+), 4 deletions(-)
diff --git a/src/__tests__/linear-fit-single-implementation-guard.test.ts b/src/__tests__/linear-fit-single-implementation-guard.test.ts
index b1ee601f5..424ca3b43 100644
--- a/src/__tests__/linear-fit-single-implementation-guard.test.ts
+++ b/src/__tests__/linear-fit-single-implementation-guard.test.ts
@@ -57,6 +57,14 @@ describe("one least-squares line", () => {
),
);
expect(offenders).toEqual([]);
+ // The six readers now call the implementation: a sweep that finds fewer
+ // call sites read the wrong tree or lost one.
+ const callers = files.filter((file) =>
+ /\b(?:linearFit|fitFromSums|trendLineByTime)\s*\(/.test(
+ stripComments(readFileSync(join(SRC, file), "utf8")),
+ ),
+ );
+ expect(callers.length).toBeGreaterThanOrEqual(7);
// The implementation itself matches, so the sweep read real files.
expect(
HAND_ROLLED_SLOPE.test(
diff --git a/src/lib/insights/recent-readings.ts b/src/lib/insights/recent-readings.ts
index f7761440e..3d210cf98 100644
--- a/src/lib/insights/recent-readings.ts
+++ b/src/lib/insights/recent-readings.ts
@@ -70,7 +70,9 @@ export async function readRecentReadings(
* the multi-metric overview. It read the newest 5 000 rows across all types
* at once, so a dense stream (pulse every few minutes) filled the whole read
* within days and every other type fell out of the overview. One index
- * descent per type now bounds each type on its own.
+ * descent per type now bounds each type on its own. The driving relation is
+ * aliased `mt`, the way `readLatestEver` aliases it, so the i18n call-site
+ * scanner does not read the SQL as a translation call.
*/
export async function readLatestReadingsPerType(
userId: string,
@@ -81,13 +83,13 @@ export async function readLatestReadingsPerType(
const rows = await prisma.$queryRaw<
Array<{ type: MeasurementType; value: number; measured_at: Date }>
>`
- SELECT t."type" AS "type", x."value", x."measured_at"
- FROM unnest(${[...types]}::text[]) AS t("type")
+ SELECT mt."type" AS "type", x."value", x."measured_at"
+ FROM unnest(${[...types]}::text[]) AS mt("type")
CROSS JOIN LATERAL (
SELECT m."value", m."measured_at"
FROM measurements m
WHERE m."user_id" = ${userId}
- AND m."type" = t."type"::measurement_type
+ AND m."type" = mt."type"::measurement_type
AND m."deleted_at" IS NULL
ORDER BY m."measured_at" DESC
LIMIT ${perType}
From dc5120b59a504a8f1871846ffe2d18520d9dede1 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Tue, 20 Oct 2026 10:04:11 +0200
Subject: [PATCH 13/13] feat(lists): read and show the list cap on every capped
list
Twelve routes answer a capped list with meta.truncated / meta.limit on
the envelope, and the web client read only data, so no surface said a
list had stopped short. apiGetList / apiPostList are now the one client
reader of the cap and ListCapNotice the one line that shows it; every
capped list on the web reads its cap and shows the notice under the list,
the paged readers (side effects, illness episodes) stop on the route's
answer instead of a full-page guess, and the ECG list moves its five
readers onto one hook. The record activity feed sent its cap twice, in
data and on the envelope; it rides the envelope alone now. A guard
derives the capped routes from the tree and binds each to a client that
reads its cap.
---
docs/api/openapi.yaml | 9 +-
messages/de.json | 4 +-
messages/en.json | 4 +-
messages/es.json | 4 +-
messages/fr.json | 4 +-
messages/it.json | 4 +-
messages/ko.json | 4 +-
messages/nl.json | 4 +-
messages/pl.json | 4 +-
src/__tests__/list-cap-reader-guard.test.ts | 117 ++++++++++++++++++
src/app/api/account/activity/route.ts | 12 +-
.../__tests__/ecg-cross-link-mount.test.tsx | 7 +-
src/app/insights/catalog/page.tsx | 9 +-
src/app/insights/ecg/__tests__/page.test.tsx | 2 +-
src/app/insights/ecg/page.tsx | 11 +-
src/components/cycle/cycle-view.tsx | 9 +-
src/components/cycle/use-cycle.ts | 4 +-
.../use-illness-episodes-paging.test.ts | 28 ++++-
src/components/illness/use-illness.ts | 17 ++-
.../__tests__/ecg-cross-link.test.tsx | 4 +-
.../insights/__tests__/ecg-section.test.tsx | 20 ++-
.../__tests__/rhythm-events-card.test.tsx | 5 +-
src/components/insights/ecg-cross-link.tsx | 30 +----
src/components/insights/ecg-section.tsx | 28 ++---
.../insights/insights-layout-shell.tsx | 9 +-
.../insights/rhythm-events-card.tsx | 15 ++-
src/components/insights/use-ecg-list.ts | 37 ++++++
.../__tests__/side-effects-section.test.tsx | 25 +++-
.../medications/side-effects-section.tsx | 16 ++-
.../__tests__/mental-wellbeing.test.tsx | 3 +-
.../mental-health/mental-wellbeing.tsx | 9 +-
.../__tests__/allergy-manager.test.tsx | 3 +-
.../__tests__/family-history-manager.test.tsx | 3 +-
src/components/records/allergy-manager.tsx | 20 ++-
.../records/family-history-manager.tsx | 20 ++-
.../__tests__/privacy-section.test.tsx | 10 +-
.../access/__tests__/access-panel.test.tsx | 43 +++++--
.../settings/access/grants-given-card.tsx | 2 +
.../settings/access/grants-received-card.tsx | 2 +
.../settings/access/record-activity-card.tsx | 19 +--
.../settings/security-activity-card.tsx | 16 ++-
.../ui/__tests__/list-cap-notice.test.tsx | 36 ++++++
src/components/ui/list-cap-notice.tsx | 30 +++++
src/lib/api/api-fetch.ts | 30 +++++
src/lib/api/list-cap-shape.ts | 22 ++++
src/lib/api/list-cap.ts | 14 +--
src/lib/openapi/routes/account-sharing.ts | 7 +-
src/lib/queries/use-account-grants.ts | 35 ++++--
tests/integration/sharing-audit-actor.test.ts | 8 +-
49 files changed, 574 insertions(+), 204 deletions(-)
create mode 100644 src/__tests__/list-cap-reader-guard.test.ts
create mode 100644 src/components/insights/use-ecg-list.ts
create mode 100644 src/components/ui/__tests__/list-cap-notice.test.tsx
create mode 100644 src/components/ui/list-cap-notice.tsx
create mode 100644 src/lib/api/list-cap-shape.ts
diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index 89de3e04e..ee80675e3 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -21436,7 +21436,8 @@ paths:
per day with a count, rather than one per request. `retentionDays` rides the response because audit history is
purged on a schedule the operator configures: a view that showed the rows without the window would imply a
completeness it does not have, and clients must state the number the server sends rather than assume the 365-day
- default. Not delegable: a grant is not an introduction to the other people in the household."
+ default. Whether the list stopped at its row ceiling is `meta.truncated` (it was a `truncated` field in `data`
+ before v1.43.7). Not delegable: a grant is not an introduction to the other people in the household."
responses:
"200":
description: The activity, and how far back it can see.
@@ -81390,15 +81391,9 @@ components:
description: How far back this feed can see, in days. Resolved from the instance's audit retention setting (365 by
default, operator-configurable), NOT a constant — an instance running a 90-day window sends 90. The query is
bounded to this window, so it is the real limit of the list and not only a caption. Show it beside the rows.
- truncated:
- type: boolean
- description: True when the read reached its row ceiling, so `entries` is the most recent activity inside the window
- rather than all of it. A client must say so — a list that stops at its own cap in silence lets the oldest
- row read as the beginning. False means the list is everything inside the window.
required:
- entries
- retentionDays
- - truncated
additionalProperties: false
description: The owner's record of what somebody else did in their record, bounded by the instance's audit retention.
RecordActivityEntry:
diff --git a/messages/de.json b/messages/de.json
index b2f45cd89..a71da0818 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -1,5 +1,6 @@
{
"common": {
+ "listCapped": "Hier stehen die letzten {count}; ältere Einträge sind nicht aufgeführt.",
"save": "Speichern",
"conflictReloaded": "Vom Server neu geladen. Bitte nimm deine Änderung noch einmal vor.",
"cancel": "Abbrechen",
@@ -12327,8 +12328,7 @@
"loadError": "Die Aktivität konnte nicht geladen werden.",
"opened": "{name} hat deine Akte {count}× geöffnet",
"acted": "{name} hat etwas in deiner Akte geändert",
- "deletedAccount": "Ein gelöschtes Konto",
- "truncated": "Diese Liste zeigt die neueste Aktivität, nicht die gesamte."
+ "deletedAccount": "Ein gelöschtes Konto"
},
"delegate": {
"addOnlyNote": "Du kannst zu dieser Akte etwas hinzufügen. Einträge ändern oder löschen kann nur {name}."
diff --git a/messages/en.json b/messages/en.json
index b24c7614c..98a9f9034 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -1,5 +1,6 @@
{
"common": {
+ "listCapped": "Showing the latest {count}; older entries are not listed here.",
"save": "Save",
"conflictReloaded": "Reloaded from the server. Please make your change again.",
"cancel": "Cancel",
@@ -12327,8 +12328,7 @@
"loadError": "The activity could not be loaded.",
"opened": "{name} opened your record {count}×",
"acted": "{name} made a change in your record",
- "deletedAccount": "A deleted account",
- "truncated": "This list shows the most recent activity, not all of it."
+ "deletedAccount": "A deleted account"
},
"delegate": {
"addOnlyNote": "You can add to this record. Changing or removing entries is something only {name} can do."
diff --git a/messages/es.json b/messages/es.json
index ad0911981..e601d625c 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -1,5 +1,6 @@
{
"common": {
+ "listCapped": "Se muestran los últimos {count}; las entradas anteriores no aparecen aquí.",
"save": "Guardar",
"conflictReloaded": "Recargado desde el servidor. Vuelve a aplicar tu cambio.",
"cancel": "Cancelar",
@@ -12327,8 +12328,7 @@
"loadError": "No se pudo cargar la actividad.",
"opened": "{name} abrió tu historial {count}×",
"acted": "{name} hizo un cambio en tu historial",
- "deletedAccount": "Una cuenta eliminada",
- "truncated": "Esta lista muestra la actividad más reciente, no toda."
+ "deletedAccount": "Una cuenta eliminada"
},
"delegate": {
"addOnlyNote": "Puedes añadir cosas a este historial. Modificar o borrar entradas solo lo puede hacer {name}."
diff --git a/messages/fr.json b/messages/fr.json
index c646bb00d..44e6024ed 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -1,5 +1,6 @@
{
"common": {
+ "listCapped": "Seuls les {count} derniers sont affichés ; les entrées plus anciennes ne figurent pas ici.",
"save": "Enregistrer",
"conflictReloaded": "Rechargé depuis le serveur. Refais ta modification.",
"cancel": "Annuler",
@@ -12327,8 +12328,7 @@
"loadError": "L'activité n'a pas pu être chargée.",
"opened": "{name} a ouvert votre dossier {count}×",
"acted": "{name} a fait une modification dans votre dossier",
- "deletedAccount": "Un compte supprimé",
- "truncated": "Cette liste montre l'activité la plus récente, pas la totalité."
+ "deletedAccount": "Un compte supprimé"
},
"delegate": {
"addOnlyNote": "Vous pouvez ajouter des éléments à ce dossier. Seul {name} peut modifier ou supprimer des entrées."
diff --git a/messages/it.json b/messages/it.json
index ed4265878..80fdced4b 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -1,5 +1,6 @@
{
"common": {
+ "listCapped": "Sono mostrati gli ultimi {count}; le voci più vecchie non sono elencate qui.",
"save": "Salva",
"conflictReloaded": "Ricaricato dal server. Applica di nuovo la tua modifica.",
"cancel": "Annulla",
@@ -12327,8 +12328,7 @@
"loadError": "Non è stato possibile caricare l'attività.",
"opened": "{name} ha aperto la tua cartella {count}×",
"acted": "{name} ha fatto una modifica nella tua cartella",
- "deletedAccount": "Un account eliminato",
- "truncated": "Questo elenco mostra l'attività più recente, non tutta."
+ "deletedAccount": "Un account eliminato"
},
"delegate": {
"addOnlyNote": "Puoi aggiungere voci a questa cartella. Solo {name} può modificarle o eliminarle."
diff --git a/messages/ko.json b/messages/ko.json
index a4a77cdba..d224ba278 100644
--- a/messages/ko.json
+++ b/messages/ko.json
@@ -1,5 +1,6 @@
{
"common": {
+ "listCapped": "최근 {count}개만 표시되며, 이전 항목은 여기에 나오지 않습니다.",
"save": "저장",
"conflictReloaded": "서버에서 다시 불러왔어요. 변경 내용을 다시 입력해 주세요.",
"cancel": "취소",
@@ -12327,8 +12328,7 @@
"loadError": "활동 내역을 불러오지 못했어요.",
"opened": "{name} 님이 내 기록을 {count}번 열었어요",
"acted": "{name} 님이 내 기록을 수정했어요",
- "deletedAccount": "삭제된 계정",
- "truncated": "이 목록은 최근 활동만 보여줘요. 전부는 아니에요."
+ "deletedAccount": "삭제된 계정"
},
"delegate": {
"addOnlyNote": "이 기록에 추가할 수 있어요. 수정과 삭제는 {name} 님만 할 수 있어요."
diff --git a/messages/nl.json b/messages/nl.json
index 5ea71537a..b6dd5fe1e 100644
--- a/messages/nl.json
+++ b/messages/nl.json
@@ -1,5 +1,6 @@
{
"common": {
+ "listCapped": "Alleen de laatste {count} worden getoond; oudere items staan hier niet.",
"save": "Opslaan",
"conflictReloaded": "Opnieuw geladen van de server. Voer je wijziging opnieuw uit.",
"cancel": "Annuleren",
@@ -12327,8 +12328,7 @@
"loadError": "De activiteit kon niet worden geladen.",
"opened": "{name} heeft je dossier {count}× geopend",
"acted": "{name} heeft iets gewijzigd in je dossier",
- "deletedAccount": "Een verwijderd account",
- "truncated": "Deze lijst toont de meest recente activiteit, niet alles."
+ "deletedAccount": "Een verwijderd account"
},
"delegate": {
"addOnlyNote": "Je kunt iets toevoegen aan dit dossier. Registraties wijzigen of verwijderen kan alleen {name}."
diff --git a/messages/pl.json b/messages/pl.json
index b41dfa858..5cd38bb74 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -1,5 +1,6 @@
{
"common": {
+ "listCapped": "Widać tylko ostatnie {count}; starsze wpisy nie są tu wymienione.",
"save": "Zapisz",
"conflictReloaded": "Odświeżono z serwera. Wprowadź zmianę jeszcze raz.",
"cancel": "Anuluj",
@@ -12327,8 +12328,7 @@
"loadError": "Nie udało się wczytać aktywności.",
"opened": "{name} otworzył twoją kartę {count}×",
"acted": "{name} wprowadził zmianę w twojej karcie",
- "deletedAccount": "Usunięte konto",
- "truncated": "Ta lista pokazuje najnowszą aktywność, a nie całą."
+ "deletedAccount": "Usunięte konto"
},
"delegate": {
"addOnlyNote": "Możesz dodawać wpisy do tej karty. Zmieniać je i usuwać może tylko {name}."
diff --git a/src/__tests__/list-cap-reader-guard.test.ts b/src/__tests__/list-cap-reader-guard.test.ts
new file mode 100644
index 000000000..3c6752832
--- /dev/null
+++ b/src/__tests__/list-cap-reader-guard.test.ts
@@ -0,0 +1,117 @@
+/**
+ * Structural guard: every list the server caps has a client that reads the
+ * cap.
+ *
+ * A capped list says so on the envelope (`apiListSuccess`, `meta.truncated`
+ * / `meta.limit`). The web client read only `data` (`apiGet`), so the signal
+ * reached nobody: twelve routes sent it and no surface showed it. A capped
+ * route is now bound to a reader, a production file outside the API tree
+ * that fetches the route's path through `apiGetList` / `apiPostList`, the
+ * one client reader of the cap, which a surface pairs with `ListCapNotice`.
+ *
+ * The routes are derived from the tree (every route file that answers with
+ * `apiListSuccess`), never listed by hand, so a newly capped route fails here
+ * until something reads its cap. Comments are stripped first; the planted
+ * cases prove the matchers fire.
+ */
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+import { describe, expect, it } from "vitest";
+
+import { walkSourceFiles } from "./helpers/source-files";
+
+const SRC = join(process.cwd(), "src");
+
+/** Capped routes with no list on screen, and why. */
+const NO_LIST_ON_SCREEN: Record = {
+ "/api/personal-records":
+ "The web reads it for one decorative badge (a record set in the last 30 days), not as a list; the native client owns the list.",
+};
+
+function stripComments(text: string): string {
+ return text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
+}
+
+/** `app/api/a/[id]/b/route.ts` → `/api/a/[id]/b`. */
+function routePathOf(file: string): string {
+ return `/${file.replace(/^app\//, "").replace(/\/route\.tsx?$/, "")}`;
+}
+
+/** A matcher for the route's path as a client spells it. */
+function clientPathPattern(route: string): RegExp {
+ const body = route
+ .split("/")
+ .map((seg) =>
+ /^\[.+\]$/.test(seg)
+ ? "\\$\\{[^}]+\\}"
+ : seg.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"),
+ )
+ .join("/");
+ return new RegExp(
+ `\\bapi(?:Get|Post)List\\s*<[\\s\\S]{0,200}?>\\s*\\(\\s*["\`]${body}(?:[?"\`])|\\bapi(?:Get|Post)List\\s*\\(\\s*["\`]${body}(?:[?"\`])`,
+ );
+}
+
+describe("capped lists have a reader", () => {
+ const files = walkSourceFiles(SRC, { floor: 3000 }).filter(
+ (file) =>
+ !file.startsWith("generated/") &&
+ !file.includes("__tests__/") &&
+ !/\.test\.tsx?$/.test(file),
+ );
+ const read = (file: string) =>
+ stripComments(readFileSync(join(SRC, file), "utf8"));
+
+ const cappedRoutes = files
+ .filter((f) => /^app\/api\/.+\/route\.tsx?$/.test(f))
+ .filter((f) => /\bapiListSuccess\s*\(/.test(read(f)))
+ .map(routePathOf)
+ .sort();
+ const clients = files
+ .filter((f) => !f.startsWith("app/api/"))
+ .map((f) => read(f));
+
+ it("the matcher binds a client spelling to its route", () => {
+ expect(
+ clientPathPattern("/api/medications/[id]/side-effects").test(
+ "apiGetList<{ items: Row[] }>(`/api/medications/${medicationId}/side-effects?from=1`)",
+ ),
+ ).toBe(true);
+ expect(
+ clientPathPattern("/api/allergies").test(
+ 'apiGetList("/api/allergies")',
+ ),
+ ).toBe(true);
+ // A plain `apiGet` reads `data` only and does not count.
+ expect(
+ clientPathPattern("/api/allergies").test(
+ 'apiGet("/api/allergies")',
+ ),
+ ).toBe(false);
+ // A longer path that shares the prefix does not count either.
+ expect(
+ clientPathPattern("/api/allergies").test(
+ 'apiGetList("/api/allergies/x")',
+ ),
+ ).toBe(false);
+ });
+
+ it("finds the capped routes", () => {
+ expect(cappedRoutes.length).toBeGreaterThanOrEqual(12);
+ });
+
+ it("binds every capped route to a client that reads its cap", () => {
+ const unread = cappedRoutes.filter(
+ (route) =>
+ !(route in NO_LIST_ON_SCREEN) &&
+ !clients.some((code) => clientPathPattern(route).test(code)),
+ );
+ expect(unread).toEqual([]);
+ });
+
+ it("keeps the exemptions to routes that are still capped", () => {
+ for (const route of Object.keys(NO_LIST_ON_SCREEN)) {
+ expect(cappedRoutes).toContain(route);
+ }
+ });
+});
diff --git a/src/app/api/account/activity/route.ts b/src/app/api/account/activity/route.ts
index 8e781d144..d72b9db82 100644
--- a/src/app/api/account/activity/route.ts
+++ b/src/app/api/account/activity/route.ts
@@ -142,13 +142,9 @@ export const GET = apiHandler(async () => {
createdAt: row.createdAt.toISOString(),
}));
- // Exact: one row past the cap was asked for, so a list that is exactly
- // full no longer reads as cut.
- const truncated = cap.truncated;
-
annotate({
action: { name: "sharing.activity.list" },
- meta: { entry_count: entries.length, truncated },
+ meta: { entry_count: entries.length, truncated: cap.truncated },
});
return apiListSuccess(
@@ -157,11 +153,9 @@ export const GET = apiHandler(async () => {
// The bound the view states out loud. Resolved here so the UI never has to
// hardcode a number the operator can change.
retentionDays,
- // Whether the list is everything inside that window, or its most recent
- // hundred rows. The view says which; silence would let the oldest line
- // read as the beginning.
- truncated,
},
+ // Whether the list is everything inside that window, or its most recent
+ // hundred rows, rides the envelope like every capped list's (`meta`).
cap,
);
});
diff --git a/src/app/insights/__tests__/ecg-cross-link-mount.test.tsx b/src/app/insights/__tests__/ecg-cross-link-mount.test.tsx
index 2f9c1a86f..6849bfbf8 100644
--- a/src/app/insights/__tests__/ecg-cross-link-mount.test.tsx
+++ b/src/app/insights/__tests__/ecg-cross-link-mount.test.tsx
@@ -56,8 +56,11 @@ function render(node: React.ReactNode) {
});
// Seed the ECG list the cross-link reads so its data-availability gate opens.
client.setQueryData(queryKeys.insightsEcgList(), {
- recordings: [{ id: "a", classification: null }],
- hasRecordings: true,
+ data: {
+ recordings: [{ id: "a", classification: null }],
+ hasRecordings: true,
+ },
+ cap: null,
});
return renderToStaticMarkup(
diff --git a/src/app/insights/catalog/page.tsx b/src/app/insights/catalog/page.tsx
index 722a0c42b..38bda883e 100644
--- a/src/app/insights/catalog/page.tsx
+++ b/src/app/insights/catalog/page.tsx
@@ -43,6 +43,7 @@ import {
type CatalogEntry,
} from "@/lib/insights/metric-catalog";
import type { ManagerGroup } from "@/lib/insights/sub-page-metric";
+import { useEcgList } from "@/components/insights/use-ecg-list";
/**
* `/insights/catalog` — "what can HealthLog track" (2026-07-17
@@ -252,11 +253,7 @@ export default function MetricCatalogPage() {
enabled: isAuthenticated && nutrientsModuleEnabled,
});
- const ecgQuery = useQuery({
- queryKey: queryKeys.insightsEcgList(),
- queryFn: () => apiGet<{ hasRecordings: boolean }>("/api/insights/ecg"),
- enabled: isAuthenticated,
- });
+ const ecgQuery = useEcgList();
const availability: InsightInputs | undefined = isAuthenticated
? {
@@ -319,7 +316,7 @@ export default function MetricCatalogPage() {
function isPresent(entry: CatalogEntry): boolean | null {
if (entry.kind === "info") return null;
- if (entry.id === "ecg") return ecgQuery.data?.hasRecordings === true;
+ if (entry.id === "ecg") return ecgQuery.data?.data.hasRecordings === true;
if (!availability || !entry.metric) return false;
return hasMetricData(entry.metric, availability);
}
diff --git a/src/app/insights/ecg/__tests__/page.test.tsx b/src/app/insights/ecg/__tests__/page.test.tsx
index 2698461ae..db42dbebf 100644
--- a/src/app/insights/ecg/__tests__/page.test.tsx
+++ b/src/app/insights/ecg/__tests__/page.test.tsx
@@ -52,7 +52,7 @@ function render(data: { recordings: EcgRecord[]; hasRecordings: boolean }) {
const client = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: Infinity } },
});
- client.setQueryData(queryKeys.insightsEcgList(), data);
+ client.setQueryData(queryKeys.insightsEcgList(), { data, cap: null });
return renderToStaticMarkup(
diff --git a/src/app/insights/ecg/page.tsx b/src/app/insights/ecg/page.tsx
index 174687e2b..10cc0205a 100644
--- a/src/app/insights/ecg/page.tsx
+++ b/src/app/insights/ecg/page.tsx
@@ -2,17 +2,15 @@
import Link from "next/link";
import { Activity } from "lucide-react";
-import { useQuery } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
-import { queryKeys } from "@/lib/query-keys";
-import { apiGet } from "@/lib/api/api-fetch";
import { Button } from "@/components/ui/button";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { SubPageShell } from "@/components/insights/sub-page-shell";
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
import { EcgSection } from "@/components/insights/ecg-section";
+import { useEcgList } from "@/components/insights/use-ecg-list";
/**
* v1.30 — `/insights/ecg` (UX/IA audit finding H1).
@@ -39,11 +37,8 @@ export default function InsightsEcgPage() {
// Reuse the SAME query cell `` reads (`insightsEcgList`) so the
// page and the section share one cache entry. The page only needs the
// `hasRecordings` flag to choose between the empty state and the section.
- const { data, isLoading, isError, error, refetch } = useQuery({
- queryKey: queryKeys.insightsEcgList(),
- queryFn: () => apiGet<{ hasRecordings: boolean }>("/api/insights/ecg"),
- enabled: isAuthenticated,
- });
+ const { data: page, isLoading, isError, error, refetch } = useEcgList();
+ const data = page?.data;
// A read failure must be visible, not a heading over an empty body that
// reads the same as "still loading" (§6). The section teaser below unmounts
diff --git a/src/components/cycle/cycle-view.tsx b/src/components/cycle/cycle-view.tsx
index 4742525eb..1b273ee93 100644
--- a/src/components/cycle/cycle-view.tsx
+++ b/src/components/cycle/cycle-view.tsx
@@ -33,6 +33,7 @@ import {
useCycleInsights,
useCycleProfile,
} from "./use-cycle";
+import { ListCapNotice } from "@/components/ui/list-cap-notice";
/**
* v1.15.0 — the cycle vertical's client orchestrator.
@@ -415,9 +416,13 @@ export function CycleView() {
-
+
+
- apiGet(`/api/cycle/cycles?limit=${limit}`),
+ apiGetList(`/api/cycle/cycles?limit=${limit}`),
staleTime: 60_000,
});
}
diff --git a/src/components/illness/__tests__/use-illness-episodes-paging.test.ts b/src/components/illness/__tests__/use-illness-episodes-paging.test.ts
index ae403d1b5..e447bc115 100644
--- a/src/components/illness/__tests__/use-illness-episodes-paging.test.ts
+++ b/src/components/illness/__tests__/use-illness-episodes-paging.test.ts
@@ -16,16 +16,22 @@ vi.mock("@tanstack/react-query", () => ({
useQueryClient: () => ({}),
}));
+const journal = vi.hoisted(() => ({ total: 230 }));
+
vi.mock("@/lib/api/api-fetch", () => ({
- apiGet: async (url: string) => {
+ apiGet: vi.fn(),
+ apiGetList: async (url: string) => {
calls.push(url);
- // The route's own paging: 50 unless asked otherwise, from offset 0.
+ // The route's own paging: 50 unless asked otherwise, from offset 0, and
+ // the cap it sends: a row past the page exists or it does not.
const params = new URL(url, "http://x").searchParams;
const offset = Number(params.get("offset") ?? 0);
const limit = Number(params.get("limit") ?? 50);
- const total = 230;
- const count = Math.max(0, Math.min(limit, total - offset));
- return Array.from({ length: count }, (_, i) => ({ id: `e${offset + i}` }));
+ const count = Math.max(0, Math.min(limit, journal.total - offset));
+ return {
+ data: Array.from({ length: count }, (_, i) => ({ id: `e${offset + i}` })),
+ cap: { truncated: offset + count < journal.total, limit },
+ };
},
apiPost: vi.fn(),
apiPatch: vi.fn(),
@@ -47,4 +53,16 @@ describe("useIllnessEpisodes", () => {
true,
);
});
+
+ it("stops on a full last page the route says is the end", async () => {
+ calls.length = 0;
+ journal.total = 200;
+ const options = useIllnessEpisodes(true) as unknown as {
+ queryFn: () => Promise<{ id: string }[]>;
+ };
+ expect(await options.queryFn()).toHaveLength(200);
+ // Two full pages of 100, no third request for an empty one.
+ expect(calls).toHaveLength(2);
+ journal.total = 230;
+ });
});
diff --git a/src/components/illness/use-illness.ts b/src/components/illness/use-illness.ts
index b4b69288a..562ba032b 100644
--- a/src/components/illness/use-illness.ts
+++ b/src/components/illness/use-illness.ts
@@ -10,7 +10,13 @@
*/
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
-import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch";
+import {
+ apiDelete,
+ apiGet,
+ apiGetList,
+ apiPatch,
+ apiPost,
+} from "@/lib/api/api-fetch";
import {
illnessDependentKeys,
invalidateKeys,
@@ -37,7 +43,8 @@ const EPISODE_PAGE_SIZE = 100;
* most), and the journal, its settings and every picker read this as the
* whole record; a single unpaged call dropped the oldest episodes once there
* were more than one page of them, and a person then found an old condition
- * only through search. So this reads page after page until a short one.
+ * only through search. So this reads page after page until the route says
+ * nothing is left.
*/
export function useIllnessEpisodes(includeResolved = true, enabled = true) {
return useQuery({
@@ -46,11 +53,13 @@ export function useIllnessEpisodes(includeResolved = true, enabled = true) {
queryFn: async () => {
const all: IllnessEpisodeDTO[] = [];
for (let offset = 0; ; offset += EPISODE_PAGE_SIZE) {
- const page = await apiGet(
+ const { data: page, cap } = await apiGetList(
`/api/illness/episodes?includeResolved=${includeResolved}&limit=${EPISODE_PAGE_SIZE}&offset=${offset}`,
);
all.push(...page);
- if (page.length < EPISODE_PAGE_SIZE) return all;
+ // The route says whether a row past this page exists, so a last page
+ // that is exactly full ends the walk without one more request.
+ if (!cap?.truncated) return all;
}
},
});
diff --git a/src/components/insights/__tests__/ecg-cross-link.test.tsx b/src/components/insights/__tests__/ecg-cross-link.test.tsx
index 3f1bb8b0e..838b0cd7a 100644
--- a/src/components/insights/__tests__/ecg-cross-link.test.tsx
+++ b/src/components/insights/__tests__/ecg-cross-link.test.tsx
@@ -36,7 +36,9 @@ interface Item {
function render(
data: { recordings: Item[]; hasRecordings: boolean } | undefined,
) {
- useQueryMock.mockReturnValue({ data });
+ useQueryMock.mockReturnValue({
+ data: data ? { data, cap: null } : undefined,
+ });
return renderToStaticMarkup(
diff --git a/src/components/insights/__tests__/ecg-section.test.tsx b/src/components/insights/__tests__/ecg-section.test.tsx
index 8dff451da..198aeb846 100644
--- a/src/components/insights/__tests__/ecg-section.test.tsx
+++ b/src/components/insights/__tests__/ecg-section.test.tsx
@@ -54,8 +54,12 @@ function renderSection(
data:
{ recordings: EcgRecordingListItem[]; hasRecordings: boolean } | undefined,
props: { limit?: number } = {},
+ cap: { truncated: boolean; limit: number } | null = null,
) {
- useQueryMock.mockReturnValue({ data, isLoading: false });
+ useQueryMock.mockReturnValue({
+ data: data ? { data, cap } : undefined,
+ isLoading: false,
+ });
return renderToStaticMarkup(
@@ -174,4 +178,18 @@ describe("", () => {
expect(html).not.toContain('data-slot="ecg-disclaimer"');
expect(html).not.toContain("Single-lead ECG strips");
});
+
+ it("says when the full list stopped at the route's ceiling, never on the teaser", () => {
+ const data = { recordings: [IRREGULAR_REC], hasRecordings: true };
+ const cap = { truncated: true, limit: 200 };
+ expect(renderSection(data, {}, cap)).toContain(
+ 'data-slot="list-cap-notice"',
+ );
+ expect(renderSection(data, { limit: 3 }, cap)).not.toContain(
+ 'data-slot="list-cap-notice"',
+ );
+ expect(renderSection(data, {})).not.toContain(
+ 'data-slot="list-cap-notice"',
+ );
+ });
});
diff --git a/src/components/insights/__tests__/rhythm-events-card.test.tsx b/src/components/insights/__tests__/rhythm-events-card.test.tsx
index 7de61bc59..223c6cb7d 100644
--- a/src/components/insights/__tests__/rhythm-events-card.test.tsx
+++ b/src/components/insights/__tests__/rhythm-events-card.test.tsx
@@ -42,7 +42,10 @@ interface RhythmEvent {
function renderWith(
data: { events: RhythmEvent[]; hasEvents: boolean } | undefined,
) {
- useQueryMock.mockReturnValue({ data, isLoading: false });
+ useQueryMock.mockReturnValue({
+ data: data ? { data, cap: null } : undefined,
+ isLoading: false,
+ });
return renderToStaticMarkup(
diff --git a/src/components/insights/ecg-cross-link.tsx b/src/components/insights/ecg-cross-link.tsx
index 15174c2a7..d0d30407c 100644
--- a/src/components/insights/ecg-cross-link.tsx
+++ b/src/components/insights/ecg-cross-link.tsx
@@ -2,14 +2,11 @@
import Link from "next/link";
import { ArrowRight, HeartPulse } from "lucide-react";
-import { useQuery } from "@tanstack/react-query";
-import { useAuth } from "@/hooks/use-auth";
-import { queryKeys } from "@/lib/query-keys";
-import { apiGet } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import { TileHeader } from "@/components/insights/tile-header";
+import { useEcgList } from "./use-ecg-list";
/**
* ECG cross-link, the pointer from the resting-HR / pulse context into
@@ -29,18 +26,6 @@ import { TileHeader } from "@/components/insights/tile-header";
* surfaces share one cache entry with an identical response shape.
*/
-type EcgClassification = "IRREGULAR" | "NOT_DETECTED" | "INCONCLUSIVE" | null;
-
-interface EcgRecordingListItem {
- id: string;
- classification: EcgClassification;
-}
-
-interface EcgListResponse {
- recordings: EcgRecordingListItem[];
- hasRecordings: boolean;
-}
-
/** The device verdict, surfaced verbatim and attributed to the device. */
const RESULT_LABEL_KEYS: Record = {
IRREGULAR: "insights.ecg.result.irregular",
@@ -54,20 +39,9 @@ interface EcgCrossLinkProps {
}
export function EcgCrossLink({ enabled = true, className }: EcgCrossLinkProps) {
- const { isAuthenticated } = useAuth();
const { t } = useTranslations();
- const { data } = useQuery({
- queryKey: queryKeys.insightsEcgList(),
- queryFn: async () => {
- try {
- return await apiGet("/api/insights/ecg");
- } catch {
- throw new Error(t("insights.ecg.loadError"));
- }
- },
- enabled: enabled && isAuthenticated,
- });
+ const data = useEcgList(enabled).data?.data;
// Data-availability gate — never paint an empty pointer.
if (!data || !data.hasRecordings || data.recordings.length === 0) return null;
diff --git a/src/components/insights/ecg-section.tsx b/src/components/insights/ecg-section.tsx
index 2741a7d8c..34e724bf3 100644
--- a/src/components/insights/ecg-section.tsx
+++ b/src/components/insights/ecg-section.tsx
@@ -1,12 +1,8 @@
"use client";
import Link from "next/link";
-import { useQuery } from "@tanstack/react-query";
import { Activity, ChevronRight, HeartPulse } from "lucide-react";
-import { useAuth } from "@/hooks/use-auth";
-import { queryKeys } from "@/lib/query-keys";
-import { apiGet } from "@/lib/api/api-fetch";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import { SectionHeading } from "@/components/ui/section-heading";
@@ -18,6 +14,8 @@ import { ECG_OVERVIEW_LIMIT } from "@/lib/insights/ecg-overview-limit";
import { DayLinkAt } from "@/components/day/day-link";
import { InsightSectionCard } from "./insight-section-card";
+import { useEcgList } from "./use-ecg-list";
+import { ListCapNotice } from "@/components/ui/list-cap-notice";
// Re-exported so the tests (and any teaser call site) can keep importing the
// overview cap from the component they are exercising; the value itself lives
@@ -59,11 +57,6 @@ export interface EcgRecordingListItem {
hasWaveform: boolean;
}
-interface EcgListResponse {
- recordings: EcgRecordingListItem[];
- hasRecordings: boolean;
-}
-
interface EcgSectionProps {
enabled?: boolean;
className?: string;
@@ -89,21 +82,11 @@ export function EcgSection({
hideHeading = false,
limit,
}: EcgSectionProps) {
- const { isAuthenticated } = useAuth();
const { t } = useTranslations();
const fmt = useFormatters();
- const { data, isLoading } = useQuery({
- queryKey: queryKeys.insightsEcgList(),
- queryFn: async () => {
- try {
- return await apiGet("/api/insights/ecg");
- } catch {
- throw new Error(t("insights.ecg.loadError"));
- }
- },
- enabled: enabled && isAuthenticated,
- });
+ const { data: page, isLoading } = useEcgList(enabled);
+ const data = page?.data;
// Data-availability gate — never paint an empty card.
if (isLoading || !data || !data.hasRecordings) return null;
@@ -205,6 +188,9 @@ export function EcgSection({
);
})}
+ {/* The teaser keeps its newest few on purpose; the full list says
+ when the server stopped short of the history. */}
+ {limit == null && }
);
diff --git a/src/components/insights/insights-layout-shell.tsx b/src/components/insights/insights-layout-shell.tsx
index 4414792c7..c04feeb8f 100644
--- a/src/components/insights/insights-layout-shell.tsx
+++ b/src/components/insights/insights-layout-shell.tsx
@@ -18,6 +18,7 @@ import { useInsightsLayoutQuery } from "@/hooks/use-insights-layout";
import { queryKeys } from "@/lib/query-keys";
import type { InsightInputs } from "@/lib/insights/metric-availability";
import { apiGet } from "@/lib/api/api-fetch";
+import { useEcgList } from "@/components/insights/use-ecg-list";
/**
* v1.4.25 — client shell for `src/app/insights/layout.tsx`.
@@ -119,12 +120,8 @@ export function InsightsLayoutShell({ children }: { children: ReactNode }) {
// ECG page, so the flag lands once per route (dedup-shared, no extra
// traffic). Undefined until it resolves → the pill stays off on first paint,
// exactly like the metric pills light up as their summaries land.
- const ecgProbe = useQuery({
- queryKey: queryKeys.insightsEcgList(),
- queryFn: () => apiGet<{ hasRecordings: boolean }>("/api/insights/ecg"),
- enabled: isAuthenticated,
- });
- const hasEcgRecordings = ecgProbe.data?.hasRecordings === true;
+ const ecgProbe = useEcgList();
+ const hasEcgRecordings = ecgProbe.data?.data.hasRecordings === true;
// v1.4.31 — memoise the `availability` prop so an unchanged
// payload doesn't recreate the object on every cache-write of
diff --git a/src/components/insights/rhythm-events-card.tsx b/src/components/insights/rhythm-events-card.tsx
index 6c953a550..92d7ee1da 100644
--- a/src/components/insights/rhythm-events-card.tsx
+++ b/src/components/insights/rhythm-events-card.tsx
@@ -6,7 +6,8 @@ import type { LucideIcon } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { queryKeys } from "@/lib/query-keys";
-import { apiGet } from "@/lib/api/api-fetch";
+import { apiGetList } from "@/lib/api/api-fetch";
+import { ListCapNotice } from "@/components/ui/list-cap-notice";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import { SectionHeading } from "@/components/ui/section-heading";
@@ -91,11 +92,17 @@ export function RhythmEventsCard({
const { t } = useTranslations();
const fmt = useFormatters();
- const { data, isLoading, isError, error, refetch } = useQuery({
+ const {
+ data: page,
+ isLoading,
+ isError,
+ error,
+ refetch,
+ } = useQuery({
queryKey: queryKeys.insightsRhythmEvents(),
queryFn: async () => {
try {
- return await apiGet(
+ return await apiGetList(
"/api/insights/rhythm-events",
);
} catch {
@@ -104,6 +111,7 @@ export function RhythmEventsCard({
},
enabled: enabled && isAuthenticated,
});
+ const data = page?.data;
// A failed read is said, never swallowed: this card carries health alerts,
// and silently rendering nothing on an error would hide them.
@@ -186,6 +194,7 @@ export function RhythmEventsCard({
);
})}
+
{/* Load-bearing regulatory disclaimer — permanent, non-dismissible.
Plain React text children (no markdown library — XSS rule). */}
diff --git a/src/components/insights/use-ecg-list.ts b/src/components/insights/use-ecg-list.ts
new file mode 100644
index 000000000..f49491178
--- /dev/null
+++ b/src/components/insights/use-ecg-list.ts
@@ -0,0 +1,37 @@
+"use client";
+
+import { useQuery } from "@tanstack/react-query";
+
+import { useAuth } from "@/hooks/use-auth";
+import { apiGetList } from "@/lib/api/api-fetch";
+import { useTranslations } from "@/lib/i18n/context";
+import { queryKeys } from "@/lib/query-keys";
+
+import type { EcgRecordingListItem } from "./ecg-section";
+
+export interface EcgListResponse {
+ recordings: EcgRecordingListItem[];
+ hasRecordings: boolean;
+}
+
+/**
+ * The ECG list, one query cell for every reader (the section, the page, the
+ * cross-link, the tab strip, the catalog). The cell holds `{ data, cap }`,
+ * so every reader shares one shape and the cap the route sends reaches the
+ * list that shows it.
+ */
+export function useEcgList(enabled = true) {
+ const { isAuthenticated } = useAuth();
+ const { t } = useTranslations();
+ return useQuery({
+ queryKey: queryKeys.insightsEcgList(),
+ queryFn: async () => {
+ try {
+ return await apiGetList("/api/insights/ecg");
+ } catch {
+ throw new Error(t("insights.ecg.loadError"));
+ }
+ },
+ enabled: enabled && isAuthenticated,
+ });
+}
diff --git a/src/components/medications/__tests__/side-effects-section.test.tsx b/src/components/medications/__tests__/side-effects-section.test.tsx
index 03df2c4dc..180f997fd 100644
--- a/src/components/medications/__tests__/side-effects-section.test.tsx
+++ b/src/components/medications/__tests__/side-effects-section.test.tsx
@@ -444,6 +444,7 @@ describe(" — timeline rows", () => {
describe(" — paging", () => {
function sideEffectsResponse(
items: Array<{ id: string; occurredAt: string }>,
+ truncated = false,
): Response {
return new Response(
JSON.stringify({
@@ -458,12 +459,32 @@ describe(" — paging", () => {
meta: { total: items.length },
},
error: null,
+ // The route's cap on the envelope: a row past this page exists.
+ meta: { truncated, limit: 50 },
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
}
- it("offers a next page after a full one and asks for the entries before it", async () => {
+ it("offers no next page after an exactly full one the route says is complete", async () => {
+ const base = Date.parse("2026-05-20T12:00:00.000Z");
+ const fullPage = Array.from({ length: 50 }, (_, i) => ({
+ id: `se-full-${i}`,
+ occurredAt: new Date(base - i * 60_000).toISOString(),
+ }));
+ vi.stubGlobal(
+ "fetch",
+ vi.fn().mockResolvedValueOnce(sideEffectsResponse(fullPage, false)),
+ );
+ const client = makeClient();
+ render(, client);
+ await executeSideEffectsQuery(client, "med-full");
+ await waitForQueryStatus(client, "med-full", "success");
+ const html = render(, client);
+ expect(html).not.toContain(en.medications.sideEffects.loadMore);
+ });
+
+ it("offers a next page when the route says one exists, and asks for the entries before it", async () => {
const base = Date.parse("2026-05-20T12:00:00.000Z");
const firstPage = Array.from({ length: 50 }, (_, i) => ({
id: `se-${i}`,
@@ -472,7 +493,7 @@ describe(" — paging", () => {
const oldest = firstPage[firstPage.length - 1];
const fetchMock = vi
.fn()
- .mockResolvedValueOnce(sideEffectsResponse(firstPage))
+ .mockResolvedValueOnce(sideEffectsResponse(firstPage, true))
// The boundary entry comes back once more and is shown once.
.mockResolvedValueOnce(
sideEffectsResponse([
diff --git a/src/components/medications/side-effects-section.tsx b/src/components/medications/side-effects-section.tsx
index 114c1b23b..b28beff76 100644
--- a/src/components/medications/side-effects-section.tsx
+++ b/src/components/medications/side-effects-section.tsx
@@ -48,7 +48,7 @@ import {
severityLikertLabel,
} from "@/lib/medications/side-effects/taxonomy";
import { MedicationDetailSection } from "@/components/medications/medication-detail-section";
-import { apiDelete, apiFetchEnvelope, apiPost } from "@/lib/api/api-fetch";
+import { apiDelete, apiGetList, apiPost } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
/**
@@ -168,15 +168,19 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) {
const from = pageParam
? new Date(pageParam.from)
: new Date(to.getTime() - 30 * 24 * 60 * 60 * 1000);
- const url = `/api/medications/${medicationId}/side-effects?from=${encodeURIComponent(from.toISOString())}&to=${encodeURIComponent(to.toISOString())}&limit=${SIDE_EFFECTS_PAGE_SIZE}`;
- const { data: payload } = await apiFetchEnvelope<{
+ const range = `from=${encodeURIComponent(from.toISOString())}&to=${encodeURIComponent(to.toISOString())}`;
+ const { data: payload, cap } = await apiGetList<{
items: SideEffectRow[];
meta: { total: number };
- }>(url);
- return { ...payload, from: from.toISOString() };
+ }>(
+ `/api/medications/${medicationId}/side-effects?${range}&limit=${SIDE_EFFECTS_PAGE_SIZE}`,
+ );
+ return { ...payload, cap, from: from.toISOString() };
},
getNextPageParam: (lastPage): SideEffectPageParam | undefined => {
- if (lastPage.items.length < SIDE_EFFECTS_PAGE_SIZE) return undefined;
+ // The route says whether a row past the page exists; a page that is
+ // exactly full is not a cut one.
+ if (!lastPage.cap?.truncated) return undefined;
const oldest = lastPage.items[lastPage.items.length - 1];
return {
from: lastPage.from,
diff --git a/src/components/mental-health/__tests__/mental-wellbeing.test.tsx b/src/components/mental-health/__tests__/mental-wellbeing.test.tsx
index b2c0565e3..c001dce23 100644
--- a/src/components/mental-health/__tests__/mental-wellbeing.test.tsx
+++ b/src/components/mental-health/__tests__/mental-wellbeing.test.tsx
@@ -43,7 +43,8 @@ function withProviders(node: React.ReactNode): string {
// settled empty result. The failed and in-flight states are covered in
// `failed-read-never-empty.test.tsx`.
queryClient.setQueryData(queryKeys.mentalHealthAssessments(), {
- assessments: [],
+ data: { assessments: [] },
+ cap: null,
});
return renderToStaticMarkup(
diff --git a/src/components/mental-health/mental-wellbeing.tsx b/src/components/mental-health/mental-wellbeing.tsx
index c47885781..3919ed1e6 100644
--- a/src/components/mental-health/mental-wellbeing.tsx
+++ b/src/components/mental-health/mental-wellbeing.tsx
@@ -31,7 +31,7 @@ import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslations } from "@/lib/i18n/context";
-import { apiGet, apiPost } from "@/lib/api/api-fetch";
+import { apiGetList, apiPost } from "@/lib/api/api-fetch";
import { queryKeys, refetchInactiveDailyReads } from "@/lib/query-keys";
import { INSTRUMENTS, INSTRUMENT_ORDER } from "@/lib/mental-health/instruments";
import { PageHeader } from "@/components/ui/page-header";
@@ -49,6 +49,7 @@ import type {
InstrumentId,
Phase,
} from "./types";
+import { ListCapNotice } from "@/components/ui/list-cap-notice";
export function MentalWellbeing() {
const { t, locale } = useTranslations();
@@ -72,7 +73,7 @@ export function MentalWellbeing() {
// assessments was told the assessments do not exist. Error is now rendered as
// error, loading as loading, and only a genuinely empty result as empty.
const {
- data: history,
+ data: historyPage,
isPending: historyPending,
isError: historyError,
error: historyFailure,
@@ -80,10 +81,11 @@ export function MentalWellbeing() {
} = useQuery({
queryKey: queryKeys.mentalHealthAssessments(),
queryFn: () =>
- apiGet<{ assessments: AssessmentRow[] }>(
+ apiGetList<{ assessments: AssessmentRow[] }>(
"/api/mental-health/assessments",
),
});
+ const history = historyPage?.data;
const mutation = useMutation({
mutationFn: (body: unknown) =>
@@ -230,6 +232,7 @@ export function MentalWellbeing() {
onStart={() => begin(detailInstrument)}
/>
)}
+
>
)}
diff --git a/src/components/records/__tests__/allergy-manager.test.tsx b/src/components/records/__tests__/allergy-manager.test.tsx
index dd2b60a9b..01e7c2c2a 100644
--- a/src/components/records/__tests__/allergy-manager.test.tsx
+++ b/src/components/records/__tests__/allergy-manager.test.tsx
@@ -7,7 +7,7 @@ const mocks = vi.hoisted(() => ({
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({
- data: mocks.rows,
+ data: { data: mocks.rows, cap: null },
isLoading: false,
isError: false,
refetch: vi.fn(),
@@ -18,6 +18,7 @@ vi.mock("@tanstack/react-query", () => ({
vi.mock("@/lib/api/api-fetch", () => ({
apiDelete: vi.fn(),
apiGet: vi.fn(),
+ apiGetList: vi.fn(),
}));
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
vi.mock("@/components/ui/responsive-sheet", () => ({
diff --git a/src/components/records/__tests__/family-history-manager.test.tsx b/src/components/records/__tests__/family-history-manager.test.tsx
index 1064b5607..8158c95ec 100644
--- a/src/components/records/__tests__/family-history-manager.test.tsx
+++ b/src/components/records/__tests__/family-history-manager.test.tsx
@@ -7,7 +7,7 @@ const mocks = vi.hoisted(() => ({
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({
- data: mocks.rows,
+ data: { data: mocks.rows, cap: null },
isLoading: false,
isError: false,
refetch: vi.fn(),
@@ -18,6 +18,7 @@ vi.mock("@tanstack/react-query", () => ({
vi.mock("@/lib/api/api-fetch", () => ({
apiDelete: vi.fn(),
apiGet: vi.fn(),
+ apiGetList: vi.fn(),
}));
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
vi.mock("@/components/ui/responsive-sheet", () => ({
diff --git a/src/components/records/allergy-manager.tsx b/src/components/records/allergy-manager.tsx
index 901c79731..c6a1ed919 100644
--- a/src/components/records/allergy-manager.tsx
+++ b/src/components/records/allergy-manager.tsx
@@ -14,12 +14,13 @@ import { EmptyState } from "@/components/ui/empty-state";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { Skeleton } from "@/components/ui/skeleton";
-import { apiDelete, apiGet } from "@/lib/api/api-fetch";
+import { apiDelete, apiGetList } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { AllergyForm } from "./allergy-form";
import type { AllergyDTO } from "@/lib/records/dto";
+import { ListCapNotice } from "@/components/ui/list-cap-notice";
/**
* v1.25 — the structured allergy/intolerance manager. Renders in
@@ -43,9 +44,15 @@ export function AllergyManager({
const [editing, setEditing] = useState(null);
const [formFooterEl, setFormFooterEl] = useState(null);
- const { data, isLoading, isError, error, refetch } = useQuery({
+ const {
+ data: page,
+ isLoading,
+ isError,
+ error,
+ refetch,
+ } = useQuery({
queryKey: queryKeys.allergyList(true),
- queryFn: () => apiGet("/api/allergies"),
+ queryFn: () => apiGetList("/api/allergies"),
});
const deleteMutation = useMutation({
@@ -74,7 +81,7 @@ export function AllergyManager({
queryClient.invalidateQueries({ queryKey: queryKeys.allergies() });
}
- const rows = data ?? [];
+ const rows = page?.data ?? [];
function renderRow(row: AllergyDTO) {
const detail = [
@@ -168,7 +175,10 @@ export function AllergyManager({
}
/>
) : (
-
+ <>
+
+
+ >
)}
{/* The add action closes the list (design standards §12); an empty
diff --git a/src/components/records/family-history-manager.tsx b/src/components/records/family-history-manager.tsx
index ac2db6312..2f4aa0c08 100644
--- a/src/components/records/family-history-manager.tsx
+++ b/src/components/records/family-history-manager.tsx
@@ -13,12 +13,13 @@ import { EmptyState } from "@/components/ui/empty-state";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { Skeleton } from "@/components/ui/skeleton";
-import { apiDelete, apiGet } from "@/lib/api/api-fetch";
+import { apiDelete, apiGetList } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { FamilyHistoryForm } from "./family-history-form";
import type { FamilyHistoryEntryDTO } from "@/lib/records/dto";
+import { ListCapNotice } from "@/components/ui/list-cap-notice";
/**
* v1.25 — the structured family-history manager. Renders in the
@@ -33,9 +34,15 @@ export function FamilyHistoryManager() {
const [editing, setEditing] = useState(null);
const [formFooterEl, setFormFooterEl] = useState(null);
- const { data, isLoading, isError, error, refetch } = useQuery({
+ const {
+ data: page,
+ isLoading,
+ isError,
+ error,
+ refetch,
+ } = useQuery({
queryKey: queryKeys.familyHistoryList(),
- queryFn: () => apiGet("/api/family-history"),
+ queryFn: () => apiGetList("/api/family-history"),
});
const deleteMutation = useMutation({
@@ -64,7 +71,7 @@ export function FamilyHistoryManager() {
queryClient.invalidateQueries({ queryKey: queryKeys.familyHistory() });
}
- const rows = data ?? [];
+ const rows = page?.data ?? [];
function renderRow(row: FamilyHistoryEntryDTO) {
const detail = [
@@ -130,7 +137,10 @@ export function FamilyHistoryManager() {
}
/>
) : (
-
+ <>
+
+
+ >
)}
{/* The add action closes the list as the card's action row (design
diff --git a/src/components/settings/__tests__/privacy-section.test.tsx b/src/components/settings/__tests__/privacy-section.test.tsx
index 288a064d4..3104f1fe5 100644
--- a/src/components/settings/__tests__/privacy-section.test.tsx
+++ b/src/components/settings/__tests__/privacy-section.test.tsx
@@ -32,7 +32,10 @@ function render(node: React.ReactNode, locale: "en" | "de" = "en") {
// Seed the embedded session + trusted-device + activity reads so the
// assembled cards paint without firing network.
client.setQueryData(queryKeys.sessions(), { sessions: [] });
- client.setQueryData(queryKeys.securityActivity(), { events: [] });
+ client.setQueryData(queryKeys.securityActivity(), {
+ data: { events: [] },
+ cap: null,
+ });
// Seed a trusted device so the card paints (it hides itself when empty).
client.setQueryData(queryKeys.trustedDevices(), {
devices: [
@@ -80,7 +83,10 @@ describe(" — assembled Data & Privacy dashboard", () => {
},
});
client.setQueryData(queryKeys.sessions(), { sessions: [] });
- client.setQueryData(queryKeys.securityActivity(), { events: [] });
+ client.setQueryData(queryKeys.securityActivity(), {
+ data: { events: [] },
+ cap: null,
+ });
client.setQueryData(queryKeys.trustedDevices(), { devices: [] });
const html = renderToStaticMarkup(
diff --git a/src/components/settings/access/__tests__/access-panel.test.tsx b/src/components/settings/access/__tests__/access-panel.test.tsx
index fe87aa436..aa6262296 100644
--- a/src/components/settings/access/__tests__/access-panel.test.tsx
+++ b/src/components/settings/access/__tests__/access-panel.test.tsx
@@ -32,7 +32,9 @@ function grantList(partial: Partial = {}): GrantList {
return { given: [], received: [], retentionDays: 365, ...partial };
}
-const activityRef: { value: RecordActivity | undefined } = { value: undefined };
+const activityRef: {
+ value: (RecordActivity & { cap: ListCap | null }) | undefined;
+} = { value: undefined };
vi.mock("@/lib/queries/use-account-grants", async (importOriginal) => {
const actual =
@@ -64,6 +66,7 @@ vi.mock("@/hooks/use-account-switch", () => ({
import { GrantsGivenCard } from "../grants-given-card";
import { GrantsReceivedCard } from "../grants-received-card";
import { RecordActivityCard } from "../record-activity-card";
+import type { ListCap } from "@/lib/api/list-cap-shape";
function render(node: React.ReactNode): string {
return renderToStaticMarkup(
@@ -205,7 +208,7 @@ describe("when my record was opened", () => {
// see would imply a completeness it does not have: somebody checking last
// spring would read an empty list as "no" when the honest answer is
// "further back than this reaches".
- activityRef.value = { entries: [], retentionDays: 90, truncated: false };
+ activityRef.value = { entries: [], retentionDays: 90, cap: null };
const html = render();
expect(html).toContain('data-slot="record-activity-retention"');
expect(html).toContain("last 90 days");
@@ -215,22 +218,26 @@ describe("when my record was opened", () => {
// The second completeness claim. The window bounds how far back; the row
// cap bounds how many, and a list that stopped at its ceiling in silence
// let the oldest visible line read as the beginning.
- activityRef.value = { entries: [], retentionDays: 365, truncated: true };
+ activityRef.value = {
+ entries: [],
+ retentionDays: 365,
+ cap: { truncated: true, limit: 100 },
+ };
const capped = render();
- expect(capped).toContain('data-slot="record-activity-truncated"');
- expect(capped).toContain("most recent activity");
+ expect(capped).toContain('data-slot="list-cap-notice"');
+ expect(capped).toContain("Showing the latest 100");
// And it is not an unconditional hedge: a caveat that is always on is one
// people learn to skip, which would cost the sentence its meaning on the
// one screen where it is true.
- activityRef.value = { entries: [], retentionDays: 365, truncated: false };
+ activityRef.value = { entries: [], retentionDays: 365, cap: null };
const whole = render();
- expect(whole).not.toContain('data-slot="record-activity-truncated"');
+ expect(whole).not.toContain('data-slot="list-cap-notice"');
expect(whole).toContain('data-slot="record-activity-retention"');
});
it("prints the operator's window, never a compiled-in 365", () => {
- activityRef.value = { entries: [], retentionDays: 30, truncated: false };
+ activityRef.value = { entries: [], retentionDays: 30, cap: null };
const html = render();
expect(html).toContain("last 30 days");
expect(html).not.toContain("365");
@@ -247,7 +254,7 @@ describe("when my record was opened", () => {
it("counts the openings rather than listing them one by one", () => {
activityRef.value = {
retentionDays: 365,
- truncated: false,
+ cap: null,
entries: [
{
id: "a1",
@@ -270,7 +277,7 @@ describe("when my record was opened", () => {
// it themselves.
activityRef.value = {
retentionDays: 365,
- truncated: false,
+ cap: null,
entries: [
{
id: "a2",
@@ -285,3 +292,19 @@ describe("when my record was opened", () => {
expect(html).toContain("A deleted account");
});
});
+
+describe("grant lists at their ceiling", () => {
+ it("say so under the list, and only when the server cut it", () => {
+ grantsRef.value = {
+ ...grantList({ given: [BASE] }),
+ cap: { truncated: true, limit: 100 },
+ } as GrantList;
+ expect(render()).toContain(
+ 'data-slot="list-cap-notice"',
+ );
+ grantsRef.value = grantList({ given: [BASE] });
+ expect(render()).not.toContain(
+ 'data-slot="list-cap-notice"',
+ );
+ });
+});
diff --git a/src/components/settings/access/grants-given-card.tsx b/src/components/settings/access/grants-given-card.tsx
index f7e6f5e49..71dc4d500 100644
--- a/src/components/settings/access/grants-given-card.tsx
+++ b/src/components/settings/access/grants-given-card.tsx
@@ -19,6 +19,7 @@ import {
useRevokeGrant,
type GrantRow,
} from "@/lib/queries/use-account-grants";
+import { ListCapNotice } from "@/components/ui/list-cap-notice";
/**
* v1.36.0 — who can open this record.
@@ -87,6 +88,7 @@ export function GrantsGivenCard() {
))}
)}
+
);
diff --git a/src/components/settings/access/grants-received-card.tsx b/src/components/settings/access/grants-received-card.tsx
index 9d75f9ba3..470e7a87c 100644
--- a/src/components/settings/access/grants-received-card.tsx
+++ b/src/components/settings/access/grants-received-card.tsx
@@ -23,6 +23,7 @@ import {
useRenounceGrant,
type GrantRow,
} from "@/lib/queries/use-account-grants";
+import { ListCapNotice } from "@/components/ui/list-cap-notice";
/**
* v1.36.0 — the records this account has been offered, and the ones it holds.
@@ -110,6 +111,7 @@ export function GrantsReceivedCard() {
))}
)}
+
);
diff --git a/src/components/settings/access/record-activity-card.tsx b/src/components/settings/access/record-activity-card.tsx
index 06fe54112..ae19c1b57 100644
--- a/src/components/settings/access/record-activity-card.tsx
+++ b/src/components/settings/access/record-activity-card.tsx
@@ -13,6 +13,7 @@ import {
useRecordActivity,
type RecordActivityEntry,
} from "@/lib/queries/use-account-grants";
+import { ListCapNotice } from "@/components/ui/list-cap-notice";
/**
* v1.36.0 — when this record was opened, and by whom.
@@ -63,22 +64,12 @@ export function RecordActivityCard() {
{t("recordSharing.activity.retention", {
days: data.retentionDays,
})}
- {/* The second completeness claim. The window says how far back
- this can see; the cap says how many rows came back, and a list
- that stopped at its ceiling without saying so lets the oldest
- line read as the beginning. Rendered only when the ceiling was
- actually hit — an unconditional hedge would teach people to
- ignore it. */}
- {data.truncated && (
- <>
- {" "}
-
- {t("recordSharing.activity.truncated")}
-
- >
- )}
)}
+ {/* The second completeness claim: the window says how far back this
+ can see, the cap how many rows came back. Rendered only when the
+ ceiling was actually hit. */}
+
{isLoading && (
- apiGet<{ events: ActivityRow[] }>("/api/auth/me/security-activity"),
+ apiGetList<{ events: ActivityRow[] }>("/api/auth/me/security-activity"),
enabled: isAuthenticated,
});
- const events = data?.events ?? [];
+ const events = page?.data.events ?? [];
return (
@@ -152,6 +159,7 @@ export function SecurityActivityCard({
))}
)}
+
);
diff --git a/src/components/ui/__tests__/list-cap-notice.test.tsx b/src/components/ui/__tests__/list-cap-notice.test.tsx
new file mode 100644
index 000000000..6fb300c0d
--- /dev/null
+++ b/src/components/ui/__tests__/list-cap-notice.test.tsx
@@ -0,0 +1,36 @@
+import { describe, expect, it } from "vitest";
+import { renderToStaticMarkup } from "react-dom/server";
+
+import { I18nProvider } from "@/lib/i18n/context";
+import { readListCap } from "@/lib/api/list-cap-shape";
+
+import { ListCapNotice } from "../list-cap-notice";
+
+const render = (node: React.ReactNode) =>
+ renderToStaticMarkup({node});
+
+describe("ListCapNotice", () => {
+ it("says the list stopped at its ceiling, with the number", () => {
+ const html = render();
+ expect(html).toContain('data-slot="list-cap-notice"');
+ expect(html).toContain("Showing the latest 50");
+ });
+
+ it("renders nothing for a complete list or no cap", () => {
+ expect(
+ render(),
+ ).toBe("");
+ expect(render()).toBe("");
+ });
+});
+
+describe("readListCap", () => {
+ it("reads the envelope's cap and nothing else", () => {
+ expect(
+ readListCap({ truncated: true, limit: 100, requestId: "r" }),
+ ).toEqual({ truncated: true, limit: 100 });
+ expect(readListCap({ aggregate: {} })).toBeNull();
+ expect(readListCap({ truncated: "yes", limit: 1 })).toBeNull();
+ expect(readListCap(undefined)).toBeNull();
+ });
+});
diff --git a/src/components/ui/list-cap-notice.tsx b/src/components/ui/list-cap-notice.tsx
new file mode 100644
index 000000000..c1fe6160b
--- /dev/null
+++ b/src/components/ui/list-cap-notice.tsx
@@ -0,0 +1,30 @@
+"use client";
+
+import type { ListCap } from "@/lib/api/list-cap-shape";
+import { useTranslations } from "@/lib/i18n/context";
+import { cn } from "@/lib/utils";
+
+/**
+ * The line under a list the server cut at its ceiling. Renders nothing for a
+ * complete list, so a surface passes the cap it read (`apiGetList`) and
+ * never decides on its own whether to show it. Meta tier: one muted sentence
+ * at the `text-xs` floor.
+ */
+export function ListCapNotice({
+ cap,
+ className,
+}: {
+ cap: ListCap | null | undefined;
+ className?: string;
+}) {
+ const { t } = useTranslations();
+ if (!cap?.truncated) return null;
+ return (
+
+ {t("common.listCapped", { count: String(cap.limit) })}
+
+ );
+}
diff --git a/src/lib/api/api-fetch.ts b/src/lib/api/api-fetch.ts
index 95c13f927..ffabbc4a8 100644
--- a/src/lib/api/api-fetch.ts
+++ b/src/lib/api/api-fetch.ts
@@ -56,6 +56,7 @@ import {
validateResponseContext,
withRecordFenceHeaders,
} from "./record-fence";
+import { readListCap, type ListCap } from "@/lib/api/list-cap-shape";
/** HTTP-level failure from an `/api/...` route — non-OK status. */
export class ApiError extends Error {
@@ -214,6 +215,35 @@ function withJsonBody(
return merged;
}
+/**
+ * GET a capped list — resolves with `data` and the envelope's list cap
+ * (`meta.truncated` / `meta.limit`), null when the route sent none. The one
+ * client reader of the cap; pair it with `ListCapNotice`.
+ */
+export async function apiGetList(
+ path: string,
+ init?: RequestInit,
+): Promise<{ data: T; cap: ListCap | null }> {
+ const { data, meta } = await apiFetchEnvelope(path, {
+ ...init,
+ method: "GET",
+ });
+ return { data, cap: readListCap(meta) };
+}
+
+/** POST variant of {@link apiGetList}, for a list read with a body. */
+export async function apiPostList(
+ path: string,
+ body?: unknown,
+ init?: RequestInit,
+): Promise<{ data: T; cap: ListCap | null }> {
+ const { data, meta } = await apiFetchEnvelope(
+ path,
+ withDefaultIdempotencyKey(withJsonBody("POST", body, init)),
+ );
+ return { data, cap: readListCap(meta) };
+}
+
/** GET — resolves with the unwrapped `data` payload. */
export function apiGet(
path: string,
diff --git a/src/lib/api/list-cap-shape.ts b/src/lib/api/list-cap-shape.ts
new file mode 100644
index 000000000..f6004cf2d
--- /dev/null
+++ b/src/lib/api/list-cap-shape.ts
@@ -0,0 +1,22 @@
+/**
+ * The wire shape of a list cap, safe for the client bundle.
+ *
+ * A capped list answers with `meta: { truncated, limit }` on the envelope
+ * (`apiListSuccess` in `./list-cap.ts`). That is the one place the signal
+ * rides; `readListCap` is the one place a client reads it.
+ */
+export interface ListCap {
+ /** A row past `limit` exists and was not returned. */
+ truncated: boolean;
+ /** The most rows this response could carry. */
+ limit: number;
+}
+
+/** The cap an envelope's `meta` carries, or null when it carries none. */
+export function readListCap(meta: unknown): ListCap | null {
+ if (!meta || typeof meta !== "object") return null;
+ const { truncated, limit } = meta as Record;
+ if (typeof truncated !== "boolean") return null;
+ if (typeof limit !== "number" || !Number.isFinite(limit)) return null;
+ return { truncated, limit };
+}
diff --git a/src/lib/api/list-cap.ts b/src/lib/api/list-cap.ts
index e2bc9ef4f..98cd2436c 100644
--- a/src/lib/api/list-cap.ts
+++ b/src/lib/api/list-cap.ts
@@ -17,16 +17,16 @@
* `data` keeps exactly the shape the route always had and a client that never
* reads `meta` decodes it unchanged. `truncated` is exact: true only when a
* row past the limit exists. The OpenAPI side is `listEnvelope` in
- * `src/lib/openapi/routes/shared.ts`.
+ * `src/lib/openapi/routes/shared.ts`. A client reads it through `apiGetList`
+ * / `apiPostList` (`src/lib/api/api-fetch.ts`) and shows it with
+ * `ListCapNotice`; `src/__tests__/list-cap-reader-guard.test.ts` binds every
+ * capped route to such a reader.
*/
import { apiSuccessWithMeta } from "@/lib/api-response";
-export interface ListCap {
- /** A row past `limit` exists and was not returned. */
- truncated: boolean;
- /** The most rows this response could carry. */
- limit: number;
-}
+import type { ListCap } from "./list-cap-shape";
+
+export type { ListCap } from "./list-cap-shape";
/** The `take` that lets {@link capRows} tell a full list from a cut one. */
export function overfetch(limit: number): number {
diff --git a/src/lib/openapi/routes/account-sharing.ts b/src/lib/openapi/routes/account-sharing.ts
index c12a73a2d..54a0e12fc 100644
--- a/src/lib/openapi/routes/account-sharing.ts
+++ b/src/lib/openapi/routes/account-sharing.ts
@@ -354,11 +354,6 @@ const recordActivityResponse = z
.describe(
"How far back this feed can see, in days. Resolved from the instance's audit retention setting (365 by default, operator-configurable), NOT a constant — an instance running a 90-day window sends 90. The query is bounded to this window, so it is the real limit of the list and not only a caption. Show it beside the rows.",
),
- truncated: z
- .boolean()
- .describe(
- "True when the read reached its row ceiling, so `entries` is the most recent activity inside the window rather than all of it. A client must say so — a list that stops at its own cap in silence lets the oldest row read as the beginning. False means the list is everything inside the window.",
- ),
})
.meta({
id: "RecordActivity",
@@ -875,7 +870,7 @@ export const accountSharingPaths: NonNullable = {
tags: ["Account sharing"],
summary: "Who opened this record, and when",
description:
- "Every audit row filed under this account that somebody else performed, newest first — one row per delegate per day with a count, rather than one per request. `retentionDays` rides the response because audit history is purged on a schedule the operator configures: a view that showed the rows without the window would imply a completeness it does not have, and clients must state the number the server sends rather than assume the 365-day default. Not delegable: a grant is not an introduction to the other people in the household.",
+ "Every audit row filed under this account that somebody else performed, newest first — one row per delegate per day with a count, rather than one per request. `retentionDays` rides the response because audit history is purged on a schedule the operator configures: a view that showed the rows without the window would imply a completeness it does not have, and clients must state the number the server sends rather than assume the 365-day default. Whether the list stopped at its row ceiling is `meta.truncated` (it was a `truncated` field in `data` before v1.43.7). Not delegable: a grant is not an introduction to the other people in the household.",
responses: {
...stdResponses,
"200": {
diff --git a/src/lib/queries/use-account-grants.ts b/src/lib/queries/use-account-grants.ts
index f2db0e1b9..549b7aade 100644
--- a/src/lib/queries/use-account-grants.ts
+++ b/src/lib/queries/use-account-grants.ts
@@ -24,9 +24,10 @@ import {
type QueryClient,
} from "@tanstack/react-query";
-import { apiDelete, apiGet, apiPost } from "@/lib/api/api-fetch";
+import { apiDelete, apiGetList, apiPost } from "@/lib/api/api-fetch";
import { grantDependentKeys, queryKeys } from "@/lib/query-keys";
import type { ShareDomain } from "@/lib/sharing/scope";
+import type { ListCap } from "@/lib/api/list-cap-shape";
/** The other party to a grant, as far as anyone is told about them. */
export interface GrantParty {
@@ -93,21 +94,26 @@ export interface RecordActivity {
* the list is everything that ever happened.
*/
retentionDays: number;
- /**
- * True when the read hit its row ceiling, so the list is the most recent
- * activity inside the window rather than all of it.
- *
- * Two different completeness claims, and both have to be stated: the window
- * bounds how far back, this bounds how many. A list that silently stopped at
- * its own cap would let the oldest line read as the beginning.
- */
- truncated: boolean;
}
+/** A capped list as its hook hands it out: the payload and the route's cap. */
+type WithCap = T & {
+ /**
+ * The route's list cap (`meta` on the envelope), null when it sent none.
+ * Shown with `ListCapNotice`: the retention window bounds how far back the
+ * feed sees, the cap how many rows came back, and a list that stopped at
+ * its ceiling in silence would let its oldest line read as the beginning.
+ */
+ cap: ListCap | null;
+};
+
export function useAccountGrants(enabled = true) {
return useQuery({
queryKey: queryKeys.accountGrants(),
- queryFn: () => apiGet("/api/account/grants"),
+ queryFn: async (): Promise> => {
+ const { data, cap } = await apiGetList("/api/account/grants");
+ return { ...data, cap };
+ },
enabled,
});
}
@@ -115,7 +121,12 @@ export function useAccountGrants(enabled = true) {
export function useRecordActivity(enabled = true) {
return useQuery({
queryKey: queryKeys.accountActivity(),
- queryFn: () => apiGet("/api/account/activity"),
+ queryFn: async (): Promise> => {
+ const { data, cap } = await apiGetList(
+ "/api/account/activity",
+ );
+ return { ...data, cap };
+ },
enabled,
});
}
diff --git a/tests/integration/sharing-audit-actor.test.ts b/tests/integration/sharing-audit-actor.test.ts
index 426b86d9c..f29121eb2 100644
--- a/tests/integration/sharing-audit-actor.test.ts
+++ b/tests/integration/sharing-audit-actor.test.ts
@@ -462,8 +462,8 @@ describe("the activity feed states its own limits", () => {
data: {
entries: { action: string }[];
retentionDays: number;
- truncated: boolean;
};
+ meta: { truncated: boolean; limit: number };
};
}
@@ -509,7 +509,9 @@ describe("the activity feed states its own limits", () => {
}
const small = await feed();
expect(small.data.entries).toHaveLength(3);
- expect(small.data.truncated).toBe(false);
+ // The cap rides the envelope, like every capped list's.
+ expect(small.meta.truncated).toBe(false);
+ expect(small.data).not.toHaveProperty("truncated");
// Over it: same shape, different claim.
await getPrismaClient().auditLog.createMany({
@@ -522,6 +524,6 @@ describe("the activity feed states its own limits", () => {
});
const large = await feed();
expect(large.data.entries).toHaveLength(100);
- expect(large.data.truncated).toBe(true);
+ expect(large.meta).toMatchObject({ truncated: true, limit: 100 });
});
});