mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +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
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user