mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
A targeted performance + polish patch. The headline is a faster dashboard on data-rich accounts: the pulse chart drops up to ~5 000 raw rows per range tab in favour of one row per day, the duplicate `/api/dashboard/widgets` fetch on every mount collapses to one, and three long-running analytics reads are bounded to the trailing window the tile path actually needs. Two production regressions close — the `aggregate=daily|weekly|monthly` path on `/api/measurements` 500'd for every grain, and the same path averaged cumulative step counts instead of summing.
264 lines
8.1 KiB
TypeScript
264 lines
8.1 KiB
TypeScript
/**
|
|
* v1.4.29 C2 — `aggregate=daily` against a real Postgres.
|
|
*
|
|
* The mocked-`$queryRaw` unit test in
|
|
* `src/app/api/measurements/__tests__/range-aggregation-route.test.ts`
|
|
* passes because nothing exercises the actual SQL. In production the
|
|
* `date_trunc(${truncUnit}, …)` call 500'd for every grain because
|
|
* Postgres rejects a bound parameter for `date_trunc`'s unit argument.
|
|
*
|
|
* This suite hits the route against a real database so the SQL has
|
|
* to compile + execute. It would fail on the pre-fix code and passes
|
|
* after switching `${truncUnit}` from a bound parameter to a SQL
|
|
* literal via `Prisma.raw`.
|
|
*/
|
|
import { NextRequest } from "next/server";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import { cookieJar } from "./mock-next-headers";
|
|
import { getPrismaClient, truncateAllTables } from "./setup";
|
|
|
|
const TEST_USER_ID = "user-aggregate-daily-test";
|
|
|
|
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();
|
|
await getPrismaClient().user.create({
|
|
data: {
|
|
id: TEST_USER_ID,
|
|
username: "aggregate-daily",
|
|
email: "aggregate-daily@example.test",
|
|
},
|
|
});
|
|
const session = await getPrismaClient().session.create({
|
|
data: {
|
|
userId: TEST_USER_ID,
|
|
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
|
|
},
|
|
});
|
|
cookieJar.set("healthlog_session", session.id);
|
|
});
|
|
|
|
function getRequest(query: string): NextRequest {
|
|
return new NextRequest(`http://localhost/api/measurements?${query}`, {
|
|
method: "GET",
|
|
});
|
|
}
|
|
|
|
describe("GET /api/measurements?aggregate=… (real Postgres)", () => {
|
|
it("returns bucketed rows for grain=daily without 500'ing on date_trunc", async () => {
|
|
// Two pulse rows on the same calendar day plus one on a second
|
|
// day — three rows total, two distinct daily buckets.
|
|
const prisma = getPrismaClient();
|
|
await prisma.measurement.createMany({
|
|
data: [
|
|
{
|
|
userId: TEST_USER_ID,
|
|
type: "PULSE",
|
|
value: 60,
|
|
unit: "bpm",
|
|
source: "MANUAL",
|
|
measuredAt: new Date("2026-05-01T08:00:00.000Z"),
|
|
},
|
|
{
|
|
userId: TEST_USER_ID,
|
|
type: "PULSE",
|
|
value: 80,
|
|
unit: "bpm",
|
|
source: "MANUAL",
|
|
measuredAt: new Date("2026-05-01T20:00:00.000Z"),
|
|
},
|
|
{
|
|
userId: TEST_USER_ID,
|
|
type: "PULSE",
|
|
value: 70,
|
|
unit: "bpm",
|
|
source: "MANUAL",
|
|
measuredAt: new Date("2026-05-02T12:00:00.000Z"),
|
|
},
|
|
],
|
|
});
|
|
|
|
const { GET } = await import("@/app/api/measurements/route");
|
|
const response = await GET(
|
|
getRequest(
|
|
"type=PULSE&from=2026-05-01T00:00:00Z&to=2026-05-03T00:00:00Z&aggregate=daily&limit=365",
|
|
),
|
|
);
|
|
|
|
// Pre-fix the response was 500; post-fix it must be 200 with two
|
|
// daily buckets.
|
|
expect(response.status).toBe(200);
|
|
const json = (await response.json()) as {
|
|
data: {
|
|
measurements: Array<{
|
|
type: string;
|
|
value: number;
|
|
measuredAt: string;
|
|
count: number;
|
|
}>;
|
|
meta: { aggregate?: string };
|
|
};
|
|
};
|
|
expect(json.data.meta.aggregate).toBe("daily");
|
|
expect(json.data.measurements).toHaveLength(2);
|
|
|
|
const may1 = json.data.measurements.find((m) =>
|
|
m.measuredAt.startsWith("2026-05-01"),
|
|
);
|
|
const may2 = json.data.measurements.find((m) =>
|
|
m.measuredAt.startsWith("2026-05-02"),
|
|
);
|
|
expect(may1).toBeDefined();
|
|
expect(may1!.count).toBe(2);
|
|
expect(may2).toBeDefined();
|
|
expect(may2!.count).toBe(1);
|
|
});
|
|
|
|
it("returns bucketed rows for grain=weekly", async () => {
|
|
const prisma = getPrismaClient();
|
|
await prisma.measurement.create({
|
|
data: {
|
|
userId: TEST_USER_ID,
|
|
type: "WEIGHT",
|
|
value: 80,
|
|
unit: "kg",
|
|
source: "MANUAL",
|
|
measuredAt: new Date("2026-05-01T08:00:00.000Z"),
|
|
},
|
|
});
|
|
|
|
const { GET } = await import("@/app/api/measurements/route");
|
|
const response = await GET(
|
|
getRequest(
|
|
"type=WEIGHT&from=2026-04-01T00:00:00Z&to=2026-05-30T00:00:00Z&aggregate=weekly&limit=105",
|
|
),
|
|
);
|
|
|
|
expect(response.status).toBe(200);
|
|
const json = (await response.json()) as {
|
|
data: { meta: { aggregate?: string } };
|
|
};
|
|
expect(json.data.meta.aggregate).toBe("weekly");
|
|
});
|
|
|
|
it("returns bucketed rows for grain=monthly", async () => {
|
|
const prisma = getPrismaClient();
|
|
await prisma.measurement.create({
|
|
data: {
|
|
userId: TEST_USER_ID,
|
|
type: "WEIGHT",
|
|
value: 80,
|
|
unit: "kg",
|
|
source: "MANUAL",
|
|
measuredAt: new Date("2026-05-01T08:00:00.000Z"),
|
|
},
|
|
});
|
|
|
|
const { GET } = await import("@/app/api/measurements/route");
|
|
const response = await GET(
|
|
getRequest(
|
|
"type=WEIGHT&from=2025-01-01T00:00:00Z&to=2026-05-30T00:00:00Z&aggregate=monthly&limit=24",
|
|
),
|
|
);
|
|
|
|
expect(response.status).toBe(200);
|
|
const json = (await response.json()) as {
|
|
data: { meta: { aggregate?: string } };
|
|
};
|
|
expect(json.data.meta.aggregate).toBe("monthly");
|
|
});
|
|
|
|
// v1.4.29 — cumulative HK types (steps, active energy, flights,
|
|
// distance, daylight) must aggregate with SUM, not AVG. Five rows
|
|
// of 1000 steps each on the same day must collapse to 5000, not
|
|
// 1000. See R-A finding 2.
|
|
it("sums cumulative HK types instead of averaging", async () => {
|
|
const prisma = getPrismaClient();
|
|
const baseTime = new Date("2026-05-01T08:00:00.000Z").getTime();
|
|
await prisma.measurement.createMany({
|
|
data: Array.from({ length: 5 }, (_, i) => ({
|
|
userId: TEST_USER_ID,
|
|
type: "ACTIVITY_STEPS" as const,
|
|
value: 1000,
|
|
unit: "steps",
|
|
source: "MANUAL" as const,
|
|
measuredAt: new Date(baseTime + i * 60 * 60 * 1000),
|
|
})),
|
|
});
|
|
|
|
const { GET } = await import("@/app/api/measurements/route");
|
|
const response = await GET(
|
|
getRequest(
|
|
"type=ACTIVITY_STEPS&from=2026-05-01T00:00:00Z&to=2026-05-02T00:00:00Z&aggregate=daily&limit=365",
|
|
),
|
|
);
|
|
|
|
expect(response.status).toBe(200);
|
|
const json = (await response.json()) as {
|
|
data: {
|
|
measurements: Array<{ value: number; count: number }>;
|
|
};
|
|
};
|
|
expect(json.data.measurements).toHaveLength(1);
|
|
expect(json.data.measurements[0].value).toBe(5000);
|
|
expect(json.data.measurements[0].count).toBe(5);
|
|
});
|
|
|
|
it("averages spot types (pulse) rather than summing", async () => {
|
|
// Belt-and-braces guard so a future refactor that mis-wires the
|
|
// SUM branch back across every type catches loudly here.
|
|
const prisma = getPrismaClient();
|
|
const baseTime = new Date("2026-05-01T08:00:00.000Z").getTime();
|
|
await prisma.measurement.createMany({
|
|
data: [60, 70, 80].map((v, i) => ({
|
|
userId: TEST_USER_ID,
|
|
type: "PULSE" as const,
|
|
value: v,
|
|
unit: "bpm",
|
|
source: "MANUAL" as const,
|
|
measuredAt: new Date(baseTime + i * 60 * 60 * 1000),
|
|
})),
|
|
});
|
|
|
|
const { GET } = await import("@/app/api/measurements/route");
|
|
const response = await GET(
|
|
getRequest(
|
|
"type=PULSE&from=2026-05-01T00:00:00Z&to=2026-05-02T00:00:00Z&aggregate=daily&limit=365",
|
|
),
|
|
);
|
|
|
|
expect(response.status).toBe(200);
|
|
const json = (await response.json()) as {
|
|
data: { measurements: Array<{ value: number }> };
|
|
};
|
|
expect(json.data.measurements).toHaveLength(1);
|
|
expect(json.data.measurements[0].value).toBe(70); // (60+70+80)/3
|
|
});
|
|
});
|