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

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

No files matched your search

@@ -331,14 +331,29 @@ describe("Google Health backfill bounded by progress, not pages (#1220)", () =>
expect(fake.heartRateRequests).toBeLessThanOrEqual(10 + 2 * slices);
}, 600_000);
it("parks a walk Google keeps answering with the same page, tries it again a day later, and only then ends it", async () => {
it("parks a walk Google keeps answering with the same page, and ends it only after a day brings no progress", async () => {
await seed({ total: 5_000, spacing: 60_000, perPage: 1000 });
fake.loop = true;
const { GOOGLE_HEALTH_NO_PROGRESS_PAGES } =
await import("@/lib/google-health/backfill-bounds");
const { googleHealthBackfillEndedList } =
await import("@/lib/google-health/sync-core");
await import("@/lib/google-health/backfill-progress");
const walk = 1 + GOOGLE_HEALTH_NO_PROGRESS_PAGES;
const prisma = getPrismaClient();
/** Let a day pass for the parked walk. */
const dayLater = async () => {
const progress = (await connection()).backfillProgress as {
ended: Record<string, { endedAt: string; attempts?: number }>;
};
progress.ended.fetchHeartRate!.endedAt = new Date(
Date.now() - 25 * HOUR,
).toISOString();
await prisma.googleHealthConnection.update({
where: { userId: USER },
data: { backfillProgress: progress },
});
return progress.ended.fetchHeartRate!;
};
// First attempt: the first page and the twenty that repeat it. Not
// complete, nothing published, and the next slice a day out.
@@ -354,23 +369,26 @@ describe("Google Health backfill bounded by progress, not pages (#1220)", () =>
expect(fake.heartRateRequests).toBe(walk);
expect((await connection()).backfillCompletedAt).toBeNull();
// A day later the walk goes on from its edge, gets one page further,
// meets the same wall, and this time the history ends there.
const prisma = getPrismaClient();
const progress = c.backfillProgress as {
ended: Record<string, { endedAt: string }>;
};
progress.ended.fetchHeartRate!.endedAt = new Date(
Date.now() - 25 * HOUR,
).toISOString();
await prisma.googleHealthConnection.update({
where: { userId: USER },
data: { backfillProgress: progress },
});
// A day later the walk goes on from its edge and gets one page further
// before the wall. That is progress: it parks again, counting afresh,
// rather than ending a history that is still coming in.
expect((await dayLater()).attempts ?? 1).toBe(1);
await runChain(5, sent.slices.at(-1)!);
expect(fake.heartRateRequests).toBe(2 * walk);
c = await connection();
expect(c.backfillCompletedAt).toBeNull();
expect(await pulseRows()).toBe(1999);
expect((await dayLater()).attempts ?? 1).toBe(1);
// Another day, and nothing lies past the edge any more: the walk meets
// the wall where it stood, and this time the history ends there.
fake.total = 1999;
await runChain(5, sent.slices.at(-1)!);
// No page of this attempt is new, the first included.
expect(fake.heartRateRequests).toBe(
2 * walk + GOOGLE_HEALTH_NO_PROGRESS_PAGES,
);
c = await connection();
expect(c.backfillCompletedAt).toBeInstanceOf(Date);
expect(googleHealthBackfillEndedList(c.backfillProgress)).toEqual([
{
@@ -413,7 +431,7 @@ describe("Google Health backfill bounded by progress, not pages (#1220)", () =>
await runChain(5);
const { googleHealthBackfillEndedList } =
await import("@/lib/google-health/sync-core");
await import("@/lib/google-health/backfill-progress");
const c = await connection();
expect(c.backfillCompletedAt).toBeInstanceOf(Date);
const ended = googleHealthBackfillEndedList(c.backfillProgress);
@@ -239,4 +239,56 @@ describe("GET /api/measurements?groupBy=day paging (#1245)", () => {
expect(windowed.data.meta.total).toBe(inWindow);
expect(windowed.data.measurements).toHaveLength(PAGE);
}, 240_000);
// The fold's per-day narrowing, pinned against Postgres rather than a mock
// that answers SQL by its text: a day is cut in the user's zone, and within
// the winning source the top-ranked device's groups stand alone.
it("cuts days in the user's zone and keeps the winning source's top device", async () => {
const user = await seedSession("day-paging-device-user");
const key = dayKeyAgo(5);
const at = (iso: string) => new Date(`${key}T${iso}Z`);
const base = {
userId: user.id,
type: "ACTIVITY_STEPS" as const,
unit: "steps",
source: "APPLE_HEALTH" as const,
};
await getPrismaClient().measurement.createMany({
data: [
// The watch and the phone both counted the morning.
{
...base,
value: 6000,
deviceType: "watch",
measuredAt: at("08:00:00"),
externalId: "w1",
},
{
...base,
value: 5500,
deviceType: "phone",
measuredAt: at("08:05:00"),
externalId: "p1",
},
// 23:30 UTC is already the next day in Berlin.
{
...base,
value: 700,
deviceType: "watch",
measuredAt: at("23:30:00"),
externalId: "w2",
},
],
});
const body = (await (
await callList(`type=ACTIVITY_STEPS&groupBy=day&limit=${PAGE}`)
).json()) as ListEnvelope & {
data: { meta: { droppedDuplicates?: number } };
};
const byDay = new Map(body.data.measurements.map((r) => [r.dayKey, r]));
expect(byDay.get(key)?.value).toBe(6000);
expect(byDay.get(dayKeyAgo(4))?.value).toBe(700);
expect(body.data.meta.total).toBe(2);
expect(body.data.meta.droppedDuplicates).toBe(1);
}, 240_000);
});
@@ -0,0 +1,307 @@
/**
* The sleep list pages its nights in SQL (`sleepNightPage`) and fills them
* from the JS reconstruction (`reconstructSleepSessions`). The two cut the
* same stage rows into sessions and name each session's night by the same
* rule, or the list drops, doubles or mislabels nights. This suite runs both
* over one fixture set of awkward shapes and holds them to the same nights,
* then holds the list, the night view and the series to one answer per day.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
return {
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
})),
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = cookieJar.get(name);
return value ? { name, value } : undefined;
},
set: (name: string, value: string) => cookieJar.set(name, value),
delete: (name: string) => cookieJar.delete(name),
})),
};
});
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
});
afterEach(() => {
vi.restoreAllMocks();
});
type Stage = "IN_BED" | "AWAKE" | "ASLEEP" | "REM" | "CORE" | "DEEP" | null;
interface Seg {
/** Segment end, minutes after the fixture day's UTC midnight. */
end: number;
minutes: number;
stage: Stage;
source?: "APPLE_HEALTH" | "WHOOP" | "WITHINGS";
deviceType?: string | null;
}
const H = 60;
/** A run of contiguous segments of `step` minutes cycling through stages. */
function run(
start: number,
end: number,
step: number,
stages: Stage[],
extra: Partial<Seg> = {},
): Seg[] {
const out: Seg[] = [];
let i = 0;
for (let t = start + step; t <= end; t += step) {
out.push({
end: t,
minutes: step,
stage: stages[i % stages.length],
...extra,
});
i += 1;
}
return out;
}
const NIGHT: Stage[] = ["CORE", "DEEP", "CORE", "REM", "AWAKE"];
/**
* Ten shapes, one per day modulo ten, minutes relative to the day's UTC
* midnight (negative = the evening before).
*/
function shape(i: number): Seg[] {
switch (i % 10) {
case 0:
// A dense watch night in five-minute segments.
return run(-1 * H, 7 * H, 5, NIGHT, { deviceType: "watch" });
case 1:
// The watch stops before midnight, the phone's in-bed runs past it.
return [
...run(-6 * H, -10, 10, NIGHT, { deviceType: "watch" }),
{ end: 20, minutes: 6 * H + 20, stage: "IN_BED", deviceType: "phone" },
];
case 2:
// An afternoon nap and no night.
return run(13 * H, 14 * H, 15, ["CORE", "REM"]);
case 3:
// A night and a nap.
return [
...run(-2 * H, 6 * H, 30, NIGHT),
...run(14 * H, 14 * H + 40, 20, ["CORE"]),
];
case 4:
// A split night, three and a half hours apart: two sessions.
return [
...run(-2 * H, 1 * H, 30, NIGHT),
...run(4 * H + 30, 7 * H, 30, NIGHT),
];
case 5:
// Exactly the session gap apart: one session.
return [
...run(-2 * H, 1 * H, 30, NIGHT),
...run(4 * H, 7 * H, 30, NIGHT),
];
case 6:
// One long stage block.
return [
{ end: 3 * H, minutes: 4 * H, stage: "CORE" },
{ end: 5 * H, minutes: 2 * H, stage: "REM" },
];
case 7:
// A bare aggregate with its granular twin, and a zero-minute granular
// row from a second writer beside a bare night.
return [
{ end: 6 * H, minutes: 7 * H, stage: "ASLEEP" },
...run(-1 * H, 6 * H, 60, ["CORE", "DEEP", "REM"]),
{ end: 6 * H, minutes: 0, stage: "REM", source: "WITHINGS" },
{ end: 6 * H, minutes: 6 * H, stage: "ASLEEP", source: "WITHINGS" },
];
case 8:
// An in-bed-only evening and a stage-less night.
return [
{ end: -8 * H, minutes: 40, stage: "IN_BED" },
{ end: -8 * H + 30, minutes: 20, stage: "AWAKE" },
{ end: 6 * H, minutes: 6 * H, stage: null },
];
default:
// A summary-shaped writer (every stage on one instant) beside a
// partial timed night.
return [
{ end: 7 * H, minutes: 200, stage: "CORE", source: "WHOOP" },
{ end: 7 * H, minutes: 90, stage: "DEEP", source: "WHOOP" },
{ end: 7 * H, minutes: 80, stage: "REM", source: "WHOOP" },
...run(2 * H, 7 * H, 30, NIGHT),
];
}
}
const BASE = Date.UTC(2024, 2, 1); // three years back, across two DST turns
const DAYS = 120;
async function seed(timezone: string) {
const prisma = getPrismaClient();
const user = await prisma.user.create({
data: {
username: `parity-${timezone.replace("/", "-")}`.toLowerCase(),
email: `parity-${timezone.length}@example.test`,
role: "USER",
timezone,
},
});
const session = await prisma.session.create({
data: { userId: user.id, expiresAt: new Date(Date.now() + 3_600_000) },
});
cookieJar.set("healthlog_session", session.id);
const rows = [];
for (let d = 0; d < DAYS; d++) {
const midnight = BASE + d * 86_400_000;
for (const [k, seg] of shape(d).entries()) {
rows.push({
userId: user.id,
type: "SLEEP_DURATION" as const,
value: seg.minutes,
unit: "minutes",
source: seg.source ?? ("APPLE_HEALTH" as const),
deviceType: seg.deviceType ?? null,
measuredAt: new Date(midnight + seg.end * 60_000),
externalId: `p-${d}-${k}`,
sleepStage: seg.stage,
});
}
}
await prisma.measurement.createMany({ data: rows });
return user;
}
interface ListRow {
dayKey: string;
value: number;
napCount: number;
napAsleepMinutes: number;
}
async function list(query: string) {
const { GET } = await import("@/app/api/measurements/route");
const res = await GET(
new NextRequest(`http://localhost/api/measurements?${query}`),
);
expect(res.status).toBe(200);
return (await res.json()) as {
data: { measurements: ListRow[]; meta: { total: number } };
};
}
async function nightView(date: string) {
const { GET } = await import("@/app/api/sleep/night/route");
const res = await GET(
new NextRequest(`http://localhost/api/sleep/night?date=${date}`),
);
expect(res.status).toBe(200);
return (await res.json()) as {
data: {
night: string | null;
main: { asleepMinutes: number } | null;
naps: Array<{ asleepMinutes: number }>;
};
};
}
describe("sleep nights: SQL page and JS reconstruction agree", () => {
for (const tz of ["UTC", "Europe/Berlin"]) {
it(`names the same nights over every shape (${tz})`, async () => {
const user = await seed(tz);
const { sleepNightPage, readSleepRows } =
await import("@/lib/measurements/list/sleep-nights");
const { reconstructSleepSessions } =
await import("@/lib/analytics/sleep-night");
const page = await sleepNightPage(user.id, tz, {
sortDir: "asc",
limit: 10_000,
offset: 0,
});
const rows = await readSleepRows(
user.id,
new Date(BASE - 2 * 86_400_000),
new Date(BASE + (DAYS + 2) * 86_400_000),
);
const scorable = reconstructSleepSessions(rows, tz, null).filter(
(s) => s.asleepMinutes > 0,
);
const jsNights = [...new Set(scorable.map((s) => s.night))].sort();
expect(page.nights.map((n) => n.night)).toEqual(jsNights);
expect(page.total).toBe(jsNights.length);
// Every reconstructed session lies inside its night's SQL span.
const span = new Map(page.nights.map((n) => [n.night, n]));
for (const s of scorable) {
const n = span.get(s.night)!;
expect(s.start.getTime()).toBeGreaterThanOrEqual(
n.spanStart.getTime() - 1000,
);
expect(s.end.getTime()).toBeLessThanOrEqual(n.spanEnd.getTime() + 1000);
}
}, 240_000);
}
it("gives every page night a row the night view answers for the same day", async () => {
await seed("Europe/Berlin");
const first = await list("type=SLEEP_DURATION&limit=7&sortDir=asc");
const total = first.data.meta.total;
const rows: ListRow[] = [];
for (let offset = 0; offset < total; offset += 7) {
const page = await list(
`type=SLEEP_DURATION&limit=7&offset=${offset}&sortDir=asc`,
);
// No short page before the last one.
expect(page.data.measurements).toHaveLength(Math.min(7, total - offset));
rows.push(...page.data.measurements);
}
expect(rows).toHaveLength(total);
let napOnly = 0;
for (const row of rows) {
const view = await nightView(row.dayKey);
expect(view.data.night).toBe(row.dayKey);
expect(view.data.main?.asleepMinutes ?? 0).toBe(row.value);
expect(view.data.naps).toHaveLength(row.napCount);
if (!view.data.main) {
napOnly += 1;
expect(row.napCount).toBeGreaterThan(0);
}
}
// The afternoon-nap days have no main night and still have their row.
expect(napOnly).toBe(DAYS / 10);
}, 480_000);
it("reaches the oldest night from the series' All range", async () => {
await seed("Europe/Berlin");
const { GET } = await import("@/app/api/measurements/series/route");
const res = await GET(
new NextRequest(
"http://localhost/api/measurements/series?kind=sleep&days=3650",
),
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { points: Array<{ id: string }> };
};
const oldest = (await list("type=SLEEP_DURATION&limit=1&sortDir=asc")).data
.measurements[0].dayKey;
// Years old, past any one-year cut.
expect(body.data.points[0].id).toBe(`sleep:${oldest}`);
expect(oldest < "2024-03-03").toBe(true);
}, 240_000);
});