mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
Merge remote-tracking branch 'origin/main' into wip/v140-I
This commit is contained in:
commit
9c2b57df33
10 files changed
+566
-57
No files matched your search
@@ -9,6 +9,9 @@ vi.mock("@/lib/db", () => ({
|
||||
// v1.28.25 — dense kinds (glucose / pulse) day-bucket in SQL for
|
||||
// windows beyond the 90-day raw cap.
|
||||
$queryRaw: vi.fn(),
|
||||
// The series reads go through the ladder-winning source per day (see
|
||||
// `seriesRowsFrom`), which is raw SQL with the user id as `$1`.
|
||||
$queryRawUnsafe: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -65,8 +68,47 @@ function req(query: string): NextRequest {
|
||||
return new NextRequest(`http://localhost/api/measurements/series?${query}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* The route reads its series through `$queryRawUnsafe` (the ladder-winning
|
||||
* source per day). This file's fixtures are written as rows per type and as the
|
||||
* two aggregate results, so the adapter routes each statement back to them:
|
||||
* the day/hour bucket and the stats aggregate take the next `$queryRaw` result,
|
||||
* and a raw read asks `findMany` for the type named in the SQL and returns the
|
||||
* rows in the column names the statement selects. The SQL itself (which source
|
||||
* wins, which rows count) is covered against a real database in
|
||||
* `tests/integration/series-source-ladder.test.ts`.
|
||||
*/
|
||||
function adaptSeriesSql(sql: string): Promise<unknown> {
|
||||
if (/GROUP BY local_day|STDDEV_POP/.test(sql)) {
|
||||
return (prisma.$queryRaw as unknown as () => Promise<unknown>)();
|
||||
}
|
||||
const type = /'([A-Z0-9_]+)'::"measurement_type"/.exec(sql)?.[1];
|
||||
const find = prisma.measurement.findMany as unknown as (
|
||||
a: unknown,
|
||||
) => Promise<
|
||||
Array<{
|
||||
id: string;
|
||||
value: number;
|
||||
measuredAt: Date;
|
||||
valueMin?: number | null;
|
||||
valueMax?: number | null;
|
||||
}>
|
||||
>;
|
||||
return find({ where: { type } }).then((rows) =>
|
||||
rows.map((r) => ({
|
||||
id: r.id,
|
||||
value: r.value,
|
||||
measured_at: r.measuredAt,
|
||||
value_min: r.valueMin ?? null,
|
||||
value_max: r.valueMax ?? null,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(prisma.$queryRawUnsafe).mockImplementation(((sql: string) =>
|
||||
adaptSeriesSql(sql)) as never);
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.measurement.count).mockResolvedValue(0 as never);
|
||||
vi.mocked(prisma.$queryRaw).mockResolvedValue([] as never);
|
||||
@@ -216,14 +258,14 @@ describe("GET /api/measurements/series", () => {
|
||||
expect(body.data.points[1].valueMin).toBeNull();
|
||||
expect(body.data.points[1].valueMax).toBeNull();
|
||||
|
||||
// The pulse read selects the spread columns.
|
||||
const selectArg = vi.mocked(prisma.measurement.findMany).mock.calls[0][0];
|
||||
expect(
|
||||
(selectArg as { select: Record<string, boolean> }).select.valueMin,
|
||||
).toBe(true);
|
||||
expect(
|
||||
(selectArg as { select: Record<string, boolean> }).select.valueMax,
|
||||
).toBe(true);
|
||||
// The pulse read selects the spread columns, and reads the pulse rows of
|
||||
// this person's ladder-winning source (the user id is the first parameter).
|
||||
const [sql, userId] = vi.mocked(prisma.$queryRawUnsafe).mock
|
||||
.calls[0] as unknown as [string, string];
|
||||
expect(sql).toContain('m."value_min"');
|
||||
expect(sql).toContain('m."value_max"');
|
||||
expect(sql).toContain(`'PULSE'::"measurement_type"`);
|
||||
expect(userId).toBe("user-1");
|
||||
});
|
||||
|
||||
it("collapses sleep stage rows into one night point in hours (v1.11.4)", async () => {
|
||||
@@ -627,8 +669,12 @@ describe("GET /api/measurements/series — dense-kind day-bucketing (v1.28.25)",
|
||||
expect(body.data.stats.count).toBe(43_200);
|
||||
expect(body.data.stats.mean).toBe(72.1);
|
||||
// The bucket read folds per hour, not per day.
|
||||
const bucketSql = vi.mocked(prisma.$queryRaw).mock.calls[0] as unknown[];
|
||||
expect(bucketSql.slice(1)).toContain("hour");
|
||||
const bucketCall = vi
|
||||
.mocked(prisma.$queryRawUnsafe)
|
||||
.mock.calls.find(([sql]) =>
|
||||
/GROUP BY local_day/.test(sql as string),
|
||||
) as unknown[];
|
||||
expect(bucketCall.slice(1)).toContain("hour");
|
||||
});
|
||||
|
||||
it("hour-buckets a CGM glucose window too dense to send raw, in the user's unit", async () => {
|
||||
@@ -690,8 +736,12 @@ describe("GET /api/measurements/series — dense-kind day-bucketing (v1.28.25)",
|
||||
max: 14.4,
|
||||
stdDev: 2,
|
||||
});
|
||||
const bucketSql = vi.mocked(prisma.$queryRaw).mock.calls[0] as unknown[];
|
||||
expect(bucketSql.slice(1)).toContain("hour");
|
||||
const bucketCall = vi
|
||||
.mocked(prisma.$queryRawUnsafe)
|
||||
.mock.calls.find(([sql]) =>
|
||||
/GROUP BY local_day/.test(sql as string),
|
||||
) as unknown[];
|
||||
expect(bucketCall.slice(1)).toContain("hour");
|
||||
});
|
||||
|
||||
it("keeps fingerstick glucose at or under the row cap raw", async () => {
|
||||
|
||||
@@ -22,6 +22,7 @@ vi.mock("@/lib/db", () => ({
|
||||
user: { findUnique: vi.fn() },
|
||||
auditLog: { create: vi.fn() },
|
||||
$queryRaw: vi.fn(),
|
||||
$queryRawUnsafe: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -74,6 +75,7 @@ beforeEach(() => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.$queryRaw).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.$queryRawUnsafe).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
glucoseUnit: "mg/dL",
|
||||
|
||||
@@ -26,6 +26,7 @@ import type { MeasurementType, SleepStage } from "@/generated/prisma/client";
|
||||
import { reconstructSleepNights } from "@/lib/analytics/sleep-night";
|
||||
import { VALUE_RANGES } from "@/lib/validations/measurement";
|
||||
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
|
||||
import { seriesRowsFrom } from "@/lib/measurements/series-canonical";
|
||||
import { resolveUserTimezone, userDayKey } from "@/lib/tz/resolver";
|
||||
import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose";
|
||||
|
||||
@@ -399,49 +400,57 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
// v1.28.25 — long-window read of a sample-dense kind (CGM glucose,
|
||||
// per-sample / hourly pulse). Day-bucket in SQL instead of walking
|
||||
// every raw row into JS: one aggregate pass in Postgres returns at
|
||||
// most `days` rows regardless of sample density. Parameter-bound
|
||||
// tagged-template `$queryRaw`, mirroring the rollup tier's
|
||||
// `date_trunc(... ) GROUP BY` aggregate (measurement-rollups.ts).
|
||||
const userTz = await resolveUserTimezone(user.id);
|
||||
// most `days` rows regardless of sample density. Bound parameters
|
||||
// mirror the rollup tier's `date_trunc(... ) GROUP BY` aggregate
|
||||
// (measurement-rollups.ts), and the rows are the ladder-winning source per
|
||||
// day, the same rule the rollup tier and sleep apply.
|
||||
const [userTz, priorityJson] = await Promise.all([
|
||||
resolveUserTimezone(user.id),
|
||||
loadUserSourcePriority(user.id),
|
||||
]);
|
||||
const type = KIND_TO_TYPE[kind];
|
||||
const bucketRows = await prisma.$queryRaw<
|
||||
const seriesRows = seriesRowsFrom(priorityJson, type, days);
|
||||
const bucketRows = await prisma.$queryRawUnsafe<
|
||||
Array<{
|
||||
bucket_start: Date;
|
||||
mean: number;
|
||||
min_value: number;
|
||||
max_value: number;
|
||||
}>
|
||||
>`
|
||||
>(
|
||||
`
|
||||
WITH localized AS (
|
||||
SELECT
|
||||
m."value",
|
||||
m."value_min",
|
||||
m."value_max",
|
||||
date_trunc(
|
||||
${grain},
|
||||
(m."measured_at" AT TIME ZONE 'UTC') AT TIME ZONE ${userTz}
|
||||
$2,
|
||||
(m."measured_at" AT TIME ZONE 'UTC') AT TIME ZONE $3
|
||||
) AS local_day
|
||||
FROM measurements m
|
||||
WHERE m."user_id" = ${user.id}
|
||||
AND m."type" = ${type}::"measurement_type"
|
||||
AND m."measured_at" >= ${since}
|
||||
AND m."deleted_at" IS NULL
|
||||
FROM ${seriesRows}
|
||||
WHERE m."measured_at" >= $4
|
||||
)
|
||||
SELECT
|
||||
local_day AT TIME ZONE ${userTz} AS bucket_start,
|
||||
local_day AT TIME ZONE $3 AS bucket_start,
|
||||
AVG("value")::double precision AS mean,
|
||||
MIN(COALESCE("value_min", "value"))::double precision AS min_value,
|
||||
MAX(COALESCE("value_max", "value"))::double precision AS max_value
|
||||
FROM localized
|
||||
GROUP BY local_day
|
||||
ORDER BY bucket_start ASC
|
||||
`;
|
||||
`,
|
||||
user.id,
|
||||
grain,
|
||||
userTz,
|
||||
since,
|
||||
);
|
||||
// Stats stay aggregated over the RAW rows (not the day buckets) so the
|
||||
// strip's mean/min/max/stdDev/count are the same figures the raw path
|
||||
// computed: `AVG`/`MIN`/`MAX` over `value`, `STDDEV_POP` matching the
|
||||
// JS population-variance helper, exact row count. Aggregate-only —
|
||||
// no row transfer.
|
||||
const [rawAgg] = await prisma.$queryRaw<
|
||||
const [rawAgg] = await prisma.$queryRawUnsafe<
|
||||
Array<{
|
||||
n: number;
|
||||
mean: number | null;
|
||||
@@ -449,19 +458,20 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
max: number | null;
|
||||
sd: number | null;
|
||||
}>
|
||||
>`
|
||||
>(
|
||||
`
|
||||
SELECT
|
||||
COUNT(*)::int AS n,
|
||||
AVG(m."value")::double precision AS mean,
|
||||
MIN(m."value")::double precision AS min,
|
||||
MAX(m."value")::double precision AS max,
|
||||
STDDEV_POP(m."value")::double precision AS sd
|
||||
FROM measurements m
|
||||
WHERE m."user_id" = ${user.id}
|
||||
AND m."type" = ${type}::"measurement_type"
|
||||
AND m."measured_at" >= ${since}
|
||||
AND m."deleted_at" IS NULL
|
||||
`;
|
||||
FROM ${seriesRows}
|
||||
WHERE m."measured_at" >= $2
|
||||
`,
|
||||
user.id,
|
||||
since,
|
||||
);
|
||||
const round2 = (v: number) => Math.round(v * 100) / 100;
|
||||
const dayId = (d: Date) =>
|
||||
grain === "day"
|
||||
@@ -538,21 +548,37 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
// hourly average. Only PULSE rows ever carry a non-null spread (the
|
||||
// hourly HR bucket); every other kind selects the bare value shape.
|
||||
const includeSpread = kind === "pulse";
|
||||
const rows = await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
type,
|
||||
measuredAt: { gte: since },
|
||||
deletedAt: null,
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: {
|
||||
id: true,
|
||||
value: true,
|
||||
measuredAt: true,
|
||||
...(includeSpread ? { valueMin: true, valueMax: true } : {}),
|
||||
},
|
||||
});
|
||||
// The ladder-winning source per day (see `seriesRowsFrom`), so a second
|
||||
// provider neither blends into nor duplicates the series. Bounded as before:
|
||||
// every dense kind reaches this read only inside 90 days and under 10 000
|
||||
// rows (counted first), and the sparse kinds are a few readings a day.
|
||||
const sourcePriority = await loadUserSourcePriority(user.id);
|
||||
const rawRows = await prisma.$queryRawUnsafe<
|
||||
Array<{
|
||||
id: string;
|
||||
value: number;
|
||||
measured_at: Date;
|
||||
value_min: number | null;
|
||||
value_max: number | null;
|
||||
}>
|
||||
>(
|
||||
`
|
||||
SELECT m."id", m."value", m."measured_at", m."value_min", m."value_max"
|
||||
FROM ${seriesRowsFrom(sourcePriority, type, days)}
|
||||
WHERE m."measured_at" >= $2
|
||||
ORDER BY m."measured_at" ASC, m."id" ASC
|
||||
`,
|
||||
user.id,
|
||||
since,
|
||||
);
|
||||
const rows = rawRows.map((r) => ({
|
||||
id: r.id,
|
||||
value: r.value,
|
||||
measuredAt: r.measured_at,
|
||||
...(includeSpread
|
||||
? { valueMin: r.value_min, valueMax: r.value_max }
|
||||
: {}),
|
||||
}));
|
||||
if (kind === "glucose") {
|
||||
// v1.16.16 — glucose is stored canonical mg/dL; convert each point to
|
||||
// the user's display unit AT SERIALIZATION so the wire DTO is unit-
|
||||
|
||||
Reference in new issue
Block a user