mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
Merge remote-tracking branch 'origin/main' into fix/v1397-derive
This commit is contained in:
commit
533b75fdcf
70 files changed
+4167
-566
No files matched your search
@@ -33,6 +33,7 @@ import { getGlobalBoss } from "@/lib/jobs/boss-instance";
|
||||
import {
|
||||
ENCRYPTION_KEY_ROTATE_QUEUE,
|
||||
ENCRYPTION_KEY_ROTATE_SINGLETON,
|
||||
ENCRYPTION_KEY_ROTATE_EXPIRE_SECONDS,
|
||||
} from "@/lib/jobs/encryption-key-rotate";
|
||||
import { getActiveKeyId } from "@/lib/crypto";
|
||||
import { NextRequest } from "next/server";
|
||||
@@ -57,6 +58,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
singletonKey: ENCRYPTION_KEY_ROTATE_SINGLETON,
|
||||
retryLimit: 2,
|
||||
retryDelay: 60,
|
||||
expireInSeconds: ENCRYPTION_KEY_ROTATE_EXPIRE_SECONDS,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -37,6 +37,9 @@ vi.mock("@/lib/cycle/phase-crosstab", async (importOriginal) => ({
|
||||
discoverPhaseCorrelations(args),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/measurements/day-aggregates", () => ({
|
||||
readSourceDayAggregates: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
auditLog: { create: vi.fn() },
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
type SymptomDay,
|
||||
} from "@/lib/cycle/symptom-phase";
|
||||
import { addDays } from "@/lib/cycle/day-math";
|
||||
import { readSourceDayAggregates } from "@/lib/measurements/day-aggregates";
|
||||
import { moodDateKey } from "@/lib/mood/date-key";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
|
||||
@@ -130,27 +131,19 @@ export const GET = apiHandler(async () => {
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: { measuredAt: true, value: true },
|
||||
}),
|
||||
// The outcome metrics the phase contrast compares — soft-delete-scoped,
|
||||
// canonical-source deduped per day inside `metricDayMap`. Skipped whole
|
||||
// for a caller outside `measurements`.
|
||||
// The outcome metrics the phase contrast compares, soft-delete-scoped.
|
||||
// A year of steps, heart-rate variability and sensor glucose is far too
|
||||
// many readings to hold as objects (#1023), and the crosstab uses only
|
||||
// each day's sum and count after picking one source and device per day.
|
||||
// So the fold runs in SQL, one row per type, local day, source and
|
||||
// device, the read the coach's cycle snapshot already makes. Skipped
|
||||
// whole for a caller outside `measurements`.
|
||||
seesMeasurements
|
||||
? prisma.measurement.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
deletedAt: null,
|
||||
type: { in: PHASE_CROSSTAB_METRIC_TYPES },
|
||||
measuredAt: {
|
||||
gte: new Date(Date.parse(`${from}T00:00:00Z`)),
|
||||
},
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: {
|
||||
type: true,
|
||||
value: true,
|
||||
measuredAt: true,
|
||||
source: true,
|
||||
deviceType: true,
|
||||
},
|
||||
? readSourceDayAggregates({
|
||||
userId: user.id,
|
||||
types: PHASE_CROSSTAB_METRIC_TYPES,
|
||||
since: new Date(Date.parse(`${from}T00:00:00Z`)),
|
||||
timeZone: tz,
|
||||
})
|
||||
: [],
|
||||
prisma.user.findUnique({
|
||||
@@ -200,10 +193,13 @@ export const GET = apiHandler(async () => {
|
||||
today,
|
||||
);
|
||||
|
||||
// Each row stands for its day's readings: `value` is their sum and
|
||||
// `count` their number, so `metricDayMap` gets the raw readings' day mean.
|
||||
const measurements: CrossMetricMeasurement[] = measurementRows.map((m) => ({
|
||||
type: m.type,
|
||||
value: m.value,
|
||||
measuredAt: m.measuredAt,
|
||||
value: m.sum,
|
||||
count: m.n,
|
||||
measuredAt: m.firstAt,
|
||||
source: m.source,
|
||||
deviceType: m.deviceType,
|
||||
}));
|
||||
|
||||
@@ -19,8 +19,12 @@ import { unzipSync } from "fflate";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
// `count` sizes the report's pulse window (#1023): a sparse account.
|
||||
measurement: { findMany: vi.fn(), count: vi.fn(async () => 0) },
|
||||
// `groupBy` sizes the report's dense windows (#1023): a sparse account.
|
||||
measurement: {
|
||||
findMany: vi.fn(),
|
||||
count: vi.fn(async () => 0),
|
||||
groupBy: vi.fn(async () => []),
|
||||
},
|
||||
medication: { findMany: vi.fn() },
|
||||
medicationIntakeEvent: { findMany: vi.fn() },
|
||||
moodEntry: { findMany: vi.fn() },
|
||||
|
||||
@@ -30,6 +30,16 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
// The glucose and heart-rate streams fold in SQL; this file pins the route's
|
||||
// composition over Prisma mocks, so both readers are mocked at the module.
|
||||
vi.mock("@/lib/targets/glucose-read", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/targets/glucose-read")>()),
|
||||
readGlucoseTargetSummaries: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/analytics/resting-pulse-read", () => ({
|
||||
readRestingPulseProxy: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findUnique: vi.fn() },
|
||||
@@ -84,6 +94,11 @@ import { GET } from "../route";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
|
||||
import {
|
||||
foldGlucoseTargetRows,
|
||||
readGlucoseTargetSummaries,
|
||||
} from "@/lib/targets/glucose-read";
|
||||
import { readRestingPulseProxy } from "@/lib/analytics/resting-pulse-read";
|
||||
|
||||
const SESSION_USER = {
|
||||
id: "user-targets-1",
|
||||
@@ -153,6 +168,8 @@ beforeEach(() => {
|
||||
// v1.28.25 — the latest-ever-per-type read is a raw `DISTINCT ON`
|
||||
// (Prisma's `distinct` dedups client-side after pulling every row).
|
||||
(prisma.$queryRawUnsafe as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||
vi.mocked(readGlucoseTargetSummaries).mockResolvedValue([]);
|
||||
vi.mocked(readRestingPulseProxy).mockResolvedValue([]);
|
||||
});
|
||||
|
||||
describe("GET /api/insights/targets — mood-rollup tier swap", () => {
|
||||
@@ -338,16 +355,26 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => {
|
||||
it("bounds the glucose read with the same one-year floor (v1.16.8)", async () => {
|
||||
// The glucose section used to scan the user's entire BLOOD_GLUCOSE
|
||||
// history on every cold build (no `measuredAt` filter at all) just
|
||||
// to read the latest value per context. The read now carries the
|
||||
// same 365-day floor as the latest-ever-per-type query.
|
||||
// to read the latest value per context. It now folds in SQL, newest
|
||||
// per context over a year and per day over thirty days, and no glucose
|
||||
// or heart-rate reading is read as a row.
|
||||
await callGet(makeReq());
|
||||
|
||||
const calls = (
|
||||
prisma.measurement.findMany as ReturnType<typeof vi.fn>
|
||||
).mock.calls.map((c) => c[0]);
|
||||
const glucoseCall = calls.find((c) => c?.where?.type === "BLOOD_GLUCOSE");
|
||||
expect(glucoseCall).toBeDefined();
|
||||
expect(glucoseCall?.where?.measuredAt?.gte).toBeInstanceOf(Date);
|
||||
expect(
|
||||
calls.find((c) => c?.where?.type === "BLOOD_GLUCOSE"),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
calls.filter((c) => c?.where?.type?.in?.includes?.("PULSE")),
|
||||
).toEqual([]);
|
||||
const [args] = vi.mocked(readGlucoseTargetSummaries).mock.calls[0];
|
||||
const day = 86_400_000;
|
||||
expect(Date.now() - args.since.getTime()).toBeGreaterThan(364 * day);
|
||||
expect(Date.now() - args.since.getTime()).toBeLessThan(366 * day);
|
||||
expect(Date.now() - args.recentSince.getTime()).toBeGreaterThan(29 * day);
|
||||
expect(Date.now() - args.recentSince.getTime()).toBeLessThan(31 * day);
|
||||
});
|
||||
|
||||
it("pins the complete ordered public response for representative populated data", async () => {
|
||||
@@ -408,10 +435,20 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => {
|
||||
).mockImplementation(
|
||||
async (args: { where?: { type?: string | { in: string[] } } }) => {
|
||||
if (args.where?.type === "SLEEP_DURATION") return sleepRows;
|
||||
if (args.where?.type === "BLOOD_GLUCOSE") return glucoseRows;
|
||||
return genericRows;
|
||||
},
|
||||
);
|
||||
vi.mocked(readGlucoseTargetSummaries).mockImplementation(
|
||||
async ({ recentSince, timeZone }) =>
|
||||
foldGlucoseTargetRows(
|
||||
glucoseRows.map((row) => ({
|
||||
...row,
|
||||
glucoseContext: row.glucoseContext as "FASTING",
|
||||
})),
|
||||
recentSince,
|
||||
timeZone,
|
||||
),
|
||||
);
|
||||
(prisma.$queryRawUnsafe as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
{ type: "WEIGHT", value: 70 },
|
||||
{ type: "BLOOD_PRESSURE_SYS", value: 125 },
|
||||
|
||||
@@ -543,9 +543,18 @@ async function sleepListResponse(
|
||||
loadUserSourcePriority(user.id),
|
||||
]);
|
||||
|
||||
// Bound the read window: honour an explicit from/to, otherwise cap to
|
||||
// the last year so the per-stage scan stays small.
|
||||
const since = from ?? new Date(Date.now() - SLEEP_LIST_MAX_DAYS * 86_400_000);
|
||||
// Bound the read window to a year ending at `to` (or now). An explicit
|
||||
// `from` narrows it but never widens it: an early `from` used to walk every
|
||||
// per-stage row back to that date, which on a multi-year, multi-source
|
||||
// account is the unbounded read this cap exists to stop. Older nights are
|
||||
// reached by moving `to` back, and `meta.windowFrom` says where a cut
|
||||
// window started so a client can tell a cut from an empty history.
|
||||
const windowEnd = to ?? new Date();
|
||||
const floor = new Date(
|
||||
windowEnd.getTime() - SLEEP_LIST_MAX_DAYS * 86_400_000,
|
||||
);
|
||||
const since = from && from > floor ? from : floor;
|
||||
const windowCut = from !== undefined && from < floor;
|
||||
const rows = await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
@@ -690,7 +699,13 @@ async function sleepListResponse(
|
||||
});
|
||||
return apiSuccess({
|
||||
measurements,
|
||||
meta: { total: totalNights, limit, offset, groupBy: "night" },
|
||||
meta: {
|
||||
total: totalNights,
|
||||
limit,
|
||||
offset,
|
||||
groupBy: "night",
|
||||
...(windowCut ? { windowFrom: since.toISOString() } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -49,11 +49,23 @@ const SLEEP_SERIES_MAX_DAYS = 365;
|
||||
* byte-identical. Sparse kinds (weight, BP, …) are untouched.
|
||||
*/
|
||||
const DENSE_SERIES_RAW_WINDOW_DAYS = 90;
|
||||
const DENSE_SERIES_KINDS: ReadonlySet<string> = new Set(["glucose", "pulse"]);
|
||||
/**
|
||||
* The kinds a device can write at sampling rate. Heart-rate variability and
|
||||
* blood oxygen joined pulse and glucose once watches and rings began writing
|
||||
* them every few minutes overnight: their 3650-day "Alle" range read every
|
||||
* raw row, which is the read this rule exists to stop.
|
||||
*/
|
||||
const DENSE_SERIES_KINDS: ReadonlySet<string> = new Set([
|
||||
"glucose",
|
||||
"pulse",
|
||||
"heartRateVariability",
|
||||
"oxygenSaturation",
|
||||
]);
|
||||
|
||||
/**
|
||||
* #1023 — inside the raw window, a pulse or glucose series with more rows
|
||||
* than this is bucketed per local hour in SQL instead of sent raw.
|
||||
* #1023 — inside the raw window, a dense-kind series (pulse, glucose, HRV,
|
||||
* blood oxygen) with more rows than this is bucketed per local hour in SQL
|
||||
* instead of sent raw.
|
||||
*
|
||||
* A watch that records heart rate once a minute puts 43 000 rows in the
|
||||
* default 30-day window and 130 000 in 90 days: a 15 MB response the client
|
||||
@@ -381,7 +393,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
},
|
||||
})) > DENSE_SERIES_RAW_ROW_CAP)
|
||||
) {
|
||||
// Day buckets past the raw window; hour buckets for a pulse or CGM
|
||||
// Day buckets past the raw window; hour buckets for a dense-kind
|
||||
// stream too dense to send raw inside it.
|
||||
const grain = days > DENSE_SERIES_RAW_WINDOW_DAYS ? "day" : "hour";
|
||||
// v1.28.25 — long-window read of a sample-dense kind (CGM glucose,
|
||||
@@ -489,17 +501,19 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// pulse — `value` is the bucket's average; valueMin/valueMax carry the
|
||||
// bucket's low/high band (folding each hourly import bucket's own
|
||||
// `value` is the bucket's average. For pulse, valueMin/valueMax carry
|
||||
// the bucket's low/high band (folding each hourly import bucket's own
|
||||
// spread via the COALESCE above), same band semantics as the per-hour
|
||||
// shape.
|
||||
// shape. The other dense kinds keep the bare point shape their raw rows
|
||||
// have, so a client reads a bucket the way it reads a reading.
|
||||
points = bucketRows.map((r) => ({
|
||||
id: dayId(r.bucket_start),
|
||||
at: r.bucket_start.toISOString(),
|
||||
value: round2(r.mean),
|
||||
secondary: null,
|
||||
valueMin: r.min_value,
|
||||
valueMax: r.max_value,
|
||||
...(kind === "pulse"
|
||||
? { valueMin: r.min_value, valueMax: r.max_value }
|
||||
: {}),
|
||||
}));
|
||||
if (
|
||||
rawAgg !== undefined &&
|
||||
|
||||
Reference in new issue
Block a user