mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
Merge branch 'fix/v1437-review' into release/v1.43.7
# Conflicts: # src/app/api/analytics/route.ts
This commit is contained in:
commit
45c027aee2
217 files changed
+3636
-2127
No files matched your search
@@ -35,6 +35,8 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
measurement: { findMany: vi.fn() },
|
||||
// The empty recovery page reads the Oura fields for its reason.
|
||||
user: { findUnique: vi.fn().mockResolvedValue(null) },
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { walkSourceFiles } from "@/__tests__/helpers/source-files";
|
||||
|
||||
/**
|
||||
* No raw control byte in a source file, apart from tab, newline and carriage
|
||||
* return.
|
||||
*
|
||||
* A control byte typed into a string literal is invisible in an editor, a
|
||||
* diff and a review, renders as nothing or as a box, and survives copy and
|
||||
* paste unseen; the same value written as an escape (`"\x01"`) is the same
|
||||
* string and can be read. Three such bytes had crept in, one of them in a
|
||||
* production join key.
|
||||
*/
|
||||
const CONTROL = /[\x00-\x08\x0b\x0c\x0e-\x1f]/;
|
||||
|
||||
describe("source files carry no raw control bytes", () => {
|
||||
it("every file under src/ is free of them", () => {
|
||||
const src = join(process.cwd(), "src");
|
||||
const files = walkSourceFiles(src, {
|
||||
floor: 3000,
|
||||
extensions: [".ts", ".tsx", ".js", ".mjs", ".css", ".json", ".md"],
|
||||
}).filter((file) => !file.startsWith("generated/"));
|
||||
// A sweep that read nothing would agree with any tree.
|
||||
expect(files.length).toBeGreaterThan(3000);
|
||||
const offenders: string[] = [];
|
||||
for (const file of files) {
|
||||
const lines = readFileSync(join(src, file), "utf8").split("\n");
|
||||
lines.forEach((line, i) => {
|
||||
if (CONTROL.test(line)) offenders.push(`${file}:${i + 1}`);
|
||||
});
|
||||
}
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it("recognises a control byte when one is there", () => {
|
||||
expect(CONTROL.test("a\u0001b")).toBe(true);
|
||||
expect(CONTROL.test("tab\tnewline\ncr\r")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Every read-failure primitive is handed the error it reports.
|
||||
*
|
||||
* `QueryErrorRow` and `QueryErrorCard` decide what a failed read offers from
|
||||
* the error itself: nothing (or the surface's refused state) for a 403, a
|
||||
* note without a retry for a 429, sign-in for a 401, a retry otherwise. A
|
||||
* call site that renders one without the error falls back to "failed" for
|
||||
* every status, which is how a refused share showed an error card and a spent
|
||||
* budget offered a retry that could only be refused again.
|
||||
*
|
||||
* The prop is required in the type, so the compiler already catches a missing
|
||||
* one. What it cannot catch is a literal that defeats it (`error={null}`,
|
||||
* `error={undefined}`), so this guard reads every opening tag under `src/`
|
||||
* and refuses both. The matcher is whitespace-tolerant (tags span lines) and
|
||||
* the match count is asserted, so a matcher that stops finding the tags fails
|
||||
* rather than passing over nothing.
|
||||
*/
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { join, relative } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const SRC = join(__dirname, "..");
|
||||
|
||||
function walk(dir: string, out: string[] = []): string[] {
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
if (entry.name === "__tests__" || entry.name === "generated") continue;
|
||||
const path = join(dir, entry.name);
|
||||
if (entry.isDirectory()) walk(path, out);
|
||||
else if (entry.name.endsWith(".tsx") && !entry.name.includes(".test."))
|
||||
out.push(path);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The opening tag's attribute text, from the name to its closing `>`. */
|
||||
function openingTags(source: string): string[] {
|
||||
const tags: string[] = [];
|
||||
const open = /<(QueryErrorRow|QueryErrorCard)(?=[\s/>])/g;
|
||||
for (let m = open.exec(source); m; m = open.exec(source)) {
|
||||
let depth = 0;
|
||||
let i = m.index + m[0].length;
|
||||
for (; i < source.length; i++) {
|
||||
const ch = source[i];
|
||||
if (ch === "{") depth++;
|
||||
else if (ch === "}") depth--;
|
||||
else if (ch === ">" && depth === 0) break;
|
||||
}
|
||||
tags.push(source.slice(m.index, i + 1));
|
||||
}
|
||||
return tags;
|
||||
}
|
||||
|
||||
/** Comments mention the primitives by name (`<QueryErrorCard>`); skip them. */
|
||||
function stripComments(source: string): string {
|
||||
return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
|
||||
}
|
||||
|
||||
const sites = walk(SRC).flatMap((file) =>
|
||||
openingTags(stripComments(readFileSync(file, "utf8"))).map((tag) => ({
|
||||
file: relative(SRC, file),
|
||||
tag,
|
||||
})),
|
||||
);
|
||||
|
||||
describe("read-failure primitives carry their error", () => {
|
||||
it("finds the call sites it guards", () => {
|
||||
// About 120 at the time of writing. A drop to a handful means the
|
||||
// matcher broke, not that the sites went away.
|
||||
expect(sites.length).toBeGreaterThan(100);
|
||||
});
|
||||
|
||||
it("passes the query's error at every site", () => {
|
||||
const offenders = sites
|
||||
.filter(
|
||||
({ tag }) =>
|
||||
!/\serror=\{/.test(tag) ||
|
||||
/\serror=\{\s*(null|undefined)\s*\}/.test(tag),
|
||||
)
|
||||
.map(({ file, tag }) => `${file}: ${tag.split("\n")[0]}`);
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -216,6 +216,78 @@ describe("date formatters name the calendar they render in", () => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A component renders dates through `useFormatters()`, never through the
|
||||
* shared cache directly.
|
||||
*
|
||||
* The two-argument `getDateTimeFormat(locale, options)` from
|
||||
* `@/lib/intl/formatter-cache` can name a zone and still be wrong: it takes
|
||||
* the locale and the hour cycle from wherever the caller found them and
|
||||
* knows nothing of the reader's date-order preference. The workout surfaces
|
||||
* rebuilt their own formatters this way and showed "Dec 16" to a reader who
|
||||
* had chosen YYYY-MM-DD. The three-argument `(locale, timeZone, options)`
|
||||
* form from `@/lib/tz/intl-cache` builds day keys and clock parts, not
|
||||
* display text, and stays allowed.
|
||||
*/
|
||||
describe("components format dates through useFormatters", () => {
|
||||
const CALL = /\bgetDateTimeFormat\s*\(/g;
|
||||
|
||||
function scan() {
|
||||
const files = execFileSync(
|
||||
"grep",
|
||||
[
|
||||
"-rlE",
|
||||
"getDateTimeFormat\\s*\\(",
|
||||
"src/components",
|
||||
"--include=*.ts",
|
||||
"--include=*.tsx",
|
||||
],
|
||||
{ cwd: REPO_ROOT, encoding: "utf8" },
|
||||
)
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.filter((f) => !f.includes("__tests__"));
|
||||
const offenders: string[] = [];
|
||||
let calls = 0;
|
||||
for (const file of files) {
|
||||
const source = readFileSync(resolve(REPO_ROOT, file), "utf8");
|
||||
CALL.lastIndex = 0;
|
||||
for (let m = CALL.exec(source); m; m = CALL.exec(source)) {
|
||||
const lineStart = source.lastIndexOf("\n", m.index) + 1;
|
||||
if (/^\s*(?:\/\/|\*|\/\*)/.test(source.slice(lineStart, m.index)))
|
||||
continue;
|
||||
calls += 1;
|
||||
const args = argumentList(source, m.index + m[0].length - 1);
|
||||
if (topLevelArgCount(args) < 3) {
|
||||
offenders.push(
|
||||
`${file}:${source.slice(0, m.index).split("\n").length}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { offenders, calls };
|
||||
}
|
||||
|
||||
it("still sees the cache's calls in components (the matcher matches)", () => {
|
||||
expect(scan().calls).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("has no two-argument getDateTimeFormat in src/components", () => {
|
||||
expect(scan().offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it("the matcher flags the two-argument form across lines", () => {
|
||||
const source =
|
||||
'getDateTimeFormat(\n locale,\n { timeZone, month: "short" },\n)';
|
||||
CALL.lastIndex = 0;
|
||||
const m = CALL.exec(source)!;
|
||||
expect(
|
||||
topLevelArgCount(argumentList(source, m.index + m[0].length - 1)),
|
||||
).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A stated calendar date must not go through a zone-aware short formatter.
|
||||
*
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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())}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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()}
|
||||
/>
|
||||
|
||||
@@ -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()}
|
||||
/>
|
||||
|
||||
@@ -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()}
|
||||
/>
|
||||
|
||||
@@ -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()}
|
||||
/>
|
||||
|
||||
@@ -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()}
|
||||
/>
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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()}
|
||||
/>
|
||||
|
||||
@@ -58,6 +58,7 @@ export default function HandoverDecisionPage() {
|
||||
|
||||
{decision.isError ? (
|
||||
<QueryErrorCard
|
||||
error={decision.error}
|
||||
title={t("recordSharing.handoverDecision.loadError")}
|
||||
onRetry={() => void decision.refetch()}
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -75,7 +75,9 @@ describe("<PageHeader> — one canonical H1", () => {
|
||||
|
||||
describe("<QueryErrorCard> — retry affordance", () => {
|
||||
it("renders a retry button when onRetry is provided", () => {
|
||||
const html = render(<QueryErrorCard onRetry={() => {}} />);
|
||||
const html = render(
|
||||
<QueryErrorCard error={new Error("boom")} onRetry={() => {}} />,
|
||||
);
|
||||
expect(html).toContain('data-slot="query-error-card"');
|
||||
expect(html).toContain('role="alert"');
|
||||
// The retry affordance is a real <button> so a failed read is
|
||||
@@ -85,7 +87,7 @@ describe("<QueryErrorCard> — retry affordance", () => {
|
||||
});
|
||||
|
||||
it("omits the retry button when no onRetry is supplied", () => {
|
||||
const html = render(<QueryErrorCard />);
|
||||
const html = render(<QueryErrorCard error={new Error("boom")} />);
|
||||
expect(html).toContain('data-slot="query-error-card"');
|
||||
expect(html).not.toContain("<button");
|
||||
});
|
||||
|
||||
@@ -26,7 +26,10 @@ import en from "../../../messages/en.json";
|
||||
* rather than by driving a real fetch.
|
||||
*/
|
||||
|
||||
vi.mock("@/lib/api/api-fetch", () => ({
|
||||
vi.mock("@/lib/api/api-fetch", async (importOriginal) => ({
|
||||
// `ApiError` stays real: the error primitives read its status.
|
||||
ApiError: (await importOriginal<typeof import("@/lib/api/api-fetch")>())
|
||||
.ApiError,
|
||||
apiGet: () => new Promise(() => {}),
|
||||
apiPost: () => new Promise(() => {}),
|
||||
}));
|
||||
|
||||
@@ -25,7 +25,9 @@ function markup(node: ReactNode): string {
|
||||
);
|
||||
}
|
||||
|
||||
function boundaryWith(fallback: (retry: () => void) => ReactNode) {
|
||||
function boundaryWith(
|
||||
fallback: (retry: () => void, error: unknown) => ReactNode,
|
||||
) {
|
||||
const boundary = new Boundary({
|
||||
renderFallback: fallback,
|
||||
children: <p>card body</p>,
|
||||
@@ -53,18 +55,27 @@ describe("AdminCardBoundary", () => {
|
||||
});
|
||||
|
||||
it("turns a thrown render into the fallback, not a page failure", () => {
|
||||
expect(Boundary.getDerivedStateFromError()).toEqual({ failed: true });
|
||||
const thrown = new Error("render failed");
|
||||
expect(Boundary.getDerivedStateFromError(thrown)).toEqual({
|
||||
failed: true,
|
||||
error: thrown,
|
||||
});
|
||||
|
||||
const fallback = vi.fn(() => <p data-slot="fallback">fallback</p>);
|
||||
const fallback = vi.fn<(retry: () => void, error: unknown) => ReactNode>(
|
||||
() => <p data-slot="fallback">fallback</p>,
|
||||
);
|
||||
const boundary = boundaryWith(fallback);
|
||||
boundary.setState(Boundary.getDerivedStateFromError());
|
||||
boundary.setState(Boundary.getDerivedStateFromError(thrown));
|
||||
|
||||
expect(markup(boundary.render())).toContain('data-slot="fallback"');
|
||||
expect(fallback).toHaveBeenCalledWith(boundary.retry);
|
||||
// The fallback is handed what was thrown, so its error card reads it.
|
||||
expect(fallback).toHaveBeenCalledWith(boundary.retry, thrown);
|
||||
});
|
||||
|
||||
it("paints the translated error card with a keyboard-reachable retry", () => {
|
||||
const html = markup(<AdminCardError onRetry={() => {}} />);
|
||||
const html = markup(
|
||||
<AdminCardError error={new Error("render failed")} onRetry={() => {}} />,
|
||||
);
|
||||
|
||||
expect(html).toContain('data-slot="query-error-card"');
|
||||
expect(html).toContain('role="alert"');
|
||||
|
||||
@@ -18,17 +18,28 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
*/
|
||||
export function AdminCardBoundary({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<Boundary renderFallback={(retry) => <AdminCardError onRetry={retry} />}>
|
||||
<Boundary
|
||||
renderFallback={(retry, error) => (
|
||||
<AdminCardError error={error} onRetry={retry} />
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</Boundary>
|
||||
);
|
||||
}
|
||||
|
||||
/** What a failed card paints in its place. */
|
||||
export function AdminCardError({ onRetry }: { onRetry: () => void }) {
|
||||
export function AdminCardError({
|
||||
error,
|
||||
onRetry,
|
||||
}: {
|
||||
error: unknown;
|
||||
onRetry: () => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<QueryErrorCard
|
||||
error={error}
|
||||
title={t("admin.cardError.title")}
|
||||
description={t("admin.cardError.description")}
|
||||
onRetry={onRetry}
|
||||
@@ -37,12 +48,13 @@ export function AdminCardError({ onRetry }: { onRetry: () => void }) {
|
||||
}
|
||||
|
||||
export interface BoundaryProps {
|
||||
renderFallback: (retry: () => void) => ReactNode;
|
||||
renderFallback: (retry: () => void, error: unknown) => ReactNode;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
interface BoundaryState {
|
||||
failed: boolean;
|
||||
error?: unknown;
|
||||
attempt: number;
|
||||
}
|
||||
|
||||
@@ -50,8 +62,10 @@ interface BoundaryState {
|
||||
export class Boundary extends Component<BoundaryProps, BoundaryState> {
|
||||
state: BoundaryState = { failed: false, attempt: 0 };
|
||||
|
||||
static getDerivedStateFromError(): Pick<BoundaryState, "failed"> {
|
||||
return { failed: true };
|
||||
static getDerivedStateFromError(
|
||||
error: unknown,
|
||||
): Pick<BoundaryState, "failed" | "error"> {
|
||||
return { failed: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error): void {
|
||||
@@ -64,11 +78,17 @@ export class Boundary extends Component<BoundaryProps, BoundaryState> {
|
||||
}
|
||||
|
||||
retry = (): void => {
|
||||
this.setState((s) => ({ failed: false, attempt: s.attempt + 1 }));
|
||||
this.setState((s) => ({
|
||||
failed: false,
|
||||
error: undefined,
|
||||
attempt: s.attempt + 1,
|
||||
}));
|
||||
};
|
||||
|
||||
render(): ReactNode {
|
||||
if (this.state.failed) return this.props.renderFallback(this.retry);
|
||||
if (this.state.failed) {
|
||||
return this.props.renderFallback(this.retry, this.state.error);
|
||||
}
|
||||
// A new key on retry remounts the card, so it starts from a clean state
|
||||
// rather than re-rendering the tree that threw.
|
||||
return <Fragment key={this.state.attempt}>{this.props.children}</Fragment>;
|
||||
|
||||
@@ -104,6 +104,7 @@ export function AiQualitySection() {
|
||||
</div>
|
||||
) : query.isError ? (
|
||||
<QueryErrorRow
|
||||
error={query.error}
|
||||
message={t("admin.aiQuality.loadError")}
|
||||
onRetry={() => void query.refetch()}
|
||||
/>
|
||||
|
||||
@@ -847,7 +847,7 @@ export function BackupsSection() {
|
||||
const fmt = useFormatters();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
const { data, isLoading, isError, error, refetch } = useQuery({
|
||||
queryKey: queryKeys.adminBackups(),
|
||||
queryFn: async () => {
|
||||
return apiGet<BackupsList>("/api/admin/backups");
|
||||
@@ -1243,6 +1243,7 @@ export function BackupsSection() {
|
||||
</div>
|
||||
) : isError ? (
|
||||
<QueryErrorRow
|
||||
error={error}
|
||||
message={t("admin.section.backups.loadError")}
|
||||
onRetry={() => void refetch()}
|
||||
/>
|
||||
|
||||
@@ -99,6 +99,7 @@ export function CoachFeedbackSection() {
|
||||
</div>
|
||||
) : query.isError ? (
|
||||
<QueryErrorRow
|
||||
error={query.error}
|
||||
message={t("admin.coachFeedback.loadError")}
|
||||
onRetry={() => void query.refetch()}
|
||||
/>
|
||||
|
||||
@@ -144,7 +144,7 @@ export function HostMetricsChart() {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
const { data, isLoading, isError, error, refetch } = useQuery({
|
||||
queryKey: queryKeys.adminHostMetrics("2h"),
|
||||
queryFn: async () => {
|
||||
return apiGet<HostMetricsApiResponse>("/api/admin/host-metrics?since=2h");
|
||||
@@ -198,6 +198,7 @@ export function HostMetricsChart() {
|
||||
}
|
||||
/>
|
||||
<QueryErrorRow
|
||||
error={error}
|
||||
message={t("admin.hostMetrics.loadError")}
|
||||
onRetry={() => void refetch()}
|
||||
/>
|
||||
|
||||
@@ -155,6 +155,7 @@ export function KeyBackupCard() {
|
||||
/>
|
||||
{status.isError ? (
|
||||
<QueryErrorRow
|
||||
error={status.error}
|
||||
message={t("admin.keyBackup.loadError")}
|
||||
onRetry={() => void status.refetch()}
|
||||
/>
|
||||
|
||||
@@ -111,6 +111,7 @@ export function MeasurementMaintenanceCard() {
|
||||
if (status.isError) {
|
||||
return (
|
||||
<QueryErrorCard
|
||||
error={status.error}
|
||||
title={t("admin.section.maintenance.loadError")}
|
||||
onRetry={() => void status.refetch()}
|
||||
/>
|
||||
|
||||
@@ -141,7 +141,7 @@ function AccountRow({ row }: { row: OffhostAccountRow }) {
|
||||
export function OffhostBackupsSection() {
|
||||
const { t } = useTranslations();
|
||||
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
const { data, isLoading, isError, error, refetch } = useQuery({
|
||||
queryKey: queryKeys.adminBackups(),
|
||||
queryFn: async () => apiGet<BackupsList>("/api/admin/backups"),
|
||||
});
|
||||
@@ -170,6 +170,7 @@ export function OffhostBackupsSection() {
|
||||
</div>
|
||||
) : isError ? (
|
||||
<QueryErrorRow
|
||||
error={error}
|
||||
message={t("admin.section.backups.offhost.loadError")}
|
||||
onRetry={() => void refetch()}
|
||||
/>
|
||||
|
||||
@@ -125,6 +125,7 @@ export function PulseDayMeanCard() {
|
||||
if (status.isError) {
|
||||
return (
|
||||
<QueryErrorCard
|
||||
error={status.error}
|
||||
title={t("admin.section.pulseDayMean.loadError")}
|
||||
onRetry={() => void status.refetch()}
|
||||
/>
|
||||
|
||||
@@ -38,7 +38,7 @@ interface AuditLogResponse {
|
||||
export function RecentAuditPreview() {
|
||||
const { t } = useTranslations();
|
||||
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
const { data, isLoading, isError, error, refetch } = useQuery({
|
||||
queryKey: queryKeys.adminAuditOverview(),
|
||||
queryFn: async () => {
|
||||
return apiGet<AuditLogResponse>(
|
||||
@@ -84,6 +84,7 @@ export function RecentAuditPreview() {
|
||||
</div>
|
||||
) : isError ? (
|
||||
<QueryErrorRow
|
||||
error={error}
|
||||
message={t("admin.overview.auditLoadError")}
|
||||
onRetry={() => void refetch()}
|
||||
/>
|
||||
|
||||
@@ -128,7 +128,7 @@ export function FailingJobsCard({
|
||||
export function SystemStatusSection() {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const { data: status, isError, refetch } = useSystemStatus();
|
||||
const { data: status, isError, error, refetch } = useSystemStatus();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -277,6 +277,7 @@ export function SystemStatusSection() {
|
||||
// transient 500 (rolling deploy, DB blip) doesn't require a
|
||||
// full page reload to recover.
|
||||
<QueryErrorRow
|
||||
error={error}
|
||||
message={t("admin.systemStatusLoadError")}
|
||||
onRetry={() => void refetch()}
|
||||
retryLabel={t("admin.systemStatusRetry")}
|
||||
|
||||
@@ -31,7 +31,7 @@ import { useKeyBackupStatus } from "./use-key-backup-status";
|
||||
|
||||
export function SystemStatusSummary() {
|
||||
const { t } = useTranslations();
|
||||
const { data: status, isError, refetch } = useSystemStatus();
|
||||
const { data: status, isError, error, refetch } = useSystemStatus();
|
||||
const { data: keyBackup } = useKeyBackupStatus();
|
||||
|
||||
return (
|
||||
@@ -110,6 +110,7 @@ export function SystemStatusSummary() {
|
||||
</div>
|
||||
) : isError ? (
|
||||
<QueryErrorRow
|
||||
error={error}
|
||||
message={t("admin.overview.snapshotLoadError")}
|
||||
onRetry={() => void refetch()}
|
||||
/>
|
||||
|
||||
@@ -93,6 +93,7 @@ export function BodySitesSection({
|
||||
|
||||
{list.isError ? (
|
||||
<QueryErrorCard
|
||||
error={list.error}
|
||||
title={t("bodySites.loadError")}
|
||||
onRetry={() => void list.refetch()}
|
||||
/>
|
||||
|
||||
@@ -87,6 +87,7 @@ export function CustomMetricDetail({
|
||||
const {
|
||||
data: metric,
|
||||
isError: metricError,
|
||||
error: metricFailure,
|
||||
refetch: refetchMetric,
|
||||
} = useQuery({
|
||||
queryKey: queryKeys.customMetricDetail(customMetricId),
|
||||
@@ -98,6 +99,7 @@ export function CustomMetricDetail({
|
||||
data: list,
|
||||
isLoading,
|
||||
isError: listError,
|
||||
error: listFailure,
|
||||
refetch: refetchList,
|
||||
} = useInfiniteQuery({
|
||||
queryKey: queryKeys.customMetricEntries({
|
||||
@@ -179,6 +181,7 @@ export function CustomMetricDetail({
|
||||
description={description}
|
||||
/>
|
||||
<QueryErrorCard
|
||||
error={metricError ? metricFailure : listFailure}
|
||||
description={t("customMetrics.loadError")}
|
||||
onRetry={() => {
|
||||
if (metricError) void refetchMetric();
|
||||
|
||||
@@ -45,7 +45,7 @@ export function CustomMetricList() {
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [addFooterEl, setAddFooterEl] = useState<HTMLDivElement | null>(null);
|
||||
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
const { data, isLoading, isError, error, refetch } = useQuery({
|
||||
queryKey: queryKeys.customMetrics(),
|
||||
queryFn: () => apiGet<CustomMetricListResponse>("/api/custom-metrics"),
|
||||
});
|
||||
@@ -93,6 +93,7 @@ export function CustomMetricList() {
|
||||
</Card>
|
||||
) : isError ? (
|
||||
<QueryErrorCard
|
||||
error={error}
|
||||
description={t("customMetrics.loadError")}
|
||||
onRetry={() => refetch()}
|
||||
/>
|
||||
|
||||
@@ -42,6 +42,7 @@ export function CustomMetricValuesList({
|
||||
data: list,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
refetch,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
@@ -76,6 +77,7 @@ export function CustomMetricValuesList({
|
||||
if (isError) {
|
||||
return (
|
||||
<QueryErrorCard
|
||||
error={error}
|
||||
description={t("customMetrics.loadError")}
|
||||
onRetry={() => void refetch()}
|
||||
/>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Droplets } from "lucide-react";
|
||||
|
||||
import { ReadFailureRow } from "@/components/ui/read-failure-row";
|
||||
import { QueryErrorRow } from "@/components/ui/query-error-row";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { CycleRing } from "./cycle-ring";
|
||||
@@ -71,7 +71,7 @@ export function CycleRingTile({ className }: { className?: string }) {
|
||||
if (calendar.isLoading && !calendar.data) return null;
|
||||
if (calendar.isError && !calendar.data) {
|
||||
return (
|
||||
<ReadFailureRow
|
||||
<QueryErrorRow
|
||||
error={calendar.error}
|
||||
slot="wellness-cycle-tile-error"
|
||||
message={t("cycle.wellnessRing.loadError")}
|
||||
|
||||
@@ -362,6 +362,7 @@ export function CycleView() {
|
||||
// Inside the calendar card already — a borderless row keeps
|
||||
// it in line with the plain loading line above it (§3/§6).
|
||||
<QueryErrorRow
|
||||
error={calendar.error}
|
||||
message={t("cycle.loadError")}
|
||||
onRetry={() => void calendar.refetch()}
|
||||
className="border-0 bg-transparent p-0"
|
||||
@@ -405,6 +406,7 @@ export function CycleView() {
|
||||
<TabsContent value="predictions" className="mt-4 space-y-4">
|
||||
{calendarError ? (
|
||||
<QueryErrorCard
|
||||
error={calendar.error}
|
||||
title={t("cycle.loadError")}
|
||||
onRetry={() => void calendar.refetch()}
|
||||
/>
|
||||
@@ -435,6 +437,7 @@ export function CycleView() {
|
||||
<TabsContent value="insights" className="mt-4 space-y-4">
|
||||
{insights.isError ? (
|
||||
<QueryErrorCard
|
||||
error={insights.error}
|
||||
title={t("cycle.loadError")}
|
||||
onRetry={() => void insights.refetch()}
|
||||
/>
|
||||
@@ -454,6 +457,7 @@ export function CycleView() {
|
||||
<TabsContent value="settings" className="mt-4">
|
||||
{profileQuery.isError ? (
|
||||
<QueryErrorCard
|
||||
error={profileQuery.error}
|
||||
title={t("cycle.loadError")}
|
||||
onRetry={() => void profileQuery.refetch()}
|
||||
/>
|
||||
|
||||
@@ -24,7 +24,7 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
import { randomId } from "@/lib/random-id";
|
||||
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
|
||||
import { useCalendarDate } from "@/hooks/use-calendar-date";
|
||||
import { readFailureKind } from "@/components/ui/read-failure-row";
|
||||
import { readFailureKind } from "@/lib/api/read-failure";
|
||||
|
||||
import {
|
||||
cycleDependentKeys,
|
||||
@@ -158,7 +158,7 @@ export function useCycleRingDial(enabled: boolean): {
|
||||
enabled &&
|
||||
calendar.isError &&
|
||||
!calendar.data &&
|
||||
readFailureKind(calendar.error) !== "denied",
|
||||
readFailureKind(calendar.error) !== "refused",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { makeFormatters } from "@/lib/format-locale";
|
||||
import type { WorkoutListEntry } from "@/hooks/use-workouts";
|
||||
|
||||
/**
|
||||
@@ -17,6 +18,13 @@ vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({ isAuthenticated: true }),
|
||||
}));
|
||||
|
||||
// The day label follows the reader's date order through `useFormatters()`.
|
||||
const READER_FORMATTERS = makeFormatters("en", "UTC", "H24", "YMD");
|
||||
vi.mock("@/lib/i18n/context", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/i18n/context")>()),
|
||||
useFormatters: () => READER_FORMATTERS,
|
||||
}));
|
||||
|
||||
let mockResult: {
|
||||
data:
|
||||
| {
|
||||
@@ -147,5 +155,8 @@ describe("<RecentWorkoutsTile>", () => {
|
||||
expect(html).toContain("Cycling");
|
||||
expect(html).toContain("30m");
|
||||
expect(html).toContain("1h 0m");
|
||||
expect(html).toContain(
|
||||
READER_FORMATTERS.dateWithWeekdaySmart("2026-05-15T07:00:00Z"),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -4,12 +4,11 @@ import Link from "next/link";
|
||||
import { Activity } from "lucide-react";
|
||||
|
||||
import { formatDurationMinutes } from "@/lib/i18n/duration";
|
||||
import { useDisplayTimezone, useTranslations } from "@/lib/i18n/context";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { useWorkouts, type WorkoutListEntry } from "@/hooks/use-workouts";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { getDateTimeFormat } from "@/lib/intl/formatter-cache";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { iconForSport } from "@/lib/workouts/sport-icons";
|
||||
@@ -32,16 +31,6 @@ import { iconForSport } from "@/lib/workouts/sport-icons";
|
||||
* round-trip per session.
|
||||
*/
|
||||
|
||||
function formatDayLabel(iso: string, locale: string, timeZone: string): string {
|
||||
const d = new Date(iso);
|
||||
return getDateTimeFormat(locale, {
|
||||
timeZone,
|
||||
weekday: "short",
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
}).format(d);
|
||||
}
|
||||
|
||||
function renderRow(workout: WorkoutListEntry, sportName: string) {
|
||||
const Icon = iconForSport(workout.sportType);
|
||||
return (
|
||||
@@ -61,9 +50,9 @@ function renderRow(workout: WorkoutListEntry, sportName: string) {
|
||||
}
|
||||
|
||||
export function RecentWorkoutsTile() {
|
||||
const { t, locale } = useTranslations();
|
||||
const timeZone = useDisplayTimezone();
|
||||
const { data, isError, refetch } = useWorkouts({ limit: 3 });
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const { data, isError, error, refetch } = useWorkouts({ limit: 3 });
|
||||
|
||||
// A query's `isLoading` is not a hydration-safe branch: TanStack reports the
|
||||
// optimistic mount fetch on the client's very first render and cannot on the
|
||||
@@ -116,6 +105,7 @@ export function RecentWorkoutsTile() {
|
||||
</div>
|
||||
) : isError ? (
|
||||
<QueryErrorCard
|
||||
error={error}
|
||||
onRetry={() => refetch()}
|
||||
className="border-0 bg-transparent shadow-none"
|
||||
/>
|
||||
@@ -147,7 +137,7 @@ export function RecentWorkoutsTile() {
|
||||
>
|
||||
{renderRow(workout, sportName)}
|
||||
<span className="text-muted-foreground shrink-0 text-xs tabular-nums">
|
||||
{formatDayLabel(workout.startedAt, locale, timeZone)}
|
||||
{fmt.dateWithWeekdaySmart(workout.startedAt)}
|
||||
</span>
|
||||
<span className="ml-1 shrink-0 text-xs font-medium tabular-nums">
|
||||
{formatDurationMinutes(
|
||||
|
||||
@@ -274,6 +274,7 @@ export function DayView({
|
||||
|
||||
{day.isError ? (
|
||||
<QueryErrorRow
|
||||
error={day.error}
|
||||
message={t("day.loadFailed")}
|
||||
onRetry={() => void day.refetch()}
|
||||
slot="day-error"
|
||||
|
||||
@@ -17,8 +17,9 @@ import {
|
||||
} from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { ReadFailureRow } from "@/components/ui/read-failure-row";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { QueryErrorRow } from "@/components/ui/query-error-row";
|
||||
import type { EncounterDTO } from "@/lib/encounters/dto";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import type {
|
||||
@@ -35,7 +36,7 @@ import {
|
||||
import { getUnitForType } from "@/lib/measurements/unit-map";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
import { DAY_SECTION_LABEL, dayTitle, useNotableText } from "./day-sections";
|
||||
import { dayTitle, useNotableText } from "./day-sections";
|
||||
import { DayLink } from "./day-link";
|
||||
import { dateKeyOfInstant } from "./day-url";
|
||||
import { tileKeyOf } from "./day-values-model";
|
||||
@@ -195,7 +196,7 @@ export function SinceLastVisit({
|
||||
|
||||
if (window.isError && rows.length === 0) {
|
||||
return (
|
||||
<ReadFailureRow
|
||||
<QueryErrorRow
|
||||
error={window.error}
|
||||
slot="since-last-visit-error"
|
||||
message={t("day.sinceLastVisit.loadError")}
|
||||
@@ -212,24 +213,27 @@ export function SinceLastVisit({
|
||||
const nextDay = dateKeyOfInstant(next.occurredAt, timeZone);
|
||||
|
||||
return (
|
||||
<Card className="gap-3 py-3 md:gap-3 md:py-4">
|
||||
<Card className="gap-2 py-3 md:py-4">
|
||||
<CardHeader>
|
||||
<TileHeader
|
||||
size="sm"
|
||||
icon={Stethoscope}
|
||||
id={`since-last-visit-${next.id}`}
|
||||
titleAs="h3"
|
||||
title={t("day.sinceLastVisit.title", { date: lastLabel })}
|
||||
right={
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{tCount("day.sinceLastVisit.count", rows.length)}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<section
|
||||
data-slot="since-last-visit"
|
||||
aria-labelledby={`since-last-visit-${next.id}`}
|
||||
className="space-y-2"
|
||||
>
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<h3
|
||||
id={`since-last-visit-${next.id}`}
|
||||
className={DAY_SECTION_LABEL}
|
||||
>
|
||||
{t("day.sinceLastVisit.title", { date: lastLabel })}
|
||||
</h3>
|
||||
<span className="text-muted-foreground shrink-0 text-xs">
|
||||
{tCount("day.sinceLastVisit.count", rows.length)}
|
||||
</span>
|
||||
</div>
|
||||
<ul className="border-border divide-border divide-y rounded-lg border px-3.5">
|
||||
{visible.map((row) => (
|
||||
<li
|
||||
|
||||
@@ -135,6 +135,7 @@ export function DocumentLinkPicker({
|
||||
</div>
|
||||
) : list.isError ? (
|
||||
<QueryErrorRow
|
||||
error={list.error}
|
||||
message={t("documents.list.loadError")}
|
||||
onRetry={() => list.refetch()}
|
||||
/>
|
||||
|
||||
@@ -287,7 +287,7 @@ export function DocumentRecordLinks({
|
||||
title={t("documents.detail.vaccinationsLabel")}
|
||||
slot="document-vaccination-links"
|
||||
pending={vaccinations.isPending || !seedFresh}
|
||||
error={vaccinations.isError || seedError}
|
||||
error={vaccinations.error || seedError}
|
||||
errorLabel={t("links.picker.loadError")}
|
||||
onRetry={() => {
|
||||
if (vaccinations.isError) void vaccinations.refetch();
|
||||
|
||||
@@ -1076,6 +1076,7 @@ export function DocumentsView() {
|
||||
)
|
||||
) : list.isError ? (
|
||||
<QueryErrorCard
|
||||
error={list.error}
|
||||
title={t("documents.list.loadError")}
|
||||
onRetry={() => void list.refetch()}
|
||||
/>
|
||||
|
||||
@@ -77,6 +77,7 @@ export function EpisodeDocumentsCard({ episodeId }: { episodeId: string }) {
|
||||
if (list.isError) {
|
||||
return (
|
||||
<QueryErrorCard
|
||||
error={list.error}
|
||||
title={t("documents.episodeCard.loadError")}
|
||||
onRetry={() => void list.refetch()}
|
||||
/>
|
||||
|
||||
@@ -552,6 +552,7 @@ export function DocumentSourcePicker({
|
||||
</div>
|
||||
) : search.isError ? (
|
||||
<QueryErrorRow
|
||||
error={search.error}
|
||||
message={sourceErrorMessage(t, errorCodeOf(search.error))}
|
||||
onRetry={() => void search.refetch()}
|
||||
slot="document-source-error"
|
||||
|
||||
@@ -297,5 +297,20 @@ describe("<VisitsSection> since the last visit", () => {
|
||||
expect(html).toContain('data-slot="since-last-visit"');
|
||||
expect(html).toContain('data-slot="since-last-visit-row"');
|
||||
expect(upcomingCards(html)).toBe(upcomingCards(baseline) + 1);
|
||||
// The card heads like every compact card: TileHeader at the small size,
|
||||
// a real heading under the section's own, at the compact density.
|
||||
const card = html.slice(
|
||||
html.lastIndexOf(
|
||||
'<div data-slot="card"',
|
||||
html.indexOf("since-last-visit-"),
|
||||
),
|
||||
);
|
||||
expect(card).toMatch(
|
||||
/^<div data-slot="card" class="[^"]*gap-2 py-3 md:py-4/,
|
||||
);
|
||||
expect(card).toContain('data-slot="tile-header"');
|
||||
expect(card).toMatch(
|
||||
/<h3 id="since-last-visit-[^"]+" data-slot="card-title" class="[^"]*text-sm/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -96,6 +96,7 @@ export function ProceduresSection({ enabled = true }: { enabled?: boolean }) {
|
||||
|
||||
{list.isError ? (
|
||||
<QueryErrorCard
|
||||
error={list.error}
|
||||
title={t("encounters.procedures.loadError")}
|
||||
onRetry={() => void list.refetch()}
|
||||
/>
|
||||
|
||||
@@ -110,6 +110,7 @@ export function VisitsSection({ enabled = true }: { enabled?: boolean }) {
|
||||
|
||||
{list.isError ? (
|
||||
<QueryErrorCard
|
||||
error={list.error}
|
||||
title={t("encounters.loadError")}
|
||||
onRetry={() => void list.refetch()}
|
||||
/>
|
||||
|
||||
@@ -52,6 +52,7 @@ export function EpisodeVisitsCard({ episodeId }: { episodeId: string }) {
|
||||
if (list.isError) {
|
||||
return (
|
||||
<QueryErrorCard
|
||||
error={list.error}
|
||||
title={t("encounters.loadError")}
|
||||
onRetry={() => void list.refetch()}
|
||||
/>
|
||||
|
||||
@@ -58,6 +58,7 @@ export function IllnessEpisodeDetail({ episodeId }: { episodeId: string }) {
|
||||
data: episode,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
refetch,
|
||||
} = useIllnessEpisode(episodeId);
|
||||
const resolve = useResolveEpisode();
|
||||
@@ -69,6 +70,7 @@ export function IllnessEpisodeDetail({ episodeId }: { episodeId: string }) {
|
||||
if (isError) {
|
||||
return (
|
||||
<QueryErrorCard
|
||||
error={error}
|
||||
title={t("illness.loadError")}
|
||||
onRetry={() => void refetch()}
|
||||
/>
|
||||
|
||||
@@ -370,6 +370,7 @@ export function IllnessView() {
|
||||
data: episodes,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
refetch,
|
||||
} = useIllnessEpisodes(true);
|
||||
const resolve = useResolveEpisode();
|
||||
@@ -460,6 +461,7 @@ export function IllnessView() {
|
||||
// A read failure is NOT an empty journal — surface the error + Retry so
|
||||
// an outage never reads as "no episodes yet".
|
||||
<QueryErrorCard
|
||||
error={error}
|
||||
title={t("illness.listLoadError")}
|
||||
onRetry={() => void refetch()}
|
||||
/>
|
||||
|
||||
@@ -3,7 +3,6 @@ import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import type { DataSummary } from "@/lib/analytics/trends";
|
||||
|
||||
/**
|
||||
@@ -46,12 +45,6 @@ function render(node: React.ReactNode) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false, staleTime: Infinity } },
|
||||
});
|
||||
// The recovery page's empty note waits for the connection status; an
|
||||
// account with no integrations is the settled answer here.
|
||||
queryClient.setQueryData(queryKeys.integrationsStatus(), {
|
||||
threshold: 3,
|
||||
integrations: [],
|
||||
});
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>
|
||||
@@ -204,21 +197,20 @@ describe("<SleepQualitySection> data-gating", () => {
|
||||
|
||||
describe("<RecoverySection> data-gating", () => {
|
||||
it("shows the calm empty note when there is no recovery data at all", () => {
|
||||
analyticsMock.mockReturnValue(
|
||||
analyticsWith({ SLEEP_DURATION: summary({ count: 10 }) }),
|
||||
);
|
||||
// The server resolves the empty reason beside the summaries.
|
||||
const empty = analyticsWith({ SLEEP_DURATION: summary({ count: 10 }) });
|
||||
analyticsMock.mockReturnValue({
|
||||
...empty,
|
||||
data: { ...empty.data, recoveryEmpty: "general" },
|
||||
});
|
||||
const html = render(<RecoverySection />);
|
||||
expect(html).toContain('data-slot="recovery-empty"');
|
||||
// Routed through the shared <EmptyState> (dashed bordered card), not the
|
||||
// old hand-rolled div — the empty title comes from the unified primitive.
|
||||
expect(html).toContain("border-dashed");
|
||||
expect(html).not.toContain('data-slot="recovery-block-DAY_STRAIN"');
|
||||
// An Oura account sees this note too: it must say where Oura's readiness
|
||||
// lives rather than tell a connected user to connect Oura.
|
||||
expect(html).not.toContain("Connect a WHOOP, Polar or Oura account");
|
||||
expect(html).toContain(
|
||||
"Oura's readiness appears as your recovery score",
|
||||
);
|
||||
expect(html).toContain("WHOOP or Polar");
|
||||
});
|
||||
|
||||
it("renders a canonical chart block for the present signal", () => {
|
||||
|
||||
@@ -3,16 +3,13 @@ import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import type { DataSummary } from "@/lib/analytics/trends";
|
||||
|
||||
/**
|
||||
* The recovery page's empty note says what is missing for THIS account. An
|
||||
* Oura ring fills the page through its resilience level alone, so a connected
|
||||
* ring with an empty page asks for a reconnect only when the server recorded
|
||||
* that the grant leaves resilience out, and otherwise says only that no
|
||||
* level has arrived. The connection facts come from the server's
|
||||
* integrations read; the page only picks the sentence, and waits for it.
|
||||
* The recovery page's empty note says what is missing for THIS account, and
|
||||
* the reason is the server's (`recoveryEmpty` on the analytics slice): the
|
||||
* page only picks the sentence for it and never reads the connection status
|
||||
* itself. Every reason offers the same way on, the integrations settings.
|
||||
*/
|
||||
|
||||
vi.mock("next/dynamic", () => ({
|
||||
@@ -50,26 +47,24 @@ function summary(count: number): DataSummary {
|
||||
};
|
||||
}
|
||||
|
||||
/** An Oura-only account: readiness, HRV and sleep, no resilience level. */
|
||||
/** An Oura-only account: readiness, HRV and sleep, no recovery type. */
|
||||
const OURA_SUMMARIES = {
|
||||
SLEEP_DURATION: summary(30),
|
||||
RECOVERY_SCORE: summary(30),
|
||||
HRV_RMSSD: summary(30),
|
||||
};
|
||||
|
||||
function render(
|
||||
oura: Record<string, unknown> | null,
|
||||
opts: { statusLoaded?: boolean } = {},
|
||||
) {
|
||||
function render(recoveryEmpty: string | null) {
|
||||
analyticsMock.mockReturnValue({
|
||||
data: { summaries: OURA_SUMMARIES, recoveryEmpty },
|
||||
isLoading: false,
|
||||
isEmpty: false,
|
||||
error: null,
|
||||
refetch: () => {},
|
||||
});
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false, staleTime: Infinity } },
|
||||
defaultOptions: { queries: { retry: false, enabled: false } },
|
||||
});
|
||||
if (opts.statusLoaded !== false) {
|
||||
queryClient.setQueryData(queryKeys.integrationsStatus(), {
|
||||
threshold: 3,
|
||||
integrations: oura ? [{ integration: "oura", ...oura }] : [],
|
||||
});
|
||||
}
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<I18nProvider initialLocale="en">
|
||||
@@ -79,67 +74,24 @@ function render(
|
||||
);
|
||||
}
|
||||
|
||||
function health(unavailable: Array<{ item: string; cause: string }>) {
|
||||
return {
|
||||
state: unavailable.length > 0 ? "limited" : "ok",
|
||||
reason: unavailable.length > 0 ? "permission_limited" : "data_arriving",
|
||||
unavailable,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
analyticsMock.mockReset();
|
||||
analyticsMock.mockReturnValue({
|
||||
data: { summaries: OURA_SUMMARIES },
|
||||
isLoading: false,
|
||||
isEmpty: false,
|
||||
error: null,
|
||||
refetch: () => {},
|
||||
});
|
||||
});
|
||||
|
||||
describe("<RecoverySection> empty note", () => {
|
||||
it("asks for the heart-health permission when the grant withholds resilience", () => {
|
||||
const html = render({
|
||||
connected: true,
|
||||
health: health([{ item: "resilience", cause: "permission" }]),
|
||||
});
|
||||
expect(html).toContain('data-empty-case="oura-permission"');
|
||||
expect(html).toContain("allow heart-health access");
|
||||
it.each([
|
||||
["oura_permission", "heart-health access"],
|
||||
["oura_no_resilience", "as soon as Oura provides one"],
|
||||
["general", "WHOOP or Polar"],
|
||||
])("renders the server's %s reason with the one action", (reason, text) => {
|
||||
const html = render(reason);
|
||||
expect(html).toContain(`data-empty-case="${reason}"`);
|
||||
expect(html).toContain(text);
|
||||
expect(html).toContain('href="/settings/integrations"');
|
||||
});
|
||||
|
||||
it("says only that no level has arrived when no withheld permission is recorded", () => {
|
||||
const html = render({
|
||||
connected: true,
|
||||
health: health([{ item: "spo2", cause: "permission" }]),
|
||||
});
|
||||
expect(html).toContain('data-empty-case="oura-no-resilience"');
|
||||
expect(html).toContain("no resilience level has arrived");
|
||||
// The server cannot prove the grant covers resilience, so the note
|
||||
// never says it does.
|
||||
expect(html).not.toContain("permission covers");
|
||||
expect(html).not.toContain('href="/settings/integrations"');
|
||||
});
|
||||
|
||||
it("stays neutral when the connection carries no health block", () => {
|
||||
const html = render({ connected: true });
|
||||
expect(html).toContain('data-empty-case="oura-no-resilience"');
|
||||
expect(html).not.toContain("permission covers");
|
||||
});
|
||||
|
||||
it("shows no note until the connection status has loaded", () => {
|
||||
const html = render(
|
||||
{ connected: true, health: health([]) },
|
||||
{ statusLoaded: false },
|
||||
);
|
||||
expect(html).toContain('data-slot="recovery-loading"');
|
||||
it("shows no note when the server says the page has data", () => {
|
||||
const html = render(null);
|
||||
expect(html).not.toContain("data-empty-case");
|
||||
});
|
||||
|
||||
it("keeps the general note for an account without Oura", () => {
|
||||
const html = render(null);
|
||||
expect(html).toContain('data-empty-case="general"');
|
||||
expect(html).toContain("WHOOP and Polar record");
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { describe, it, expect, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { makeFormatters } from "@/lib/format-locale";
|
||||
import { WorkoutList } from "../workout-list";
|
||||
import type { WorkoutListEntry } from "@/hooks/use-workouts";
|
||||
|
||||
@@ -18,6 +19,14 @@ vi.mock("@/hooks/use-auth", () => ({
|
||||
useAccountOnceMounted: () => account.user,
|
||||
}));
|
||||
|
||||
// The reader's date and clock preferences reach the list through
|
||||
// `useFormatters()`; this reader orders dates year first, on a 24-hour clock.
|
||||
const READER_FORMATTERS = makeFormatters("en", "UTC", "H24", "YMD");
|
||||
vi.mock("@/lib/i18n/context", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/i18n/context")>()),
|
||||
useFormatters: () => READER_FORMATTERS,
|
||||
}));
|
||||
|
||||
/**
|
||||
* v1.4.32 — `<WorkoutList>` unit tests.
|
||||
*
|
||||
@@ -54,6 +63,13 @@ describe("<WorkoutList>", () => {
|
||||
expect(html).toContain("30m");
|
||||
});
|
||||
|
||||
it("writes the start in the reader's own date order and clock", () => {
|
||||
const html = render(<WorkoutList workouts={[ROW]} />);
|
||||
expect(html).toContain(
|
||||
`${READER_FORMATTERS.dateWithWeekdaySmart(ROW.startedAt)}, ${READER_FORMATTERS.time(ROW.startedAt)}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("renders the distance and energy chips when present", () => {
|
||||
const html = render(<WorkoutList workouts={[ROW]} />);
|
||||
expect(html).toContain("5.20 km");
|
||||
|
||||
@@ -205,7 +205,7 @@ export function CoachResults({
|
||||
const expected = hasLive
|
||||
? countResultsInSection(liveWithProvenance(live ?? [], metas), section)
|
||||
: countResultsInSection(metas, section);
|
||||
const { ref, results, isError, refetch } = useCoachMessageResults({
|
||||
const { ref, results, isError, error, refetch } = useCoachMessageResults({
|
||||
conversationId,
|
||||
messageId,
|
||||
enabled: !hasLive && expected > 0,
|
||||
@@ -222,6 +222,7 @@ export function CoachResults({
|
||||
>
|
||||
{!hasLive && isError ? (
|
||||
<QueryErrorRow
|
||||
error={error}
|
||||
slot="coach-results-error"
|
||||
retrySlot="coach-results-retry"
|
||||
message={t(COACH_RESULT_UI_KEYS.loadFailed)}
|
||||
|
||||
@@ -12,7 +12,7 @@ import { useCoachConversation } from "./use-coach";
|
||||
*/
|
||||
export function ConversationTranscript({ id }: { id: string }) {
|
||||
const { t } = useTranslations();
|
||||
const { data, isLoading, isError, refetch } = useCoachConversation(id);
|
||||
const { data, isLoading, isError, error, refetch } = useCoachConversation(id);
|
||||
if (isLoading) {
|
||||
return (
|
||||
<p className="text-muted-foreground px-3 pb-3 text-xs">
|
||||
@@ -23,6 +23,7 @@ export function ConversationTranscript({ id }: { id: string }) {
|
||||
if (isError || !data) {
|
||||
return (
|
||||
<QueryErrorRow
|
||||
error={error}
|
||||
className="mx-3 mb-3"
|
||||
onRetry={() => void refetch()}
|
||||
slot="coach-conversations-transcript-error"
|
||||
|
||||
@@ -270,6 +270,7 @@ export function useCoachConversationHistory(
|
||||
conversations,
|
||||
isLoading: query.isLoading,
|
||||
isError: query.isError,
|
||||
error: query.error,
|
||||
hasNextPage: query.hasNextPage,
|
||||
isFetchingNextPage: query.isFetchingNextPage,
|
||||
fetchNextPage: query.fetchNextPage,
|
||||
|
||||
@@ -173,10 +173,8 @@ export function CoincidentDeviationCard({
|
||||
className,
|
||||
}: CoincidentDeviationCardProps) {
|
||||
const { t } = useTranslations();
|
||||
const { data, isError, refetch } = useDerivedMetric<CoincidentDeviationValue>(
|
||||
COINCIDENT_METRIC,
|
||||
{ enabled },
|
||||
);
|
||||
const { data, isError, error, refetch } =
|
||||
useDerivedMetric<CoincidentDeviationValue>(COINCIDENT_METRIC, { enabled });
|
||||
|
||||
// A failed read says so with a retry. Without this branch the card sat on
|
||||
// its skeleton forever, which read as "still loading" rather than failed.
|
||||
@@ -192,6 +190,7 @@ export function CoincidentDeviationCard({
|
||||
title={t("insights.derived.coincident.cardTitle")}
|
||||
/>
|
||||
<QueryErrorRow
|
||||
error={error}
|
||||
slot="coincident-deviation-error"
|
||||
onRetry={() => void refetch()}
|
||||
/>
|
||||
|
||||
@@ -3,6 +3,8 @@ import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { ApiError } from "@/lib/api/api-fetch";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
/**
|
||||
* The strip sits above the chart on every metric sub-page. While its read is
|
||||
@@ -37,4 +39,57 @@ describe("CoachReadStrip — placeholder while the read loads", () => {
|
||||
expect(markup).toContain('data-state="loading"');
|
||||
expect(markup).toContain('aria-busy="true"');
|
||||
});
|
||||
|
||||
function renderFailed(error: Error) {
|
||||
// `retryOnMount: false` keeps the seeded failure from being reset to a
|
||||
// fresh load on render.
|
||||
const client = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false, enabled: false, retryOnMount: false },
|
||||
},
|
||||
});
|
||||
client
|
||||
.getQueryCache()
|
||||
.build(client, { queryKey: queryKeys.insightsCoachRead("WEIGHT", "en") })
|
||||
.setState({ status: "error", error, fetchStatus: "idle" });
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={client}>
|
||||
<I18nProvider initialLocale="en">
|
||||
<CoachReadStrip metricType="WEIGHT" unit="kg" />
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
/** The class list of the strip body, the element after the header. */
|
||||
function bodyClass(markup: string): string | undefined {
|
||||
return /data-slot="card-content" class="([^"]*)"/.exec(markup)?.[1];
|
||||
}
|
||||
|
||||
it("holds the same frame and body height when the read fails", () => {
|
||||
const loading = renderToStaticMarkup(
|
||||
<QueryClientProvider
|
||||
client={
|
||||
new QueryClient({
|
||||
defaultOptions: { queries: { retry: false, enabled: false } },
|
||||
})
|
||||
}
|
||||
>
|
||||
<I18nProvider initialLocale="en">
|
||||
<CoachReadStrip metricType="WEIGHT" unit="kg" />
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
const failed = renderFailed(new ApiError("Boom", 500));
|
||||
expect(failed).toContain('data-slot="coach-read-strip"');
|
||||
expect(failed).toContain('data-state="error"');
|
||||
expect(failed).toContain('data-slot="coach-read-strip-error"');
|
||||
expect(bodyClass(failed)).toBeDefined();
|
||||
expect(bodyClass(failed)).toBe(bodyClass(loading));
|
||||
expect(bodyClass(failed)).toContain("min-h-8");
|
||||
});
|
||||
|
||||
it("draws nothing for a refused read", () => {
|
||||
expect(renderFailed(new ApiError("Forbidden", 403))).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
/**
|
||||
* A rhythm sub-score withheld for too few nights names the floor the server
|
||||
* published (`rhythmMinNights`), not a number the sheet carries itself.
|
||||
*/
|
||||
|
||||
const anatomy = vi.fn();
|
||||
vi.mock("../score-anatomy-view", () => ({
|
||||
ScoreAnatomyView: (props: Record<string, unknown>) => {
|
||||
anatomy(props);
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
vi.mock("@/components/insights/insight-status-card", () => ({
|
||||
InsightStatusCard: () => null,
|
||||
}));
|
||||
vi.mock("../score-history", () => ({
|
||||
ScoreHistoryChart: () => null,
|
||||
ScoreTrendChartDynamic: () => null,
|
||||
}));
|
||||
vi.mock("@/lib/i18n/context", () => ({
|
||||
useTranslations: () => ({
|
||||
t: (key: string, vars?: Record<string, string | number>) =>
|
||||
vars ? `${key}:${JSON.stringify(vars)}` : key,
|
||||
}),
|
||||
}));
|
||||
vi.mock("../use-derived-metric", () => ({
|
||||
useDerivedMetric: () => ({
|
||||
isLoading: false,
|
||||
data: {
|
||||
status: "ok",
|
||||
value: {
|
||||
score: 70,
|
||||
band: "yellow",
|
||||
subScores: [
|
||||
{ key: "consistency", value: null, weight: 0.2, gap: "few_nights" },
|
||||
],
|
||||
rhythmNights: 2,
|
||||
rhythmMinNights: 5,
|
||||
},
|
||||
coverage: 1,
|
||||
confidence: "high",
|
||||
provenance: {},
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
import { CompositeScoreAnatomy } from "../composite-score-anatomy";
|
||||
|
||||
describe("sleep score sheet — the few-nights gap", () => {
|
||||
it("names the published floor", () => {
|
||||
renderToStaticMarkup(<CompositeScoreAnatomy metric="SLEEP_SCORE" />);
|
||||
const props = anatomy.mock.calls.at(-1)![0] as {
|
||||
contributors: Array<{ key: string; missingLabel?: string }>;
|
||||
};
|
||||
const consistency = props.contributors.find((c) => c.key === "consistency");
|
||||
expect(consistency?.missingLabel).toContain('"count":2');
|
||||
expect(consistency?.missingLabel).toContain('"needed":5');
|
||||
});
|
||||
});
|
||||
@@ -10,8 +10,9 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { metricFractionDigits } from "@/lib/measurements/value-domain";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { readFailureKind } from "@/lib/api/read-failure";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { ReadFailureRow } from "@/components/ui/read-failure-row";
|
||||
import { QueryErrorRow } from "@/components/ui/query-error-row";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import type { CoachReadStripData } from "@/lib/insights/derived/coach-read-shape";
|
||||
@@ -60,6 +61,13 @@ export interface CoachReadStripProps {
|
||||
valueScale?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The strip's body holds the height of the error row's Retry button (32 px,
|
||||
* 44 px on a touch screen) in every state, so the placeholder, a failure and
|
||||
* a one-line read all stand the same height and the chart below never moves.
|
||||
*/
|
||||
const STRIP_BODY = "min-h-8 pointer-coarse:min-h-11";
|
||||
|
||||
export function CoachReadStrip({
|
||||
metricType,
|
||||
unit,
|
||||
@@ -97,26 +105,19 @@ export function CoachReadStrip({
|
||||
// baseline line degrades to the "learning" copy), so the placeholder only
|
||||
// ever gives way to a card of its own height. A failed read gives way to a
|
||||
// notice with a retry: dropping the strip read as "nothing to say about
|
||||
// today", which is not what happened. The route is a manage-level read, so
|
||||
// a viewer on a read-only share is refused it; that draws nothing, and a
|
||||
// spent analytics budget says so without offering a retry.
|
||||
// today", which is not what happened. The notice sits inside the same card
|
||||
// shell as the placeholder, so a failure does not move the chart either.
|
||||
// The route is a manage-level read, so a viewer on a read-only share is
|
||||
// refused it; that draws nothing, and a spent analytics budget says so
|
||||
// without offering a retry.
|
||||
if (!mounted || !data) {
|
||||
if (!isAuthenticated) return null;
|
||||
if (isError) {
|
||||
return (
|
||||
<ReadFailureRow
|
||||
error={error}
|
||||
slot="coach-read-strip-error"
|
||||
message={t("insights.coach.readStrip.loadError")}
|
||||
onRetry={() => void refetch()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (isError && readFailureKind(error) === "refused") return null;
|
||||
return (
|
||||
<Card
|
||||
data-slot="coach-read-strip"
|
||||
data-state="loading"
|
||||
aria-busy="true"
|
||||
data-state={isError ? "error" : "loading"}
|
||||
aria-busy={isError ? undefined : "true"}
|
||||
className="gap-2 py-3 md:py-4"
|
||||
>
|
||||
<CardHeader>
|
||||
@@ -126,8 +127,18 @@ export function CoachReadStrip({
|
||||
titleAs="h2"
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-5.5 w-3/4" />
|
||||
<CardContent className={STRIP_BODY}>
|
||||
{isError ? (
|
||||
<QueryErrorRow
|
||||
error={error}
|
||||
slot="coach-read-strip-error"
|
||||
message={t("insights.coach.readStrip.loadError")}
|
||||
onRetry={() => void refetch()}
|
||||
className="flex-row items-center justify-between rounded-none border-0 bg-transparent p-0"
|
||||
/>
|
||||
) : (
|
||||
<Skeleton className="h-5.5 w-3/4" />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
@@ -236,12 +247,12 @@ export function CoachReadStrip({
|
||||
<TileHeader
|
||||
icon={Sparkles}
|
||||
title={t("insights.coach.readStrip.label")}
|
||||
// L3 — top-level section on every metric sub-page, sibling of the
|
||||
// Top-level section on every metric sub-page, sibling of the
|
||||
// already-`h2` stat strip directly under the page `h1`.
|
||||
titleAs="h2"
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className={STRIP_BODY}>
|
||||
<div className="min-w-0 space-y-1 text-sm leading-relaxed">
|
||||
{baselineLine ? (
|
||||
<p data-slot="coach-read-baseline" className="text-pretty">
|
||||
|
||||
@@ -186,6 +186,7 @@ export function CompositeScoreAnatomy({
|
||||
return (
|
||||
<div className={className}>
|
||||
<QueryErrorRow
|
||||
error={query.error}
|
||||
slot="composite-anatomy-error"
|
||||
onRetry={() => void query.refetch()}
|
||||
/>
|
||||
@@ -242,6 +243,7 @@ export function CompositeScoreAnatomy({
|
||||
s.gap === "few_nights"
|
||||
? t("insights.derived.composite.SLEEP_SCORE.gap.fewNights", {
|
||||
count: v.rhythmNights ?? 0,
|
||||
needed: v.rhythmMinNights,
|
||||
})
|
||||
: s.gap === "no_main_sleep"
|
||||
? t("insights.derived.composite.SLEEP_SCORE.gap.noMainSleep")
|
||||
|
||||
@@ -891,6 +891,7 @@ export function VitalsDashboard({ batch, layout, className }: DashboardProps) {
|
||||
subtitle={t("insights.derived.vitals.sectionSubtitle")}
|
||||
/>
|
||||
<QueryErrorRow
|
||||
error={batch.error}
|
||||
slot="vitals-dashboard-error"
|
||||
retrySlot="vitals-dashboard-retry"
|
||||
message={t("insights.derived.vitals.loadError")}
|
||||
|
||||
@@ -45,7 +45,7 @@ export function EcgDetail({ recordingId }: { recordingId: string }) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
const { data, isLoading, isError, error, refetch } = useQuery({
|
||||
queryKey: queryKeys.insightsEcgDetail(recordingId, false),
|
||||
queryFn: async () => {
|
||||
try {
|
||||
@@ -63,6 +63,7 @@ export function EcgDetail({ recordingId }: { recordingId: string }) {
|
||||
// alert is content, not muted meta — it also has to be recoverable.
|
||||
return (
|
||||
<QueryErrorCard
|
||||
error={error}
|
||||
title={t("insights.ecg.loadError")}
|
||||
onRetry={() => refetch()}
|
||||
/>
|
||||
|
||||
@@ -392,6 +392,7 @@ export function HealthKitMetricPage({
|
||||
explainerParams={explainerParams}
|
||||
>
|
||||
<QueryErrorRow
|
||||
error={error}
|
||||
slot="healthkit-metric-error"
|
||||
retrySlot="healthkit-metric-retry"
|
||||
message={t("insights.subPage.loadError")}
|
||||
|
||||
@@ -120,7 +120,7 @@ export function IntradayPulseChart({
|
||||
setSelectedDateKey(next === todayKey ? null : next);
|
||||
};
|
||||
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
const { data, isLoading, isError, error, refetch } = useQuery({
|
||||
queryKey: queryKeys.insightsPulseIntraday(dateKey),
|
||||
queryFn: () =>
|
||||
apiGet<IntradayPulseDto>(`/api/insights/pulse/intraday?date=${dateKey}`),
|
||||
@@ -255,6 +255,7 @@ export function IntradayPulseChart({
|
||||
<Skeleton className="h-[180px] w-full rounded-lg" />
|
||||
) : isError ? (
|
||||
<QueryErrorCard
|
||||
error={error}
|
||||
onRetry={() => refetch()}
|
||||
className="border-0 bg-transparent shadow-none"
|
||||
/>
|
||||
|
||||
@@ -132,7 +132,8 @@ export function MoodInsightsSections({
|
||||
} = {}) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
const { data, isLoading, isPending, isError, refetch } = useMoodInsights();
|
||||
const { data, isLoading, isPending, isError, error, refetch } =
|
||||
useMoodInsights();
|
||||
|
||||
// `isPending`, not `isLoading`: before the session resolves the read is
|
||||
// disabled rather than loading, and the frame must already be there.
|
||||
@@ -161,7 +162,7 @@ export function MoodInsightsSections({
|
||||
// "rest" region (mirroring the loading convention above), rather than
|
||||
// stacking an identical row in all three regions down the page.
|
||||
return region === "rest" ? (
|
||||
<QueryErrorRow onRetry={() => refetch()} />
|
||||
<QueryErrorRow error={error} onRetry={() => refetch()} />
|
||||
) : null;
|
||||
}
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ import { Compass } from "lucide-react";
|
||||
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { ReadFailureRow } from "@/components/ui/read-failure-row";
|
||||
import { QueryErrorRow } from "@/components/ui/query-error-row";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { formatDate } from "@/lib/date-format";
|
||||
@@ -71,7 +71,7 @@ export function MoodPrognosisCard() {
|
||||
|
||||
if (isError && !data) {
|
||||
return (
|
||||
<ReadFailureRow
|
||||
<QueryErrorRow
|
||||
error={error}
|
||||
slot="mood-prognosis-error"
|
||||
message={t("insights.mood.prognosis.loadError")}
|
||||
|
||||
@@ -45,7 +45,7 @@ export function MoodWhatStandsOut({
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { t } = useTranslations();
|
||||
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
const { data, isLoading, isError, error, refetch } = useQuery({
|
||||
queryKey: queryKeys.insightsCorrelations(),
|
||||
queryFn: async () => {
|
||||
// Correlations are statistics, never gated on AI; a rejection is a
|
||||
@@ -81,6 +81,7 @@ export function MoodWhatStandsOut({
|
||||
) : null}
|
||||
{isError ? (
|
||||
<QueryErrorRow
|
||||
error={error}
|
||||
slot="mood-discovery-error"
|
||||
message={t("insights.pattern.loadError")}
|
||||
onRetry={() => void refetch()}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { Coffee } from "lucide-react";
|
||||
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { ReadFailureRow } from "@/components/ui/read-failure-row";
|
||||
import { QueryErrorRow } from "@/components/ui/query-error-row";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { NutrientDailyBarChartDynamic } from "@/components/charts/nutrient-daily-bar-chart-dynamic";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
@@ -43,7 +43,7 @@ export function CaffeineCard() {
|
||||
|
||||
if (isError && !data) {
|
||||
return (
|
||||
<ReadFailureRow
|
||||
<QueryErrorRow
|
||||
error={error}
|
||||
slot="nutrients-caffeine-error"
|
||||
message={t("nutrients.caffeine.loadError")}
|
||||
|
||||
@@ -30,7 +30,7 @@ export function HydrationCard() {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
const { data, isLoading, isError, error, refetch } = useQuery({
|
||||
queryKey: queryKeys.nutrientDaily("water", WINDOW_DAYS),
|
||||
queryFn: () =>
|
||||
apiGet<NutrientDailySeries>(
|
||||
@@ -39,7 +39,7 @@ export function HydrationCard() {
|
||||
});
|
||||
|
||||
if (isError) {
|
||||
return <QueryErrorCard onRetry={refetch} />;
|
||||
return <QueryErrorCard error={error} onRetry={refetch} />;
|
||||
}
|
||||
|
||||
const todayTotal = data?.days.at(-1)?.amount ?? 0;
|
||||
|
||||
@@ -159,7 +159,7 @@ export function MicronutrientsCard() {
|
||||
// its "no micronutrient data" EmptyState — the same output as an account that
|
||||
// genuinely logs no nutrients. The sibling hydration card already routes
|
||||
// through QueryErrorCard; this one now matches it.
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
const { data, isLoading, isError, error, refetch } = useQuery({
|
||||
queryKey: queryKeys.nutrientIntake(WINDOW_DAYS),
|
||||
queryFn: () =>
|
||||
apiGet<NutrientIntakeOverview>(`/api/nutrients?days=${WINDOW_DAYS}`),
|
||||
@@ -180,6 +180,7 @@ export function MicronutrientsCard() {
|
||||
<CardContent className="space-y-3">
|
||||
{isError ? (
|
||||
<QueryErrorCard
|
||||
error={error}
|
||||
title={t("nutrients.micronutrients.loadError")}
|
||||
onRetry={() => void refetch()}
|
||||
/>
|
||||
|
||||
@@ -30,13 +30,14 @@ interface FenceProps {
|
||||
|
||||
interface FenceState {
|
||||
failed: boolean;
|
||||
error?: unknown;
|
||||
}
|
||||
|
||||
class SectionFence extends Component<FenceProps, FenceState> {
|
||||
state: FenceState = { failed: false };
|
||||
|
||||
static getDerivedStateFromError(): FenceState {
|
||||
return { failed: true };
|
||||
static getDerivedStateFromError(error: unknown): FenceState {
|
||||
return { failed: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error): void {
|
||||
@@ -58,9 +59,10 @@ class SectionFence extends Component<FenceProps, FenceState> {
|
||||
data-section={this.props.sectionId}
|
||||
>
|
||||
<QueryErrorRow
|
||||
error={this.state.error}
|
||||
message={this.props.message}
|
||||
retryLabel={this.props.retryLabel}
|
||||
onRetry={() => this.setState({ failed: false })}
|
||||
onRetry={() => this.setState({ failed: false, error: undefined })}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -16,11 +16,10 @@ import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
pickStatus,
|
||||
useIntegrationStatuses,
|
||||
type IntegrationStatusViewModel,
|
||||
} from "@/components/settings/integrations/shared";
|
||||
import type {
|
||||
RECOVERY_SUMMARY_TYPES,
|
||||
RecoveryEmptyReason,
|
||||
} from "@/lib/insights/recovery-empty";
|
||||
import { ChartSkeleton } from "@/components/charts/chart-skeleton";
|
||||
import { RestModeBanner } from "@/components/insights/rest-mode-banner";
|
||||
import type { RestModeAnnotation } from "@/lib/analytics/score/types";
|
||||
@@ -30,7 +29,8 @@ import { RecoveryMetricBlock } from "@/components/insights/recovery/recovery-met
|
||||
import { ResilienceTile } from "@/components/insights/recovery/resilience-tile";
|
||||
|
||||
interface BlockSpec {
|
||||
type: string;
|
||||
/** One of the types the server's empty decision counts, by construction. */
|
||||
type: (typeof RECOVERY_SUMMARY_TYPES)[number];
|
||||
chartKey: ChartOverlayKey;
|
||||
statusMetric: MetricStatusMetricId;
|
||||
title: string;
|
||||
@@ -47,32 +47,14 @@ interface BlockGroup {
|
||||
}
|
||||
|
||||
/**
|
||||
* Why this account's recovery page is empty, from the server's view of its
|
||||
* Oura connection. An Oura ring feeds this page through its resilience level
|
||||
* alone. When the server records that the grant leaves resilience out, the
|
||||
* note asks for one reconnect. Otherwise it says only that no level has
|
||||
* arrived: the server cannot tell "the grant covers it" from "not recorded
|
||||
* yet" (the scope note is cleared on every reconnect and written only by a
|
||||
* sync that reached Oura), so the note never claims coverage. Everyone else
|
||||
* gets the general note naming what fills the page.
|
||||
* The empty note per server-resolved reason (`recoveryEmpty` on the
|
||||
* analytics slice). The page never works the reason out itself.
|
||||
*/
|
||||
type RecoveryEmptyCase = "general" | "oura-permission" | "oura-no-resilience";
|
||||
|
||||
const EMPTY_COPY = {
|
||||
general: "insights.recovery.empty",
|
||||
"oura-permission": "insights.recovery.emptyOuraPermission",
|
||||
"oura-no-resilience": "insights.recovery.emptyOuraNoResilience",
|
||||
} as const satisfies Record<RecoveryEmptyCase, string>;
|
||||
|
||||
function recoveryEmptyCase(
|
||||
oura: IntegrationStatusViewModel | undefined,
|
||||
): RecoveryEmptyCase {
|
||||
if (!oura?.connected) return "general";
|
||||
const withheld = oura.health?.unavailable.some(
|
||||
(u) => u.item === "resilience" && u.cause === "permission",
|
||||
);
|
||||
return withheld ? "oura-permission" : "oura-no-resilience";
|
||||
}
|
||||
oura_permission: "insights.recovery.emptyOuraPermission",
|
||||
oura_no_resilience: "insights.recovery.emptyOuraNoResilience",
|
||||
} as const satisfies Record<RecoveryEmptyReason, string>;
|
||||
|
||||
/**
|
||||
* v1.17.1 → rebuilt v1.18.1 (B1–B3) — body of the `/insights/recovery`
|
||||
@@ -204,28 +186,11 @@ export function RecoverySection() {
|
||||
},
|
||||
];
|
||||
|
||||
// v1.19.2 — resilience is an ordinal band (limited … exceptional), surfaced
|
||||
// as a calm dedicated tile rather than a chart block, so it counts toward the
|
||||
// data gate independently of `groups` (a chart-block list).
|
||||
const hasResilience = (summaries?.["RESILIENCE"]?.count ?? 0) > 0;
|
||||
// Whether the page has anything to show, and why not, is the server's
|
||||
// answer (`recoveryEmpty`), so web and iOS read one decision.
|
||||
const emptyReason = data?.recoveryEmpty ?? null;
|
||||
|
||||
const hasAny =
|
||||
summaries != null &&
|
||||
(hasResilience ||
|
||||
groups.some((group) =>
|
||||
group.blocks.some((block) => (summaries[block.type]?.count ?? 0) > 0),
|
||||
));
|
||||
|
||||
// Only an empty page asks which connection could fill it, so a page with
|
||||
// data never pays for the integrations read.
|
||||
const statuses = useIntegrationStatuses(
|
||||
!isLoading && summaries != null && !hasAny,
|
||||
);
|
||||
const emptyCase = recoveryEmptyCase(pickStatus(statuses.data, "oura"));
|
||||
|
||||
// The empty note waits for the connection status, so it never reads as
|
||||
// the general note first and then changes under the reader.
|
||||
if (isLoading || summaries == null || (!hasAny && statuses.isPending)) {
|
||||
if (isLoading || summaries == null) {
|
||||
return (
|
||||
<div className="space-y-3" data-slot="recovery-loading">
|
||||
<ChartSkeleton />
|
||||
@@ -233,22 +198,20 @@ export function RecoverySection() {
|
||||
);
|
||||
}
|
||||
|
||||
if (!hasAny) {
|
||||
if (emptyReason) {
|
||||
return (
|
||||
<EmptyState
|
||||
data-slot="recovery-empty"
|
||||
data-empty-case={emptyCase}
|
||||
data-empty-case={emptyReason}
|
||||
icon={<Heart className="size-6" />}
|
||||
title={t("insights.recovery.emptyTitle")}
|
||||
description={t(EMPTY_COPY[emptyCase])}
|
||||
description={t(EMPTY_COPY[emptyReason])}
|
||||
action={
|
||||
emptyCase === "oura-permission" ? (
|
||||
<Button size="sm" variant="outline" asChild>
|
||||
<Link href="/settings/integrations">
|
||||
{t("insights.recovery.emptyAction")}
|
||||
</Link>
|
||||
</Button>
|
||||
) : undefined
|
||||
<Button size="sm" variant="outline" asChild>
|
||||
<Link href="/settings/integrations">
|
||||
{t("insights.recovery.emptyAction")}
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -91,7 +91,7 @@ export function RhythmEventsCard({
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
const { data, isLoading, isError, error, refetch } = useQuery({
|
||||
queryKey: queryKeys.insightsRhythmEvents(),
|
||||
queryFn: async () => {
|
||||
try {
|
||||
@@ -119,6 +119,7 @@ export function RhythmEventsCard({
|
||||
title={t("insights.rhythmEvents.sectionTitle")}
|
||||
/>
|
||||
<QueryErrorRow
|
||||
error={error}
|
||||
slot="rhythm-events-error"
|
||||
message={t("insights.rhythmEvents.loadError")}
|
||||
onRetry={() => void refetch()}
|
||||
|
||||
@@ -58,9 +58,11 @@ interface TileHeaderProps {
|
||||
* owning block (the recovery blocks) or a grid of sibling tiles (dashboard
|
||||
* tiles, insights overview grids) both nest correctly as `h1 → h2, h2, …`;
|
||||
* just don't nest a `titleAs="h2"` tile inside another heading's own
|
||||
* subsection. Default (unset) keeps every other tile a plain CardTitle.
|
||||
* subsection. `"h3"` is the same for a card that sits inside a section
|
||||
* which already owns an `h2` (the since-last-visit card under the visits
|
||||
* section). Default (unset) keeps every other tile a plain CardTitle.
|
||||
*/
|
||||
titleAs?: "h2";
|
||||
titleAs?: "h2" | "h3";
|
||||
}
|
||||
|
||||
export function TileHeader({
|
||||
@@ -73,6 +75,7 @@ export function TileHeader({
|
||||
id,
|
||||
titleAs,
|
||||
}: TileHeaderProps) {
|
||||
const Heading = titleAs ?? "h2";
|
||||
const titleClassNames = cn(
|
||||
size === "sm" ? "text-sm" : "text-base",
|
||||
titleClassName,
|
||||
@@ -93,16 +96,16 @@ export function TileHeader({
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : null}
|
||||
{titleAs === "h2" ? (
|
||||
{titleAs ? (
|
||||
// Heading semantics with the exact CardTitle visual treatment so the
|
||||
// tile can carry a real `<h2>` where it owns the subpage's heading.
|
||||
<h2
|
||||
// tile can carry a real heading where it owns one.
|
||||
<Heading
|
||||
id={id}
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold", titleClassNames)}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
</Heading>
|
||||
) : (
|
||||
<CardTitle id={id} className={titleClassNames}>
|
||||
{title}
|
||||
|
||||
@@ -120,6 +120,7 @@ export function WorkoutDetailDayContext({
|
||||
{sleep.isError ? (
|
||||
// A failed read is not an absent night.
|
||||
<QueryErrorRow
|
||||
error={sleep.error}
|
||||
onRetry={sleep.refetch}
|
||||
slot="workout-detail-day-sleep-error"
|
||||
/>
|
||||
@@ -169,6 +170,7 @@ export function WorkoutDetailDayContext({
|
||||
</p>
|
||||
{mood.isError ? (
|
||||
<QueryErrorRow
|
||||
error={mood.error}
|
||||
onRetry={mood.refetch}
|
||||
slot="workout-detail-day-mood-error"
|
||||
/>
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
import { getNumberFormat, getDateTimeFormat } from "@/lib/intl/formatter-cache";
|
||||
import { getNumberFormat } from "@/lib/intl/formatter-cache";
|
||||
import {
|
||||
applyDisplayTransform,
|
||||
getQuantityTransform,
|
||||
paceSecondsPerDistanceUnit,
|
||||
type UnitPreference,
|
||||
} from "@/lib/measurements/display-transform";
|
||||
import {
|
||||
hourCycleOptions,
|
||||
type TimeFormatPreference,
|
||||
} from "@/lib/format-locale";
|
||||
|
||||
/**
|
||||
* Shared formatters for the workout-detail surface. Pure helpers pulled
|
||||
@@ -120,28 +116,3 @@ export function formatPaceSeconds(secPerUnit: number, unit: string): string {
|
||||
}
|
||||
return `${m}:${s.toString().padStart(2, "0")} min/${unit}`;
|
||||
}
|
||||
|
||||
export function formatDateRange(
|
||||
startedAt: string,
|
||||
endedAt: string,
|
||||
locale: string,
|
||||
timeFormat: TimeFormatPreference,
|
||||
timeZone: string,
|
||||
): string {
|
||||
const start = new Date(startedAt);
|
||||
const end = new Date(endedAt);
|
||||
const dateFmt = getDateTimeFormat(locale, {
|
||||
timeZone,
|
||||
weekday: "long",
|
||||
day: "2-digit",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
const timeFmt = getDateTimeFormat(locale, {
|
||||
timeZone,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
...hourCycleOptions(timeFormat),
|
||||
});
|
||||
return `${dateFmt.format(start)}, ${timeFmt.format(start)}–${timeFmt.format(end)}`;
|
||||
}
|
||||
@@ -1,16 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
useDisplayTimezone,
|
||||
useTimeFormatPreference,
|
||||
useTranslations,
|
||||
} from "@/lib/i18n/context";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { MEASUREMENT_SOURCE_LIST_LABEL_KEYS } from "@/lib/i18n/source-labels";
|
||||
import { iconForSport } from "@/lib/workouts/sport-icons";
|
||||
import type { WorkoutDetailPayload } from "@/hooks/use-workouts";
|
||||
import { useUnitDisplay } from "@/hooks/use-unit-display";
|
||||
|
||||
import { formatDuration, formatDistance, formatDateRange } from "./format";
|
||||
import { formatDuration, formatDistance } from "./format";
|
||||
|
||||
/**
|
||||
* Hero header — the at-a-glance verdict row. Sport icon badge, source
|
||||
@@ -35,8 +31,7 @@ function renderSportIconBadge(sportType: string) {
|
||||
|
||||
export function WorkoutDetailHeader({ workout }: WorkoutDetailHeaderProps) {
|
||||
const { t, locale } = useTranslations();
|
||||
const timeFormat = useTimeFormatPreference();
|
||||
const timeZone = useDisplayTimezone();
|
||||
const fmt = useFormatters();
|
||||
const { preference } = useUnitDisplay();
|
||||
const sportLabelKey = `insights.workouts.sport.${workout.sportType}`;
|
||||
const sportLabel = t(sportLabelKey);
|
||||
@@ -68,13 +63,7 @@ export function WorkoutDetailHeader({ workout }: WorkoutDetailHeaderProps) {
|
||||
{sportName}
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{formatDateRange(
|
||||
workout.startedAt,
|
||||
workout.endedAt,
|
||||
locale,
|
||||
timeFormat,
|
||||
timeZone,
|
||||
)}
|
||||
{`${fmt.dateWithWeekdaySmart(workout.startedAt)}, ${fmt.time(workout.startedAt)}–${fmt.time(workout.endedAt)}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,17 +5,9 @@ import Link from "next/link";
|
||||
import { HeartPulse, Loader2, Map as MapIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getNumberFormat, getDateTimeFormat } from "@/lib/intl/formatter-cache";
|
||||
import { getNumberFormat } from "@/lib/intl/formatter-cache";
|
||||
import { formatDurationMinutes } from "@/lib/i18n/duration";
|
||||
import {
|
||||
useDisplayTimezone,
|
||||
useTimeFormatPreference,
|
||||
useTranslations,
|
||||
} from "@/lib/i18n/context";
|
||||
import {
|
||||
hourCycleOptions,
|
||||
type TimeFormatPreference,
|
||||
} from "@/lib/format-locale";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { iconForSport } from "@/lib/workouts/sport-icons";
|
||||
import type { WorkoutListEntry } from "@/hooks/use-workouts";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -40,30 +32,6 @@ function formatEnergy(kcal: number, locale: string): string {
|
||||
}).format(Math.round(kcal));
|
||||
}
|
||||
|
||||
function formatDate(
|
||||
iso: string,
|
||||
locale: string,
|
||||
timeFormat: TimeFormatPreference,
|
||||
timeZone: string,
|
||||
): string {
|
||||
const d = new Date(iso);
|
||||
// Show the year whenever the workout is not from the current year, so a
|
||||
// "Do., 16. Dez." from a previous year is never mistaken for this year's.
|
||||
const yearOf = (value: Date) =>
|
||||
getDateTimeFormat(locale, { timeZone, year: "numeric" }).format(value);
|
||||
const showYear = yearOf(d) !== yearOf(new Date());
|
||||
return getDateTimeFormat(locale, {
|
||||
timeZone,
|
||||
weekday: "short",
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
...(showYear ? { year: "numeric" as const } : {}),
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
...hourCycleOptions(timeFormat),
|
||||
}).format(d);
|
||||
}
|
||||
|
||||
export interface WorkoutListProps {
|
||||
workouts: WorkoutListEntry[];
|
||||
className?: string;
|
||||
@@ -82,8 +50,9 @@ export function WorkoutList({
|
||||
onLoadMore,
|
||||
}: WorkoutListProps) {
|
||||
const { t, locale } = useTranslations();
|
||||
const timeFormat = useTimeFormatPreference();
|
||||
const timeZone = useDisplayTimezone();
|
||||
// The reader's date order, hour cycle and zone, as everywhere else; the
|
||||
// year shows whenever the workout is not from this year.
|
||||
const fmt = useFormatters();
|
||||
const { preference } = useUnitDisplay();
|
||||
|
||||
return (
|
||||
@@ -150,12 +119,7 @@ export function WorkoutList({
|
||||
stays readable beside the stats column. */}
|
||||
<span className="text-muted-foreground text-xs">
|
||||
<DayLinkAt at={workout.startedAt} className="z-10">
|
||||
{formatDate(
|
||||
workout.startedAt,
|
||||
locale,
|
||||
timeFormat,
|
||||
timeZone,
|
||||
)}
|
||||
{`${fmt.dateWithWeekdaySmart(workout.startedAt)}, ${fmt.time(workout.startedAt)}`}
|
||||
</DayLinkAt>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -57,7 +57,7 @@ export function BiomarkerManager() {
|
||||
const canMerge = canManageDomain("labs");
|
||||
const [mergeOpen, setMergeOpen] = useState(false);
|
||||
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
const { data, isLoading, isError, error, refetch } = useQuery({
|
||||
queryKey: queryKeys.biomarkers(),
|
||||
queryFn: () => apiGet<BiomarkerListResponse>("/api/biomarkers"),
|
||||
});
|
||||
@@ -195,6 +195,7 @@ export function BiomarkerManager() {
|
||||
</div>
|
||||
) : isError ? (
|
||||
<QueryErrorCard
|
||||
error={error}
|
||||
title={t("labs.biomarker.loadError")}
|
||||
onRetry={() => void refetch()}
|
||||
/>
|
||||
|
||||
@@ -191,6 +191,7 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
|
||||
data: list,
|
||||
isLoading,
|
||||
isError: listError,
|
||||
error: listFailure,
|
||||
refetch: refetchList,
|
||||
} = useInfiniteQuery({
|
||||
queryKey: queryKeys.labResultsInfinite({ biomarkerId, sortDir: "desc" }),
|
||||
@@ -332,6 +333,7 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
|
||||
description={description}
|
||||
/>
|
||||
<QueryErrorCard
|
||||
error={markerError ? markerFailure : listFailure}
|
||||
title={t("labs.loadError")}
|
||||
onRetry={() => {
|
||||
if (markerError) void refetchMarker();
|
||||
|
||||
@@ -146,7 +146,7 @@ export function LabList({
|
||||
// (beneath the heading); the overview rows no longer carry it.
|
||||
// One entry per biomarker over the whole history, grouped server-side: a
|
||||
// capped page of raw rows dropped every marker measured only long ago.
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
const { data, isLoading, isError, error, refetch } = useQuery({
|
||||
queryKey: queryKeys.labResultsOverview(),
|
||||
queryFn: () => apiGet<LabOverviewResponse>("/api/labs/overview"),
|
||||
});
|
||||
@@ -203,6 +203,7 @@ export function LabList({
|
||||
// outage never reads as "you have no lab results".
|
||||
return (
|
||||
<QueryErrorCard
|
||||
error={error}
|
||||
title={t("labs.loadError")}
|
||||
onRetry={() => void refetch()}
|
||||
/>
|
||||
|
||||
@@ -34,6 +34,7 @@ export function LabValuesList({ biomarkerId }: { biomarkerId: string }) {
|
||||
data: list,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
refetch,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
@@ -63,6 +64,7 @@ export function LabValuesList({ biomarkerId }: { biomarkerId: string }) {
|
||||
if (isError) {
|
||||
return (
|
||||
<QueryErrorCard
|
||||
error={error}
|
||||
title={t("labs.loadError")}
|
||||
onRetry={() => void refetch()}
|
||||
/>
|
||||
|
||||
Loaded 100 of 217 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user