Merge branch 'fix/v1437-review' into release/v1.43.7

# Conflicts:
#	src/app/api/analytics/route.ts
This commit is contained in:
Marc-André Bombeck committed 2026-10-20 06:20:42 +02:00
commit 45c027aee2
217 files changed
+3636 -2127

No files matched your search

+2 -2
View File
@@ -293,7 +293,7 @@ export default function AchievementsPage() {
// alongside `<RecentAchievementsCard>` and
// `<AchievementUnlockNotifier>` so the three consumers never trigger
// more than one network call on a cold dashboard mount.
const { data, isLoading, isError, refetch } = useAchievementsQuery({
const { data, isLoading, isError, error, refetch } = useAchievementsQuery({
enabled: isAuthenticated && achievementsEnabled,
});
@@ -326,7 +326,7 @@ export default function AchievementsPage() {
title={t("achievements.title")}
description={t("achievements.subtitle")}
/>
<QueryErrorCard onRetry={() => refetch()} />
<QueryErrorCard error={error} onRetry={() => refetch()} />
</div>
);
}
+6 -2
View File
@@ -24,8 +24,11 @@ export default function AdminOverviewPage() {
// Many sub-sections share `useAdminSettings()` and render defaults
// silently on error — the banner makes the failure visible so the
// admin knows the toggles below aren't reflecting real state.
const { isError: settingsError, refetch: refetchSettings } =
useAdminSettings();
const {
isError: settingsError,
error: settingsFailure,
refetch: refetchSettings,
} = useAdminSettings();
if (!user || user.role !== "ADMIN") return null;
@@ -36,6 +39,7 @@ export default function AdminOverviewPage() {
now (its own grid row, so the nav lines up with the first card). */}
{settingsError && (
<QueryErrorRow
error={settingsFailure}
message={t("admin.adminSettingsLoadError")}
onRetry={() => void refetchSettings()}
/>
@@ -364,8 +364,12 @@ describe("GET /api/analytics", () => {
string,
{ lastSeenAt: string; daysAgo: number } | null
>;
recoveryEmpty: string | null;
};
};
// A weight-only account without Oura: the recovery page's empty reason
// is resolved here, not by the client.
expect(body.data.recoveryEmpty).toBe("general");
// The slim slice produced WEIGHT from the SQL pass; no chunked
// findMany was called.
expect(prisma.measurement.findMany).not.toHaveBeenCalled();
+4
View File
@@ -7,6 +7,7 @@ import { NO_STORE_BUT_BFCACHE } from "@/lib/http/cache-headers";
import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
import { summarize, type DataPoint } from "@/lib/analytics/trends";
import { readSummariesSlice } from "@/lib/analytics/summaries-slice";
import { loadRecoveryEmpty } from "@/lib/insights/recovery-empty-read";
import { resolveEffectiveBpTargets } from "@/lib/analytics/effective-range";
import { bpTargetsEqual, getBpTargets } from "@/lib/analytics/bp-targets";
import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
@@ -129,6 +130,9 @@ export const GET = apiHandler(async (request?: Request) => {
...slim,
summaries: slimGated.summaries,
lastSeenByType: slimGated.lastSeenByType,
// Resolved here, from the gated summaries and the Oura connection, so
// a client renders the recovery page's empty note without deciding it.
recoveryEmpty: await loadRecoveryEmpty(user.id, slimGated.summaries),
});
slimRes.headers.set("Cache-Control", NO_STORE_BUT_BFCACHE);
return slimRes;
+1 -1
View File
@@ -3,7 +3,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiSuccess } from "@/lib/api-response";
import { loadOneConnectionHealth } from "@/lib/integrations/connection-health-load";
import { googleHealthBackfillEndedList } from "@/lib/google-health/sync-core";
import { googleHealthBackfillEndedList } from "@/lib/google-health/backfill-progress";
/**
* Get Google Health connection status for the current user (v1.26.0).
+1 -1
View File
@@ -55,7 +55,7 @@ import { loadConnectionHealth } from "@/lib/integrations/connection-health-load"
import {
googleHealthBackfillEndedList,
type GoogleHealthBackfillEndedEntry,
} from "@/lib/google-health/sync-core";
} from "@/lib/google-health/backfill-progress";
import type { ConnectionHealth } from "@/lib/integrations/connection-health";
export const dynamic = "force-dynamic";
@@ -2,22 +2,29 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
/**
* v1.4.37 W7c — GET /api/measurements gains two new branches for
* cumulative HK types (steps, active energy, distance, flights,
* daylight):
* GET /api/measurements has two branches for cumulative HK types (steps,
* active energy, distance, flights, daylight):
*
* 1. `?type=ACTIVITY_STEPS&groupBy=day` → one synthesised row per
* user-TZ day with `value` = SUM and `sampleCount` = number of
* per-sample rows behind the bucket.
* user-TZ day, built by `dayGroupedListResponse`. What the rows hold
* (whole days, the source ladder, paging, the day cut in the user's
* zone) is pinned against Postgres in
* `tests/integration/measurements-day-grouped-paging.test.ts`; this
* suite pins only which branch a request reaches.
* 2. `?type=ACTIVITY_STEPS&dayKey=YYYY-MM-DD` → raw per-sample rows
* for that single calendar day in the user's IANA timezone.
*
* Spot metrics (BP, weight, pulse, etc.) keep the legacy
* `findMany`-only behaviour even when these params are present —
* the schema accepts the params but the route gates each branch on
* Spot metrics (BP, weight, pulse, etc.) keep the plain list even when
* these params are present: the route gates each branch on
* `CUMULATIVE_HK_TYPES`.
*/
vi.mock("@/lib/measurements/list/day-grouped", () => ({
dayGroupedListResponse: vi.fn(async () =>
Response.json({ data: { measurements: [], meta: {} }, error: null }),
),
}));
vi.mock("@/lib/db", () => ({
prisma: {
measurement: {
@@ -32,7 +39,7 @@ vi.mock("@/lib/db", () => ({
// blob via `loadUserSourcePriority` (prisma.user.findUnique). `null`
// resolves to the default ladders.
user: { findUnique: vi.fn() },
// v1.4.43 W6 — validation-failed paths write an audit breadcrumb.
// v1.4.43 — validation-failed paths write an audit breadcrumb.
auditLog: { create: vi.fn().mockResolvedValue({}) },
$queryRaw: vi.fn(),
},
@@ -55,6 +62,7 @@ vi.mock("next/headers", () => ({
import { GET } from "../route";
import { auditLog } from "@/lib/auth/audit";
import { dayGroupedListResponse } from "@/lib/measurements/list/day-grouped";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
@@ -74,71 +82,10 @@ function getRequest(query: string): NextRequest {
});
}
interface Group {
day_key: string;
source: string;
device_type: string | null;
unit: string;
total: number;
n: number;
}
/** One SQL fold row: a day's readings of one source, device and unit. */
function group(
day_key: string,
total: number,
n: number,
over: Partial<Group> = {},
): Group {
return {
day_key,
source: "APPLE_HEALTH",
device_type: null,
unit: "steps",
total,
n,
...over,
};
}
/**
* The day-grouped branch answers from SQL alone: one query picks the page's
* days, each row carrying the distinct-day total from a window count; a
* second folds those days into source groups. A page past the end has no row
* to carry the total, so only then is it counted on its own. Answer each
* query by its text.
*/
function mockDayPage(
days: string[],
total = days.length,
groups: Group[] = [],
) {
vi.mocked(prisma.$queryRaw).mockImplementation(((
strings: TemplateStringsArray,
) => {
const text = strings.join("");
if (text.includes("COUNT(DISTINCT")) return Promise.resolve([{ total }]);
if (text.includes("COUNT(*) OVER ()")) {
return Promise.resolve(days.map((day_key) => ({ day_key, total })));
}
if (text.includes("SUM(")) return Promise.resolve(groups);
return Promise.resolve([]);
}) as never);
}
/** The `$queryRaw` calls whose text contains `needle`. */
function rawCallsWith(needle: string) {
return vi
.mocked(prisma.$queryRaw)
.mock.calls.filter(([strings]) =>
(strings as unknown as string[]).join("").includes(needle),
);
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
// v1.4.43 W6 — validation-failed paths fire a best-effort audit-row
// v1.4.43 — validation-failed paths fire a best-effort audit-row
// write; the route swallows rejections so the test only needs a
// resolved mock to keep the catch-block silent.
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
@@ -150,176 +97,36 @@ beforeEach(() => {
vi.mocked(prisma.user.findUnique).mockResolvedValue(null as never);
});
describe("GET /api/measurements — groupBy=day (W7c collapsed list)", () => {
it("collapses per-sample step rows into one row per user-TZ day with sampleCount", async () => {
// Three Apple-Watch chunks on 2026-05-15 (Europe/Berlin) summing to 5400
// and two on 2026-05-16 summing to 7000, as SQL folds them.
mockDayPage(["2026-05-16", "2026-05-15"], 2, [
group("2026-05-15", 5400, 3),
group("2026-05-16", 7000, 2),
]);
describe("GET /api/measurements — groupBy=day", () => {
it("hands a cumulative type to the day-grouped list with the request's filters", async () => {
const res = await GET(
getRequest(
"type=ACTIVITY_STEPS&groupBy=day&from=2026-05-15T00:00:00Z&to=2026-05-17T00:00:00Z",
"type=ACTIVITY_STEPS&groupBy=day&limit=25&offset=50&sourceEq=APPLE_HEALTH&from=2026-05-01T00:00:00Z",
),
);
expect(res.status).toBe(200);
const json = (await res.json()) as {
data: {
measurements: Array<{
type: string;
value: number;
unit: string;
dayKey: string;
sampleCount: number;
measuredAt: string;
}>;
meta: { groupBy?: string; total: number };
};
};
expect(json.data.meta.groupBy).toBe("day");
expect(json.data.measurements.length).toBe(2);
// Sort is desc by default — newer day first.
const [day16, day15] = json.data.measurements;
expect(day16.dayKey).toBe("2026-05-16");
expect(day16.value).toBe(7000);
expect(day16.sampleCount).toBe(2);
expect(day16.unit).toBe("steps");
expect(day15.dayKey).toBe("2026-05-15");
expect(day15.value).toBe(5400);
expect(day15.sampleCount).toBe(3);
// The collapsed row's measuredAt anchors to local noon so the
// row sorts cleanly between same-day spot samples (canonical
// daily-timestamp contract shared with the drain helper).
expect(day15.measuredAt).toContain("2026-05-15T10:00:00");
});
// #1245 — the fold covers exactly the page's days: from the start of the
// oldest page day to the end of the newest, in the user's zone, on top of
// the request's own filters. Nothing outside the page is scanned, and no
// raw row is read into the process at all.
it("folds only the page's days, in SQL", async () => {
mockDayPage(["2026-05-16", "2026-05-15"], 40);
await GET(getRequest("type=ACTIVITY_STEPS&groupBy=day"));
const [fold] = rawCallsWith("SUM(");
const instants = (fold ?? [])
.slice(1)
.filter((v): v is Date => v instanceof Date)
.map((d) => d.toISOString());
// Europe/Berlin is UTC+2 in May.
expect(instants).toEqual([
"2026-05-14T22:00:00.000Z",
"2026-05-16T22:00:00.000Z",
]);
expect(dayGroupedListResponse).toHaveBeenCalledWith(
expect.objectContaining({ id: "user-1" }),
expect.objectContaining({
type: "ACTIVITY_STEPS",
limit: 25,
offset: 50,
sortDir: "desc",
sourceEq: "APPLE_HEALTH",
from: new Date("2026-05-01T00:00:00Z"),
}),
);
expect(prisma.measurement.findMany).not.toHaveBeenCalled();
});
it("folds nothing when the page holds no day", async () => {
mockDayPage([], 3);
// A day-grouped row is a whole day's sum; a value range filters single
// readings and would leave partial days behind, so the pair is refused.
it("refuses a value range beside groupBy=day", async () => {
const res = await GET(
getRequest("type=ACTIVITY_STEPS&groupBy=day&limit=25&offset=25"),
getRequest("type=ACTIVITY_STEPS&groupBy=day&valueMin=100"),
);
expect(res.status).toBe(200);
const json = (await res.json()) as {
data: { measurements: unknown[]; meta: { total: number } };
};
expect(json.data.measurements).toEqual([]);
expect(json.data.meta.total).toBe(3);
expect(rawCallsWith("SUM(")).toHaveLength(0);
// Past the end, and only there, the total is counted on its own.
expect(rawCallsWith("COUNT(DISTINCT")).toHaveLength(1);
});
// v1.29.6 — a day with both an Apple Health AND a Withings write for the
// same cumulative metric must collapse to the ladder-canonical source
// before summing, exactly like the dashboard rollup tile
// (`collapseRollupRowsBySource`) does. Without the collapse, 9000
// Apple-Health steps + 8800 Withings steps summed to a fabricated 17800 —
// a number the tile never showed.
it("collapses cross-source rows to the ladder-canonical source before summing", async () => {
mockDayPage(["2026-05-15"], 1, [
group("2026-05-15", 8800, 1, { source: "WITHINGS" }),
group("2026-05-15", 9000, 1),
]);
const res = await GET(
getRequest(
"type=ACTIVITY_STEPS&groupBy=day&from=2026-05-15T00:00:00Z&to=2026-05-16T00:00:00Z",
),
);
expect(res.status).toBe(200);
const json = (await res.json()) as {
data: {
measurements: Array<{
dayKey: string;
value: number;
source: string;
sampleCount: number;
}>;
meta: { droppedDuplicates?: number };
};
};
expect(json.data.measurements.length).toBe(1);
const [day] = json.data.measurements;
// APPLE_HEALTH beats WITHINGS on the default `steps` ladder — the
// Withings row drops out of the sum entirely rather than adding on top.
expect(day.value).toBe(9000);
expect(day.source).toBe("APPLE_HEALTH");
expect(day.sampleCount).toBe(1);
expect(json.data.meta.droppedDuplicates).toBe(1);
});
// #1245 — `limit` / `offset` page over days and reach the SQL that picks
// them; `meta.total` is the distinct-day count, not the page length, and
// no row is marked partial because every page day is read whole.
it("pages over days and reports the distinct-day total", async () => {
mockDayPage(["2026-04-20"], 40, [group("2026-04-20", 3400, 1)]);
const res = await GET(
getRequest("type=ACTIVITY_STEPS&groupBy=day&limit=25&offset=25"),
);
expect(res.status).toBe(200);
const json = (await res.json()) as {
data: {
measurements: Array<{ dayKey: string; partial?: boolean }>;
meta: { total: number; limit: number; offset: number };
};
};
expect(json.data.meta).toMatchObject({ total: 40, limit: 25, offset: 25 });
expect(json.data.measurements.map((m) => m.dayKey)).toEqual(["2026-04-20"]);
expect(json.data.measurements[0].partial).toBeUndefined();
const [pageCall] = rawCallsWith("COUNT(*) OVER ()");
expect(pageCall?.slice(1)).toEqual(expect.arrayContaining([25]));
expect(pageCall?.slice(1).filter((v) => v === 25)).toHaveLength(2);
// The total rode on the page's own rows; there was no second pass over
// the history, only the fold of the page's one day.
expect(prisma.$queryRaw).toHaveBeenCalledTimes(2);
expect(rawCallsWith("COUNT(DISTINCT")).toHaveLength(0);
});
// A device split inside the winning source keeps the top device's
// groups only, the same narrowing the picker applies to raw rows.
it("keeps the top-ranked device's groups within the winning source", async () => {
mockDayPage(["2026-05-15"], 1, [
group("2026-05-15", 6000, 40, { device_type: "watch" }),
group("2026-05-15", 5500, 12, { device_type: "phone" }),
]);
const res = await GET(getRequest("type=ACTIVITY_STEPS&groupBy=day"));
const json = (await res.json()) as {
data: {
measurements: Array<{ value: number; sampleCount: number }>;
meta: { droppedDuplicates: number };
};
};
expect(json.data.measurements).toHaveLength(1);
expect(json.data.measurements[0]).toMatchObject({
value: 6000,
sampleCount: 40,
});
expect(json.data.meta.droppedDuplicates).toBe(12);
expect(res.status).toBe(422);
expect(dayGroupedListResponse).not.toHaveBeenCalled();
});
it("falls back to the legacy findMany path when groupBy=day is set on a non-cumulative type", async () => {
@@ -351,7 +158,7 @@ describe("GET /api/measurements — groupBy=day (W7c collapsed list)", () => {
});
});
describe("GET /api/measurements — dayKey drill-down (W7c)", () => {
describe("GET /api/measurements — dayKey drill-down", () => {
it("returns per-sample rows for the requested calendar day in the user's TZ", async () => {
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{
@@ -402,7 +209,7 @@ describe("GET /api/measurements — dayKey drill-down (W7c)", () => {
);
});
// v1.4.37 W10 H-1 — DST awareness. The drill-down window must honour
// v1.4.37 — DST awareness. The drill-down window must honour
// the day's true local span (23 h on spring-forward, 25 h on
// fall-back) so the row count matches what the collapsed daily row
// displays. Previous shape (`canonicalDailyTimestamp ± 12h`) leaked
@@ -451,24 +258,23 @@ describe("GET /api/measurements — dayKey drill-down (W7c)", () => {
const json = (await res.json()) as {
data: { meta: { dayKey?: string } };
};
// No dayKey echo on the legacy path — proves the W7c branch
// No dayKey echo on the legacy path — proves the drill-down branch
// didn't fire for the spot metric.
expect(json.data.meta.dayKey).toBeUndefined();
});
});
describe("GET /api/measurements — schema rejections (W10 reconcile)", () => {
describe("GET /api/measurements — schema rejections", () => {
// #1245 — the day-grouped mode pages over days, so an offset is a real
// page request now rather than a refused combination.
it("accepts offset>0 when groupBy=day is set", async () => {
mockDayPage([], 0);
const res = await GET(
getRequest("type=ACTIVITY_STEPS&groupBy=day&offset=25"),
);
expect(res.status).toBe(200);
});
// v1.4.37 W10 H1 — same restriction for the drill-down branch; it
// v1.4.37 — same restriction for the drill-down branch; it
// returns a single bounded page per dayKey, not a cursor.
it("rejects offset>0 when dayKey is set", async () => {
const res = await GET(
@@ -478,7 +284,7 @@ describe("GET /api/measurements — schema rejections (W10 reconcile)", () => {
expect(prisma.measurement.findMany).not.toHaveBeenCalled();
});
// v1.4.37 W10 H2 — `2026-02-30` is a YYYY-MM-DD string but not a
// v1.4.37 — `2026-02-30` is a YYYY-MM-DD string but not a
// real calendar date. `new Date()` silently overflows it to March
// 2, so the drill-down would point at the wrong day. The validator
// refine must reject it at parse time.
+14 -595
View File
@@ -29,12 +29,6 @@ import {
type AggregateGrain,
} from "@/lib/measurements/range-aggregation";
import { CUMULATIVE_HK_TYPES } from "@/lib/measurements/apple-health-mapping";
import { metricKeyForType } from "@/lib/measurements/cumulative-day-sum";
import { pickCanonicalSourceRows } from "@/lib/analytics/source-priority";
import {
canonicalDailyTimestamp,
dayKeyForUserTz,
} from "@/lib/measurements/drain-per-sample-cumulative";
import { localDayWindow } from "@/lib/tz/local-day";
import { withIdempotency } from "@/lib/idempotency";
import { isP2002 } from "@/lib/prisma-errors";
@@ -50,11 +44,8 @@ import {
readDailySeries,
readLiveBuckets,
} from "@/lib/measurements/daily-series-read";
import {
reconstructSleepSessions,
pickMainNightAndNaps,
SESSION_GAP_MS,
} from "@/lib/analytics/sleep-night";
import { dayGroupedListResponse } from "@/lib/measurements/list/day-grouped";
import { sleepListResponse } from "@/lib/measurements/list/sleep-list";
import { resolveStoredTimezone, resolveUserTimezone } from "@/lib/tz/resolver";
import {
isNamedSleepStageBody,
@@ -72,12 +63,10 @@ import {
oppositeMergeSource,
} from "@/lib/measurements/cross-source-merge";
import { NextRequest } from "next/server";
import {
Prisma,
type MeasurementType,
type MeasurementSource,
type GlucoseContext,
type SleepStage,
import type {
MeasurementType,
MeasurementSource,
GlucoseContext,
} from "@/generated/prisma/client";
export const GET = apiHandler(async (request: NextRequest) => {
@@ -86,7 +75,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
const params = Object.fromEntries(request.nextUrl.searchParams);
const parsed = listMeasurementsSchema.safeParse(params);
if (!parsed.success) {
// v1.4.43 W6 — surface every Zod issue under `details.issues` so a
// v1.4.43 — surface every Zod issue under `details.issues` so a
// multi-field iOS query (chart loader bug, drill-down regression)
// doesn't iterate one error per round-trip. Best-effort audit-
// ledger breadcrumb keyed `measurements.list.validation-failed`.
@@ -133,7 +122,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
const where = {
userId: user.id,
// v1.4.40 W-DELETED — soft-deleted readings are invisible to every
// v1.4.40 — soft-deleted readings are invisible to every
// list / count / aggregate read. The dedicated `sync/state` route
// keeps surfacing them via its own filter so iOS can reconcile
// tombstones; every other consumer (list view, analytics fan-out,
@@ -185,7 +174,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
});
}
// v1.4.37 W7c — list-view drill-down to per-sample rows for a single
// v1.4.37 — list-view drill-down to per-sample rows for a single
// calendar day in the user's IANA timezone. Resolves the day's UTC
// bounds against `User.timezone`, then runs a plain `findMany` so the
// expandable list row shows every per-sample chunk the daily collapse
@@ -196,7 +185,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
// drill-down opens exactly the day the row summed.
const tz = await resolveStoredTimezone(user.timezone);
// Resolve `[dayStart, dayEnd)` for the requested calendar day in
// the user's local zone. v1.4.37 W10 — `localDayWindow` honours
// the user's local zone. v1.4.37 — `localDayWindow` honours
// DST: the previous shape (`canonicalDailyTimestamp ± 12 h`)
// silently leaked or hid one hour of samples on spring-forward
// (23-h day) and fall-back (25-h day) by always spanning exactly
@@ -232,7 +221,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
});
}
// v1.4.37 W7c — collapsed "one row per day" mode for cumulative
// v1.4.37 — collapsed "one row per day" mode for cumulative
// types. The list page renders the day's SUM as a single synthesised
// row with a `sampleCount` chevron that drills back into the
// per-sample rows via the `dayKey=…` branch above. `limit` / `offset`
@@ -248,26 +237,24 @@ export const GET = apiHandler(async (request: NextRequest) => {
offset,
sortDir,
sourceEq,
valueMin,
valueMax,
from,
to,
});
}
// v1.4.28 FB-D2 — server-side aggregation. Gated on an explicit
// v1.4.28 — server-side aggregation. Gated on an explicit
// `aggregate` param so the iOS contract (raw `MeasurementWireDTO`
// shape on `GET /api/measurements`) is byte-stable for any caller
// that omits the new query parameter. The chart-data client opts
// in; iOS does not.
//
// R4-CODE-C1 — `take` no longer applies BEFORE bucketising. The
// `take` no longer applies BEFORE bucketising. The
// aggregation runs in Postgres via `date_trunc`, the bucket cap is
// applied AFTER, so a 1-year `aggregate=daily` window walks every
// row in the window and returns up to 365 buckets per type instead
// of truncating to the first N raw rows.
//
// SD-H1 — "All time" range semantics. When the chart's "All" tab
// "All time" range semantics. When the chart's "All" tab
// is active, the client passes `from` = the user's earliest
// measurement (or a sentinel like 1970-01-01) plus `to` = now plus
// `aggregate=monthly` (or `weekly` when full history < 2 years).
@@ -369,574 +356,6 @@ export const GET = apiHandler(async (request: NextRequest) => {
});
});
/**
* The day-grouped list for a cumulative type, paged over local days.
*
* Everything heavy runs in Postgres. The first query picks the page: the
* distinct local days that hold a reading, sorted and cut by `limit` /
* `offset`, each row carrying the distinct-day count of the whole filtered
* set as `meta.total` from a window count. The second folds only the page's
* days, one row per day, source, device and unit with its sum and sample
* count, and the source ladder picks among those groups exactly as it would
* among the raw rows (it keeps or drops whole source and device groups per
* day, never single rows). So a page costs its own days whatever the length
* of the history, every day is complete, and no raw row reaches the heap.
*
* Until v1.43.6 the branch read the newest `limit` raw rows (the web asked
* for 5000), bucketed them and returned every bucket. A long history came
* back as thousands of rows in one response, and every day older than the
* 5000th raw row was missing with nothing to say so.
*
* A day exists for the page when any reading of the type falls on it; the
* source collapse narrows a day's groups but never empties one, so the count
* and the folded rows agree.
*/
async function dayGroupedListResponse(
user: { id: string; timezone?: string | null },
opts: {
type: MeasurementType;
limit: number;
offset: number;
sortDir: "asc" | "desc";
sourceEq?: MeasurementSource;
valueMin?: number;
valueMax?: number;
from?: Date;
to?: Date;
},
) {
const { type, limit, offset, sortDir } = opts;
// One resolver for the stored zone, shared with the drill-down and the
// sleep list: a usable IANA name or the instance default, never a value
// Postgres would refuse. The drill-down must cut the same days.
const tz = await resolveStoredTimezone(user.timezone);
// The request's filters, as SQL: the same ones the plain list applies.
const filters = Prisma.sql`
m."user_id" = ${user.id}
AND m."type" = ${type}::measurement_type
AND m."deleted_at" IS NULL
${opts.sourceEq ? Prisma.sql`AND m."source" = ${opts.sourceEq}::measurement_source` : Prisma.empty}
${opts.valueMin != null ? Prisma.sql`AND m."value" >= ${opts.valueMin}` : Prisma.empty}
${opts.valueMax != null ? Prisma.sql`AND m."value" <= ${opts.valueMax}` : Prisma.empty}
${opts.from ? Prisma.sql`AND m."measured_at" >= ${opts.from}` : Prisma.empty}
${opts.to ? Prisma.sql`AND m."measured_at" <= ${opts.to}` : Prisma.empty}
`;
const localDay = Prisma.sql`to_char((m."measured_at" AT TIME ZONE 'UTC') AT TIME ZONE ${tz}, 'YYYY-MM-DD')`;
// Direction comes from the Zod enum, never from raw input. The window
// count runs before LIMIT, so every row of the page carries the
// distinct-day total of the whole filtered set.
const direction = Prisma.raw(sortDir === "asc" ? "ASC" : "DESC");
const pageDays = await prisma.$queryRaw<
Array<{ day_key: string; total: number }>
>`
SELECT d.day_key, (COUNT(*) OVER ())::int AS total
FROM (
SELECT ${localDay} AS day_key
FROM measurements m
WHERE ${filters}
GROUP BY 1
) d
ORDER BY d.day_key ${direction}
LIMIT ${limit} OFFSET ${offset}
`;
// A page past the end has no row to carry the total; only then is it
// counted on its own.
const total =
pageDays.length > 0
? Number(pageDays[0].total)
: offset > 0
? Number(
(
await prisma.$queryRaw<Array<{ total: number }>>`
SELECT COUNT(DISTINCT ${localDay})::int AS total
FROM measurements m
WHERE ${filters}
`
)[0]?.total ?? 0,
)
: 0;
const dayKeys = pageDays.map((d) => d.day_key);
// The page's days are consecutive in the sorted day set, so the span from
// the first one's start to the last one's end holds exactly their rows.
let groups: Array<{
day_key: string;
source: MeasurementSource;
device_type: string | null;
unit: string;
total: number;
n: number;
}> = [];
if (dayKeys.length > 0) {
const sorted = [...dayKeys].sort();
const spanStart = localDayWindow(sorted[0], tz).dayStart;
const spanEnd = localDayWindow(sorted[sorted.length - 1], tz).dayEnd;
// Newest group first within a day, so the day's label comes from its
// latest reading, as it did when raw rows were read newest first.
groups = await prisma.$queryRaw`
SELECT
${localDay} AS day_key,
m."source"::text AS source,
m."device_type" AS device_type,
m."unit" AS unit,
SUM(m."value")::float8 AS total,
COUNT(*)::int AS n
FROM measurements m
WHERE ${filters}
AND m."measured_at" >= ${spanStart}
AND m."measured_at" < ${spanEnd}
GROUP BY 1, 2, 3, 4
ORDER BY 1, MAX(m."measured_at") DESC, 2, 3, 4
`;
}
// v1.29.6 — collapse overlapping sources to the ladder-canonical
// reading per day BEFORE summing. Without this, a day with both an
// Apple Health AND a Withings write for the same cumulative metric
// (e.g. steps) summed BOTH sources into one inflated total — a day
// the dashboard tile (which reads through the source-collapsed
// rollup, `collapseRollupRowsBySource` in
// `src/lib/rollups/measurement-read.ts`) showed correctly. Uses the
// same `metricKeyForType` ladder lookup the rollup collapse uses, so
// this list and the tile agree on which source wins. A type with no
// ladder (e.g. TIME_IN_DAYLIGHT, FALL_COUNT) has nothing to collapse
// against — every group passes through unchanged. Each group stands at
// its day's local noon, so the picker files it under the day SQL cut.
const groupRows = groups.map((g) => ({
...g,
type,
deviceType: g.device_type,
measuredAt: canonicalDailyTimestamp(g.day_key, tz),
}));
const metricKey = metricKeyForType(type);
const priorityJson = metricKey ? await loadUserSourcePriority(user.id) : null;
const canonicalGroups = metricKey
? pickCanonicalSourceRows(groupRows, metricKey, priorityJson, (d) =>
dayKeyForUserTz(d, tz),
).canonicalRows
: groupRows;
// Sum per local day; unit and source come from the day's first kept group
// so the rendered row carries a sensible label even when sources mix.
const buckets = new Map<
string,
{ total: number; sampleCount: number; unit: string; source: string }
>();
for (const g of canonicalGroups) {
const slot = buckets.get(g.day_key);
if (!slot) {
buckets.set(g.day_key, {
total: g.total,
sampleCount: g.n,
unit: g.unit,
source: g.source,
});
} else {
slot.total += g.total;
slot.sampleCount += g.n;
}
}
const rowsScanned = groups.reduce((sum, g) => sum + g.n, 0);
const rowsKept = canonicalGroups.reduce((sum, g) => sum + g.n, 0);
// Rows follow the page's day order from SQL.
const measurements = dayKeys.flatMap((dayKey) => {
const b = buckets.get(dayKey);
if (!b) return [];
return [
{
// Synthetic id so the list-row key stays stable across pages
// and the row never collides with a real `Measurement.id`.
id: `day:${type}:${dayKey}`,
type,
value: b.total,
unit: b.unit,
source: b.source,
// Anchor the row's timestamp to local noon so the row sorts
// cleanly between same-day spot samples — matches the
// `canonicalDailyTimestamp` convention used by the drain.
measuredAt: canonicalDailyTimestamp(dayKey, tz).toISOString(),
notes: null,
dayKey,
sampleCount: b.sampleCount,
},
];
});
annotate({
action: { name: "measurement.list" },
meta: {
total,
type,
groupBy: "day",
days: measurements.length,
rowsScanned,
droppedDuplicates: rowsScanned - rowsKept,
},
});
return apiSuccess({
measurements,
meta: {
total,
limit,
offset,
groupBy: "day",
droppedDuplicates: rowsScanned - rowsKept,
},
});
}
/**
* A sleep page holds at most this many nights. The nights' stage rows are
* read and reconstructed in the process, so the bound sits on the nights;
* the applied value comes back as `meta.limit`, and `meta.total` still counts
* every night, so a caller asking for more pages on rather than losing any.
* Until v1.43.6 the list read no more than a year of nights however large
* the limit, so this is a widening, never a new cut.
*/
const SLEEP_NIGHT_PAGE_MAX = 1000;
/**
* v1.11.5 — per-night collapse for the SLEEP_DURATION list. Sleep is
* stored one row per STAGE per night (minutes); a plain list leaks every
* stage segment. This collapses the raw rows into the canonical per-night
* shape used by the chart series + dashboard tile.
*
* Two modes:
* - `dayKey` set → drill-down: return the night's stage SEGMENTS (the
* canonical source's per-stage spans) so the list row can reveal the
* hypnogram's underlying rows, mirroring the cumulative-type drill-down.
* - no `dayKey` → one synthetic row per night, value = 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.
*
* The nights are paged in Postgres (`sleepNightPage`), like the days of the
* cumulative list, and only the stage rows of the page's nights are read.
* Until v1.43.6 the read stopped at a year before `to` and the list ended
* there without saying so to the client.
*/
async function sleepListResponse(
user: { id: string; timezone?: string | null },
opts: {
from?: Date;
to?: Date;
limit: number;
offset: number;
sortDir: "asc" | "desc";
dayKey?: string;
},
) {
const { from, to, offset, sortDir, dayKey } = opts;
const limit = Math.min(opts.limit, SLEEP_NIGHT_PAGE_MAX);
const [tz, priorityJson] = await Promise.all([
resolveStoredTimezone(user.timezone),
loadUserSourcePriority(user.id),
]);
const page = await sleepNightPage(user.id, tz, {
from,
to,
sortDir,
...(dayKey ? { night: dayKey, limit: 1, offset: 0 } : { limit, offset }),
});
const nightKeys = page.nights.map((n) => n.night);
// The page's sessions lie between the first one's start and the last one's
// end, and every other session is more than the session gap away from that
// span, so this read holds the page's sessions whole and no part of any
// other.
let rows: Array<{
id: string;
value: number;
measuredAt: Date;
sleepStage: SleepStage | null;
source: MeasurementSource;
deviceType: string | null;
}> = [];
if (page.nights.length > 0) {
let spanStart = page.nights[0].spanStart;
let spanEnd = page.nights[0].spanEnd;
for (const n of page.nights) {
if (n.spanStart < spanStart) spanStart = n.spanStart;
if (n.spanEnd > spanEnd) spanEnd = n.spanEnd;
}
rows = await prisma.measurement.findMany({
where: {
userId: user.id,
type: "SLEEP_DURATION",
deletedAt: null,
AND: [
{ measuredAt: { gte: spanStart, lte: spanEnd } },
...(from ? [{ measuredAt: { gte: from } }] : []),
...(to ? [{ measuredAt: { lte: to } }] : []),
],
},
orderBy: [{ measuredAt: "asc" }, { id: "asc" }],
select: {
id: true,
value: true,
measuredAt: true,
sleepStage: true,
source: true,
// Writer-level collapse: two HealthKit apps behind one source (watch
// stages vs phone in-bed) must not blend into one night.
deviceType: true,
},
});
}
// Each reconstructed session is filed under the page night whose span
// holds it, and takes that night's label. Matching by the reconstruction's
// own label lost a night whenever the two named different days: the page
// counted it under one, the reconstruction filed it under the other, and
// the filter dropped it from both.
const sessions = reconstructSleepSessions(rows, tz, priorityJson).flatMap(
(session) => {
// A second of slack absorbs the sub-millisecond rounding between the
// two start computations; sessions sit hours apart, so it can never
// reach a neighbour.
const home = page.nights.find(
(n) =>
n.spanStart.getTime() - 1000 <= session.start.getTime() &&
session.end.getTime() <= n.spanEnd.getTime() + 1000,
);
return home ? [{ ...session, night: home.night }] : [];
},
);
// Drill-down — return the requested night's canonical stage segments.
if (dayKey) {
const { main, naps } = pickMainNightAndNaps(
sessions.filter((s) => s.night === dayKey),
);
const dayUnit = getUnitForType("SLEEP_DURATION");
// One row per stage segment of the main night + each nap, so the
// drill-down reveals exactly the rows that summed into the headline.
const segments = [main, ...naps]
.filter((s): s is NonNullable<typeof s> => s != null)
.flatMap((s) => s.segments);
const measurements = segments
.sort((a, b) =>
sortDir === "asc"
? a.start.getTime() - b.start.getTime()
: b.start.getTime() - a.start.getTime(),
)
.map((seg, i) => ({
id: `sleep-seg:${dayKey}:${i}`,
type: "SLEEP_DURATION",
value: Math.round(seg.minutes),
unit: dayUnit,
source: main?.source ?? naps[0]?.source ?? "MANUAL",
measuredAt: seg.end.toISOString(),
sleepStage: seg.stage,
notes: null,
}));
annotate({
action: { name: "measurement.list" },
meta: {
total: measurements.length,
type: "SLEEP_DURATION",
dayKey,
mode: "sleep-drill-down",
},
});
return apiSuccess({
measurements,
meta: { total: measurements.length, limit, offset: 0, dayKey },
});
}
// Per-night collapse — one synthetic row per wake day. The headline
// value is the MAIN night's TIME ASLEEP (the longest session that day),
// per the nap convention: naps are surfaced as a count, never folded
// into the main night's headline.
const byDay = new Map<string, typeof sessions>();
for (const s of sessions) {
const list = byDay.get(s.night) ?? [];
list.push(s);
byDay.set(s.night, list);
}
const dayUnit = getUnitForType("SLEEP_DURATION");
// Rows follow the page's night order from SQL.
const measurements = nightKeys.flatMap((day) => {
const { main, naps } = pickMainNightAndNaps(byDay.get(day) ?? []);
if (!main) return [];
return [
{
// Synthetic id mirrors the series route's `sleep:<night>` shape.
id: `sleep:${day}`,
type: "SLEEP_DURATION",
value: Math.round(main.asleepMinutes),
unit: dayUnit,
source: main.source ?? "MANUAL",
measuredAt: main.end.toISOString(),
notes: null,
dayKey: day,
// Drives the drill-down chevron — number of stage segments behind
// the night, matching the cumulative-type `sampleCount` contract.
sampleCount: main.segments.length,
sleepStages: main.stages as Record<string, number>,
inBedMinutes: main.inBedMinutes,
awakeMinutes: main.awakeMinutes,
napCount: naps.length,
napAsleepMinutes: naps.reduce((sum, n) => sum + n.asleepMinutes, 0),
awakenings: main.awakenings,
},
];
});
annotate({
action: { name: "measurement.list" },
meta: {
total: page.total,
type: "SLEEP_DURATION",
mode: "sleep-per-night",
nights: measurements.length,
rowsScanned: rows.length,
},
});
return apiSuccess({
measurements,
meta: {
total: page.total,
limit,
offset,
groupBy: "night",
},
});
}
/**
* One page of sleep nights, chosen in Postgres.
*
* 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 (a
* bare or granular asleep stage, or a stage-less one, with minutes), and its
* night is the local day its last row ends on. Nights are grouped, sorted
* and cut by `limit` / `offset`; `total` is the night count of the whole
* filtered set from the same query. `spanStart` / `spanEnd` bound the
* night's counting sessions, so the caller reads exactly their rows.
*
* This label is the list's own: the page is cut by it, a row carries it and
* a drill-down asks by it. It is taken over every writer, where the JS
* reconstruction takes the winning writer only, so the two can name
* different days (a losing phone's in-bed span running past midnight while
* the watch stops before it). The caller therefore never matches the two by
* label: it files each reconstructed session under the night whose span
* holds it, which is exact, because a reconstructed session is one of these
* sessions narrowed to its winner.
*
* With `night` set (a drill-down) the scan is cut to a window of 36 hours
* either side of that local day. Sessions are split at a three-hour gap, so
* a session ending on that day starts inside the window unless it lasted
* over a day and a half, and every session the cut truncates ends on another
* day.
*/
/** How far a drill-down's session scan reaches either side of its day. */
const DRILL_DOWN_REACH_MS = 36 * 60 * 60 * 1000;
async function sleepNightPage(
userId: string,
tz: string,
opts: {
from?: Date;
to?: Date;
sortDir: "asc" | "desc";
limit: number;
offset: number;
night?: string;
},
): Promise<{
nights: Array<{ night: string; spanStart: Date; spanEnd: Date }>;
total: number;
}> {
const gapSeconds = SESSION_GAP_MS / 1000;
const window = opts.night ? localDayWindow(opts.night, tz) : null;
// Direction comes from the Zod enum, never from raw input.
const direction = Prisma.raw(opts.sortDir === "asc" ? "ASC" : "DESC");
const nightsCte = Prisma.sql`
WITH r AS (
SELECT
m."measured_at" AS e,
m."measured_at" - m."value" * interval '1 minute' AS s,
(m."value" > 0 AND (m."sleep_stage" IS NULL
OR m."sleep_stage" IN ('ASLEEP', 'REM', 'CORE', 'DEEP'))) AS asleep
FROM measurements m
WHERE m."user_id" = ${userId}
AND m."type" = 'SLEEP_DURATION'::measurement_type
AND m."deleted_at" IS NULL
${opts.from ? Prisma.sql`AND m."measured_at" >= ${opts.from}` : Prisma.empty}
${opts.to ? Prisma.sql`AND m."measured_at" <= ${opts.to}` : Prisma.empty}
${
window
? Prisma.sql`AND m."measured_at" >= ${new Date(window.dayStart.getTime() - DRILL_DOWN_REACH_MS)}
AND m."measured_at" < ${new Date(window.dayEnd.getTime() + DRILL_DOWN_REACH_MS)}`
: Prisma.empty
}
), o AS (
SELECT r.*, MAX(e) OVER (
ORDER BY s, e ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING
) AS prev_end
FROM r
), g AS (
SELECT o.*, SUM(
CASE WHEN prev_end IS NULL
OR s - prev_end > make_interval(secs => ${gapSeconds}::double precision) THEN 1 ELSE 0 END
) OVER (ORDER BY s, e ROWS UNBOUNDED PRECEDING) AS sid
FROM o
), sess AS (
SELECT MIN(s) AS s, MAX(e) AS e
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,
MIN(s) AS span_start,
MAX(e) AS span_end
FROM sess
GROUP BY 1
)
`;
const rows = await prisma.$queryRaw<
Array<{ night: string; span_start: Date; span_end: Date; total: number }>
>`
${nightsCte}
SELECT night, span_start, span_end, (COUNT(*) OVER ())::int AS total
FROM nights
${opts.night ? Prisma.sql`WHERE night = ${opts.night}` : Prisma.empty}
ORDER BY night ${direction}
LIMIT ${opts.limit} OFFSET ${opts.offset}
`;
// A page past the end has no row to carry the total; only then is it
// counted on its own.
const total =
rows.length > 0
? Number(rows[0].total)
: opts.offset > 0
? Number(
(
await prisma.$queryRaw<Array<{ total: number }>>`
${nightsCte}
SELECT COUNT(*)::int AS total FROM nights
`
)[0]?.total ?? 0,
)
: 0;
return {
nights: rows.map((r) => ({
night: r.night,
spanStart: r.span_start,
spanEnd: r.span_end,
})),
total,
};
}
/**
* Does this body attribute a source to any reading — any source at all?
*
@@ -341,8 +341,17 @@ describe("GET /api/measurements/series", () => {
it("collapses sleep stage rows into one night point in hours (v1.11.4)", async () => {
// SLEEP_DURATION is stored one row per STAGE per night (minutes). The
// series must return ONE point per night carrying the TIME-ASLEEP
// total in hours, not a point per stage.
// total in hours, not a point per stage. Postgres names the night and
// its span; the stage rows of that span are reconstructed.
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.$queryRaw).mockResolvedValue([
{
night: "2026-06-04",
span_start: new Date("2026-06-03T15:00:00.000Z"),
span_end: new Date("2026-06-04T05:00:00.000Z"),
total: 1,
},
] as never);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{
id: "s1",
+11 -39
View File
@@ -29,7 +29,7 @@ import { annotate } from "@/lib/logging/context";
import { summarize, type DataPoint } from "@/lib/analytics/trends";
import { redactForExcerpt } from "@/lib/observability/redact-payload";
import type { MeasurementType, SleepStage } from "@/generated/prisma/client";
import { reconstructSleepNights } from "@/lib/analytics/sleep-night";
import { readSleepNights } from "@/lib/measurements/list/sleep-nights";
import { VALUE_RANGES } from "@/lib/validations/measurement";
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
import { usesHourlyMeanDay } from "@/lib/measurements/day-statistic";
@@ -56,13 +56,6 @@ import { getServerTranslator } from "@/lib/i18n/server-translator";
import { resolveJobLocale } from "@/lib/i18n/job-locale";
import { READING_SPREAD, standardDeviation } from "@/lib/stats/spread";
/**
* v1.11.4 — sleep is read row-per-stage (not from the day rollup), so its
* window is capped to one year even when the client requests the 3650-day
* "Alle" range. Matches the slim slice's `withSleepNightTotals` sleep read.
*/
const SLEEP_SERIES_MAX_DAYS = 365;
/**
* v1.28.25 — sample-dense kinds read raw rows only up to this window.
* A CGM account stores ~288 BLOOD_GLUCOSE rows/day (105k+ rows/year) and
@@ -220,7 +213,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
const rawQuery = Object.fromEntries(request.nextUrl.searchParams);
const parsed = querySchema.safeParse(rawQuery);
if (!parsed.success) {
// v1.4.43 W6 — iOS chart loader hot path; multi-issue 422 +
// v1.4.43 — iOS chart loader hot path; multi-issue 422 +
// audit breadcrumb keyed `measurements.series.validation-failed`.
const issues = sanitiseZodIssues(parsed.error.issues);
// v1.4.48 H-iOS-2 — surface the iOS-sent query shape alongside
@@ -294,42 +287,21 @@ export const GET = apiHandler(async (request: NextRequest) => {
// dual-source night to one canonical source via the user's `sleep`
// priority ladder.
//
// v1.11.4 — sleep is the one kind read row-per-stage (≈5 rows/night ×
// source-count) rather than from the day-bucketed rollup, so an
// unbounded "Alle" (days = 3650) request on a multi-year dual-source
// account would walk a six-figure row set into JS. Cap the sleep read
// at SLEEP_SERIES_MAX_DAYS (365 d) — the same one-year bound the slim
// slice's `withSleepNightTotals` uses — so the window stays small
// regardless of the requested range. Other kinds read from the rollup
// tier and keep the full 3650-day range.
const sleepSince = new Date(
Date.now() - Math.min(days, SLEEP_SERIES_MAX_DAYS) * 86_400_000,
);
// Sleep is the one kind read row-per-stage rather than from the rollup
// tier, so the nights are named in Postgres and reconstructed a chunk at
// a time (`readSleepNights`): "All" reaches as far back as the history
// does, and the stage rows in the heap stay a chunk's worth.
const [tz, priorityJson] = await Promise.all([
resolveUserTimezone(user.id),
loadUserSourcePriority(user.id),
]);
const rows = await prisma.measurement.findMany({
where: {
userId: user.id,
type: "SLEEP_DURATION",
measuredAt: { gte: sleepSince },
deletedAt: null,
},
orderBy: { measuredAt: "asc" },
select: {
id: true,
value: true,
measuredAt: true,
sleepStage: true,
source: true,
// Writer-level collapse: two HealthKit apps behind one source
// (watch stages vs phone in-bed) must not blend into one night.
deviceType: true,
},
const nights = await readSleepNights(user.id, tz, priorityJson, {
from: since,
// A window of `days` days holds at most one night more than that.
maxNights: days + 1,
});
unit = "h";
points = reconstructSleepNights(rows, tz, priorityJson)
points = nights
.filter((n) => n.asleepMinutes > 0)
.map((n) => {
const stageHours = Object.fromEntries(
@@ -3,7 +3,7 @@ import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
measurement: { findMany: vi.fn() },
measurement: { findMany: vi.fn(), findFirst: vi.fn() },
auditLog: { create: vi.fn() },
},
}));
@@ -76,6 +76,7 @@ function stage(
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.measurement.findFirst).mockResolvedValue(null as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
});
@@ -138,12 +139,48 @@ describe("GET /api/sleep/night", () => {
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);
const res = await GET(req(""));
const body = await res.json();
expect(body.data.night).toBe("2026-06-04");
expect(body.data.main.asleepMinutes).toBe(300);
});
it("reads a night of any age from a day and a half either side of it", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
stage("2019-03-01T06:00:00.000Z", "CORE", 420),
] as never);
const res = await GET(req("date=2019-03-01"));
const body = await res.json();
// Years past any trailing window, the night is still served.
expect(body.data.main.asleepMinutes).toBe(420);
const where = vi.mocked(prisma.measurement.findMany).mock.calls[0][0]!
.where as { AND: Array<{ measuredAt: { gte: Date; lte: Date } }> };
expect(where.AND[0].measuredAt.gte.toISOString()).toBe(
"2019-02-27T12:00:00.000Z",
);
expect(where.AND[0].measuredAt.lte.toISOString()).toBe(
"2019-03-03T12:00:00.000Z",
);
});
it("answers a day of naps alone with its naps and no main night", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
// 13:00 to 14:00 UTC, nothing overnight.
stage("2026-06-04T14:00:00.000Z", "CORE", 60),
] as never);
const res = await GET(req("date=2026-06-04"));
const body = await res.json();
expect(body.data.night).toBe("2026-06-04");
expect(body.data.main).toBeNull();
expect(body.data.naps).toHaveLength(1);
expect(body.data.naps[0].asleepMinutes).toBe(60);
});
it("surfaces a daytime nap separately from the main night", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
@@ -169,7 +206,7 @@ describe("GET /api/sleep/night", () => {
expect(body.data.naps).toEqual([]);
});
// A5 — a reconstruction edge (a session of only bare ASLEEP / stage-less rows
// A reconstruction edge (a session of only bare ASLEEP / stage-less rows
// alongside a granular partition for the same span) must return a valid empty
// night, NEVER a 500 from the unguarded `segments[0]` access.
it("returns 200 + empty night on an IN_BED/AWAKE-only reconstruction, never 500", async () => {
+19 -41
View File
@@ -17,13 +17,16 @@
* (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 in the trailing-year window.
* `date` omitted → the most recent night that holds sleep, however old.
*
* 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
* answered and a long history never reaches the heap.
*
* Auth: cookie session OR Bearer token (`requireAuth`). Soft-delete-filtered.
*/
import { NextRequest } from "next/server";
import { z } from "zod/v4";
import { prisma } from "@/lib/db";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import {
apiSuccess,
@@ -32,18 +35,18 @@ import {
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import {
reconstructSleepSessions,
pickMainNightAndNaps,
type SleepSession,
} from "@/lib/analytics/sleep-night";
import {
latestSleepNight,
readNightSessions,
} from "@/lib/measurements/list/sleep-nights";
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { isCalendarDateKey } from "@/lib/tz/date-only";
/** Sleep is read row-per-stage, so the window is bounded to one year. */
const SLEEP_NIGHT_MAX_DAYS = 365;
const querySchema = z.object({
// Optional wake-day key; when omitted the latest night is returned.
date: z
@@ -127,36 +130,14 @@ export const GET = apiHandler(async (request: NextRequest) => {
loadUserSourcePriority(user.id),
]);
const since = new Date(Date.now() - SLEEP_NIGHT_MAX_DAYS * 86_400_000);
const rows = await prisma.measurement.findMany({
where: {
userId: user.id,
type: "SLEEP_DURATION",
deletedAt: null,
measuredAt: { gte: since },
},
orderBy: { measuredAt: "asc" },
select: {
value: true,
measuredAt: true,
sleepStage: true,
source: true,
// Writer-level collapse: two HealthKit apps behind one source (watch
// stages vs phone in-bed) must not blend into one night.
deviceType: true,
},
});
const sessions = reconstructSleepSessions(rows, tz, priorityJson);
// Resolve the target wake day: the requested `date`, else the most
// recent wake day that has a scorable session.
const scorable = sessions.filter((s) => s.asleepMinutes > 0);
const targetDay =
date ??
(scorable.length > 0 ? scorable[scorable.length - 1].night : undefined);
const daySessions = sessions.filter((s) => s.night === targetDay);
const { main, naps } = pickMainNightAndNaps(daySessions);
date ?? (await latestSleepNight(user.id, tz, priorityJson)) ?? undefined;
const { main, naps } = targetDay
? pickMainNightAndNaps(
(await readNightSessions(user.id, targetDay, tz, priorityJson))
.sessions,
)
: { main: null, naps: [] };
annotate({
action: { name: "sleep.night" },
@@ -167,13 +148,10 @@ export const GET = apiHandler(async (request: NextRequest) => {
},
});
if (!main) {
return apiSuccess({ night: targetDay ?? null, main: null, naps: [] });
}
// A day of naps alone answers with its naps and no main night.
return apiSuccess({
night: main.night,
main: serializeSession(main),
night: targetDay ?? null,
main: main ? serializeSession(main) : null,
naps: naps.map(serializeSession),
});
});
+2 -1
View File
@@ -69,6 +69,7 @@ function CoachConversationsBody({ readOnly }: { readOnly: boolean }) {
conversations,
isLoading,
isError,
error,
refetch,
hasNextPage,
isFetchingNextPage,
@@ -182,7 +183,7 @@ function CoachConversationsBody({ readOnly }: { readOnly: boolean }) {
// A load failure must not masquerade as "no conversations yet" —
// that reads as a data-loss scare and offers no way back. Mirror
// /coach/plans and surface a retry.
<QueryErrorCard onRetry={() => refetch()} />
<QueryErrorCard error={error} onRetry={() => refetch()} />
) : groups.length === 0 ? (
<div
data-slot="coach-conversations-empty"
+1 -1
View File
@@ -240,7 +240,7 @@ function CoachPlansBody({ readOnly }: { readOnly: boolean }) {
)}
>
{query.isError ? (
<QueryErrorCard onRetry={() => query.refetch()} />
<QueryErrorCard error={query.error} onRetry={() => query.refetch()} />
) : query.isLoading ? (
<div
data-slot="coach-plans-loading"
+12 -1
View File
@@ -278,31 +278,41 @@ export default function MetricCatalogPage() {
// the whole page, and never reads as an honest "Not tracked yet".
const probes: Record<
ProbeId,
{ isLoading: boolean; isError: boolean; retry: () => void }
{
isLoading: boolean;
isError: boolean;
error: unknown;
retry: () => void;
}
> = {
analytics: {
isLoading: analyticsQuery.isLoading,
isError: analyticsQuery.isError,
error: analyticsQuery.error,
retry: () => void analyticsQuery.refetch(),
},
comprehensive: {
isLoading: comprehensiveQuery.isLoading,
isError: comprehensiveQuery.isError,
error: comprehensiveQuery.error,
retry: () => void comprehensiveQuery.refetch(),
},
workouts: {
isLoading: workoutsProbe.isLoading,
isError: workoutsProbe.isError,
error: workoutsProbe.error,
retry: () => workoutsProbe.refetch(),
},
nutrients: {
isLoading: nutrientsModuleEnabled && nutrientsProbe.isLoading,
isError: nutrientsModuleEnabled && nutrientsProbe.isError,
error: nutrientsProbe.error,
retry: () => void nutrientsProbe.refetch(),
},
ecg: {
isLoading: ecgQuery.isLoading,
isError: ecgQuery.isError,
error: ecgQuery.error,
retry: () => void ecgQuery.refetch(),
},
};
@@ -358,6 +368,7 @@ export default function MetricCatalogPage() {
<CardContent className="space-y-2">
{failed.length > 0 ? (
<QueryErrorRow
error={probes[failed[0]].error}
slot="catalog-group-error"
message={t("metricCatalog.loadError")}
onRetry={() => failed.forEach((id) => probes[id].retry())}
+2 -2
View File
@@ -39,7 +39,7 @@ export default function InsightsEcgPage() {
// Reuse the SAME query cell `<EcgSection>` 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, refetch } = useQuery({
const { data, isLoading, isError, error, refetch } = useQuery({
queryKey: queryKeys.insightsEcgList(),
queryFn: () => apiGet<{ hasRecordings: boolean }>("/api/insights/ecg"),
enabled: isAuthenticated,
@@ -55,7 +55,7 @@ export default function InsightsEcgPage() {
title={t("insights.ecg.sectionTitle")}
description={t("insights.subPage.ecgDescription")}
>
<QueryErrorCard onRetry={() => refetch()} />
<QueryErrorCard error={error} onRetry={() => refetch()} />
</SubPageShell>
);
}
+2
View File
@@ -106,6 +106,7 @@ export default function InsightsMedikamentePage() {
data: comprehensive,
isLoading: isComprehensiveLoading,
isError: isComprehensiveError,
error: comprehensiveError,
refetch: refetchComprehensive,
} = useQuery({
queryKey: queryKeys.insightsComprehensive(),
@@ -179,6 +180,7 @@ export default function InsightsMedikamentePage() {
explainerMetric="medications"
>
<QueryErrorCard
error={comprehensiveError}
description={t("insights.loadError")}
onRetry={() => refetchComprehensive()}
/>
+1
View File
@@ -99,6 +99,7 @@ export default function InsightsNutrientsPage() {
description={t("nutrients.page.description")}
>
<QueryErrorCard
error={overview.error}
title={t("nutrients.page.loadError")}
onRetry={() => void overview.refetch()}
/>
+6 -3
View File
@@ -45,9 +45,11 @@ export default function InsightsSchlafPage() {
// to paint the same sentence three times, none of them offering a way out.
// The page owns the shared state and renders one notice with a retry; the
// cards no longer carry their own copy of it.
const { isError: rhythmError, refetch: refetchRhythm } = useSleepRhythm(
Boolean(user) && !isEmpty,
);
const {
isError: rhythmError,
error: rhythmFailure,
refetch: refetchRhythm,
} = useSleepRhythm(Boolean(user) && !isEmpty);
if (isEmpty) {
return (
@@ -127,6 +129,7 @@ export default function InsightsSchlafPage() {
*/}
{rhythmError ? (
<QueryErrorCard
error={rhythmFailure}
description={t("insights.sleep.rhythm.loadError")}
onRetry={() => void refetchRhythm()}
/>
+1 -1
View File
@@ -99,7 +99,7 @@ export default function InsightsWorkoutDetailPage({
</div>
) : error ? (
// A failed query must never read as "no data" (UI-STANDARDS §6).
<QueryErrorCard onRetry={() => refetch()} />
<QueryErrorCard error={error} onRetry={() => refetch()} />
) : !data ? (
<p
data-slot="workout-detail-error"
@@ -60,6 +60,7 @@ export default function InsightsWorkoutsPageClient() {
isLoading,
isEmpty,
isError,
error,
isFetchNextPageError,
isFetchingNextPage,
hasNextPage,
@@ -111,6 +112,7 @@ export default function InsightsWorkoutsPageClient() {
nothing to aggregate. */}
{isError ? (
<QueryErrorCard
error={error}
title={t("insights.workouts.loadError")}
onRetry={() => refetch()}
/>
+2
View File
@@ -49,6 +49,7 @@ export default function MedicationDetailPage({
data: medication,
isLoading,
isError,
error,
refetch,
} = useQuery<MedicationDetailSnapshot>({
queryKey: queryKeys.medicationDetail(id),
@@ -83,6 +84,7 @@ export default function MedicationDetailPage({
<BackLink href="/medications" label={t("medications.back")} />
<div data-slot="medication-detail-error-card">
<QueryErrorCard
error={error}
description={t("medications.detail.shell.loadFailed")}
onRetry={() => void refetch()}
/>
+2
View File
@@ -333,6 +333,7 @@ export default function MedicationsPageClient() {
data: medications,
isLoading,
isError,
error,
refetch: refetchMedications,
} = useQuery({
queryKey: queryKeys.medications(),
@@ -656,6 +657,7 @@ export default function MedicationsPageClient() {
</div>
) : isError ? (
<QueryErrorCard
error={error}
description={t("medications.loadFailed")}
retryLabel={t("medications.retryLoad")}
onRetry={() => void refetchMedications()}
+2 -1
View File
@@ -48,7 +48,7 @@ export default function NotificationsPage() {
const { t } = useTranslations();
const queryClient = useQueryClient();
const { data, isLoading, isError, refetch } = useQuery({
const { data, isLoading, isError, error, refetch } = useQuery({
queryKey: queryKeys.notificationsPreferences(),
queryFn: async () => {
return apiGet<PreferencesData>("/api/notifications/preferences");
@@ -186,6 +186,7 @@ export default function NotificationsPage() {
description={t("notifications.subtitle")}
/>
<QueryErrorCard
error={error}
description={t("notifications.loadError")}
onRetry={() => refetch()}
/>
+1
View File
@@ -58,6 +58,7 @@ export default function HandoverDecisionPage() {
{decision.isError ? (
<QueryErrorCard
error={decision.error}
title={t("recordSharing.handoverDecision.loadError")}
onRetry={() => void decision.refetch()}
/>
+4 -1
View File
@@ -998,7 +998,10 @@ export default function DashboardPageClient({
) : !mounted || digestQuery.isLoading ? (
<TodayHeroSkeleton />
) : digestQuery.isError ? (
<QueryErrorCard onRetry={() => digestQuery.refetch()} />
<QueryErrorCard
error={digestQuery.error}
onRetry={() => digestQuery.refetch()}
/>
) : null
}