Merge remote-tracking branch 'origin/main' into fix/v1397-derive

This commit is contained in:
Marc-André Bombeck committed 2026-10-01 10:30:10 +02:00
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() },
+18 -22
View File
@@ -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 },
+19 -4
View File
@@ -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() } : {}),
},
});
}
+23 -9
View File
@@ -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 &&