mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
Merge branch 'fix/v139-server-bugs' into release/v1.39.0
This commit is contained in:
commit
c3ec437cad
51 files changed
+2329
-1105
No files matched your search
+11
-6
@@ -5965,21 +5965,23 @@ paths:
|
||||
- in: query
|
||||
name: from
|
||||
schema:
|
||||
description: Window start (inclusive). Defaults to 90 days before `to`; clamped to the medication's `createdAt` and a
|
||||
366-day span floor.
|
||||
description: Window start (inclusive). Defaults to 90 days before `to`; clamped to a 366-day span floor. Recorded doses
|
||||
are returned from this instant; expected slots are not minted before the medication's `createdAt`.
|
||||
type: string
|
||||
format: date-time
|
||||
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
|
||||
description: Window start (inclusive). Defaults to 90 days before `to`; clamped to the medication's `createdAt` and a
|
||||
366-day span floor.
|
||||
description: Window start (inclusive). Defaults to 90 days before `to`; clamped to a 366-day span floor. Recorded doses
|
||||
are returned from this instant; expected slots are not minted before the medication's `createdAt`.
|
||||
- in: query
|
||||
name: to
|
||||
schema:
|
||||
description: Window end (inclusive). Defaults to now. Must be ≥ `from`.
|
||||
description: Window end (inclusive). Defaults to now. Must be ≥ `from`. A dose recorded ahead of its slot (the slot's
|
||||
time within a day after `to`) is still returned.
|
||||
type: string
|
||||
format: date-time
|
||||
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
|
||||
description: Window end (inclusive). Defaults to now. Must be ≥ `from`.
|
||||
description: Window end (inclusive). Defaults to now. Must be ≥ `from`. A dose recorded ahead of its slot (the slot's
|
||||
time within a day after `to`) is still returned.
|
||||
responses:
|
||||
"200":
|
||||
description: Dose-history ledger.
|
||||
@@ -43708,10 +43710,13 @@ components:
|
||||
type: string
|
||||
format: date-time
|
||||
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
|
||||
description: "Start of the window recorded doses were read over: the requested `from` after the span clamp."
|
||||
to:
|
||||
type: string
|
||||
format: date-time
|
||||
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
|
||||
description: "End of the window recorded doses were read over: the requested `to`, or the slot time of a dose recorded
|
||||
ahead of its slot when that lies later (at most one day after `to`)."
|
||||
family:
|
||||
type: string
|
||||
enum:
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* Structural guard: background and insight readers must not materialise a
|
||||
* dense measurement stream.
|
||||
*
|
||||
* #1023: a single account syncing heart rate from a watch took the worker
|
||||
* down within half an hour. The status-card read was bounded in time
|
||||
* (`measuredAt >= now − 91 days`) but not in rows, and a stream sampled every
|
||||
* minute or faster turns 91 days into six or seven figures of objects. The
|
||||
* read also ran into the statement timeout. The cure was to fold per day in
|
||||
* SQL (`readDayAggregates`, `readLiveBuckets`), so the reader's size tracks
|
||||
* the number of days, not the sampling rate.
|
||||
*
|
||||
* What this guard pins, in the directories that run on the worker or feed an
|
||||
* insight: every `measurement.findMany` without a `take` reads a type that is
|
||||
* named as a literal and is not one a device streams. A read whose type is a
|
||||
* variable could be handed a dense type by the next caller, so it must be
|
||||
* listed below with the reason it is safe. The list is frozen both ways: a new
|
||||
* unlisted read fails, and an entry whose read is gone fails too.
|
||||
*
|
||||
* It is a tripwire, not a proof: the matcher reads source text, and a read
|
||||
* written some other way (a raw SELECT of rows, a helper in another
|
||||
* directory) passes it. The floor below fails the guard if the matcher stops
|
||||
* finding reads at all.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { walkSourceFiles } from "./helpers/source-files";
|
||||
import {
|
||||
CUMULATIVE_HK_TYPES,
|
||||
HIGH_FREQUENCY_MEAN_TYPES,
|
||||
} from "@/lib/measurements/apple-health-mapping";
|
||||
|
||||
const SRC = join(process.cwd(), "src");
|
||||
|
||||
const SCOPED_DIRS = [
|
||||
"lib/insights/",
|
||||
"lib/jobs/",
|
||||
"lib/rollups/",
|
||||
"lib/ai/",
|
||||
"lib/analytics/",
|
||||
"lib/dashboard/",
|
||||
"lib/daily/",
|
||||
];
|
||||
|
||||
/** Types a device writes at sampling rate rather than a few times a day. */
|
||||
const DENSE_TYPES: ReadonlySet<string> = new Set([
|
||||
"PULSE",
|
||||
"BLOOD_GLUCOSE",
|
||||
"HEART_RATE_VARIABILITY",
|
||||
"OXYGEN_SATURATION",
|
||||
...CUMULATIVE_HK_TYPES,
|
||||
...HIGH_FREQUENCY_MEAN_TYPES,
|
||||
]);
|
||||
|
||||
/**
|
||||
* Untaken reads whose type is not a sparse literal, keyed
|
||||
* `<file>::<type expression>`, with why each stays bounded.
|
||||
*/
|
||||
const ALLOWED: Record<string, string> = {
|
||||
"lib/insights/comprehensive-generate.ts::(shorthand)":
|
||||
'Only reached for SLEEP_DURATION (guarded by `type === "SLEEP_DURATION"`); a few stage rows per night.',
|
||||
"lib/insights/derived/vascular-age.ts::VASCULAR_AGE_TYPE":
|
||||
"A constant for a type written at most a few times a day.",
|
||||
"lib/insights/derived/fitness-age.ts::VO2_MAX_TYPE":
|
||||
"A constant for VO2 max, written at most daily.",
|
||||
"lib/insights/derived/six-minute-walk.ts::SIX_MINUTE_WALK_TYPE":
|
||||
"A constant for six-minute-walk distance, written at most daily.",
|
||||
"lib/insights/derived/wellness-scores.ts::measurementType":
|
||||
"Daily computed / provider scores (recovery, strain, readiness), one row per day.",
|
||||
"lib/jobs/reaction-line.ts::{ in: [...types]":
|
||||
"Exact-instant match (`measuredAt: row.occurredAt`), not a window.",
|
||||
"lib/jobs/step-consolidation-repair.ts::STEP_TYPE":
|
||||
"Repair job over one user's step rows for a bounded day range; runs once per repair, not per status refresh.",
|
||||
'lib/ai/coach/snapshot.ts::"BLOOD_GLUCOSE" as never':
|
||||
"Clinical CGM metrics (time in range, SD / CV, J-index, LBGI / HBGI, reading span) are functions of the individual readings, which a per-day aggregate cannot reproduce. 30 days at a sensor's fixed rate (at most one a minute) stays below 45 000 rows.",
|
||||
'lib/dashboard/snapshot.ts::"BLOOD_GLUCOSE"':
|
||||
"Same 30-day clinical glucose panel as the coach snapshot (per-reading metrics), grouped by meal context.",
|
||||
"lib/analytics/score/reader.ts::{ in: types":
|
||||
"Health-score inputs: steps (drained to one row per day nightly), sleep, waist, weight, blood pressure, fasting glucose only.",
|
||||
};
|
||||
|
||||
interface UntakenRead {
|
||||
file: string;
|
||||
line: number;
|
||||
typeExpr: string;
|
||||
}
|
||||
|
||||
function scopedFiles(): string[] {
|
||||
return walkSourceFiles(SRC, { floor: 3000 })
|
||||
.filter((p) => SCOPED_DIRS.some((d) => p.startsWith(d)))
|
||||
.filter((p) => !p.includes("__tests__"))
|
||||
.filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx"))
|
||||
.sort();
|
||||
}
|
||||
|
||||
function untakenReads(): { all: number; untaken: UntakenRead[] } {
|
||||
let all = 0;
|
||||
const untaken: UntakenRead[] = [];
|
||||
for (const file of scopedFiles()) {
|
||||
const src = readFileSync(join(SRC, file), "utf8");
|
||||
// Whitespace-tolerant: a call split across lines still matches.
|
||||
const re = /\.measurement\s*\.\s*findMany\s*\(/g;
|
||||
for (let m = re.exec(src); m; m = re.exec(src)) {
|
||||
all += 1;
|
||||
let i = m.index + m[0].length;
|
||||
let depth = 1;
|
||||
while (depth > 0 && i < src.length) {
|
||||
if (src[i] === "(") depth += 1;
|
||||
else if (src[i] === ")") depth -= 1;
|
||||
i += 1;
|
||||
}
|
||||
const call = src.slice(m.index, i);
|
||||
if (/\btake\b/.test(call)) continue;
|
||||
const typed = /\btype\s*:\s*([^,\n}]+)/.exec(call);
|
||||
const typeExpr = typed
|
||||
? typed[1].trim()
|
||||
: /\btype\s*,/.test(call)
|
||||
? "(shorthand)"
|
||||
: "(none)";
|
||||
untaken.push({
|
||||
file,
|
||||
line: src.slice(0, m.index).split("\n").length,
|
||||
typeExpr,
|
||||
});
|
||||
}
|
||||
}
|
||||
return { all, untaken };
|
||||
}
|
||||
|
||||
function sparseLiteral(typeExpr: string): boolean {
|
||||
const lit = /^"([A-Z_0-9]+)"/.exec(typeExpr);
|
||||
return lit !== null && !DENSE_TYPES.has(lit[1]);
|
||||
}
|
||||
|
||||
describe("dense measurement reads stay bounded (#1023)", () => {
|
||||
const { all, untaken } = untakenReads();
|
||||
|
||||
it("finds the reads it is meant to police", () => {
|
||||
// Guard against a matcher that silently matches nothing.
|
||||
expect(all).toBeGreaterThanOrEqual(40);
|
||||
expect(untaken.length).toBeGreaterThanOrEqual(15);
|
||||
});
|
||||
|
||||
it("every untaken read names a sparse literal type or is listed with its reason", () => {
|
||||
const offenders = untaken
|
||||
.filter((r) => !sparseLiteral(r.typeExpr))
|
||||
.filter((r) => !(`${r.file}::${r.typeExpr}` in ALLOWED))
|
||||
.map((r) => `${r.file}:${r.line} type ${r.typeExpr}`);
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it("every listed exception still exists", () => {
|
||||
const present = new Set(untaken.map((r) => `${r.file}::${r.typeExpr}`));
|
||||
expect(Object.keys(ALLOWED).filter((k) => !present.has(k))).toEqual([]);
|
||||
});
|
||||
|
||||
it("the readers fixed for #1023 fold in SQL rather than reading rows", () => {
|
||||
for (const file of [
|
||||
"lib/insights/graded-series.ts",
|
||||
"lib/rollups/tiered-context.ts",
|
||||
"lib/jobs/coach-plan-review.ts",
|
||||
"lib/measurements/daily-series-read.ts",
|
||||
]) {
|
||||
const src = readFileSync(join(SRC, file), "utf8");
|
||||
expect(src, file).not.toMatch(/\.measurement\s*\.\s*findMany\s*\(/);
|
||||
}
|
||||
const baseline = readFileSync(
|
||||
join(SRC, "lib/insights/derived/baseline.ts"),
|
||||
"utf8",
|
||||
);
|
||||
expect(baseline).not.toMatch(/\.measurement\s*\.\s*findMany\s*\(/);
|
||||
});
|
||||
});
|
||||
@@ -49,6 +49,10 @@ import { syncMfaEnrollCookie } from "@/lib/auth/mfa-enrollment";
|
||||
import { buildAvatarUrl } from "@/lib/avatar";
|
||||
import { decrypt } from "@/lib/crypto";
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
isValidTimezone,
|
||||
resolveServerDefaultTimezone,
|
||||
} from "@/lib/tz/resolver";
|
||||
import { isCycleEnabled } from "@/lib/cycle/gate";
|
||||
import {
|
||||
resolveModuleMap,
|
||||
@@ -272,7 +276,12 @@ export const GET = apiHandler(async () => {
|
||||
heightCm: user.heightCm,
|
||||
dateOfBirth: user.dateOfBirth,
|
||||
gender: user.gender,
|
||||
timezone: user.timezone,
|
||||
// The zone the server cuts this account's days in: the stored zone, or
|
||||
// the instance default when the stored one is unusable. Clients bucket
|
||||
// by this value, so it must be the resolved one, not the raw column.
|
||||
timezone: isValidTimezone(user.timezone)
|
||||
? user.timezone
|
||||
: await resolveServerDefaultTimezone(),
|
||||
onboardingCompletedAt: user.onboardingCompletedAt,
|
||||
onboardingTourCompleted: user.onboardingTourCompleted,
|
||||
// v1.39 (C1) — the needs-based setup flow, published additively:
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
// Day-mean reads fold per-day aggregates in SQL; the fake folds the mocked
|
||||
// `measurement.findMany` rows with the same rules.
|
||||
vi.mock("@/lib/measurements/day-aggregates", async () => ({
|
||||
readDayAggregates: (
|
||||
await import("@/lib/measurements/__tests__/fake-day-aggregates")
|
||||
).fakeReadDayAggregates,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findUnique: vi.fn() },
|
||||
|
||||
@@ -32,9 +32,6 @@ vi.mock("@/lib/db", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
// v1.4.39.2 — the rollup branch's coverage-mismatch fallback may call
|
||||
// `recomputeUserRollups` inline. Mock it so tests can assert whether
|
||||
// the inline fold fired without exercising the real populator.
|
||||
vi.mock("@/lib/rollups/measurement-rollups", async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import("@/lib/rollups/measurement-rollups")
|
||||
@@ -66,7 +63,7 @@ vi.mock("next/headers", () => ({
|
||||
import { GET } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { recomputeUserRollups } from "@/lib/rollups/measurement-rollups";
|
||||
import { invalidateUserTimezone } from "@/lib/tz/resolver";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
@@ -221,318 +218,50 @@ describe("GET /api/measurements — all-time semantics (SD-H1)", () => {
|
||||
expect(months.size).toBe(24);
|
||||
});
|
||||
|
||||
it("source=rollup + aggregate=daily reads measurement_rollups without firing the heavy date_trunc query", async () => {
|
||||
// v1.4.36 W1 — Insights trends row routes the three daily chart
|
||||
// fetches through the persistent DAY buckets via `source=rollup`.
|
||||
// The heavy `date_trunc` $queryRaw must NOT fire on the happy
|
||||
// path where the rollup has rows for the requested window.
|
||||
const buckets = Array.from({ length: 30 }, (_, i) => ({
|
||||
type: "WEIGHT",
|
||||
bucketStart: new Date(Date.UTC(2026, 3, 16 + i, 0, 0, 0)),
|
||||
mean: 81 + i * 0.05,
|
||||
count: 5,
|
||||
// v1.11.1 — single source per day → collapse is identity.
|
||||
source: "APPLE_HEALTH",
|
||||
}));
|
||||
vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue(
|
||||
buckets as never,
|
||||
);
|
||||
it("source=rollup + aggregate=daily folds live in the user's zone, never the UTC-day rollup (#1026)", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
timezone: "Asia/Kolkata",
|
||||
sourcePriorityJson: null,
|
||||
} as never);
|
||||
// The resolver caches per user; drop what earlier cases left behind.
|
||||
invalidateUserTimezone("user-1");
|
||||
vi.mocked(prisma.$queryRaw).mockResolvedValue([
|
||||
{
|
||||
type: "ACTIVITY_STEPS",
|
||||
bucket_start: new Date("2026-05-09T18:30:00.000Z"),
|
||||
avg: 8123,
|
||||
cnt: 96,
|
||||
min_value: 0,
|
||||
max_value: 400,
|
||||
},
|
||||
] as never);
|
||||
|
||||
const res = await GET(
|
||||
getRequest(
|
||||
"type=WEIGHT&from=2026-04-15T00:00:00Z&to=2026-05-15T00:00:00Z&aggregate=daily&source=rollup&limit=5000",
|
||||
"type=ACTIVITY_STEPS&from=2026-04-15T00:00:00Z&to=2026-05-15T00:00:00Z&aggregate=daily&source=rollup&limit=5000",
|
||||
),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const json = (await res.json()) as {
|
||||
data: {
|
||||
measurements: Array<{ type: string; value: number; count: number }>;
|
||||
meta: { aggregate?: string };
|
||||
measurements: Array<{
|
||||
measuredAt: string;
|
||||
value: number;
|
||||
minValue?: number;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
expect(prisma.measurementRollup.findMany).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.$queryRaw).not.toHaveBeenCalled();
|
||||
expect(json.data.measurements.length).toBe(30);
|
||||
// First bucket reflects the rollup mean — round-trip-stable.
|
||||
expect(json.data.measurements[0].type).toBe("WEIGHT");
|
||||
expect(json.data.measurements[0].value).toBeCloseTo(81, 5);
|
||||
expect(json.data.measurements[0].count).toBe(5);
|
||||
expect(json.data.meta.aggregate).toBe("daily");
|
||||
});
|
||||
|
||||
it("source=rollup cumulative path reads sum_value directly (v1.4.39 W-SUM)", async () => {
|
||||
// ACTIVITY_STEPS rollup row with sum_value populated. The route
|
||||
// must consume sumValue directly — NOT reconstruct from mean *
|
||||
// count. Use a sum that is NOT mean × count so the test
|
||||
// distinguishes the two paths.
|
||||
const buckets = Array.from({ length: 3 }, (_, i) => ({
|
||||
type: "ACTIVITY_STEPS",
|
||||
bucketStart: new Date(Date.UTC(2026, 4, 1 + i, 0, 0, 0)),
|
||||
mean: 2000, // would yield 10000 if multiplied by count=5
|
||||
count: 5,
|
||||
sumValue: 11000 + i * 250, // distinct from mean × count
|
||||
source: "APPLE_HEALTH",
|
||||
}));
|
||||
vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue(
|
||||
buckets as never,
|
||||
);
|
||||
|
||||
const res = await GET(
|
||||
getRequest(
|
||||
"type=ACTIVITY_STEPS&from=2026-05-01T00:00:00Z&to=2026-05-05T00:00:00Z&aggregate=daily&source=rollup&limit=5000",
|
||||
),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const json = (await res.json()) as {
|
||||
data: { measurements: Array<{ value: number }> };
|
||||
};
|
||||
expect(prisma.$queryRaw).not.toHaveBeenCalled();
|
||||
expect(json.data.measurements.map((m) => m.value)).toEqual([
|
||||
11000, 11250, 11500,
|
||||
]);
|
||||
});
|
||||
|
||||
it("source=rollup cumulative path falls back to mean*count when sum_value is NULL (v1.4.39 W-SUM)", async () => {
|
||||
// Pre-v1.4.39 row — boot-backfill hasn't converged yet. The
|
||||
// route falls back to mean × count so the chart never paints
|
||||
// a hole during the convergence window.
|
||||
const buckets = [
|
||||
{
|
||||
type: "ACTIVITY_STEPS",
|
||||
bucketStart: new Date(Date.UTC(2026, 4, 1)),
|
||||
mean: 2000,
|
||||
count: 5,
|
||||
sumValue: null,
|
||||
source: "APPLE_HEALTH",
|
||||
},
|
||||
];
|
||||
vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue(
|
||||
buckets as never,
|
||||
);
|
||||
|
||||
const res = await GET(
|
||||
getRequest(
|
||||
"type=ACTIVITY_STEPS&from=2026-05-01T00:00:00Z&to=2026-05-05T00:00:00Z&aggregate=daily&source=rollup&limit=5000",
|
||||
),
|
||||
);
|
||||
const json = (await res.json()) as {
|
||||
data: { measurements: Array<{ value: number }> };
|
||||
};
|
||||
expect(json.data.measurements[0].value).toBe(10000);
|
||||
});
|
||||
|
||||
it("falls back to live date_trunc when source=rollup returns zero buckets", async () => {
|
||||
// Empty rollup ⇒ heavy aggregate runs so brand-new accounts still
|
||||
// see a correct chart on their first render.
|
||||
vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue([] as never);
|
||||
const liveBuckets = Array.from({ length: 7 }, (_, i) => ({
|
||||
type: "WEIGHT",
|
||||
bucket_start: new Date(Date.UTC(2026, 4, 8 + i, 0, 0, 0)),
|
||||
avg: 81 + i * 0.05,
|
||||
cnt: 2,
|
||||
}));
|
||||
vi.mocked(prisma.$queryRaw).mockResolvedValue(liveBuckets as never);
|
||||
|
||||
const res = await GET(
|
||||
getRequest(
|
||||
"type=WEIGHT&from=2026-05-08T00:00:00Z&to=2026-05-15T00:00:00Z&aggregate=daily&source=rollup&limit=5000",
|
||||
),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.measurementRollup.findMany).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.$queryRaw).toHaveBeenCalledTimes(1);
|
||||
const json = (await res.json()) as {
|
||||
data: { measurements: Array<unknown> };
|
||||
};
|
||||
expect(json.data.measurements.length).toBe(7);
|
||||
});
|
||||
|
||||
it("source=rollup folds the partition inline and re-reads when the rollup is under-converged (v1.4.39.2)", async () => {
|
||||
// The maintainer's pre-fix production trace: 30-day BP_SYS window returned
|
||||
// `total: 2` from the rollup branch even though `measurements`
|
||||
// carried ~30 distinct days. The chart tripped its `< 3 daily
|
||||
// points` empty-state because the route short-circuited on
|
||||
// `rollupRows.length > 0` and never reconciled with the live
|
||||
// table. The coverage-mismatch fallback runs an inline
|
||||
// `recomputeUserRollups` for the (user, type, DAY, window)
|
||||
// partition and re-reads so the request returns the converged
|
||||
// rows.
|
||||
const sparseRollup = [
|
||||
{
|
||||
type: "BLOOD_PRESSURE_SYS",
|
||||
bucketStart: new Date(Date.UTC(2026, 4, 1, 0, 0, 0)),
|
||||
mean: 128,
|
||||
count: 1,
|
||||
sumValue: null,
|
||||
source: "APPLE_HEALTH",
|
||||
},
|
||||
{
|
||||
type: "BLOOD_PRESSURE_SYS",
|
||||
bucketStart: new Date(Date.UTC(2026, 4, 10, 0, 0, 0)),
|
||||
mean: 132,
|
||||
count: 1,
|
||||
sumValue: null,
|
||||
source: "APPLE_HEALTH",
|
||||
},
|
||||
];
|
||||
const convergedRollup = Array.from({ length: 28 }, (_, i) => ({
|
||||
type: "BLOOD_PRESSURE_SYS",
|
||||
bucketStart: new Date(Date.UTC(2026, 4, 1 + i, 0, 0, 0)),
|
||||
mean: 128 + (i % 5),
|
||||
count: 2,
|
||||
sumValue: null,
|
||||
source: "APPLE_HEALTH",
|
||||
}));
|
||||
// First read returns the sparse rows; second (post-fold) read
|
||||
// returns the converged rows.
|
||||
vi.mocked(prisma.measurementRollup.findMany)
|
||||
.mockResolvedValueOnce(sparseRollup as never)
|
||||
.mockResolvedValueOnce(convergedRollup as never);
|
||||
// Coverage probe — live `measurements` has 28 distinct days.
|
||||
vi.mocked(prisma.$queryRaw).mockResolvedValueOnce([
|
||||
{ days: BigInt(28) },
|
||||
] as never);
|
||||
|
||||
const res = await GET(
|
||||
getRequest(
|
||||
"type=BLOOD_PRESSURE_SYS&from=2026-04-30T00:00:00Z&to=2026-05-30T00:00:00Z&aggregate=daily&source=rollup&limit=5000",
|
||||
),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const json = (await res.json()) as {
|
||||
data: { measurements: Array<unknown> };
|
||||
};
|
||||
// The inline fold fired exactly once with the (type, DAY, window)
|
||||
// bound so the partition write stays small.
|
||||
expect(recomputeUserRollups).toHaveBeenCalledTimes(1);
|
||||
expect(recomputeUserRollups).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
expect.objectContaining({
|
||||
types: ["BLOOD_PRESSURE_SYS"],
|
||||
granularities: ["DAY"],
|
||||
}),
|
||||
);
|
||||
// The route re-read the rollup after the fold so the response
|
||||
// carries the converged 28 buckets — well above the chart's
|
||||
// `< 3 daily points` empty-state threshold.
|
||||
expect(prisma.measurementRollup.findMany).toHaveBeenCalledTimes(2);
|
||||
expect(json.data.measurements.length).toBe(28);
|
||||
});
|
||||
|
||||
it("source=rollup skips the coverage probe on a covered window (v1.4.39.2 hot-path)", async () => {
|
||||
// Happy case: 30-day window, 28 rollup rows present. The route
|
||||
// must short-circuit on the first read and skip the probe so the
|
||||
// covered-tenant hot path stays a single indexed read.
|
||||
const buckets = Array.from({ length: 28 }, (_, i) => ({
|
||||
type: "WEIGHT",
|
||||
bucketStart: new Date(Date.UTC(2026, 3, 16 + i, 0, 0, 0)),
|
||||
mean: 81 + i * 0.05,
|
||||
count: 2,
|
||||
sumValue: null,
|
||||
source: "APPLE_HEALTH",
|
||||
}));
|
||||
vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue(
|
||||
buckets as never,
|
||||
);
|
||||
|
||||
const res = await GET(
|
||||
getRequest(
|
||||
"type=WEIGHT&from=2026-04-15T00:00:00Z&to=2026-05-15T00:00:00Z&aggregate=daily&source=rollup&limit=5000",
|
||||
),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.measurementRollup.findMany).toHaveBeenCalledTimes(1);
|
||||
expect(recomputeUserRollups).not.toHaveBeenCalled();
|
||||
expect(prisma.$queryRaw).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("source=rollup skips the coverage fallback on short windows (v1.4.39.2)", async () => {
|
||||
// A 5-day window with 2 rollup rows is plausibly genuine sparsity
|
||||
// for a user who measures BP every other day. The coverage
|
||||
// fallback gates on `windowDays >= 7` so the probe + inline fold
|
||||
// never fire on these short windows.
|
||||
const sparseRollup = [
|
||||
{
|
||||
type: "BLOOD_PRESSURE_SYS",
|
||||
bucketStart: new Date(Date.UTC(2026, 4, 1, 0, 0, 0)),
|
||||
mean: 128,
|
||||
count: 1,
|
||||
sumValue: null,
|
||||
source: "APPLE_HEALTH",
|
||||
},
|
||||
{
|
||||
type: "BLOOD_PRESSURE_SYS",
|
||||
bucketStart: new Date(Date.UTC(2026, 4, 3, 0, 0, 0)),
|
||||
mean: 132,
|
||||
count: 1,
|
||||
sumValue: null,
|
||||
source: "APPLE_HEALTH",
|
||||
},
|
||||
];
|
||||
vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue(
|
||||
sparseRollup as never,
|
||||
);
|
||||
|
||||
const res = await GET(
|
||||
getRequest(
|
||||
"type=BLOOD_PRESSURE_SYS&from=2026-05-01T00:00:00Z&to=2026-05-06T00:00:00Z&aggregate=daily&source=rollup&limit=5000",
|
||||
),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(recomputeUserRollups).not.toHaveBeenCalled();
|
||||
expect(prisma.$queryRaw).not.toHaveBeenCalled();
|
||||
const json = (await res.json()) as {
|
||||
data: { measurements: Array<unknown> };
|
||||
};
|
||||
expect(json.data.measurements.length).toBe(2);
|
||||
});
|
||||
|
||||
it("source=rollup keeps the sparse rollup when live `measurements` agrees (v1.4.39.2)", async () => {
|
||||
// Genuine sparsity: the user measured BP twice in 30 days. The
|
||||
// probe finds the same 2 distinct days in `measurements` so the
|
||||
// route returns the 2 rollup rows without firing the inline
|
||||
// fold. The chart's `< 3 daily points` empty-state is the right
|
||||
// surface for this case — the data really is sparse.
|
||||
const sparseRollup = [
|
||||
{
|
||||
type: "BLOOD_PRESSURE_SYS",
|
||||
bucketStart: new Date(Date.UTC(2026, 4, 1, 0, 0, 0)),
|
||||
mean: 128,
|
||||
count: 1,
|
||||
sumValue: null,
|
||||
source: "APPLE_HEALTH",
|
||||
},
|
||||
{
|
||||
type: "BLOOD_PRESSURE_SYS",
|
||||
bucketStart: new Date(Date.UTC(2026, 4, 10, 0, 0, 0)),
|
||||
mean: 132,
|
||||
count: 1,
|
||||
sumValue: null,
|
||||
source: "APPLE_HEALTH",
|
||||
},
|
||||
];
|
||||
vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue(
|
||||
sparseRollup as never,
|
||||
);
|
||||
// Probe agrees — 2 distinct days in `measurements` too.
|
||||
vi.mocked(prisma.$queryRaw).mockResolvedValueOnce([
|
||||
{ days: BigInt(2) },
|
||||
] as never);
|
||||
|
||||
const res = await GET(
|
||||
getRequest(
|
||||
"type=BLOOD_PRESSURE_SYS&from=2026-04-30T00:00:00Z&to=2026-05-30T00:00:00Z&aggregate=daily&source=rollup&limit=5000",
|
||||
),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
// Probe fired (suspicious-low rollup) but the inline fold did
|
||||
// not (live and rollup agree).
|
||||
expect(prisma.$queryRaw).toHaveBeenCalledTimes(1);
|
||||
expect(recomputeUserRollups).not.toHaveBeenCalled();
|
||||
const json = (await res.json()) as {
|
||||
data: { measurements: Array<unknown> };
|
||||
};
|
||||
expect(json.data.measurements.length).toBe(2);
|
||||
expect(prisma.measurementRollup.findMany).not.toHaveBeenCalled();
|
||||
// The zone travels as a bound parameter of the fold.
|
||||
const call = vi.mocked(prisma.$queryRaw).mock.calls[0] as unknown[];
|
||||
expect(call.slice(1)).toContain("Asia/Kolkata");
|
||||
// Local midnight of 10 May in Kolkata; a SUM carries no spread band.
|
||||
expect(json.data.measurements[0]).toMatchObject({
|
||||
measuredAt: "2026-05-09T18:30:00.000Z",
|
||||
value: 8123,
|
||||
});
|
||||
expect(json.data.measurements[0].minValue).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns a weekly series when the all-time window is under two years", async () => {
|
||||
|
||||
@@ -45,20 +45,21 @@ import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy";
|
||||
import { runSafetyFloorCheck } from "@/lib/illness/safety-floor-check";
|
||||
import { afterMeasurementMutation } from "@/lib/rollups/after-measurement-mutation";
|
||||
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
|
||||
import { readDailySeries } from "@/lib/measurements/daily-series-read";
|
||||
import {
|
||||
readDailySeries,
|
||||
readLiveBuckets,
|
||||
} from "@/lib/measurements/daily-series-read";
|
||||
import {
|
||||
reconstructSleepSessions,
|
||||
pickMainNightAndNaps,
|
||||
} from "@/lib/analytics/sleep-night";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
import { buildSourceRankCase } from "@/lib/analytics/source-rank-sql";
|
||||
import { NextRequest } from "next/server";
|
||||
import type {
|
||||
MeasurementType,
|
||||
MeasurementSource,
|
||||
GlucoseContext,
|
||||
} from "@/generated/prisma/client";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireRecordAuth("read", "measurements");
|
||||
@@ -393,36 +394,12 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
// The route's response is bounded by the `BUCKET_CAP` ceiling per
|
||||
// grain (monthly: 24, weekly: 105, daily: 365) so a multi-decade
|
||||
// account never paints an unbounded series.
|
||||
// v1.4.36 W1 — rollup-table read for daily aggregates.
|
||||
//
|
||||
// The Insights trends row fires three parallel
|
||||
// `GET /api/measurements?type=…&aggregate=daily&limit=5000` requests
|
||||
// (BP_SYS / BP_DIA / WEIGHT). The legacy `date_trunc('day', …)`
|
||||
// path scanned the measurements table each time — a full year window
|
||||
// on the maintainer's account hit 3 × ~3 s on the v1.4.35 HAR. Reading from
|
||||
// the persistent `measurement_rollups` DAY buckets drops each call
|
||||
// to a small indexed read against the ~5 k-row rollup table.
|
||||
//
|
||||
// Gating:
|
||||
// - `source=rollup` is an explicit client opt-in (`@/lib/validations/measurement`
|
||||
// constrains the enum to `["rollup"]`).
|
||||
// - We require `aggregate=daily` because the rollup populator only
|
||||
// keeps DAY buckets on the synchronous write hook; the WEEK /
|
||||
// MONTH paths still depend on the async pg-boss recomputes which
|
||||
// may lag behind a recent write.
|
||||
// - `type` is mandatory so the response shape mirrors the per-type
|
||||
// ask the chart code makes.
|
||||
// - When the rollup window returns zero rows we fall through to
|
||||
// the live `date_trunc` path so a brand-new account whose
|
||||
// populator hasn't caught up still sees a correct chart on its
|
||||
// first render.
|
||||
// `source=rollup` + `aggregate=daily` — the chart's series read. Windows up
|
||||
// to the DAY cap fold live into the user's local days (#1026); wider
|
||||
// windows step up to the WEEK / MONTH / YEAR rollup tier. `type` is
|
||||
// mandatory so the response mirrors the per-type ask the chart makes.
|
||||
if (source === "rollup" && aggregate === "daily" && type && from && to) {
|
||||
// v1.18.6 — the rollup-daily read (per-source collapse + coverage
|
||||
// probe + inline fold + live-SQL fallback) moved to the shared
|
||||
// `readDailySeries` reader so the batched series endpoint
|
||||
// (`/api/measurements/series-batch`) reads through the SAME path
|
||||
// instead of duplicating this SQL. Behaviour is unchanged: the rows
|
||||
// returned here are byte-identical with the pre-extraction branch.
|
||||
// Shared with the batched series endpoint, so both read the same rows.
|
||||
const cap = Math.min(limit, BUCKET_CAP.daily);
|
||||
const priorityJson = await loadUserSourcePriority(user.id);
|
||||
const measurements = await readDailySeries({
|
||||
@@ -444,94 +421,29 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
},
|
||||
});
|
||||
}
|
||||
// `readDailySeries` already falls back to the live `date_trunc`
|
||||
// aggregate on a rollup-coverage miss, so an empty result here means
|
||||
// the window genuinely holds no rows. Fall through to the shared
|
||||
// live aggregate branch below to keep the response shape identical
|
||||
// for the truly-empty case.
|
||||
// An empty result means the window genuinely holds no rows. Fall
|
||||
// through to the shared live aggregate branch below to keep the
|
||||
// response shape identical for the truly-empty case.
|
||||
}
|
||||
|
||||
if (aggregate && aggregate !== "raw" && from && to) {
|
||||
const grain: AggregateGrain = aggregate;
|
||||
const grain: Exclude<AggregateGrain, "raw"> = aggregate;
|
||||
const cap = Math.min(limit, BUCKET_CAP[grain]);
|
||||
// Postgres `date_trunc` requires the unit argument to be a SQL
|
||||
// literal — it cannot be supplied as a prepared-statement parameter.
|
||||
// The previous `${truncUnit}` (bound) interpolation 500'd in
|
||||
// production for every grain; the grain string is restricted to
|
||||
// the `AggregateGrain` enum upstream via the Zod schema, so
|
||||
// injecting the mapped unit via `Prisma.raw` is safe. The mapping
|
||||
// also fixes the "weekly"/"monthly" passthrough — Postgres expects
|
||||
// the singular forms `week`/`month`.
|
||||
const TRUNC_UNIT: Record<Exclude<AggregateGrain, "raw">, string> = {
|
||||
daily: "day",
|
||||
weekly: "week",
|
||||
monthly: "month",
|
||||
};
|
||||
const truncUnit = TRUNC_UNIT[grain];
|
||||
const truncUnitLiteral = Prisma.raw(`'${truncUnit}'`);
|
||||
// Cumulative HK types (steps, active energy, flights, distance,
|
||||
// daylight) are partial-day increments — averaging across the day
|
||||
// understates the daily total by the per-bucket sample count.
|
||||
// SUM for those; AVG for spot metrics (BP, weight, pulse, BG,
|
||||
// body fat, mood, sleep). See R-A finding 2.
|
||||
const useSum =
|
||||
type != null && CUMULATIVE_HK_TYPES.has(type as MeasurementType);
|
||||
const aggregator = useSum
|
||||
? Prisma.raw(`SUM(m."value")::double precision`)
|
||||
: Prisma.raw(`AVG(m."value")::double precision`);
|
||||
// v1.11.1 — collapse overlapping sources to the ladder-canonical reading
|
||||
// per (type, bucket) before aggregating, so this cold/uncovered fallback
|
||||
// agrees with the warm rollup path. `canon` picks the winning source for
|
||||
// each (type, bucket) via the per-user rank; the join keeps only its rows.
|
||||
// Buckets are cut at the user's own calendar boundaries (#1026), the
|
||||
// same day the raw list and the chart's short range use. Cumulative
|
||||
// types SUM per bucket, every other type averages; overlapping sources
|
||||
// collapse to the ladder-canonical one first.
|
||||
const priorityJson = await loadUserSourcePriority(user.id);
|
||||
const rankRaw = Prisma.raw(
|
||||
buildSourceRankCase(priorityJson, 'm."type"', 'm."source"'),
|
||||
);
|
||||
const typeFilter = type
|
||||
? Prisma.sql`AND m."type" = ${type}::measurement_type`
|
||||
: Prisma.empty;
|
||||
const buckets = await prisma.$queryRaw<
|
||||
Array<{ type: string; bucket_start: Date; avg: number; cnt: number }>
|
||||
>`
|
||||
WITH canon AS (
|
||||
SELECT DISTINCT ON (m."type", date_trunc(${truncUnitLiteral}, m."measured_at"))
|
||||
m."type" AS t,
|
||||
date_trunc(${truncUnitLiteral}, m."measured_at") AS d,
|
||||
m."source" AS canon
|
||||
FROM measurements m
|
||||
WHERE m."user_id" = ${user.id}
|
||||
AND m."measured_at" >= ${from}
|
||||
AND m."measured_at" <= ${to}
|
||||
AND m."deleted_at" IS NULL
|
||||
${typeFilter}
|
||||
ORDER BY m."type", date_trunc(${truncUnitLiteral}, m."measured_at"), (${rankRaw}), m."source"
|
||||
)
|
||||
SELECT
|
||||
m."type"::text AS type,
|
||||
date_trunc(${truncUnitLiteral}, m."measured_at") AS bucket_start,
|
||||
${aggregator} AS avg,
|
||||
COUNT(*)::int AS cnt
|
||||
FROM measurements m
|
||||
JOIN canon c
|
||||
ON c.t = m."type"
|
||||
AND c.d = date_trunc(${truncUnitLiteral}, m."measured_at")
|
||||
AND c.canon = m."source"
|
||||
WHERE m."user_id" = ${user.id}
|
||||
AND m."measured_at" >= ${from}
|
||||
AND m."measured_at" <= ${to}
|
||||
AND m."deleted_at" IS NULL
|
||||
${typeFilter}
|
||||
GROUP BY m."type", bucket_start
|
||||
ORDER BY bucket_start ASC
|
||||
LIMIT ${cap}
|
||||
`;
|
||||
|
||||
const measurements = buckets.map((b) => ({
|
||||
type: b.type,
|
||||
value: Number(b.avg),
|
||||
measuredAt: b.bucket_start.toISOString(),
|
||||
count: Number(b.cnt),
|
||||
}));
|
||||
const measurements = await readLiveBuckets({
|
||||
userId: user.id,
|
||||
type: (type as MeasurementType | undefined) ?? null,
|
||||
from,
|
||||
to,
|
||||
cap,
|
||||
priorityJson,
|
||||
grain,
|
||||
timeZone: await resolveUserTimezone(user.id),
|
||||
});
|
||||
annotate({
|
||||
action: { name: "measurement.list" },
|
||||
meta: { total: measurements.length, type, aggregate: grain },
|
||||
|
||||
@@ -264,3 +264,165 @@ describe("GET /api/medications/[id]/dose-history", () => {
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Issue #1028 — a dose logged for earlier today on a medication created later
|
||||
* the same day vanished from the trailing-window ledger while the full
|
||||
* history (which has no window) showed it. The window floor was clamped to
|
||||
* the medication's creation instant and the same clamped floor filtered the
|
||||
* intakes, so any dose recorded with a time before the moment the medication
|
||||
* was added fell out of "Last 90 days". The creation floor exists to keep
|
||||
* the ledger from minting phantom slots before the medication existed; it
|
||||
* says nothing about which recorded doses belong in the window.
|
||||
*
|
||||
* Pinned in a positive and a negative UTC offset so a zone-dependent floor
|
||||
* cannot hide behind one of them.
|
||||
*/
|
||||
describe("GET /api/medications/[id]/dose-history — intakes recorded before creation (#1028)", () => {
|
||||
for (const tz of ["Asia/Kolkata", "America/Los_Angeles"]) {
|
||||
it(`keeps a same-day dose logged before the medication was added (${tz})`, async () => {
|
||||
const today = new Date("2026-09-22T12:00:00Z");
|
||||
const now = localHmAsUtc(today, tz, 16, 30);
|
||||
const createdAt = localHmAsUtc(today, tz, 16, 20);
|
||||
const takenAt = localHmAsUtc(today, tz, 14, 0);
|
||||
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
vi.setSystemTime(now);
|
||||
try {
|
||||
vi.mocked(getSession).mockResolvedValue({
|
||||
...SESSION_OK,
|
||||
user: { ...SESSION_OK.user, timezone: tz },
|
||||
} as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
|
||||
id: "med-1",
|
||||
startsOn: null,
|
||||
endsOn: null,
|
||||
oneShot: false,
|
||||
createdAt,
|
||||
scheduleRevisions: [],
|
||||
schedules: [
|
||||
{
|
||||
id: "sched-1",
|
||||
windowStart: "08:00",
|
||||
windowEnd: "08:00",
|
||||
daysOfWeek: null,
|
||||
timesOfDay: ["08:00"],
|
||||
reminderGraceMinutes: null,
|
||||
rrule: null,
|
||||
rollingIntervalDays: null,
|
||||
scheduleType: "SCHEDULED",
|
||||
cyclicOnWeeks: null,
|
||||
cyclicOffWeeks: null,
|
||||
},
|
||||
],
|
||||
} as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([
|
||||
{
|
||||
id: "evt-earlier-today",
|
||||
scheduledFor: takenAt,
|
||||
takenAt,
|
||||
skipped: false,
|
||||
autoMissed: false,
|
||||
attributionSource: "AUTO",
|
||||
doseTaken: null,
|
||||
source: "WEB",
|
||||
},
|
||||
] as never);
|
||||
|
||||
// Exactly what the Verlauf tab sends: the trailing 90 days to now.
|
||||
const from = new Date(now.getTime() - 90 * 86_400_000).toISOString();
|
||||
const to = now.toISOString();
|
||||
const res = await GET(
|
||||
getReq(
|
||||
`?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`,
|
||||
),
|
||||
ROUTE_PARAMS,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const json = await res.json();
|
||||
const ids = (
|
||||
json.data.rows as Array<{ intake: { id: string | null } | null }>
|
||||
).map((r) => r.intake?.id);
|
||||
expect(ids).toContain("evt-earlier-today");
|
||||
// The response reports the window the recorded doses were read
|
||||
// over: the requested start, not the medication's creation.
|
||||
expect(json.data.from).toBe(from);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("GET /api/medications/[id]/dose-history — early take (#1028 class)", () => {
|
||||
it("keeps a dose taken inside its window before the slot's own time", async () => {
|
||||
const tz = "Asia/Kolkata";
|
||||
const today = new Date("2026-09-22T12:00:00Z");
|
||||
const now = localHmAsUtc(today, tz, 19, 30);
|
||||
const slot = localHmAsUtc(today, tz, 20, 0);
|
||||
const takenAt = localHmAsUtc(today, tz, 19, 15);
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
vi.setSystemTime(now);
|
||||
try {
|
||||
vi.mocked(getSession).mockResolvedValue({
|
||||
...SESSION_OK,
|
||||
user: { ...SESSION_OK.user, timezone: tz },
|
||||
} as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
|
||||
id: "med-1",
|
||||
startsOn: null,
|
||||
endsOn: null,
|
||||
oneShot: false,
|
||||
createdAt: new Date("2026-01-01T00:00:00Z"),
|
||||
scheduleRevisions: [],
|
||||
schedules: [
|
||||
{
|
||||
id: "sched-1",
|
||||
windowStart: "20:00",
|
||||
windowEnd: "20:00",
|
||||
daysOfWeek: null,
|
||||
timesOfDay: ["20:00"],
|
||||
reminderGraceMinutes: null,
|
||||
rrule: null,
|
||||
rollingIntervalDays: null,
|
||||
scheduleType: "SCHEDULED",
|
||||
cyclicOnWeeks: null,
|
||||
cyclicOffWeeks: null,
|
||||
},
|
||||
],
|
||||
} as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([
|
||||
{
|
||||
id: "evt-early",
|
||||
scheduledFor: slot,
|
||||
takenAt,
|
||||
skipped: false,
|
||||
autoMissed: false,
|
||||
attributionSource: "AUTO",
|
||||
doseTaken: null,
|
||||
source: "WEB",
|
||||
},
|
||||
] as never);
|
||||
const from = new Date(now.getTime() - 90 * 86_400_000).toISOString();
|
||||
const res = await GET(
|
||||
getReq(`?from=${encodeURIComponent(from)}`),
|
||||
ROUTE_PARAMS,
|
||||
);
|
||||
const json = await res.json();
|
||||
const rows = json.data.rows as Array<{
|
||||
status: string;
|
||||
intake: { id: string | null } | null;
|
||||
}>;
|
||||
// `to` reports how far recorded doses were read: up to the early
|
||||
// dose's slot, past the request's own now.
|
||||
expect(json.data.to).toBe(slot.toISOString());
|
||||
const early = rows.find((r) => r.intake?.id === "evt-early");
|
||||
expect(early?.status).toBe("taken_on_time");
|
||||
// The read horizon stretches only to the recorded anchor: no later
|
||||
// slot is minted as upcoming.
|
||||
expect(rows.filter((r) => r.status === "upcoming")).toHaveLength(0);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -142,12 +142,18 @@ export const GET = apiHandler(
|
||||
const requestedFrom =
|
||||
parsed.data.from ?? new Date(to.getTime() - 90 * DAY_MS);
|
||||
const spanFloor = new Date(to.getTime() - MAX_WINDOW_DAYS * DAY_MS);
|
||||
// The window the caller asked for, capped only by the span limit. This is
|
||||
// the floor for recorded doses: a dose logged with a time before the
|
||||
// medication was added (created at 16:20, "taken at 14:00") is a real
|
||||
// record and belongs in the window, exactly as the unwindowed full
|
||||
// history shows it (#1028).
|
||||
const intakeFrom = new Date(
|
||||
Math.max(requestedFrom.getTime(), spanFloor.getTime()),
|
||||
);
|
||||
// The floor for minted slots additionally stops at the medication's
|
||||
// creation, so pre-existence days never mint phantom missed slots.
|
||||
const from = new Date(
|
||||
Math.max(
|
||||
requestedFrom.getTime(),
|
||||
medication.createdAt.getTime(),
|
||||
spanFloor.getTime(),
|
||||
),
|
||||
Math.max(intakeFrom.getTime(), medication.createdAt.getTime()),
|
||||
);
|
||||
|
||||
const events = await prisma.medicationIntakeEvent.findMany({
|
||||
@@ -182,6 +188,23 @@ export const GET = apiHandler(
|
||||
|
||||
const lastIntakeAt = lastNonSkippedTakenAt(mapped);
|
||||
|
||||
// A dose may be recorded before its slot's own time: the dose window opens
|
||||
// up to two hours early, and a skip can be logged ahead. Such a row is
|
||||
// stored on the slot's anchor, which still lies after `to`, so a window
|
||||
// that ends at `to` dropped a dose the user had just recorded until the
|
||||
// slot's clock time passed (#1028). Extend the read horizon to the latest
|
||||
// anchor of a recorded row (taken or skipped) within a day of `to`, and no
|
||||
// further: pending reminder rows record nothing and never stretch it.
|
||||
const horizon = new Date(
|
||||
mapped.reduce((latest, e) => {
|
||||
const t = e.scheduledFor.getTime();
|
||||
const recorded = e.skipped || (e.takenAt !== null && e.takenAt <= to);
|
||||
return recorded && t > latest && t <= to.getTime() + DAY_MS
|
||||
? t
|
||||
: latest;
|
||||
}, to.getTime()),
|
||||
);
|
||||
|
||||
const bandMedication: BandMinterMedication = {
|
||||
id: medication.id,
|
||||
startsOn: medication.startsOn,
|
||||
@@ -223,7 +246,7 @@ export const GET = apiHandler(
|
||||
revisions: medication.scheduleRevisions ?? [],
|
||||
ctx,
|
||||
userTz,
|
||||
range: { from, to },
|
||||
range: { from, to: horizon },
|
||||
now,
|
||||
intakeInstants,
|
||||
});
|
||||
@@ -241,7 +264,7 @@ export const GET = apiHandler(
|
||||
// partition, so filter on the stored anchor (a take snapped to an
|
||||
// out-of-window slot is excluded, matching the compliance read).
|
||||
const historyIntakes: HistoryIntake[] = mapped
|
||||
.filter((e) => e.scheduledFor >= from && e.scheduledFor <= to)
|
||||
.filter((e) => e.scheduledFor >= intakeFrom && e.scheduledFor <= horizon)
|
||||
.map((e) => ({
|
||||
id: e.id,
|
||||
scheduledFor: e.scheduledFor,
|
||||
@@ -290,9 +313,13 @@ export const GET = apiHandler(
|
||||
meta: { row_count: serialized.length, family },
|
||||
});
|
||||
|
||||
// The window the recorded doses were read over (#1028): from the
|
||||
// requested start (slots additionally start at the medication's
|
||||
// creation) to the read horizon, which reaches past `to` only for a dose
|
||||
// recorded ahead of its slot.
|
||||
return apiSuccess({
|
||||
from: from.toISOString(),
|
||||
to: to.toISOString(),
|
||||
from: intakeFrom.toISOString(),
|
||||
to: horizon.toISOString(),
|
||||
family,
|
||||
hasExpectedSlots: bands.length > 0,
|
||||
rows: serialized,
|
||||
|
||||
@@ -93,9 +93,10 @@ describe("<HealthChart> — bounded range fetch", () => {
|
||||
expect(measurementsUrl!).toMatch(/aggregate=daily/);
|
||||
});
|
||||
|
||||
it("omits aggregate=daily on windows of 7 days or fewer", async () => {
|
||||
// v1.4.29 C3 — short windows keep raw fetching so the user can
|
||||
// see hour-by-hour detail on the 7-day view.
|
||||
it("asks for daily rows on the 7-day window too", async () => {
|
||||
// The chart folds every range into days. The 7-day window used to read
|
||||
// raw rows oldest-first under the 5000-row cap, which on a per-minute
|
||||
// stream cut off the newest half of the week.
|
||||
const { I18nProvider } = await import("@/lib/i18n/context");
|
||||
const { HealthChart } = await import("../health-chart");
|
||||
|
||||
@@ -116,7 +117,7 @@ describe("<HealthChart> — bounded range fetch", () => {
|
||||
u.includes("/api/measurements"),
|
||||
);
|
||||
expect(measurementsUrl).toBeDefined();
|
||||
expect(measurementsUrl!).not.toMatch(/aggregate=daily/);
|
||||
expect(measurementsUrl!).toMatch(/aggregate=daily/);
|
||||
});
|
||||
|
||||
it("re-keys the query when the range window changes", async () => {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
/**
|
||||
* A chart mount that passes no `userTimezone` must still cut days in the
|
||||
* signed-in user's zone. Several mounts (the medications page, the coach
|
||||
* chat, the recovery section) pass none, and the component used to fall
|
||||
* back to a fixed Europe/Berlin, so a user in Asia/Kolkata saw those charts
|
||||
* bucketed and labelled in Berlin days while every other chart used their
|
||||
* own. The zone now comes from the account, which the server resolves.
|
||||
*/
|
||||
|
||||
let lastKey: unknown[] | undefined;
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
keepPreviousData: (previous: unknown) => previous,
|
||||
useQuery: ({ queryKey }: { queryKey: unknown[] }) => {
|
||||
lastKey = queryKey;
|
||||
return { data: [], isLoading: false };
|
||||
},
|
||||
useQueryClient: () => ({
|
||||
cancelQueries: () => Promise.resolve(),
|
||||
getQueryData: () => undefined,
|
||||
setQueryData: () => undefined,
|
||||
invalidateQueries: () => Promise.resolve(),
|
||||
}),
|
||||
useMutation: () => ({ mutate: () => undefined, isPending: false }),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
isAuthenticated: true,
|
||||
user: { id: "u1", timezone: "Asia/Kolkata" },
|
||||
isLoading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("<HealthChart> timezone", () => {
|
||||
it("uses the account's zone when the mount passes none", async () => {
|
||||
const { I18nProvider } = await import("@/lib/i18n/context");
|
||||
const { HealthChart } = await import("../health-chart");
|
||||
|
||||
renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<HealthChart types={["ACTIVITY_STEPS"]} title="Steps" unit="" />
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
expect(lastKey).toBeDefined();
|
||||
expect(lastKey).toContain("Asia/Kolkata");
|
||||
expect(lastKey).not.toContain("Europe/Berlin");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Query string for one type's chart series (`GET /api/measurements`).
|
||||
*
|
||||
* Every range asks for one row per local calendar day. The 7-day range used
|
||||
* to ask for raw rows oldest-first under a 5 000-row limit; a per-minute
|
||||
* heart-rate stream holds 10 080 rows in seven days, so the answer stopped
|
||||
* half-way through the week and the newest days were silently missing. The
|
||||
* chart folds rows into days either way, so the daily answer draws the same
|
||||
* points while its size depends only on the number of days.
|
||||
*
|
||||
* Pure so the request the chart makes can be tested against the real route.
|
||||
*/
|
||||
export function chartSeriesParams(
|
||||
type: string,
|
||||
window: { from: string; to: string },
|
||||
): URLSearchParams {
|
||||
const params = new URLSearchParams();
|
||||
params.set("type", type);
|
||||
params.set("sortBy", "measuredAt");
|
||||
params.set("sortDir", "asc");
|
||||
params.set("from", window.from);
|
||||
params.set("to", window.to);
|
||||
params.set("limit", "5000");
|
||||
params.set("aggregate", "daily");
|
||||
params.set("source", "rollup");
|
||||
return params;
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { chartSeriesParams } from "@/components/charts/chart-series-request";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
@@ -213,10 +215,11 @@ interface HealthChartProps {
|
||||
/**
|
||||
* v1.4.25 W7b — per-user display timezone. When passed (mount sites
|
||||
* thread `useAuth().user?.timezone`), x-axis tick labels and the
|
||||
* per-day bucket keys both render in the user's zone instead of the
|
||||
* legacy Europe/Berlin pin. Defaults to "Europe/Berlin" so older
|
||||
* callers that haven't yet adopted the prop keep their previous
|
||||
* behaviour bit-for-bit.
|
||||
* per-day bucket keys both render in the user's zone. When a mount
|
||||
* passes none, the chart uses the signed-in account's zone (which
|
||||
* `/api/auth/me` reports already resolved against the server default),
|
||||
* and only before that has loaded the shared `DEFAULT_TIMEZONE` the
|
||||
* server itself falls back to.
|
||||
*/
|
||||
userTimezone?: string;
|
||||
/**
|
||||
@@ -327,8 +330,8 @@ interface MeasurementApiRow {
|
||||
value: number;
|
||||
// v1.8.5 — per-day min / max emitted by the rollup daily-aggregate
|
||||
// path (non-cumulative metrics only). Drive the Apple-Health-style
|
||||
// range band shaded around the mean line. Absent on the raw-row path
|
||||
// (windows ≤ 7 days) and on cumulative metrics.
|
||||
// range band shaded around the mean line. Not drawn on the 7-day range
|
||||
// and absent on cumulative metrics.
|
||||
// v1.18.6 — `null` accepted alongside `undefined` so the batched
|
||||
// series endpoint's row shape (which carries `number | null`) feeds
|
||||
// `preloadedSeries` directly. The bucketing loop already guards each
|
||||
@@ -340,8 +343,7 @@ interface MeasurementApiRow {
|
||||
// the underlying raw-row count per bucket. Used downstream to gate
|
||||
// the "more days needed" empty-state copy on the actual measurement
|
||||
// count rather than the number of distinct calendar days. Optional
|
||||
// because the short-window raw-row path does not populate it (each
|
||||
// row already represents one measurement, count = 1).
|
||||
// because the per-night sleep series does not carry it.
|
||||
count?: number;
|
||||
}
|
||||
|
||||
@@ -614,7 +616,7 @@ export function HealthChart({
|
||||
chartKey,
|
||||
annotations,
|
||||
verticalMarkers,
|
||||
userTimezone = "Europe/Berlin",
|
||||
userTimezone: userTimezoneProp,
|
||||
valueScale = 1,
|
||||
valueOffset = 0,
|
||||
onVisibleStats,
|
||||
@@ -625,6 +627,9 @@ export function HealthChart({
|
||||
showDataTable = false,
|
||||
}: HealthChartProps) {
|
||||
const { isAuthenticated, user } = useAuth();
|
||||
// A mount without the prop used to pin Europe/Berlin, so those charts
|
||||
// cut days in Berlin for every user whatever their profile said.
|
||||
const userTimezone = userTimezoneProp || user?.timezone || DEFAULT_TIMEZONE;
|
||||
const { t, locale } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
// Issue #490 — x-axis tick + tooltip date labels. Every timestamp this
|
||||
@@ -764,12 +769,11 @@ export function HealthChart({
|
||||
};
|
||||
}, [rangePoints, effectiveCompareBaseline]);
|
||||
|
||||
// v1.18.6 — honour the dashboard's batched slice ONLY for the
|
||||
// daily-aggregate window (`windowDays > 7`, the rollup path) and ONLY
|
||||
// when EVERY non-sleep type the chart renders is present in the slice.
|
||||
// SLEEP_DURATION is never batched (per-night `/series`), so a chart
|
||||
// that includes it always self-fetches. A range-tab change to a ≤7-day
|
||||
// window (raw rows) drops batched coverage and self-fetches.
|
||||
// v1.18.6 — honour the dashboard's batched slice ONLY for windows over
|
||||
// 7 days and ONLY when EVERY non-sleep type the chart renders is present
|
||||
// in the slice. SLEEP_DURATION is never batched (per-night `/series`),
|
||||
// so a chart that includes it always self-fetches. The 7-day tab
|
||||
// self-fetches its own window.
|
||||
//
|
||||
// v1.19.0 — also require that the requested window FITS within the
|
||||
// batch's actual coverage. The dashboard batches only a ~30-day slice;
|
||||
@@ -916,41 +920,7 @@ export function HealthChart({
|
||||
await fetchSleepNights();
|
||||
return;
|
||||
}
|
||||
const typeParams = new URLSearchParams();
|
||||
typeParams.set("type", type);
|
||||
typeParams.set("sortBy", "measuredAt");
|
||||
typeParams.set("sortDir", "asc");
|
||||
typeParams.set("from", fetchWindow.from);
|
||||
typeParams.set("to", fetchWindow.to);
|
||||
typeParams.set("limit", "5000");
|
||||
// v1.4.29 C3 — windows over 7 days ask the server to bucket
|
||||
// daily. Caps the chart's per-type payload at ~365 rows
|
||||
// instead of ~5 000 for high-density types (pulse), and drops
|
||||
// Recharts paint cost ~50× on continuous-monitoring accounts.
|
||||
// Short windows keep raw fetching so hour-by-hour detail
|
||||
// stays visible.
|
||||
//
|
||||
// v1.4.36 W1 — also route the daily aggregate through the
|
||||
// persistent rollup buckets via `source=rollup`. The route
|
||||
// reads from `measurement_rollups` instead of running a live
|
||||
// `date_trunc` scan over the raw measurements table; the
|
||||
// three parallel daily fetches on the Insights trends row
|
||||
// (BP_SYS / BP_DIA / WEIGHT) drop from ~3 s each to a small
|
||||
// indexed read against the ~5 k-row rollup table. The server
|
||||
// falls back to live SQL when the rollup is empty for the
|
||||
// requested window so brand-new accounts still see a chart
|
||||
// on their first render.
|
||||
//
|
||||
// v1.19.2 — the `aggregate=daily` ask still holds, but for windows
|
||||
// wider than the DAY bucket cap the server steps the rollup tier
|
||||
// up to WEEK / MONTH and returns whole-history coverage at that
|
||||
// tier. Each returned row is one coarse bucket; the daily fold +
|
||||
// `bucketTimeSeries` below downsample it to the visible range, so
|
||||
// the request contract is unchanged.
|
||||
if (fetchWindow.windowDays > 7) {
|
||||
typeParams.set("aggregate", "daily");
|
||||
typeParams.set("source", "rollup");
|
||||
}
|
||||
const typeParams = chartSeriesParams(type, fetchWindow);
|
||||
|
||||
// v1.18.6 — when the dashboard handed this chart a batched slice
|
||||
// for the type, fold those rows instead of a per-type fetch. The
|
||||
@@ -1011,8 +981,11 @@ export function HealthChart({
|
||||
// same scale to the spread so the band tracks the line. The
|
||||
// BMI view drops the range tuple in its `select` callback (it
|
||||
// has never rendered the band), so the raw cache always
|
||||
// carries the spread.
|
||||
// carries the spread. The 7-day range never drew the band (it
|
||||
// used to read raw rows, which carry no spread) and still does
|
||||
// not, so its picture is unchanged now that it reads days too.
|
||||
if (
|
||||
fetchWindow.windowDays > 7 &&
|
||||
typeof measurement.minValue === "number" &&
|
||||
typeof measurement.maxValue === "number"
|
||||
) {
|
||||
@@ -1044,10 +1017,9 @@ export function HealthChart({
|
||||
for (const [type, stats] of Object.entries(bucket.values)) {
|
||||
// v1.4.29.1 — cumulative HealthKit types (steps, active energy,
|
||||
// distance, flights, daylight) must reduce with sum, not the
|
||||
// per-sample average. The server already does this when the
|
||||
// chart fetches with `aggregate=daily` (windows over 7 days);
|
||||
// the 7-day path still pulls raw rows and aggregates here,
|
||||
// so the same distinction must hold client-side.
|
||||
// per-sample average. The server already returns one row per
|
||||
// day; a day that arrives as several rows (the coarser tiers
|
||||
// of the "All" range) must still add up rather than average.
|
||||
const isCumulative = CUMULATIVE_HK_TYPES.has(
|
||||
type as MeasurementType,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Issue #1028 — the Verlauf ledger's trailing window must be resolved when
|
||||
* the request fires, not when the tab mounts.
|
||||
*
|
||||
* The ledger used to pin `from` and `to` in state at mount and reuse them for
|
||||
* every fetch. A refetch after adding a dose therefore asked for rows up to
|
||||
* the moment the tab opened, and a dose recorded afterwards stayed out of
|
||||
* "Last 90 days" until a reload, while the unwindowed full history showed it.
|
||||
*
|
||||
* The test captures the query function the ledger hands to TanStack Query and
|
||||
* runs it twice at two different clock readings: both requests must reach the
|
||||
* present, which means no pinned `to` and a `from` that moves with the clock.
|
||||
*/
|
||||
import { describe, expect, it, vi, afterEach } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
const captured: { queryFn?: () => Promise<unknown>; queryKey?: unknown } = {};
|
||||
|
||||
vi.mock("@tanstack/react-query", async (orig) => {
|
||||
const actual = await orig<typeof import("@tanstack/react-query")>();
|
||||
return {
|
||||
...actual,
|
||||
useQuery: (opts: {
|
||||
queryFn: () => Promise<unknown>;
|
||||
queryKey: unknown;
|
||||
}) => {
|
||||
captured.queryFn = opts.queryFn;
|
||||
captured.queryKey = opts.queryKey;
|
||||
return { data: undefined, isLoading: true, isError: false };
|
||||
},
|
||||
useQueryClient: () => ({}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/api/api-fetch", () => ({
|
||||
apiGet: vi.fn().mockResolvedValue({ rows: [] }),
|
||||
apiPost: vi.fn(),
|
||||
apiPut: vi.fn(),
|
||||
apiDelete: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: { id: "u1", username: "t", role: "USER", timezone: "Asia/Kolkata" },
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-record-capabilities", () => ({
|
||||
useRecordCapabilities: () => ({ canWriteDomain: () => true }),
|
||||
useActiveRecordName: () => null,
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { DoseHistoryLedger } from "@/components/medications/dose-history-ledger";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("DoseHistoryLedger request window (#1028)", () => {
|
||||
it("resolves the window at fetch time so a later refetch reaches the present", async () => {
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
const mountedAt = new Date("2026-09-22T10:00:00Z");
|
||||
vi.setSystemTime(mountedAt);
|
||||
|
||||
renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<DoseHistoryLedger
|
||||
medicationId="med-1"
|
||||
medicationName="Test"
|
||||
schedules={[]}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
expect(captured.queryFn).toBeTypeOf("function");
|
||||
|
||||
await captured.queryFn!();
|
||||
// An hour later the user adds a dose; the invalidation refetches.
|
||||
const later = new Date(mountedAt.getTime() + 3_600_000);
|
||||
vi.setSystemTime(later);
|
||||
await captured.queryFn!();
|
||||
|
||||
const urls = vi.mocked(apiGet).mock.calls.map((c) => String(c[0]));
|
||||
expect(urls).toHaveLength(2);
|
||||
for (const url of urls) {
|
||||
const params = new URL(url, "http://x").searchParams;
|
||||
// No pinned upper bound: the server's `to` is its own now.
|
||||
expect(params.get("to")).toBeNull();
|
||||
}
|
||||
const secondFrom = new URL(urls[1], "http://x").searchParams.get("from");
|
||||
expect(secondFrom).toBe(
|
||||
new Date(later.getTime() - 90 * 86_400_000).toISOString(),
|
||||
);
|
||||
// The cache key carries no instant, so it stays stable across renders.
|
||||
expect(captured.queryKey).toEqual([
|
||||
"medications",
|
||||
"med-1",
|
||||
"dose-history",
|
||||
90,
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -200,27 +200,18 @@ export function DoseHistoryLedger({
|
||||
slotAt: string;
|
||||
} | null>(null);
|
||||
|
||||
// Stable window captured once at mount (a lazy state initializer keeps the
|
||||
// impure `Date.now()` out of render) so the query key + the `from`/`to`
|
||||
// request stay byte-equal across renders rather than re-keying every paint.
|
||||
const [{ fromIso, toIso }] = useState(() => {
|
||||
const now = Date.now();
|
||||
return {
|
||||
toIso: new Date(now).toISOString(),
|
||||
fromIso: new Date(now - windowDays * DAY_MS).toISOString(),
|
||||
};
|
||||
});
|
||||
|
||||
const queryKey = queryKeys.medicationDoseHistory(
|
||||
medicationId,
|
||||
fromIso,
|
||||
toIso,
|
||||
);
|
||||
// The window is resolved when the request fires, not when the ledger
|
||||
// mounts. A window pinned at mount made every refetch (including the one
|
||||
// after adding an entry) ask for rows up to the moment the tab opened, so a
|
||||
// dose recorded afterwards stayed invisible until a reload (#1028). Only
|
||||
// `from` travels: the server's `to` defaults to its own now.
|
||||
const queryKey = queryKeys.medicationDoseHistory(medicationId, windowDays);
|
||||
|
||||
const { data, isLoading, isError } = useQuery<LedgerPayload>({
|
||||
queryKey,
|
||||
queryFn: async () => {
|
||||
const search = new URLSearchParams({ from: fromIso, to: toIso });
|
||||
const from = new Date(Date.now() - windowDays * DAY_MS).toISOString();
|
||||
const search = new URLSearchParams({ from });
|
||||
return apiGet<LedgerPayload>(
|
||||
`/api/medications/${medicationId}/dose-history?${search.toString()}`,
|
||||
);
|
||||
|
||||
@@ -10,6 +10,14 @@ vi.mock("@/lib/db", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
// The crosstab reads per-source day aggregates; the fake groups the mocked
|
||||
// `measurement.findMany` rows with the same rules.
|
||||
vi.mock("@/lib/measurements/day-aggregates", async () => ({
|
||||
readSourceDayAggregates: (
|
||||
await import("@/lib/measurements/__tests__/fake-day-aggregates")
|
||||
).fakeReadSourceDayAggregates,
|
||||
}));
|
||||
|
||||
import { buildCycleSnapshotBlock } from "../cycle-snapshot";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
} from "@/lib/cycle/phase-crosstab";
|
||||
import type { CrossMetricMeasurement } from "@/lib/insights/mood-aggregates";
|
||||
import { addDays } from "@/lib/cycle/day-math";
|
||||
import { readSourceDayAggregates } from "@/lib/measurements/day-aggregates";
|
||||
import { DEFAULT_TIMEZONE, moodDateKey } from "@/lib/mood/date-key";
|
||||
|
||||
/** Trailing window the phase contrast walks (days). Mirrors the read route. */
|
||||
@@ -195,21 +196,16 @@ export async function buildCycleSnapshotBlock(
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: { measuredAt: true, value: true },
|
||||
}),
|
||||
prisma.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
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,
|
||||
},
|
||||
// A year of steps, heart-rate variability and CGM glucose is far too
|
||||
// many readings to hold as objects (#1023). The crosstab only needs
|
||||
// each day's sum and count per source and device, which is what the
|
||||
// per-source picker decides on, so the fold runs in SQL. Days are the
|
||||
// Berlin days `metricDayMap` keys on.
|
||||
readSourceDayAggregates({
|
||||
userId,
|
||||
types: PHASE_CROSSTAB_METRIC_TYPES,
|
||||
since: new Date(Date.parse(`${from}T00:00:00Z`)),
|
||||
timeZone: "Europe/Berlin",
|
||||
}),
|
||||
prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
@@ -285,8 +281,9 @@ export async function buildCycleSnapshotBlock(
|
||||
);
|
||||
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,
|
||||
}));
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
|
||||
// The graded series folds per-day aggregates in SQL; the fake folds the
|
||||
// mocked `measurement.findMany` rows with the same rules.
|
||||
vi.mock("@/lib/measurements/day-aggregates", async () => ({
|
||||
readDayAggregates: (
|
||||
await import("@/lib/measurements/__tests__/fake-day-aggregates")
|
||||
).fakeReadDayAggregates,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findUnique: vi.fn() },
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
|
||||
// The graded series folds per-day aggregates in SQL; the fake folds the
|
||||
// mocked `measurement.findMany` rows with the same rules.
|
||||
vi.mock("@/lib/measurements/day-aggregates", async () => ({
|
||||
readDayAggregates: (
|
||||
await import("@/lib/measurements/__tests__/fake-day-aggregates")
|
||||
).fakeReadDayAggregates,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findUnique: vi.fn() },
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const findMany = vi.fn();
|
||||
const queryRaw = vi.fn();
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
measurement: {
|
||||
@@ -10,12 +9,14 @@ vi.mock("@/lib/db", () => ({
|
||||
// loadUserSourcePriority lazy-loads the source-priority blob; null
|
||||
// (default findUnique) falls back to the default ladders.
|
||||
user: { findUnique: vi.fn() },
|
||||
// v1.28.25 — dense types (BLOOD_GLUCOSE / PULSE) day-bucket the
|
||||
// cold-tier fallback in SQL.
|
||||
$queryRaw: (...args: unknown[]) => queryRaw(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
const readDayAggregates = vi.fn();
|
||||
vi.mock("@/lib/measurements/day-aggregates", () => ({
|
||||
readDayAggregates: (...args: unknown[]) => readDayAggregates(...args),
|
||||
}));
|
||||
|
||||
const readBestGranularityRollups = vi.fn();
|
||||
const rollupRow = (bucketStart: Date, mean: number) => ({
|
||||
bucketStart,
|
||||
@@ -31,147 +32,160 @@ const rollupRow = (bucketStart: Date, mean: number) => ({
|
||||
vi.mock("@/lib/rollups/measurement-read-wmy", () => ({
|
||||
readBestGranularityRollups: (...args: unknown[]) =>
|
||||
readBestGranularityRollups(...args),
|
||||
// aggregateWmyBuckets is no longer imported by graded-series, but keep the
|
||||
// module shape intact for any transitive consumer.
|
||||
aggregateWmyBuckets: vi.fn(),
|
||||
}));
|
||||
|
||||
import { buildGradedSeriesWithRollups } from "../graded-series";
|
||||
import {
|
||||
buildGradedSeriesFromDayAggregates,
|
||||
buildGradedSeriesFromPoints,
|
||||
buildGradedSeriesWithRollups,
|
||||
} from "../graded-series";
|
||||
import { foldDayAggregates } from "@/lib/measurements/__tests__/fake-day-aggregates";
|
||||
import type { ReadDayAggregatesOptions } from "@/lib/measurements/day-aggregates";
|
||||
|
||||
const dayMs = 24 * 60 * 60 * 1000;
|
||||
|
||||
/** Daily readings spanning `days` back from `now`. */
|
||||
function dailyRows(days: number, now: Date) {
|
||||
/** Readings spanning `days` back from `now`, `perDay` per day. */
|
||||
function points(days: number, now: Date, perDay = 1) {
|
||||
const rows: Array<{ measuredAt: Date; value: number }> = [];
|
||||
for (let i = 0; i < days; i++) {
|
||||
rows.push({ measuredAt: new Date(now.getTime() - i * dayMs), value: 80 });
|
||||
for (let k = 0; k < perDay; k++) {
|
||||
rows.push({
|
||||
measuredAt: new Date(now.getTime() - i * dayMs - k * 3_600_000),
|
||||
value: 60 + ((i * 7 + k * 3) % 41),
|
||||
});
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
return rows.reverse();
|
||||
}
|
||||
|
||||
/** Serve `readDayAggregates` from an in-memory row set. */
|
||||
function serveFrom(rows: Array<{ measuredAt: Date; value: number }>) {
|
||||
readDayAggregates.mockImplementation(async (opts: ReadDayAggregatesOptions) =>
|
||||
foldDayAggregates(rows, opts),
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("buildGradedSeriesWithRollups — rollup-miss fallback", () => {
|
||||
describe("buildGradedSeriesWithRollups — bounded reads (#1023)", () => {
|
||||
const now = new Date("2026-05-31T12:00:00Z");
|
||||
|
||||
it("never materialises raw rows: the recent read is a per-day aggregate", async () => {
|
||||
serveFrom(points(80, now, 24));
|
||||
readBestGranularityRollups.mockResolvedValue({
|
||||
granularity: "MONTH",
|
||||
rows: [rollupRow(new Date("2025-09-01T00:00:00Z"), 79)],
|
||||
});
|
||||
|
||||
await buildGradedSeriesWithRollups("u1", "PULSE", now);
|
||||
|
||||
expect(findMany).not.toHaveBeenCalled();
|
||||
const opts = readDayAggregates.mock.calls[0][0] as ReadDayAggregatesOptions;
|
||||
expect(opts).toMatchObject({ userId: "u1", type: "PULSE" });
|
||||
expect(opts.until).toEqual(now);
|
||||
expect(opts.timeZone).toBe("Europe/Berlin");
|
||||
expect(now.getTime() - opts.since.getTime()).toBe(91 * dayMs);
|
||||
// The same age partition the parity test below proves exact: a reading
|
||||
// exactly 21 / 91 / 451 days old belongs to the older slice.
|
||||
expect(opts.segmentStarts).toEqual(
|
||||
[21, 91, 451].map((d) => new Date(now.getTime() - d * dayMs + 1)),
|
||||
);
|
||||
});
|
||||
|
||||
it("folds monthly/yearly from the bounded fallback when the tier has no coverage", async () => {
|
||||
// The bounded recent read (~90 d) returns only recent points; the
|
||||
// fallback read returns 2+ years so the fold has monthly/yearly.
|
||||
findMany
|
||||
// 1st call — bounded recent read (gte: since)
|
||||
.mockResolvedValueOnce(dailyRows(80, now))
|
||||
// 2nd call — bounded fallback read (gte: 1095-day horizon)
|
||||
.mockResolvedValueOnce(dailyRows(800, now));
|
||||
|
||||
// Tier miss: no MONTH / YEAR coverage at all.
|
||||
serveFrom(points(800, now));
|
||||
readBestGranularityRollups.mockResolvedValue(null);
|
||||
|
||||
const series = await buildGradedSeriesWithRollups("u1", "WEIGHT", now);
|
||||
|
||||
// The bug: before the fix monthly/yearly were derived from the
|
||||
// bounded ~90-day read and so were always empty even when years of
|
||||
// raw history existed. After the fix they fold from the fallback.
|
||||
expect(series.monthly.length).toBeGreaterThan(0);
|
||||
// 800 days of history puts at least one year into the yearly tail.
|
||||
expect(series.yearly.length).toBeGreaterThan(0);
|
||||
// The fallback read must have been issued...
|
||||
expect(findMany).toHaveBeenCalledTimes(2);
|
||||
// ...and bounded (v1.28.25): it must carry a `measuredAt.gte` floor
|
||||
// ~1095 days back, never an unwindowed full-history walk.
|
||||
const fallbackArgs = findMany.mock.calls[1][0] as {
|
||||
where: { measuredAt?: { gte?: Date } };
|
||||
};
|
||||
const gte = fallbackArgs.where.measuredAt?.gte;
|
||||
expect(gte).toBeInstanceOf(Date);
|
||||
expect(gte!.getTime()).toBe(now.getTime() - 1095 * dayMs);
|
||||
});
|
||||
|
||||
it("day-buckets the dense-type fallback in SQL instead of a raw walk (v1.28.25)", async () => {
|
||||
// BLOOD_GLUCOSE on a cold tier (heavy import before the rollup
|
||||
// backfill warms) used to findMany the entire CGM history. The
|
||||
// fallback now runs a single day-bucket aggregate; only the bounded
|
||||
// recent read touches findMany.
|
||||
findMany.mockResolvedValueOnce(dailyRows(80, now)); // bounded recent
|
||||
const bucketRows: Array<{ bucket_start: Date; mean: number }> = [];
|
||||
for (let i = 0; i < 800; i++) {
|
||||
bucketRows.push({
|
||||
bucket_start: new Date(now.getTime() - i * dayMs),
|
||||
mean: 100,
|
||||
});
|
||||
}
|
||||
queryRaw.mockResolvedValueOnce(bucketRows.reverse());
|
||||
|
||||
readBestGranularityRollups.mockResolvedValue(null);
|
||||
|
||||
const series = await buildGradedSeriesWithRollups(
|
||||
"u1",
|
||||
"BLOOD_GLUCOSE",
|
||||
now,
|
||||
);
|
||||
|
||||
expect(series.monthly.length).toBeGreaterThan(0);
|
||||
expect(series.yearly.length).toBeGreaterThan(0);
|
||||
// Raw findMany ran once (the bounded recent read) — the fallback
|
||||
// itself went through the SQL aggregate.
|
||||
expect(findMany).toHaveBeenCalledTimes(1);
|
||||
expect(queryRaw).toHaveBeenCalledTimes(1);
|
||||
expect(readDayAggregates).toHaveBeenCalledTimes(2);
|
||||
const fallback = readDayAggregates.mock
|
||||
.calls[1][0] as ReadDayAggregatesOptions;
|
||||
// Bounded to the yearly router's horizon, never an unwindowed walk.
|
||||
expect(fallback.since.getTime()).toBe(now.getTime() - 1095 * dayMs);
|
||||
expect(findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reads monthly/yearly from the tier when it has coverage and skips the full read", async () => {
|
||||
findMany.mockResolvedValueOnce(dailyRows(80, now));
|
||||
|
||||
// MONTH coverage for the 1-year window, YEAR coverage for the 3-year.
|
||||
it("reads monthly/yearly from the tier when it has coverage and skips the fallback", async () => {
|
||||
serveFrom(points(80, now));
|
||||
readBestGranularityRollups.mockImplementation(
|
||||
async (_u: string, _t: string, windowDays: number) => {
|
||||
if (windowDays === 365) {
|
||||
return {
|
||||
granularity: "MONTH",
|
||||
rows: [
|
||||
rollupRow(new Date("2025-09-01T00:00:00Z"), 79),
|
||||
rollupRow(new Date("2025-10-01T00:00:00Z"), 80),
|
||||
],
|
||||
};
|
||||
}
|
||||
return {
|
||||
granularity: "YEAR",
|
||||
rows: [rollupRow(new Date("2023-01-01T00:00:00Z"), 82)],
|
||||
};
|
||||
},
|
||||
async (_u: string, _t: string, windowDays: number) =>
|
||||
windowDays === 365
|
||||
? {
|
||||
granularity: "MONTH",
|
||||
rows: [
|
||||
rollupRow(new Date("2025-09-01T00:00:00Z"), 79),
|
||||
rollupRow(new Date("2025-10-01T00:00:00Z"), 80),
|
||||
],
|
||||
}
|
||||
: {
|
||||
granularity: "YEAR",
|
||||
rows: [rollupRow(new Date("2023-01-01T00:00:00Z"), 82)],
|
||||
},
|
||||
);
|
||||
|
||||
const series = await buildGradedSeriesWithRollups("u1", "WEIGHT", now);
|
||||
|
||||
expect(series.monthly.length).toBeGreaterThan(0);
|
||||
expect(series.yearly.length).toBeGreaterThan(0);
|
||||
// Only the bounded recent read — no full-history fallback when the
|
||||
// tier covers both coarse slices.
|
||||
expect(findMany).toHaveBeenCalledTimes(1);
|
||||
expect(readDayAggregates).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("falls back to a full read for only the slice the tier misses", async () => {
|
||||
findMany
|
||||
.mockResolvedValueOnce(dailyRows(80, now)) // bounded recent
|
||||
.mockResolvedValueOnce(dailyRows(800, now)); // full-history fallback
|
||||
|
||||
// MONTH covered, YEAR missing.
|
||||
it("falls back only for the slice the tier misses", async () => {
|
||||
serveFrom(points(800, now));
|
||||
readBestGranularityRollups.mockImplementation(
|
||||
async (_u: string, _t: string, windowDays: number) => {
|
||||
if (windowDays === 365) {
|
||||
return {
|
||||
granularity: "MONTH",
|
||||
rows: [rollupRow(new Date("2025-10-01T00:00:00Z"), 80)],
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
async (_u: string, _t: string, windowDays: number) =>
|
||||
windowDays === 365
|
||||
? {
|
||||
granularity: "MONTH",
|
||||
rows: [rollupRow(new Date("2025-10-01T00:00:00Z"), 80)],
|
||||
}
|
||||
: null,
|
||||
);
|
||||
|
||||
const series = await buildGradedSeriesWithRollups("u1", "WEIGHT", now);
|
||||
|
||||
expect(series.monthly.length).toBeGreaterThan(0);
|
||||
expect(series.monthly).toHaveLength(1);
|
||||
expect(series.yearly.length).toBeGreaterThan(0);
|
||||
expect(findMany).toHaveBeenCalledTimes(2);
|
||||
expect(readDayAggregates).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildGradedSeriesFromDayAggregates — parity with the raw fold", () => {
|
||||
const now = new Date("2026-05-31T12:00:00Z");
|
||||
const segmentStarts = [21, 91, 451].map(
|
||||
(d) => new Date(now.getTime() - d * dayMs + 1),
|
||||
);
|
||||
|
||||
it("matches the raw-row fold for recent / weekly / monthly and yearly stats", () => {
|
||||
// Several readings per day, including readings exactly on the segment
|
||||
// edges, so the partition itself is under test.
|
||||
const rows = [
|
||||
...points(900, now, 5),
|
||||
{ measuredAt: new Date(now.getTime() - 21 * dayMs), value: 99 },
|
||||
{ measuredAt: new Date(now.getTime() - 91 * dayMs), value: 41 },
|
||||
];
|
||||
const raw = buildGradedSeriesFromPoints(rows, now);
|
||||
const agg = buildGradedSeriesFromDayAggregates(
|
||||
foldDayAggregates(rows, {
|
||||
since: new Date(0),
|
||||
until: now,
|
||||
timeZone: "Europe/Berlin",
|
||||
segmentStarts,
|
||||
}),
|
||||
);
|
||||
expect(agg.recent).toEqual(raw.recent);
|
||||
expect(agg.weekly).toEqual(raw.weekly);
|
||||
expect(agg.monthly).toEqual(raw.monthly);
|
||||
// The yearly slope runs over day means rather than single readings;
|
||||
// every other yearly figure is exact.
|
||||
expect(agg.yearly.map(({ slope: _s, ...b }) => b)).toEqual(
|
||||
raw.yearly.map(({ slope: _s, ...b }) => b),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,13 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
|
||||
// The graded series folds per-day aggregates in SQL; the fake folds the
|
||||
// mocked `measurement.findMany` rows with the same rules.
|
||||
vi.mock("@/lib/measurements/day-aggregates", async () => ({
|
||||
readDayAggregates: (
|
||||
await import("@/lib/measurements/__tests__/fake-day-aggregates")
|
||||
).fakeReadDayAggregates,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findUnique: vi.fn() },
|
||||
|
||||
@@ -12,6 +12,14 @@ import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
* `runStatusCompletion` is stubbed to capture it.
|
||||
*/
|
||||
|
||||
// The graded series folds per-day aggregates in SQL; the fake folds the
|
||||
// mocked `measurement.findMany` rows with the same rules.
|
||||
vi.mock("@/lib/measurements/day-aggregates", async () => ({
|
||||
readDayAggregates: (
|
||||
await import("@/lib/measurements/__tests__/fake-day-aggregates")
|
||||
).fakeReadDayAggregates,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findUnique: vi.fn() },
|
||||
|
||||
@@ -19,6 +19,14 @@ import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
* generation on every navigation while a provider is degraded.
|
||||
*/
|
||||
|
||||
// The graded series folds per-day aggregates in SQL; the fake folds the
|
||||
// mocked `measurement.findMany` rows with the same rules.
|
||||
vi.mock("@/lib/measurements/day-aggregates", async () => ({
|
||||
readDayAggregates: (
|
||||
await import("@/lib/measurements/__tests__/fake-day-aggregates")
|
||||
).fakeReadDayAggregates,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findUnique: vi.fn() },
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
|
||||
// The graded series folds per-day aggregates in SQL; the fake folds the
|
||||
// mocked `measurement.findMany` rows with the same rules.
|
||||
vi.mock("@/lib/measurements/day-aggregates", async () => ({
|
||||
readDayAggregates: (
|
||||
await import("@/lib/measurements/__tests__/fake-day-aggregates")
|
||||
).fakeReadDayAggregates,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findUnique: vi.fn() },
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
|
||||
// The graded series folds per-day aggregates in SQL; the fake folds the
|
||||
// mocked `measurement.findMany` rows with the same rules.
|
||||
vi.mock("@/lib/measurements/day-aggregates", async () => ({
|
||||
readDayAggregates: (
|
||||
await import("@/lib/measurements/__tests__/fake-day-aggregates")
|
||||
).fakeReadDayAggregates,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
auditLog: { findFirst: vi.fn(), create: vi.fn() },
|
||||
|
||||
@@ -12,9 +12,27 @@
|
||||
* The numbers below are shaped like the real thing: a resting-pulse series in
|
||||
* the 60s and 70s, one impossible sample dropped into it.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { buildBaselineBand, dayMeansFromRows } from "../baseline";
|
||||
import type { ReadDayAggregatesOptions } from "@/lib/measurements/day-aggregates";
|
||||
|
||||
// The live day-mean read folds in SQL; the fake folds `rowsForRead` with the
|
||||
// same day / range rules, so these tests see the filter the read asks for.
|
||||
let rowsForRead: { value: number; measuredAt: Date }[] = [];
|
||||
const seenOptions: ReadDayAggregatesOptions[] = [];
|
||||
vi.mock("@/lib/db", () => ({ prisma: {} }));
|
||||
vi.mock("@/lib/measurements/day-aggregates", async () => {
|
||||
const { foldDayAggregates } =
|
||||
await import("@/lib/measurements/__tests__/fake-day-aggregates");
|
||||
return {
|
||||
readDayAggregates: async (opts: ReadDayAggregatesOptions) => {
|
||||
seenOptions.push(opts);
|
||||
return foldDayAggregates(rowsForRead, opts);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { buildBaselineBand, readDayMeanSeries } from "../baseline";
|
||||
import { latestDayMeanFromRows } from "../coincident-deviation";
|
||||
import { isPlausibleMetricValue } from "@/lib/measurements/value-domain";
|
||||
|
||||
@@ -47,28 +65,49 @@ describe("metric plausibility domain", () => {
|
||||
});
|
||||
});
|
||||
|
||||
/** The live (rollup-miss) day-mean read over `rows`. */
|
||||
async function dayMeansFromRows(
|
||||
rows: { value: number; measuredAt: Date }[],
|
||||
type: "PULSE",
|
||||
) {
|
||||
rowsForRead = rows;
|
||||
const { points } = await readDayMeanSeries(
|
||||
"u1",
|
||||
type,
|
||||
3650,
|
||||
new Date("2026-09-01T00:00:00Z"),
|
||||
new Map(),
|
||||
);
|
||||
return points;
|
||||
}
|
||||
|
||||
describe("per-day means behind the personal band", () => {
|
||||
it("keeps the day mean on the real readings when one sample is impossible", () => {
|
||||
it("asks the read for the metric's own plausibility domain", async () => {
|
||||
await dayMeansFromRows(pulseDay("2026-08-01", [66]), "PULSE");
|
||||
expect(seenOptions.at(-1)?.valueRange).toEqual({ min: 20, max: 300 });
|
||||
});
|
||||
|
||||
it("keeps the day mean on the real readings when one sample is impossible", async () => {
|
||||
const rows = pulseDay("2026-08-01", [66, 70, 111287531.01, 68]);
|
||||
|
||||
const [point] = dayMeansFromRows(rows, "PULSE");
|
||||
const [point] = await dayMeansFromRows(rows, "PULSE");
|
||||
|
||||
expect(point.day).toBe("2026-08-01");
|
||||
expect(point.mean).toBeCloseTo(68, 6);
|
||||
});
|
||||
|
||||
it("drops a day whose every reading is impossible rather than inventing one", () => {
|
||||
it("drops a day whose every reading is impossible rather than inventing one", async () => {
|
||||
const rows = [
|
||||
...pulseDay("2026-08-01", [66, 70, 68]),
|
||||
...pulseDay("2026-08-02", [36016.75, 111287531.01]),
|
||||
];
|
||||
|
||||
const points = dayMeansFromRows(rows, "PULSE");
|
||||
const points = await dayMeansFromRows(rows, "PULSE");
|
||||
|
||||
expect(points.map((p) => p.day)).toEqual(["2026-08-01"]);
|
||||
});
|
||||
|
||||
it("holds the band inside the metric's own domain across a poisoned month", () => {
|
||||
it("holds the band inside the metric's own domain across a poisoned month", async () => {
|
||||
const rows: { value: number; measuredAt: Date }[] = [];
|
||||
for (let d = 1; d <= 28; d += 1) {
|
||||
const day = `2026-08-${String(d).padStart(2, "0")}`;
|
||||
@@ -81,7 +120,7 @@ describe("per-day means behind the personal band", () => {
|
||||
rows.push(...pulseDay("2026-08-26", [98211.5]));
|
||||
|
||||
const band = buildBaselineBand(
|
||||
dayMeansFromRows(rows, "PULSE").map((p) => p.mean),
|
||||
(await dayMeansFromRows(rows, "PULSE")).map((p) => p.mean),
|
||||
"PULSE",
|
||||
);
|
||||
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
// Day-mean reads fold per-day aggregates in SQL; the fake folds the mocked
|
||||
// `measurement.findMany` rows with the same rules.
|
||||
vi.mock("@/lib/measurements/day-aggregates", async () => ({
|
||||
readDayAggregates: (
|
||||
await import("@/lib/measurements/__tests__/fake-day-aggregates")
|
||||
).fakeReadDayAggregates,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: { measurement: { findMany: vi.fn() } },
|
||||
}));
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
// Day-mean reads fold per-day aggregates in SQL; the fake folds the mocked
|
||||
// `measurement.findMany` rows with the same rules.
|
||||
vi.mock("@/lib/measurements/day-aggregates", async () => ({
|
||||
readDayAggregates: (
|
||||
await import("@/lib/measurements/__tests__/fake-day-aggregates")
|
||||
).fakeReadDayAggregates,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: { measurement: { findMany: vi.fn() } },
|
||||
}));
|
||||
|
||||
@@ -17,6 +17,14 @@
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
// Day-mean reads fold per-day aggregates in SQL; the fake folds the mocked
|
||||
// `measurement.findMany` rows with the same rules.
|
||||
vi.mock("@/lib/measurements/day-aggregates", async () => ({
|
||||
readDayAggregates: (
|
||||
await import("@/lib/measurements/__tests__/fake-day-aggregates")
|
||||
).fakeReadDayAggregates,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
measurement: {
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
// Day-mean reads fold per-day aggregates in SQL; the fake folds the mocked
|
||||
// `measurement.findMany` rows with the same rules.
|
||||
vi.mock("@/lib/measurements/day-aggregates", async () => ({
|
||||
readDayAggregates: (
|
||||
await import("@/lib/measurements/__tests__/fake-day-aggregates")
|
||||
).fakeReadDayAggregates,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
measurement: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
// Day-mean reads fold per-day aggregates in SQL; the fake folds the mocked
|
||||
// `measurement.findMany` rows with the same rules.
|
||||
vi.mock("@/lib/measurements/day-aggregates", async () => ({
|
||||
readDayAggregates: (
|
||||
await import("@/lib/measurements/__tests__/fake-day-aggregates")
|
||||
).fakeReadDayAggregates,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
measurement: { findMany: vi.fn() },
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
// Day-mean reads fold per-day aggregates in SQL; the fake folds the mocked
|
||||
// `measurement.findMany` rows with the same rules.
|
||||
vi.mock("@/lib/measurements/day-aggregates", async () => ({
|
||||
readDayAggregates: (
|
||||
await import("@/lib/measurements/__tests__/fake-day-aggregates")
|
||||
).fakeReadDayAggregates,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: { measurement: { findMany: vi.fn() } },
|
||||
}));
|
||||
|
||||
@@ -29,12 +29,13 @@
|
||||
* composed-bucket baseline matches the raw-DAY baseline within tolerance.
|
||||
*/
|
||||
import type { MeasurementType, PrismaClient } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { readDayAggregates } from "@/lib/measurements/day-aggregates";
|
||||
import { getAgeFromDateOfBirth } from "@/lib/analytics/pulse-targets";
|
||||
import { toProfileSex } from "@/lib/profile/sex";
|
||||
import {
|
||||
clampDerivedLowerBound,
|
||||
isPlausibleMetricValue,
|
||||
plausibleMetricRange,
|
||||
} from "@/lib/measurements/value-domain";
|
||||
import type { ProfileSex } from "@/lib/profile/sex";
|
||||
import {
|
||||
@@ -212,39 +213,6 @@ export function buildBaselineBand(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Group raw readings into per-day means, keeping only the values that sit
|
||||
* inside the metric's own plausibility domain.
|
||||
*
|
||||
* Pure, and exported because this is the arithmetic the band rests on: a
|
||||
* single stored value the application itself declares impossible drags the
|
||||
* day's mean with it, the median then carries it into the band, and the
|
||||
* personal "usual range" reads in the tens of thousands. Filtering here rather
|
||||
* than at the edge of the card keeps the number wrong in exactly one place —
|
||||
* nowhere — instead of right on one surface and wrong on the next.
|
||||
*
|
||||
* A day whose every reading is implausible produces no point at all. That is
|
||||
* the honest outcome: the day contributed no measurement, and an absent day is
|
||||
* something the coverage floor already knows how to report.
|
||||
*/
|
||||
export function dayMeansFromRows(
|
||||
rows: readonly { value: number; measuredAt: Date }[],
|
||||
type: MeasurementType,
|
||||
): DayMeanPoint[] {
|
||||
const byDay = new Map<string, { sum: number; count: number }>();
|
||||
for (const row of rows) {
|
||||
if (!isPlausibleMetricValue(type, row.value)) continue;
|
||||
const day = row.measuredAt.toISOString().slice(0, 10);
|
||||
const acc = byDay.get(day) ?? { sum: 0, count: 0 };
|
||||
acc.sum += row.value;
|
||||
acc.count += 1;
|
||||
byDay.set(day, acc);
|
||||
}
|
||||
return [...byDay.entries()]
|
||||
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
||||
.map(([day, acc]) => ({ day, mean: acc.sum / acc.count }));
|
||||
}
|
||||
|
||||
// ── reads ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -292,23 +260,22 @@ export async function readDayMeanSeries(
|
||||
// the spread is still DAY-native.
|
||||
}
|
||||
|
||||
// Per-type live fallback — bounded raw read, grouped into per-day
|
||||
// means. Honest provenance: source = "live".
|
||||
// Per-type live fallback — per-day means folded in SQL, so the read is
|
||||
// one row per day however densely the metric is sampled (#1023). Only
|
||||
// values inside the metric's own plausibility domain count: a single
|
||||
// stored value the application declares impossible would drag the day's
|
||||
// mean with it and the median would carry it into the band. A day whose
|
||||
// every reading is implausible produces no point at all. Days are UTC
|
||||
// days, matching the DAY rollup path above.
|
||||
const since = new Date(now.getTime() - windowDays * 24 * 60 * 60 * 1000);
|
||||
const rows = await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
type,
|
||||
deletedAt: null,
|
||||
measuredAt: { gte: since },
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: { value: true, measuredAt: true },
|
||||
const days = await readDayAggregates({
|
||||
userId,
|
||||
type,
|
||||
since,
|
||||
timeZone: "UTC",
|
||||
valueRange: plausibleMetricRange(type),
|
||||
});
|
||||
if (rows.length === 0) {
|
||||
return { points: [], source: "none" };
|
||||
}
|
||||
const points = dayMeansFromRows(rows, type);
|
||||
const points = days.map((d) => ({ day: d.day, mean: d.sum / d.n }));
|
||||
if (points.length === 0) {
|
||||
return { points: [], source: "none" };
|
||||
}
|
||||
|
||||
@@ -48,12 +48,15 @@
|
||||
*/
|
||||
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
readBestGranularityRollups,
|
||||
type RollupBucketRow,
|
||||
} from "@/lib/rollups/measurement-read-wmy";
|
||||
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
|
||||
import {
|
||||
readDayAggregates,
|
||||
type DayAggregateRow,
|
||||
} from "@/lib/measurements/day-aggregates";
|
||||
import { toBerlinYmd } from "@/lib/tz/resolver";
|
||||
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
@@ -83,18 +86,6 @@ const MONTHLY_WINDOW_MS = WEEKLY_WINDOW_MS + MONTHLY_MONTHS * 30 * MS_PER_DAY;
|
||||
*/
|
||||
const COLD_FALLBACK_WINDOW_DAYS = 1095;
|
||||
|
||||
/**
|
||||
* Sample-dense types whose cold-tier fallback is day-bucketed in SQL
|
||||
* (one AVG row per day) instead of read raw — same dense set the series
|
||||
* route caps (CGM glucose ~288 rows/day, PULSE per-sample / hourly).
|
||||
* The fold's output shape is unchanged; on the cold path only, a dense
|
||||
* type's monthly/yearly min/max/n derive from day means rather than
|
||||
* individual samples — the warm rollup tier restores sample-exact
|
||||
* figures as soon as the backfill lands.
|
||||
*/
|
||||
const DENSE_FALLBACK_TYPES: ReadonlySet<MeasurementType> =
|
||||
new Set<MeasurementType>(["BLOOD_GLUCOSE", "PULSE"]);
|
||||
|
||||
export interface RecentDayBucket {
|
||||
/** Berlin YYYY-MM-DD. */
|
||||
date: string;
|
||||
@@ -171,6 +162,11 @@ function berlinYearKey(date: Date): string {
|
||||
*/
|
||||
function berlinIsoWeekKey(date: Date): string {
|
||||
const { year, month, day } = toBerlinYmd(date);
|
||||
return isoWeekKeyOfYmd(year, month, day);
|
||||
}
|
||||
|
||||
/** ISO week key of a calendar date given as `YYYY`, `MM`, `DD`. */
|
||||
function isoWeekKeyOfYmd(year: string, month: string, day: string): string {
|
||||
const localMidnight = new Date(
|
||||
Date.UTC(Number(year), Number(month) - 1, Number(day)),
|
||||
);
|
||||
@@ -309,6 +305,133 @@ export function buildGradedSeriesFromPoints(
|
||||
return { recent, weekly, monthly, yearly };
|
||||
}
|
||||
|
||||
function mergeInto(
|
||||
map: Map<string, Agg>,
|
||||
key: string,
|
||||
row: DayAggregateRow,
|
||||
): void {
|
||||
const existing = map.get(key);
|
||||
if (existing) {
|
||||
existing.sum += row.sum;
|
||||
existing.n += row.n;
|
||||
if (row.min < existing.min) existing.min = row.min;
|
||||
if (row.max > existing.max) existing.max = row.max;
|
||||
} else {
|
||||
map.set(key, { sum: row.sum, min: row.min, max: row.max, n: row.n });
|
||||
}
|
||||
}
|
||||
|
||||
/** Segment boundaries matching `buildGradedSeriesFromPoints`' age split. */
|
||||
function gradedSegmentStarts(now: Date): Date[] {
|
||||
const nowMs = now.getTime();
|
||||
return [
|
||||
new Date(nowMs - RECENT_WINDOW_MS),
|
||||
new Date(nowMs - WEEKLY_WINDOW_MS),
|
||||
new Date(nowMs - MONTHLY_WINDOW_MS),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the graded series' per-day aggregates for one type, folded in SQL.
|
||||
*
|
||||
* Replaces the raw `findMany` over the window (#1023): a watch that streams
|
||||
* heart rate every few seconds put a million rows into that read, which is
|
||||
* what exhausted the worker's heap. The rows here are one per Berlin day per
|
||||
* age segment, and their sum / count / min / max are exact, so the recent,
|
||||
* weekly and monthly slices come out identical to folding the raw rows.
|
||||
*
|
||||
* The segment boundaries use the same edges as `buildGradedSeriesFromPoints`:
|
||||
* a row exactly `RECENT_WINDOW_MS` old is weekly there, so it is segment 1
|
||||
* here too.
|
||||
*/
|
||||
async function readGradedDayAggregates(
|
||||
userId: string,
|
||||
type: MeasurementType,
|
||||
since: Date,
|
||||
now: Date,
|
||||
): Promise<DayAggregateRow[]> {
|
||||
return readDayAggregates({
|
||||
userId,
|
||||
type,
|
||||
since,
|
||||
// Future-dated readings were skipped by the in-memory fold.
|
||||
until: now,
|
||||
timeZone: "Europe/Berlin",
|
||||
// `readDayAggregates` puts a row at segment k when it is older than k
|
||||
// starts (`measured_at < start`). The in-memory fold treats an age of
|
||||
// exactly the window as the older bucket, so shift each start by 1 ms.
|
||||
segmentStarts: gradedSegmentStarts(now).map(
|
||||
(d) => new Date(d.getTime() + 1),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The graded series folded from per-day aggregates (segment = age class).
|
||||
*
|
||||
* recent / weekly / monthly and every bucket's min / max / mean / n match
|
||||
* the raw-row fold exactly. The yearly slope is taken over the day means in
|
||||
* date order rather than over individual readings: a direction signal either
|
||||
* way, and the only form that stays bounded on a dense stream.
|
||||
*/
|
||||
export function buildGradedSeriesFromDayAggregates(
|
||||
rows: readonly DayAggregateRow[],
|
||||
): GradedSeries {
|
||||
const recentAgg = new Map<string, Agg>();
|
||||
const weeklyAgg = new Map<string, Agg>();
|
||||
const monthlyAgg = new Map<string, Agg>();
|
||||
const yearlyAgg = new Map<string, Agg>();
|
||||
const yearlyValues = new Map<string, number[]>();
|
||||
|
||||
const ordered = [...rows].sort((a, b) =>
|
||||
a.day < b.day ? -1 : a.day > b.day ? 1 : b.segment - a.segment,
|
||||
);
|
||||
for (const row of ordered) {
|
||||
const [year, month, day] = row.day.split("-");
|
||||
if (row.segment === 0) {
|
||||
mergeInto(recentAgg, row.day, row);
|
||||
} else if (row.segment === 1) {
|
||||
mergeInto(weeklyAgg, isoWeekKeyOfYmd(year, month, day), row);
|
||||
} else if (row.segment === 2) {
|
||||
mergeInto(monthlyAgg, `${year}-${month}`, row);
|
||||
} else {
|
||||
mergeInto(yearlyAgg, year, row);
|
||||
const list = yearlyValues.get(year);
|
||||
const dayMean = row.sum / row.n;
|
||||
if (list) list.push(dayMean);
|
||||
else yearlyValues.set(year, [dayMean]);
|
||||
}
|
||||
}
|
||||
|
||||
const toBuckets = (map: Map<string, Agg>) =>
|
||||
Array.from(map.entries())
|
||||
.map(([key, a]) => ({
|
||||
key,
|
||||
min: round(a.min),
|
||||
max: round(a.max),
|
||||
mean: round(a.sum / a.n),
|
||||
n: a.n,
|
||||
}))
|
||||
.sort((x, y) => x.key.localeCompare(y.key));
|
||||
|
||||
return {
|
||||
recent: toBuckets(recentAgg).map(({ key, ...b }) => ({ date: key, ...b })),
|
||||
weekly: toBuckets(weeklyAgg).map(({ key, ...b }) => ({
|
||||
weekISO: key,
|
||||
...b,
|
||||
})),
|
||||
monthly: toBuckets(monthlyAgg).map(({ key, ...b }) => ({
|
||||
month: key,
|
||||
...b,
|
||||
})),
|
||||
yearly: toBuckets(yearlyAgg).map(({ key, ...b }) => ({
|
||||
year: key,
|
||||
...b,
|
||||
slope: leastSquaresSlope(yearlyValues.get(key) ?? []),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Project a rollup bucket row to a monthly/yearly graded bucket. The
|
||||
* tier already carries min/max/mean/slope per bucket — no JS folding.
|
||||
@@ -335,24 +458,25 @@ function rollupYearly(rows: RollupBucketRow[]): YearlyBucket[] {
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the graded series with the recent / weekly slices folded from a
|
||||
* bounded raw read and the monthly / yearly slices read from the
|
||||
* Build the graded series with the recent / weekly slices folded from
|
||||
* per-day aggregates and the monthly / yearly slices read from the
|
||||
* pre-aggregated rollup tier.
|
||||
*
|
||||
* The recent / weekly read is capped to the recent + weekly horizon
|
||||
* (last ~90 days), so the common warm-tier path never pulls the full
|
||||
* multi-year history into memory. The rollup router picks MONTH for the
|
||||
* ~1-year window and YEAR for the multi-year tail.
|
||||
* The recent / weekly read covers the recent + weekly horizon (last ~90
|
||||
* days) and is folded in SQL to one row per day, so its size depends on
|
||||
* the number of days, never on how densely a device samples (#1023). The
|
||||
* rollup router picks MONTH for the ~1-year window and YEAR for the
|
||||
* multi-year tail.
|
||||
*
|
||||
* Coverage-miss fallback: when the tier has no MONTH (resp. YEAR)
|
||||
* coverage for a metric — a fresh account the boot-backfill has not yet
|
||||
* caught up on — the bounded ~90-day read can NOT supply the monthly /
|
||||
* yearly slices (every row in it folds into recent / weekly), so naively
|
||||
* reusing it would ship empty monthly / yearly arrays even when years of
|
||||
* raw history exist. On that miss this falls back to an in-memory fold
|
||||
* over a bounded window (1095 days — the yearly router's own horizon;
|
||||
* sample-dense types are additionally day-bucketed in SQL) so the coarse
|
||||
* slices are populated from the raw rows rather than left falsely empty.
|
||||
* raw history exist. On that miss this falls back to the same per-day
|
||||
* aggregate over a bounded window (1095 days — the yearly router's own
|
||||
* horizon) so the coarse slices are populated from the stored rows rather
|
||||
* than left falsely empty.
|
||||
* The fallback read only happens when the tier is cold, which is exactly
|
||||
* the case the write-amplified tier was meant to avoid — so the hot path
|
||||
* stays bounded and the cold path stays correct.
|
||||
@@ -363,15 +487,8 @@ export async function buildGradedSeriesWithRollups(
|
||||
now: Date,
|
||||
): Promise<GradedSeries> {
|
||||
const since = new Date(now.getTime() - WEEKLY_WINDOW_MS);
|
||||
const rawRecent = await prisma.measurement.findMany({
|
||||
where: { userId, type, deletedAt: null, measuredAt: { gte: since } },
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: { measuredAt: true, value: true },
|
||||
});
|
||||
|
||||
const recentGraded = buildGradedSeriesFromPoints(
|
||||
rawRecent.map((r) => ({ measuredAt: r.measuredAt, value: r.value })),
|
||||
now,
|
||||
const recentGraded = buildGradedSeriesFromDayAggregates(
|
||||
await readGradedDayAggregates(userId, type, since, now),
|
||||
);
|
||||
|
||||
// v1.11.2 — load the source-priority blob once and thread it into both
|
||||
@@ -420,57 +537,19 @@ export async function buildGradedSeriesWithRollups(
|
||||
}
|
||||
|
||||
// Tier coverage miss for one or both coarse slices: the bounded
|
||||
// ~90-day read can't supply them, so fold a BOUNDED history window in
|
||||
// memory and take whichever slices the tier left empty. The fallback
|
||||
// read is the exception (cold-tier accounts), never the warm-tier
|
||||
// norm. v1.28.25 — the read is bounded to the same 1095-day horizon
|
||||
// the yearly rollup router serves (it was previously unwindowed), and
|
||||
// the sample-dense types are day-bucketed in SQL so a heavy import
|
||||
// ahead of the rollup backfill can't drag six-figure row sets into JS.
|
||||
// ~90-day read can't supply them, so fold a BOUNDED history window and
|
||||
// take whichever slices the tier left empty. The fallback read is the
|
||||
// exception (cold-tier accounts), never the warm-tier norm. It is
|
||||
// bounded to the same 1095-day horizon the yearly rollup router serves,
|
||||
// and folded per day in SQL for every type, so a heavy import ahead of
|
||||
// the rollup backfill can't drag six-figure row sets into JS.
|
||||
if (!monthlyCovered || !yearlyCovered) {
|
||||
const fallbackSince = new Date(
|
||||
now.getTime() - COLD_FALLBACK_WINDOW_DAYS * MS_PER_DAY,
|
||||
);
|
||||
let fallbackPoints: Point[];
|
||||
if (DENSE_FALLBACK_TYPES.has(type)) {
|
||||
// Parameter-bound day-bucket aggregate, mirroring the rollup tier's
|
||||
// `date_trunc(...) GROUP BY` (measurement-rollups.ts) — at most
|
||||
// ~1095 rows regardless of sample density.
|
||||
const bucketRows = await prisma.$queryRaw<
|
||||
Array<{ bucket_start: Date; mean: number }>
|
||||
>`
|
||||
SELECT
|
||||
date_trunc('day', m."measured_at") AS bucket_start,
|
||||
AVG(m."value")::double precision AS mean
|
||||
FROM measurements m
|
||||
WHERE m."user_id" = ${userId}
|
||||
AND m."type" = ${type}::"measurement_type"
|
||||
AND m."measured_at" >= ${fallbackSince}
|
||||
AND m."deleted_at" IS NULL
|
||||
GROUP BY date_trunc('day', m."measured_at")
|
||||
ORDER BY bucket_start ASC
|
||||
`;
|
||||
fallbackPoints = bucketRows.map((r) => ({
|
||||
measuredAt: r.bucket_start,
|
||||
value: r.mean,
|
||||
}));
|
||||
} else {
|
||||
const rows = await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
type,
|
||||
deletedAt: null,
|
||||
measuredAt: { gte: fallbackSince },
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: { measuredAt: true, value: true },
|
||||
});
|
||||
fallbackPoints = rows.map((r) => ({
|
||||
measuredAt: r.measuredAt,
|
||||
value: r.value,
|
||||
}));
|
||||
}
|
||||
const fullGraded = buildGradedSeriesFromPoints(fallbackPoints, now);
|
||||
const fullGraded = buildGradedSeriesFromDayAggregates(
|
||||
await readGradedDayAggregates(userId, type, fallbackSince, now),
|
||||
);
|
||||
if (!monthlyCovered) monthly = fullGraded.monthly;
|
||||
if (!yearlyCovered) yearly = fullGraded.yearly;
|
||||
}
|
||||
|
||||
@@ -254,6 +254,12 @@ export interface CrossMetricMeasurement {
|
||||
measuredAt: Date;
|
||||
source?: MeasurementSource | null;
|
||||
deviceType?: string | null;
|
||||
/**
|
||||
* Readings this row stands for. Absent on a raw reading (one). A row
|
||||
* pre-aggregated per day, source and device carries its readings' sum in
|
||||
* `value` and their number here, so the day's mean stays exact.
|
||||
*/
|
||||
count?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -231,6 +231,7 @@ export function metricDayMap(
|
||||
deviceType: m.deviceType ?? null,
|
||||
type: type as MeasurementType,
|
||||
value: m.value,
|
||||
count: m.count,
|
||||
})),
|
||||
metricKey,
|
||||
userPriorityJson,
|
||||
@@ -243,7 +244,7 @@ export function metricDayMap(
|
||||
const key = berlinDayKey(m.measuredAt);
|
||||
const cur = byDay.get(key) ?? { sum: 0, count: 0 };
|
||||
cur.sum += m.value;
|
||||
cur.count += 1;
|
||||
cur.count += m.count ?? 1;
|
||||
byDay.set(key, cur);
|
||||
}
|
||||
const out = new Map<string, number>();
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
*/
|
||||
import type { MeasurementType, PrismaClient } from "@/generated/prisma/client";
|
||||
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import { readDayAggregates } from "@/lib/measurements/day-aggregates";
|
||||
|
||||
export const COACH_PLAN_REVIEW_QUEUE = "coach-plan-review";
|
||||
// Daily at 05:25 Europe/Berlin — just after the 05:20 reminder sweep, so the
|
||||
@@ -49,7 +50,7 @@ export interface CoachPlanReviewSummary {
|
||||
|
||||
type ReviewPrisma = Pick<
|
||||
PrismaClient,
|
||||
"coachReminder" | "coachPlan" | "measurement"
|
||||
"coachReminder" | "coachPlan" | "$queryRaw"
|
||||
>;
|
||||
|
||||
/** Metric valence — whether a higher reading is the better outcome. */
|
||||
@@ -282,31 +283,22 @@ export async function runCoachPlanReviewTick(
|
||||
const since = new Date(
|
||||
plan.createdAt.getTime() - BEFORE_WINDOW_DAYS * MS_PER_DAY,
|
||||
);
|
||||
const rows = await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId: plan.userId,
|
||||
type: resolved.type,
|
||||
deletedAt: null,
|
||||
measuredAt: { gte: since, lte: now },
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: { value: true, measuredAt: true },
|
||||
// Day means folded in SQL (UTC days), one row per day however
|
||||
// densely the metric is sampled (#1023).
|
||||
const byDay = await readDayAggregates({
|
||||
userId: plan.userId,
|
||||
type: resolved.type,
|
||||
since,
|
||||
until: now,
|
||||
timeZone: "UTC",
|
||||
db: prisma,
|
||||
});
|
||||
// Day-mean the raw rows, then split at the experiment start.
|
||||
const byDay = new Map<string, { sum: number; count: number }>();
|
||||
for (const row of rows) {
|
||||
const day = row.measuredAt.toISOString().slice(0, 10);
|
||||
const agg = byDay.get(day) ?? { sum: 0, count: 0 };
|
||||
agg.sum += row.value;
|
||||
agg.count += 1;
|
||||
byDay.set(day, agg);
|
||||
}
|
||||
const startKey = plan.createdAt.toISOString().slice(0, 10);
|
||||
const before: number[] = [];
|
||||
const after: number[] = [];
|
||||
const all: number[] = [];
|
||||
for (const [day, agg] of byDay) {
|
||||
const dayMean = agg.sum / agg.count;
|
||||
for (const { day, sum, n } of byDay) {
|
||||
const dayMean = sum / n;
|
||||
all.push(dayMean);
|
||||
if (day < startKey) before.push(dayMean);
|
||||
else after.push(dayMean);
|
||||
|
||||
@@ -27,10 +27,6 @@ vi.mock("@/lib/rollups/measurement-read-wmy", () => ({
|
||||
readTieredRollupSeries: mocks.readTieredRollupSeries,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rollups/measurement-rollups", () => ({
|
||||
recomputeUserRollups: vi.fn(),
|
||||
}));
|
||||
|
||||
import { readDailySeries } from "../daily-series-read";
|
||||
|
||||
const DAY_MS = 86_400_000;
|
||||
@@ -39,47 +35,25 @@ beforeEach(() => {
|
||||
mocks.findMany.mockReset();
|
||||
mocks.queryRaw.mockReset();
|
||||
mocks.readTieredRollupSeries.mockReset();
|
||||
// Default DAY-rollup read returns enough rows (≥ SUSPICIOUS_ROW_FLOOR)
|
||||
// so the short-window path resolves without the coverage probe / live
|
||||
// query firing.
|
||||
mocks.findMany.mockResolvedValue([
|
||||
{
|
||||
// The daily path folds live, one row per local day.
|
||||
mocks.queryRaw.mockResolvedValue(
|
||||
["2026-06-01", "2026-06-02", "2026-06-03"].map((d, i) => ({
|
||||
type: "WEIGHT",
|
||||
source: "MANUAL",
|
||||
bucketStart: new Date("2026-06-01T00:00:00.000Z"),
|
||||
mean: 81,
|
||||
count: 1,
|
||||
sumValue: null,
|
||||
minValue: 81,
|
||||
maxValue: 81,
|
||||
},
|
||||
{
|
||||
type: "WEIGHT",
|
||||
source: "MANUAL",
|
||||
bucketStart: new Date("2026-06-02T00:00:00.000Z"),
|
||||
mean: 82,
|
||||
count: 1,
|
||||
sumValue: null,
|
||||
minValue: 82,
|
||||
maxValue: 82,
|
||||
},
|
||||
{
|
||||
type: "WEIGHT",
|
||||
source: "MANUAL",
|
||||
bucketStart: new Date("2026-06-03T00:00:00.000Z"),
|
||||
mean: 80,
|
||||
count: 1,
|
||||
sumValue: null,
|
||||
minValue: 80,
|
||||
maxValue: 80,
|
||||
},
|
||||
]);
|
||||
bucket_start: new Date(`${d}T00:00:00.000Z`),
|
||||
avg: 80 + i,
|
||||
cnt: 1,
|
||||
min_value: 80 + i,
|
||||
max_value: 80 + i,
|
||||
})),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const TZ = "Europe/Berlin";
|
||||
|
||||
describe("readDailySeries — long-range tier step-up", () => {
|
||||
it("routes a multi-year window through the tiered reader and returns its whole-history rows", async () => {
|
||||
const to = new Date("2026-06-21T00:00:00.000Z");
|
||||
@@ -109,6 +83,7 @@ describe("readDailySeries — long-range tier step-up", () => {
|
||||
from,
|
||||
to,
|
||||
priorityJson: null,
|
||||
timeZone: TZ,
|
||||
});
|
||||
|
||||
expect(mocks.readTieredRollupSeries).toHaveBeenCalledTimes(1);
|
||||
@@ -119,8 +94,8 @@ describe("readDailySeries — long-range tier step-up", () => {
|
||||
// Whole-history coverage: the earliest 2017 bucket survives.
|
||||
expect(result[0].measuredAt).toBe("2017-01-01T00:00:00.000Z");
|
||||
expect(result).toHaveLength(2);
|
||||
// The DAY-rollup path was NOT consulted for the long window.
|
||||
expect(mocks.findMany).not.toHaveBeenCalled();
|
||||
// The daily path was NOT consulted for the long window.
|
||||
expect(mocks.queryRaw).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls through to the daily path on a tiered coverage miss (no silent empty)", async () => {
|
||||
@@ -134,11 +109,11 @@ describe("readDailySeries — long-range tier step-up", () => {
|
||||
from,
|
||||
to,
|
||||
priorityJson: null,
|
||||
timeZone: TZ,
|
||||
});
|
||||
|
||||
expect(mocks.readTieredRollupSeries).toHaveBeenCalledTimes(1);
|
||||
// Daily DAY-rollup read still ran as the fallback.
|
||||
expect(mocks.findMany).toHaveBeenCalled();
|
||||
expect(mocks.queryRaw).toHaveBeenCalled();
|
||||
expect(result).toHaveLength(3);
|
||||
});
|
||||
|
||||
@@ -146,56 +121,25 @@ describe("readDailySeries — long-range tier step-up", () => {
|
||||
const to = new Date("2026-06-21T00:00:00.000Z");
|
||||
const from = new Date(to.getTime() - 90 * DAY_MS);
|
||||
|
||||
await readDailySeries({
|
||||
userId: "u",
|
||||
type: "WEIGHT",
|
||||
from,
|
||||
to,
|
||||
priorityJson: null,
|
||||
});
|
||||
|
||||
expect(mocks.readTieredRollupSeries).not.toHaveBeenCalled();
|
||||
expect(mocks.findMany).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("readDailySeries — rollup read throw falls back to live SQL (F-DB-2)", () => {
|
||||
it("a rollup-table throw on a normal window degrades to the live date_trunc aggregate, not a 500", async () => {
|
||||
const to = new Date("2026-06-21T00:00:00.000Z");
|
||||
const from = new Date(to.getTime() - 90 * DAY_MS);
|
||||
|
||||
// The primary rollup read throws (statement_timeout / deadlock / reset).
|
||||
mocks.findMany
|
||||
.mockReset()
|
||||
.mockRejectedValue(new Error("statement timeout"));
|
||||
// Live fallback serves the tile.
|
||||
mocks.queryRaw.mockResolvedValueOnce([
|
||||
{
|
||||
type: "WEIGHT",
|
||||
bucket_start: new Date("2026-06-10T00:00:00.000Z"),
|
||||
avg: 80.5,
|
||||
cnt: 2,
|
||||
min_value: 79.8,
|
||||
max_value: 81.2,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await readDailySeries({
|
||||
userId: "u",
|
||||
type: "WEIGHT",
|
||||
from,
|
||||
to,
|
||||
priorityJson: null,
|
||||
timeZone: TZ,
|
||||
});
|
||||
|
||||
// The read did NOT throw — it fell through to live SQL and served a row.
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({ type: "WEIGHT", value: 80.5, count: 2 });
|
||||
// The live fallback must carry the min/max spread the rollup path emits —
|
||||
// otherwise a rollup-cold account silently loses the chart's spread band
|
||||
// for the same underlying data.
|
||||
expect(result[0]).toMatchObject({ minValue: 79.8, maxValue: 81.2 });
|
||||
expect(mocks.queryRaw).toHaveBeenCalled();
|
||||
expect(mocks.readTieredRollupSeries).not.toHaveBeenCalled();
|
||||
// The daily series never reads the UTC-day DAY rollup (#1026).
|
||||
expect(mocks.findMany).not.toHaveBeenCalled();
|
||||
expect(result[0]).toMatchObject({
|
||||
type: "WEIGHT",
|
||||
value: 80,
|
||||
count: 1,
|
||||
minValue: 80,
|
||||
maxValue: 80,
|
||||
});
|
||||
});
|
||||
|
||||
it("a tiered-reader throw on a long window falls through to the daily path", async () => {
|
||||
@@ -209,11 +153,10 @@ describe("readDailySeries — rollup read throw falls back to live SQL (F-DB-2)"
|
||||
from,
|
||||
to,
|
||||
priorityJson: null,
|
||||
timeZone: TZ,
|
||||
});
|
||||
|
||||
// The daily DAY-rollup read still ran as the fallback (the default findMany
|
||||
// returns 3 rows), so the throw did not 500 the read.
|
||||
expect(mocks.findMany).toHaveBeenCalled();
|
||||
expect(mocks.queryRaw).toHaveBeenCalled();
|
||||
expect(result).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* In-memory stand-in for `readDayAggregates`, for unit tests that mock
|
||||
* `@/lib/db`.
|
||||
*
|
||||
* It reads the rows through the mocked `prisma.measurement.findMany` (so a
|
||||
* test's existing row fixture keeps feeding the reader) and folds them with
|
||||
* the same day / segment / range rules the SQL applies. The integration test
|
||||
* `tests/integration/dense-read-bounded.test.ts` pins that this fold and the
|
||||
* SQL agree on real Postgres.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import type {
|
||||
MeasurementSource,
|
||||
MeasurementType,
|
||||
} from "@/generated/prisma/client";
|
||||
import type {
|
||||
DayAggregateRow,
|
||||
ReadDayAggregatesOptions,
|
||||
SourceDayAggregateRow,
|
||||
} from "@/lib/measurements/day-aggregates";
|
||||
import { userDayKey } from "@/lib/tz/format";
|
||||
|
||||
export function foldDayAggregates(
|
||||
rows: ReadonlyArray<{ measuredAt: Date; value: number }>,
|
||||
opts: Omit<ReadDayAggregatesOptions, "userId" | "type">,
|
||||
): DayAggregateRow[] {
|
||||
const starts = opts.segmentStarts ?? [];
|
||||
const byKey = new Map<string, DayAggregateRow>();
|
||||
for (const r of rows) {
|
||||
const t = r.measuredAt.getTime();
|
||||
if (t < opts.since.getTime()) continue;
|
||||
if (opts.until && t > opts.until.getTime()) continue;
|
||||
if (
|
||||
opts.valueRange &&
|
||||
(r.value < opts.valueRange.min || r.value > opts.valueRange.max)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const day = userDayKey(r.measuredAt, opts.timeZone);
|
||||
const segment = starts.filter((s) => t < s.getTime()).length;
|
||||
const key = `${day}|${segment}`;
|
||||
const acc = byKey.get(key);
|
||||
if (acc) {
|
||||
acc.n += 1;
|
||||
acc.sum += r.value;
|
||||
if (r.value < acc.min) acc.min = r.value;
|
||||
if (r.value > acc.max) acc.max = r.value;
|
||||
} else {
|
||||
byKey.set(key, {
|
||||
day,
|
||||
segment,
|
||||
n: 1,
|
||||
sum: r.value,
|
||||
min: r.value,
|
||||
max: r.value,
|
||||
});
|
||||
}
|
||||
}
|
||||
return [...byKey.values()].sort((a, b) =>
|
||||
a.day < b.day ? -1 : a.day > b.day ? 1 : b.segment - a.segment,
|
||||
);
|
||||
}
|
||||
|
||||
export async function fakeReadDayAggregates(
|
||||
opts: ReadDayAggregatesOptions,
|
||||
): Promise<DayAggregateRow[]> {
|
||||
const rows = ((await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId: opts.userId,
|
||||
type: opts.type,
|
||||
deletedAt: null,
|
||||
measuredAt: { gte: opts.since, ...(opts.until && { lte: opts.until }) },
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: { measuredAt: true, value: true },
|
||||
})) ?? []) as Array<{ measuredAt: Date; value: number }>;
|
||||
return foldDayAggregates(rows, opts);
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory stand-in for `readSourceDayAggregates`: reads the mocked
|
||||
* `measurement.findMany` rows and groups them per type, day, source and
|
||||
* device with the SQL's rules.
|
||||
*/
|
||||
export async function fakeReadSourceDayAggregates(opts: {
|
||||
userId: string;
|
||||
types: readonly MeasurementType[];
|
||||
since: Date;
|
||||
timeZone: string;
|
||||
}): Promise<SourceDayAggregateRow[]> {
|
||||
const rows = ((await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId: opts.userId,
|
||||
deletedAt: null,
|
||||
type: { in: [...opts.types] },
|
||||
measuredAt: { gte: opts.since },
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
})) ?? []) as Array<{
|
||||
type: MeasurementType;
|
||||
value: number;
|
||||
measuredAt: Date;
|
||||
source?: MeasurementSource | null;
|
||||
deviceType?: string | null;
|
||||
}>;
|
||||
const groups = new Map<string, SourceDayAggregateRow>();
|
||||
for (const r of rows) {
|
||||
if (r.measuredAt.getTime() < opts.since.getTime()) continue;
|
||||
if (!opts.types.includes(r.type)) continue;
|
||||
const day = userDayKey(r.measuredAt, opts.timeZone);
|
||||
const source = (r.source ?? "MANUAL") as MeasurementSource;
|
||||
const deviceType = r.deviceType ?? null;
|
||||
const key = `${r.type}|${day}|${source}|${deviceType}`;
|
||||
const g = groups.get(key);
|
||||
if (g) {
|
||||
g.n += 1;
|
||||
g.sum += r.value;
|
||||
if (r.measuredAt < g.firstAt) g.firstAt = r.measuredAt;
|
||||
} else {
|
||||
groups.set(key, {
|
||||
type: r.type,
|
||||
day,
|
||||
source,
|
||||
deviceType,
|
||||
n: 1,
|
||||
sum: r.value,
|
||||
firstAt: r.measuredAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
return [...groups.values()];
|
||||
}
|
||||
@@ -1,29 +1,26 @@
|
||||
/**
|
||||
* v1.18.6 — shared rollup-backed daily-series reader.
|
||||
* Shared daily-series reader for the chart surfaces.
|
||||
*
|
||||
* Extracted from the `GET /api/measurements` rollup-daily branch so the
|
||||
* single-type route AND the batched series endpoint
|
||||
* (`GET /api/measurements/series-batch`) read through the SAME path
|
||||
* instead of duplicating the rollup SQL + the coverage-fallback fold.
|
||||
*
|
||||
* `readDailySeries` reproduces the route's `source=rollup` +
|
||||
* `aggregate=daily` contract byte-for-byte: collapse per-source rollup
|
||||
* rows to the ladder-canonical reading per day, probe for an
|
||||
* under-converged rollup window and fold inline when the live table
|
||||
* carries more distinct days, and fall through to the live `date_trunc`
|
||||
* aggregate when the rollup is empty for the window (brand-new accounts /
|
||||
* write-hook race). The returned rows match the wire shape the chart
|
||||
* client consumes (`{ type, value, measuredAt, count, minValue?,
|
||||
* maxValue? }`).
|
||||
* Serves the `GET /api/measurements?aggregate=daily&source=rollup` contract
|
||||
* and the batched series endpoint (`GET /api/measurements/series-batch`) from
|
||||
* one path, so both read the same numbers. Windows up to the DAY cap are
|
||||
* folded live, one row per local calendar day of the user (#1026); longer
|
||||
* windows step up to the WEEK / MONTH / YEAR rollup tier. The returned rows
|
||||
* match the wire shape the chart client consumes (`{ type, value,
|
||||
* measuredAt, count, minValue?, maxValue? }`), with `measuredAt` the instant
|
||||
* of the bucket's local midnight.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { BUCKET_CAP } from "@/lib/measurements/range-aggregation";
|
||||
import {
|
||||
BUCKET_CAP,
|
||||
type AggregateGrain,
|
||||
} from "@/lib/measurements/range-aggregation";
|
||||
import { CUMULATIVE_HK_TYPES } from "@/lib/measurements/apple-health-mapping";
|
||||
import { collapseRollupRowsBySource } from "@/lib/rollups/measurement-read";
|
||||
import { readTieredRollupSeries } from "@/lib/rollups/measurement-read-wmy";
|
||||
import { recomputeUserRollups } from "@/lib/rollups/measurement-rollups";
|
||||
import { buildSourceRankCase } from "@/lib/analytics/source-rank-sql";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { isValidTimezone } from "@/lib/tz/format";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
|
||||
@@ -37,12 +34,9 @@ export interface DailySeriesRow {
|
||||
maxValue?: number | null;
|
||||
}
|
||||
|
||||
const SUSPICIOUS_ROW_FLOOR = 3;
|
||||
const SUSPICIOUS_WINDOW_DAYS = 7;
|
||||
|
||||
/**
|
||||
* Read one type's daily series for `[from, to]` through the rollup tier,
|
||||
* falling back to the live `date_trunc` aggregate on a coverage miss.
|
||||
* Read one type's daily series for `[from, to]`, one row per local day of
|
||||
* the user (or per tier bucket when the window exceeds the DAY cap).
|
||||
*
|
||||
* `priorityJson` is the user's source-priority ladder (load once per
|
||||
* request via `loadUserSourcePriority` and thread it across types so the
|
||||
@@ -56,6 +50,8 @@ export async function readDailySeries(opts: {
|
||||
to: Date;
|
||||
limit?: number;
|
||||
priorityJson: unknown;
|
||||
/** IANA zone the days are cut in; defaults to the user's own zone. */
|
||||
timeZone?: string;
|
||||
}): Promise<DailySeriesRow[]> {
|
||||
const { userId, type, from, to, priorityJson } = opts;
|
||||
const cap = Math.min(opts.limit ?? BUCKET_CAP.daily, BUCKET_CAP.daily);
|
||||
@@ -106,10 +102,10 @@ export async function readDailySeries(opts: {
|
||||
return tiered.rows;
|
||||
}
|
||||
// Coverage miss at every tier — fall through to the daily path, which
|
||||
// probes the live table and folds an under-converged window. The
|
||||
// daily fallback still caps at `cap`; the annotate below records that
|
||||
// the long-range read could not step up so a truncated daily slice is
|
||||
// an explicit, logged outcome rather than a silent one.
|
||||
// folds the live table. The daily fallback still caps at `cap`; the
|
||||
// annotate below records that the long-range read could not step up so
|
||||
// a truncated daily slice is an explicit, logged outcome rather than a
|
||||
// silent one.
|
||||
annotate({
|
||||
meta: {
|
||||
tiered_series_coverage_miss: true,
|
||||
@@ -119,139 +115,94 @@ export async function readDailySeries(opts: {
|
||||
});
|
||||
}
|
||||
|
||||
const readRollup = async () =>
|
||||
collapseRollupRowsBySource(
|
||||
await prisma.measurementRollup.findMany({
|
||||
where: {
|
||||
userId,
|
||||
type,
|
||||
granularity: "DAY",
|
||||
bucketStart: { gte: from, lte: to },
|
||||
},
|
||||
orderBy: { bucketStart: "asc" },
|
||||
select: {
|
||||
type: true,
|
||||
source: true,
|
||||
bucketStart: true,
|
||||
mean: true,
|
||||
count: true,
|
||||
sumValue: true,
|
||||
minValue: true,
|
||||
maxValue: true,
|
||||
},
|
||||
}),
|
||||
type,
|
||||
priorityJson,
|
||||
).slice(0, cap);
|
||||
|
||||
// F-DB-2 — the primary rollup read is wrapped: a transient rollup-table error
|
||||
// (statement_timeout, deadlock, connection reset) must NOT 500 the tile when
|
||||
// the live table can still serve it. Treat a throw as a coverage miss and fall
|
||||
// straight through to the live `date_trunc` aggregate — the same posture the
|
||||
// batched series reader already takes, so the single-type route and the batch
|
||||
// route no longer diverge on a rollup hiccup.
|
||||
let rollupRows: Awaited<ReturnType<typeof readRollup>>;
|
||||
try {
|
||||
rollupRows = await readRollup();
|
||||
} catch (err) {
|
||||
annotate({
|
||||
meta: {
|
||||
rollup_read_threw: true,
|
||||
rollup_read_error: err instanceof Error ? err.message : String(err),
|
||||
type,
|
||||
},
|
||||
});
|
||||
return readLiveDaily({ userId, type, from, to, cap, priorityJson });
|
||||
}
|
||||
|
||||
// Coverage-mismatch probe + inline fold (identical to the route).
|
||||
const windowMs = to.getTime() - from.getTime();
|
||||
const windowDays = Math.ceil(windowMs / 86_400_000);
|
||||
let effectiveRows = rollupRows;
|
||||
let coverageFallbackFired = false;
|
||||
if (
|
||||
rollupRows.length > 0 &&
|
||||
rollupRows.length < SUSPICIOUS_ROW_FLOOR &&
|
||||
windowDays >= SUSPICIOUS_WINDOW_DAYS
|
||||
) {
|
||||
const liveDayCountRows = await prisma.$queryRaw<Array<{ days: bigint }>>`
|
||||
SELECT COUNT(DISTINCT date_trunc('day', m."measured_at"))::bigint AS days
|
||||
FROM measurements m
|
||||
WHERE m."user_id" = ${userId}
|
||||
AND m."type" = ${type}::measurement_type
|
||||
AND m."measured_at" >= ${from}
|
||||
AND m."measured_at" <= ${to}
|
||||
AND m."deleted_at" IS NULL
|
||||
`;
|
||||
const liveDays = Number(liveDayCountRows[0]?.days ?? BigInt(0));
|
||||
if (liveDays > rollupRows.length) {
|
||||
coverageFallbackFired = true;
|
||||
try {
|
||||
await recomputeUserRollups(userId, {
|
||||
types: [type],
|
||||
granularities: ["DAY"],
|
||||
from,
|
||||
to,
|
||||
});
|
||||
effectiveRows = await readRollup();
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
annotate({
|
||||
meta: {
|
||||
rollup_coverage_fallback_failed: true,
|
||||
rollup_coverage_fallback_error: message,
|
||||
type,
|
||||
},
|
||||
});
|
||||
effectiveRows = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (effectiveRows.length > 0) {
|
||||
const useSum = CUMULATIVE_HK_TYPES.has(type);
|
||||
annotate({
|
||||
action: { name: "measurement.list" },
|
||||
meta: {
|
||||
total: effectiveRows.length,
|
||||
type,
|
||||
aggregate: "daily",
|
||||
source: "rollup",
|
||||
coverage_fallback: coverageFallbackFired,
|
||||
},
|
||||
});
|
||||
return effectiveRows.map((r) => ({
|
||||
type: r.type,
|
||||
value: useSum ? (r.sumValue ?? r.mean * r.count) : r.mean,
|
||||
measuredAt: r.bucketStart.toISOString(),
|
||||
count: r.count,
|
||||
minValue: useSum ? undefined : r.minValue,
|
||||
maxValue: useSum ? undefined : r.maxValue,
|
||||
}));
|
||||
}
|
||||
|
||||
// Live `date_trunc` fallback — rollup empty for the window.
|
||||
return readLiveDaily({ userId, type, from, to, cap, priorityJson });
|
||||
// The daily series is cut at the user's own midnight (#1026). The DAY
|
||||
// rollup tier cannot serve it: its buckets are UTC days, so east of UTC a
|
||||
// sample shortly after local midnight counted on the previous day, and
|
||||
// every bucket's UTC-midnight start read as the previous local day west of
|
||||
// UTC. The 7-day chart buckets raw rows by the local day, so switching
|
||||
// ranges moved daily totals between neighbouring days. The live fold is
|
||||
// one indexed pass per window (about 0.1 s for 90 days of per-minute
|
||||
// samples) and returns at most one row per day.
|
||||
const timeZone = opts.timeZone ?? (await resolveUserTimezone(userId));
|
||||
const rows = await readLiveBuckets({
|
||||
userId,
|
||||
type,
|
||||
from,
|
||||
to,
|
||||
cap,
|
||||
priorityJson,
|
||||
grain: "daily",
|
||||
timeZone,
|
||||
});
|
||||
annotate({
|
||||
action: { name: "measurement.list" },
|
||||
meta: { total: rows.length, type, aggregate: "daily", source: "live" },
|
||||
});
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** Live `date_trunc('day', …)` aggregate — the rollup-empty fallback. */
|
||||
async function readLiveDaily(opts: {
|
||||
/** SQL `date_trunc` unit per aggregate grain (a closed map, never input). */
|
||||
const TRUNC_UNIT: Record<Exclude<AggregateGrain, "raw">, string> = {
|
||||
daily: "day",
|
||||
weekly: "week",
|
||||
monthly: "month",
|
||||
};
|
||||
|
||||
/**
|
||||
* Live aggregate of the measurements table into buckets cut at the user's
|
||||
* own calendar boundaries.
|
||||
*
|
||||
* Each sample is converted to wall-clock time in `timeZone` before
|
||||
* truncation, so a bucket is the user's local day (or week / month), and the
|
||||
* returned `measuredAt` is the instant of that local midnight. The chart
|
||||
* labels a row by formatting `measuredAt` in the same zone, which lands on
|
||||
* the bucket's own day for every offset, including half-hour and negative
|
||||
* ones, and across a DST change.
|
||||
*
|
||||
* Per bucket the overlapping sources collapse to the ladder-canonical one
|
||||
* before the fold (the same pick the rollup reader makes), cumulative types
|
||||
* SUM and every other type averages. The result set is at most `cap` rows,
|
||||
* however dense the underlying stream: the fold runs in Postgres.
|
||||
*
|
||||
* `type` narrows to one type; `null` folds every type in the window (the
|
||||
* multi-type `GET /api/measurements?aggregate=` form).
|
||||
*/
|
||||
export async function readLiveBuckets(opts: {
|
||||
userId: string;
|
||||
type: MeasurementType;
|
||||
type: MeasurementType | null;
|
||||
from: Date;
|
||||
to: Date;
|
||||
cap: number;
|
||||
priorityJson: unknown;
|
||||
grain: Exclude<AggregateGrain, "raw">;
|
||||
timeZone: string;
|
||||
}): Promise<DailySeriesRow[]> {
|
||||
const { userId, type, from, to, cap, priorityJson } = opts;
|
||||
const truncUnitLiteral = Prisma.raw(`'day'`);
|
||||
const useSum = CUMULATIVE_HK_TYPES.has(type);
|
||||
const aggregator = useSum
|
||||
? Prisma.raw(`SUM(m."value")::double precision`)
|
||||
: Prisma.raw(`AVG(m."value")::double precision`);
|
||||
const { userId, type, from, to, cap, priorityJson, grain } = opts;
|
||||
// A zone Postgres does not know would 500 the read; the resolver already
|
||||
// validates stored zones, this guards a caller passing a raw string.
|
||||
const timeZone = isValidTimezone(opts.timeZone) ? opts.timeZone : "UTC";
|
||||
// `date_trunc` needs its unit as a literal; the unit comes from the closed
|
||||
// `TRUNC_UNIT` map keyed by the Zod-constrained grain, never from input.
|
||||
const unit = Prisma.raw(`'${TRUNC_UNIT[grain]}'`);
|
||||
// Cumulative types SUM per bucket, every other type averages. The type list
|
||||
// is the closed code constant, spliced as literals; `s."type"` is a GROUP BY
|
||||
// column, so choosing the aggregate per group is legal.
|
||||
const cumulativeList = [...CUMULATIVE_HK_TYPES]
|
||||
.map((t) => `'${t}'`)
|
||||
.join(",");
|
||||
const aggregator = Prisma.raw(
|
||||
`(CASE WHEN s."type"::text IN (${cumulativeList}) THEN SUM(s."value") ELSE AVG(s."value") END)::double precision`,
|
||||
);
|
||||
const rankRaw = Prisma.raw(
|
||||
buildSourceRankCase(priorityJson, 'm."type"', 'm."source"'),
|
||||
);
|
||||
const typeFilter = type
|
||||
? Prisma.sql`AND m."type" = ${type}::measurement_type`
|
||||
: Prisma.empty;
|
||||
// measured_at is a UTC wall-clock timestamp: pin it to UTC, re-read it in
|
||||
// the user's zone and truncate there. The bucket is computed once in `src`
|
||||
// and referenced by name afterwards, so the zone parameter never has to
|
||||
// match itself across SELECT and GROUP BY.
|
||||
const buckets = await prisma.$queryRaw<
|
||||
Array<{
|
||||
type: string;
|
||||
@@ -262,58 +213,54 @@ async function readLiveDaily(opts: {
|
||||
max_value: number | null;
|
||||
}>
|
||||
>`
|
||||
WITH canon AS (
|
||||
SELECT DISTINCT ON (m."type", date_trunc(${truncUnitLiteral}, m."measured_at"))
|
||||
m."type" AS t,
|
||||
date_trunc(${truncUnitLiteral}, m."measured_at") AS d,
|
||||
m."source" AS canon
|
||||
WITH src AS (
|
||||
SELECT
|
||||
m."type",
|
||||
m."source",
|
||||
m."value",
|
||||
date_trunc(${unit}, (m."measured_at" AT TIME ZONE 'UTC') AT TIME ZONE ${timeZone}) AS d
|
||||
FROM measurements m
|
||||
WHERE m."user_id" = ${userId}
|
||||
AND m."measured_at" >= ${from}
|
||||
AND m."measured_at" <= ${to}
|
||||
AND m."deleted_at" IS NULL
|
||||
AND m."type" = ${type}::measurement_type
|
||||
ORDER BY m."type", date_trunc(${truncUnitLiteral}, m."measured_at"), (${rankRaw}), m."source"
|
||||
${typeFilter}
|
||||
),
|
||||
canon AS (
|
||||
SELECT DISTINCT ON (m."type", m.d)
|
||||
m."type" AS t,
|
||||
m.d AS d,
|
||||
m."source" AS canon
|
||||
FROM src m
|
||||
ORDER BY m."type", m.d, (${rankRaw}), m."source"
|
||||
)
|
||||
SELECT
|
||||
m."type"::text AS type,
|
||||
date_trunc(${truncUnitLiteral}, m."measured_at") AS bucket_start,
|
||||
s."type"::text AS type,
|
||||
s.d AT TIME ZONE ${timeZone} AS bucket_start,
|
||||
${aggregator} AS avg,
|
||||
COUNT(*)::int AS cnt,
|
||||
MIN(m."value") AS min_value,
|
||||
MAX(m."value") AS max_value
|
||||
FROM measurements m
|
||||
MIN(s."value") AS min_value,
|
||||
MAX(s."value") AS max_value
|
||||
FROM src s
|
||||
JOIN canon c
|
||||
ON c.t = m."type"
|
||||
AND c.d = date_trunc(${truncUnitLiteral}, m."measured_at")
|
||||
AND c.canon = m."source"
|
||||
WHERE m."user_id" = ${userId}
|
||||
AND m."measured_at" >= ${from}
|
||||
AND m."measured_at" <= ${to}
|
||||
AND m."deleted_at" IS NULL
|
||||
AND m."type" = ${type}::measurement_type
|
||||
GROUP BY m."type", bucket_start
|
||||
ORDER BY bucket_start ASC
|
||||
ON c.t = s."type"
|
||||
AND c.d = s.d
|
||||
AND c.canon = s."source"
|
||||
GROUP BY s."type", s.d
|
||||
ORDER BY s.d ASC
|
||||
LIMIT ${cap}
|
||||
`;
|
||||
annotate({
|
||||
action: { name: "measurement.list" },
|
||||
meta: { total: buckets.length, type, aggregate: "daily" },
|
||||
return buckets.map((b) => {
|
||||
const isSum = CUMULATIVE_HK_TYPES.has(b.type as MeasurementType);
|
||||
return {
|
||||
type: b.type,
|
||||
value: Number(b.avg),
|
||||
measuredAt: b.bucket_start.toISOString(),
|
||||
count: Number(b.cnt),
|
||||
// A SUM has no meaningful intra-day spread; the chart draws the band
|
||||
// for averaged types only.
|
||||
minValue: isSum || b.min_value === null ? undefined : Number(b.min_value),
|
||||
maxValue: isSum || b.max_value === null ? undefined : Number(b.max_value),
|
||||
};
|
||||
});
|
||||
return buckets.map((b) => ({
|
||||
type: b.type,
|
||||
value: Number(b.avg),
|
||||
measuredAt: b.bucket_start.toISOString(),
|
||||
count: Number(b.cnt),
|
||||
// The rollup path emits these; the live fallback used to omit them, so a
|
||||
// rollup-cold account silently lost the min/max spread band on the chart
|
||||
// for the same underlying data — contradicting this file's own
|
||||
// byte-for-byte parity claim.
|
||||
minValue: b.min_value === null ? undefined : Number(b.min_value),
|
||||
maxValue: b.max_value === null ? undefined : Number(b.max_value),
|
||||
// The rollup path emits these; the live fallback used to omit them, so a
|
||||
// rollup-cold account silently lost the min/max spread band on the chart
|
||||
// for the same underlying data — contradicting this file's own
|
||||
// byte-for-byte parity claim.
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* Per-day aggregates of one measurement type, folded in Postgres.
|
||||
*
|
||||
* Several background readers (the status cards' graded series, the vitals
|
||||
* baseline, the tiered context, the plan review) used to `findMany` every raw
|
||||
* row in a time window and then fold them into days in JavaScript. The window
|
||||
* bounded the time span but not the row count, so an account whose watch
|
||||
* streams heart rate every few seconds materialised hundreds of thousands to
|
||||
* millions of objects per read. In the worker that was enough to exhaust the
|
||||
* heap, and the read itself outran the statement timeout.
|
||||
*
|
||||
* This reader returns at most one row per (day, segment), whatever the sample
|
||||
* density: the fold runs in SQL and only the aggregates cross the wire. `sum`,
|
||||
* `n`, `min` and `max` are exact, so every consumer that averaged, counted or
|
||||
* took extremes over the raw rows gets the same numbers from these rows.
|
||||
*
|
||||
* Days are cut in `timeZone`. `segmentStarts` optionally splits the window at
|
||||
* instants (newest first): a row's segment is the number of starts it lies
|
||||
* before, so a caller that treated "the last 21 days" and "the 70 days before
|
||||
* that" differently keeps exactly the same per-row partition.
|
||||
*/
|
||||
import type {
|
||||
MeasurementSource,
|
||||
MeasurementType,
|
||||
PrismaClient,
|
||||
} from "@/generated/prisma/client";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { isValidTimezone } from "@/lib/tz/format";
|
||||
|
||||
export interface DayAggregateRow {
|
||||
/** Calendar day in the requested zone, `YYYY-MM-DD`. */
|
||||
day: string;
|
||||
/** Segment index: 0 for rows at or after `segmentStarts[0]`, and so on. */
|
||||
segment: number;
|
||||
n: number;
|
||||
sum: number;
|
||||
min: number;
|
||||
max: number;
|
||||
}
|
||||
|
||||
export interface ReadDayAggregatesOptions {
|
||||
userId: string;
|
||||
type: MeasurementType;
|
||||
/** Inclusive lower bound on `measuredAt`. */
|
||||
since: Date;
|
||||
/** Inclusive upper bound on `measuredAt`; omit for no upper bound. */
|
||||
until?: Date;
|
||||
/** IANA zone the day boundary is cut in. */
|
||||
timeZone: string;
|
||||
/** Rows outside `[min, max]` are dropped before the fold. */
|
||||
valueRange?: { min: number; max: number };
|
||||
/** Segment boundaries, newest first. */
|
||||
segmentStarts?: readonly Date[];
|
||||
/** Client to read through; defaults to the shared one. */
|
||||
db?: Pick<PrismaClient, "$queryRaw">;
|
||||
}
|
||||
|
||||
export async function readDayAggregates(
|
||||
opts: ReadDayAggregatesOptions,
|
||||
): Promise<DayAggregateRow[]> {
|
||||
const timeZone = isValidTimezone(opts.timeZone) ? opts.timeZone : "UTC";
|
||||
const until = opts.until
|
||||
? Prisma.sql`AND m."measured_at" <= ${opts.until}`
|
||||
: Prisma.empty;
|
||||
const range = opts.valueRange
|
||||
? Prisma.sql`AND m."value" >= ${opts.valueRange.min} AND m."value" <= ${opts.valueRange.max}`
|
||||
: Prisma.empty;
|
||||
const starts = opts.segmentStarts ?? [];
|
||||
const segment =
|
||||
starts.length === 0
|
||||
? Prisma.sql`0`
|
||||
: Prisma.sql`(${Prisma.join(
|
||||
starts.map(
|
||||
(s) =>
|
||||
Prisma.sql`(CASE WHEN m."measured_at" < ${s} THEN 1 ELSE 0 END)`,
|
||||
),
|
||||
" + ",
|
||||
)})`;
|
||||
|
||||
// The day and segment are computed once per row in `src` and grouped by
|
||||
// name, so the bound zone / boundary parameters never have to match
|
||||
// themselves across SELECT and GROUP BY.
|
||||
const rows = await (opts.db ?? prisma).$queryRaw<
|
||||
Array<{
|
||||
day: string;
|
||||
segment: number;
|
||||
n: number;
|
||||
sum: number;
|
||||
min: number;
|
||||
max: number;
|
||||
}>
|
||||
>`
|
||||
WITH src AS (
|
||||
SELECT
|
||||
to_char((m."measured_at" AT TIME ZONE 'UTC') AT TIME ZONE ${timeZone}, 'YYYY-MM-DD') AS day,
|
||||
${segment} AS segment,
|
||||
m."value" AS value
|
||||
FROM measurements m
|
||||
WHERE m."user_id" = ${opts.userId}
|
||||
AND m."type" = ${opts.type}::measurement_type
|
||||
AND m."deleted_at" IS NULL
|
||||
AND m."measured_at" >= ${opts.since}
|
||||
${until}
|
||||
${range}
|
||||
)
|
||||
SELECT
|
||||
day,
|
||||
segment::int AS segment,
|
||||
COUNT(*)::int AS n,
|
||||
SUM(value)::double precision AS sum,
|
||||
MIN(value)::double precision AS min,
|
||||
MAX(value)::double precision AS max
|
||||
FROM src
|
||||
GROUP BY day, segment
|
||||
ORDER BY day ASC, segment DESC
|
||||
`;
|
||||
return rows.map((r) => ({
|
||||
day: r.day,
|
||||
segment: Number(r.segment),
|
||||
n: Number(r.n),
|
||||
sum: Number(r.sum),
|
||||
min: Number(r.min),
|
||||
max: Number(r.max),
|
||||
}));
|
||||
}
|
||||
|
||||
export interface SourceDayAggregateRow {
|
||||
type: MeasurementType;
|
||||
/** Calendar day in the requested zone, `YYYY-MM-DD`. */
|
||||
day: string;
|
||||
source: MeasurementSource;
|
||||
deviceType: string | null;
|
||||
n: number;
|
||||
sum: number;
|
||||
/** Earliest reading of the group; lies inside `day` in the zone. */
|
||||
firstAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-day aggregates of several types, kept apart per source and device.
|
||||
*
|
||||
* For readers that pick one source per day (`pickCanonicalSourceRows`) and
|
||||
* then add up or average that day. The picker decides from which sources and
|
||||
* device types are present on a day, never from individual values, so it
|
||||
* makes the same pick over these groups as over the raw rows, and the day's
|
||||
* sum and count follow exactly. Size: one row per type, day, source and
|
||||
* device, however densely a type is sampled.
|
||||
*/
|
||||
export async function readSourceDayAggregates(opts: {
|
||||
userId: string;
|
||||
types: readonly MeasurementType[];
|
||||
since: Date;
|
||||
timeZone: string;
|
||||
db?: Pick<PrismaClient, "$queryRaw">;
|
||||
}): Promise<SourceDayAggregateRow[]> {
|
||||
if (opts.types.length === 0) return [];
|
||||
const timeZone = isValidTimezone(opts.timeZone) ? opts.timeZone : "UTC";
|
||||
const types = Prisma.join(
|
||||
opts.types.map((t) => Prisma.sql`${t}::measurement_type`),
|
||||
);
|
||||
const rows = await (opts.db ?? prisma).$queryRaw<
|
||||
Array<{
|
||||
type: string;
|
||||
day: string;
|
||||
source: string;
|
||||
device_type: string | null;
|
||||
n: number;
|
||||
sum: number;
|
||||
first_at: Date;
|
||||
}>
|
||||
>`
|
||||
WITH src AS (
|
||||
SELECT
|
||||
m."type"::text AS type,
|
||||
to_char((m."measured_at" AT TIME ZONE 'UTC') AT TIME ZONE ${timeZone}, 'YYYY-MM-DD') AS day,
|
||||
m."source"::text AS source,
|
||||
m."device_type" AS device_type,
|
||||
m."value" AS value,
|
||||
m."measured_at" AS measured_at
|
||||
FROM measurements m
|
||||
WHERE m."user_id" = ${opts.userId}
|
||||
AND m."type" IN (${types})
|
||||
AND m."deleted_at" IS NULL
|
||||
AND m."measured_at" >= ${opts.since}
|
||||
)
|
||||
SELECT
|
||||
type, day, source, device_type,
|
||||
COUNT(*)::int AS n,
|
||||
SUM(value)::double precision AS sum,
|
||||
MIN(measured_at) AS first_at
|
||||
FROM src
|
||||
GROUP BY type, day, source, device_type
|
||||
ORDER BY day ASC, type ASC, source ASC
|
||||
`;
|
||||
return rows.map((r) => ({
|
||||
type: r.type as MeasurementType,
|
||||
day: r.day,
|
||||
source: r.source as MeasurementSource,
|
||||
deviceType: r.device_type,
|
||||
n: Number(r.n),
|
||||
sum: Number(r.sum),
|
||||
firstAt: r.first_at,
|
||||
}));
|
||||
}
|
||||
@@ -105,12 +105,22 @@ export function isPlausibleMetricValue(
|
||||
value: number,
|
||||
): boolean {
|
||||
if (!Number.isFinite(value)) return false;
|
||||
if (!type) return true;
|
||||
const range = VALUE_RANGES[type];
|
||||
const range = plausibleMetricRange(type);
|
||||
if (range === undefined) return true;
|
||||
return value >= range.min && value <= range.max;
|
||||
}
|
||||
|
||||
/**
|
||||
* The plausibility domain of a metric, for a reader that filters in SQL
|
||||
* rather than row by row (`readDayAggregates`' `valueRange`). `undefined`
|
||||
* when the metric declares none, which admits every value.
|
||||
*/
|
||||
export function plausibleMetricRange(
|
||||
type: string | null | undefined,
|
||||
): { min: number; max: number } | undefined {
|
||||
return type ? VALUE_RANGES[type] : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when EVERY named metric forbids negative values (and at least one was
|
||||
* named). Used by the chart, which can paint several series in one axis: a
|
||||
|
||||
@@ -911,12 +911,14 @@ export const doseHistoryQuery = z.object({
|
||||
.datetime({ offset: true })
|
||||
.optional()
|
||||
.describe(
|
||||
"Window start (inclusive). Defaults to 90 days before `to`; clamped to the medication's `createdAt` and a 366-day span floor.",
|
||||
"Window start (inclusive). Defaults to 90 days before `to`; clamped to a 366-day span floor. Recorded doses are returned from this instant; expected slots are not minted before the medication's `createdAt`.",
|
||||
),
|
||||
to: z.iso
|
||||
.datetime({ offset: true })
|
||||
.optional()
|
||||
.describe("Window end (inclusive). Defaults to now. Must be ≥ `from`."),
|
||||
.describe(
|
||||
"Window end (inclusive). Defaults to now. Must be ≥ `from`. A dose recorded ahead of its slot (the slot's time within a day after `to`) is still returned.",
|
||||
),
|
||||
});
|
||||
|
||||
const doseHistoryRow = z
|
||||
@@ -982,8 +984,16 @@ const doseHistoryRow = z
|
||||
|
||||
export const doseHistoryResponse = z
|
||||
.object({
|
||||
from: z.iso.datetime({ offset: true }),
|
||||
to: z.iso.datetime({ offset: true }),
|
||||
from: z.iso
|
||||
.datetime({ offset: true })
|
||||
.describe(
|
||||
"Start of the window recorded doses were read over: the requested `from` after the span clamp.",
|
||||
),
|
||||
to: z.iso
|
||||
.datetime({ offset: true })
|
||||
.describe(
|
||||
"End of the window recorded doses were read over: the requested `to`, or the slot time of a dose recorded ahead of its slot when that lies later (at most one day after `to`).",
|
||||
),
|
||||
family: z
|
||||
.enum(["daily", "weekly", "one_shot", "none"])
|
||||
.describe("Cadence family the window's slots were minted under."),
|
||||
|
||||
@@ -83,8 +83,8 @@ export const medicationKeys = {
|
||||
* Übersicht headline % flip in the same paint, then reconciles on the
|
||||
* authoritative refetch.
|
||||
*/
|
||||
medicationDoseHistory: (medicationId: string, from: string, to: string) =>
|
||||
["medications", medicationId, "dose-history", from, to] as const,
|
||||
medicationDoseHistory: (medicationId: string, windowDays: number) =>
|
||||
["medications", medicationId, "dose-history", windowDays] as const,
|
||||
medicationIntakeDrugLevelChart: (medicationId: string) =>
|
||||
["medications", medicationId, "intake", "drug-level-chart"] as const,
|
||||
/**
|
||||
|
||||
@@ -23,6 +23,14 @@ const mocks = vi.hoisted(() => ({
|
||||
queryRaw: vi.fn(),
|
||||
}));
|
||||
|
||||
// Day-mean reads fold per-day aggregates in SQL; the fake folds the mocked
|
||||
// `measurement.findMany` rows with the same rules.
|
||||
vi.mock("@/lib/measurements/day-aggregates", async () => ({
|
||||
readDayAggregates: (
|
||||
await import("@/lib/measurements/__tests__/fake-day-aggregates")
|
||||
).fakeReadDayAggregates,
|
||||
}));
|
||||
|
||||
vi.mock("../measurement-rollups", () => ({
|
||||
readRollupBuckets: mocks.readRollupBuckets,
|
||||
ensureUserRollupsFresh: mocks.ensureUserRollupsFresh,
|
||||
|
||||
@@ -33,12 +33,13 @@ import type {
|
||||
MeasurementType,
|
||||
RollupGranularity,
|
||||
} from "@/generated/prisma/client";
|
||||
import { isNearUtc, userDayKey } from "@/lib/tz/format";
|
||||
import { isNearUtc } from "@/lib/tz/format";
|
||||
import {
|
||||
ensureUserRollupsFresh,
|
||||
readRollupBuckets,
|
||||
} from "./measurement-rollups";
|
||||
import { probeRollupCoverage } from "./measurement-coverage";
|
||||
import { readDayAggregates } from "@/lib/measurements/day-aggregates";
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
@@ -112,7 +113,7 @@ export interface TieredSeries {
|
||||
export interface BuildTieredSeriesOptions {
|
||||
/**
|
||||
* The user's IANA timezone. REQUIRED — the raw 0–14d band keys its
|
||||
* daily fold on the user's local calendar day (`userDayKey`) so a
|
||||
* daily fold on the user's local calendar day so a
|
||||
* late-evening reading lands on the right day across DST boundaries
|
||||
* and for non-UTC users. The coarser DAY/WEEK/MONTH/YEAR bands read
|
||||
* straight from the rollup table, whose `bucketStart` is UTC-anchored
|
||||
@@ -244,8 +245,8 @@ function extractAnomalies(
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the raw 0–14d daily points for one type, folding same-day rows.
|
||||
* Days are keyed on the user's local calendar via `userDayKey(tz)`, so a
|
||||
* Read the 0–14d daily points for one type, folding same-day rows.
|
||||
* Days are keyed on the user's local calendar in `tz`, so a
|
||||
* 23:30-local reading folds onto its local day rather than the UTC day —
|
||||
* correct across DST transitions and for non-UTC users.
|
||||
*/
|
||||
@@ -256,26 +257,19 @@ async function readRecentDaily(
|
||||
tz: string,
|
||||
): Promise<TieredRawPoint[]> {
|
||||
const since = new Date(now - TIERED_BANDS.rawDays * DAY_MS);
|
||||
const rows = await prisma.measurement.findMany({
|
||||
where: { userId, type, measuredAt: { gte: since }, deletedAt: null },
|
||||
select: { value: true, measuredAt: true },
|
||||
orderBy: { measuredAt: "asc" },
|
||||
// Folded per local day in SQL: one row per day however densely the type
|
||||
// is sampled, never the raw stream (#1023).
|
||||
const days = await readDayAggregates({
|
||||
userId,
|
||||
type,
|
||||
since,
|
||||
timeZone: tz,
|
||||
});
|
||||
const byDay = new Map<string, { sum: number; count: number }>();
|
||||
for (const r of rows) {
|
||||
const key = userDayKey(r.measuredAt, tz);
|
||||
const entry = byDay.get(key) ?? { sum: 0, count: 0 };
|
||||
entry.sum += r.value;
|
||||
entry.count += 1;
|
||||
byDay.set(key, entry);
|
||||
}
|
||||
return [...byDay.entries()]
|
||||
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
||||
.map(([date, { sum, count }]) => ({
|
||||
date,
|
||||
value: round(sum / count),
|
||||
count,
|
||||
}));
|
||||
return days.map((d) => ({
|
||||
date: d.day,
|
||||
value: round(d.sum / d.n),
|
||||
count: d.n,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* The chart's own request, against the real route, on a dense stream.
|
||||
*
|
||||
* The 7-day range used to ask for raw rows oldest-first with a 5 000-row
|
||||
* limit. Per-minute heart rate puts 10 080 rows in seven days, so the answer
|
||||
* stopped about three and a half days in and the chart silently lost the
|
||||
* most recent days. Every range now asks for one row per local day, so the
|
||||
* whole window arrives whatever the sampling rate.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { cookieJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
import { chartSeriesParams } from "@/components/charts/chart-series-request";
|
||||
import { userDayKey } from "@/lib/tz/format";
|
||||
import { invalidateUserTimezone } from "@/lib/tz/resolver";
|
||||
|
||||
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),
|
||||
}));
|
||||
|
||||
const USER = "chart-range-dense-user";
|
||||
const TZ = "Europe/Berlin";
|
||||
const TO = new Date("2026-09-20T18:00:00.000Z");
|
||||
const MINUTES = 7 * 24 * 60;
|
||||
|
||||
beforeAll(async () => {
|
||||
const prisma = getPrismaClient();
|
||||
await truncateAllTables(prisma);
|
||||
cookieJar.clear();
|
||||
await prisma.user.create({
|
||||
data: { id: USER, username: USER, timezone: TZ },
|
||||
});
|
||||
invalidateUserTimezone(USER);
|
||||
const session = await prisma.session.create({
|
||||
data: { userId: USER, expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
await prisma.$executeRawUnsafe(
|
||||
`INSERT INTO measurements (
|
||||
id, user_id, type, value, unit, source, measured_at,
|
||||
created_at, updated_at, sync_version)
|
||||
SELECT 'cr' || lpad(g::text, 6, '0'), $1, 'PULSE'::measurement_type,
|
||||
60 + (g % 30), 'bpm', 'GOOGLE_HEALTH'::measurement_source,
|
||||
($2::timestamptz AT TIME ZONE 'UTC') - (g * interval '1 minute'),
|
||||
now(), now(), 1
|
||||
FROM generate_series(0, $3::int - 1) g`,
|
||||
USER,
|
||||
TO.toISOString(),
|
||||
MINUTES,
|
||||
);
|
||||
}, 120_000);
|
||||
|
||||
describe("7-day chart range on per-minute pulse", () => {
|
||||
it("returns every day of the window, the newest included", async () => {
|
||||
const from = new Date(TO.getTime() - 7 * 86_400_000);
|
||||
const params = chartSeriesParams("PULSE", {
|
||||
from: from.toISOString(),
|
||||
to: TO.toISOString(),
|
||||
});
|
||||
const { GET } = await import("@/app/api/measurements/route");
|
||||
const res = await GET(
|
||||
new NextRequest(`http://localhost/api/measurements?${params}`),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const json = (await res.json()) as {
|
||||
data: { measurements: Array<{ measuredAt: string }> };
|
||||
};
|
||||
const days = new Set(
|
||||
json.data.measurements.map((m) => userDayKey(new Date(m.measuredAt), TZ)),
|
||||
);
|
||||
// 20 Sep 20:00 Berlin back to 13 Sep 20:00: eight calendar days.
|
||||
expect([...days].sort()).toEqual([
|
||||
"2026-09-13",
|
||||
"2026-09-14",
|
||||
"2026-09-15",
|
||||
"2026-09-16",
|
||||
"2026-09-17",
|
||||
"2026-09-18",
|
||||
"2026-09-19",
|
||||
"2026-09-20",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* The cycle-phase crosstab reads per-day, per-source aggregates instead of a
|
||||
* year of raw readings (#1023). This pins that the day values it tests are
|
||||
* the ones the raw readings give: for every crosstab metric, `metricDayMap`
|
||||
* over the aggregates equals `metricDayMap` over the raw rows, on a fixture
|
||||
* with two sources on the same days, two device types within one source,
|
||||
* dense CGM glucose, summed steps and readings next to Berlin midnight.
|
||||
*/
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
import { readSourceDayAggregates } from "@/lib/measurements/day-aggregates";
|
||||
import { metricDayMap } from "@/lib/insights/mood-crosstab";
|
||||
import { PHASE_CROSSTAB_METRIC_TYPES } from "@/lib/cycle/phase-crosstab";
|
||||
import type { CrossMetricMeasurement } from "@/lib/insights/mood-aggregates";
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const prisma = getPrismaClient();
|
||||
const USER = "cycle-crosstab-agg";
|
||||
const SINCE = new Date("2026-06-01T00:00:00Z");
|
||||
|
||||
beforeAll(async () => {
|
||||
await truncateAllTables(prisma);
|
||||
await prisma.user.create({ data: { id: USER, username: USER } });
|
||||
// CGM glucose every 5 minutes for 60 days from two sources.
|
||||
await prisma.$executeRawUnsafe(
|
||||
`INSERT INTO measurements (id, user_id, type, value, unit, source, measured_at, created_at, updated_at, sync_version)
|
||||
SELECT 'cg' || s || g, $1, 'BLOOD_GLUCOSE'::measurement_type, 80 + (g % 60), 'mg/dL',
|
||||
(CASE WHEN s = 0 THEN 'APPLE_HEALTH' ELSE 'NIGHTSCOUT' END)::measurement_source,
|
||||
timestamp '2026-06-02 00:00:00' + (g * interval '5 minutes') + (s * interval '1 second'),
|
||||
now(), now(), 1
|
||||
FROM generate_series(0, 60 * 288 - 1) g, generate_series(0, 1) s`,
|
||||
USER,
|
||||
);
|
||||
// Steps every 15 minutes from a watch and a phone, plus Google totals on
|
||||
// some days; resting heart rate once a day near Berlin midnight.
|
||||
await prisma.$executeRawUnsafe(
|
||||
`INSERT INTO measurements (id, user_id, type, value, unit, source, device_type, measured_at, created_at, updated_at, sync_version)
|
||||
SELECT 'st' || d || g, $1, 'ACTIVITY_STEPS'::measurement_type, 10 + (g % 7), 'count',
|
||||
'APPLE_HEALTH'::measurement_source, CASE WHEN d = 0 THEN 'watch' ELSE 'phone' END,
|
||||
timestamp '2026-06-02 00:00:00' + (g * interval '15 minutes') + (d * interval '2 seconds'),
|
||||
now(), now(), 1
|
||||
FROM generate_series(0, 60 * 96 - 1) g, generate_series(0, 1) d
|
||||
WHERE d = 0 OR g % 3 = 0`,
|
||||
USER,
|
||||
);
|
||||
await prisma.$executeRawUnsafe(
|
||||
`INSERT INTO measurements (id, user_id, type, value, unit, source, measured_at, created_at, updated_at, sync_version)
|
||||
SELECT 'gs' || g, $1, 'ACTIVITY_STEPS'::measurement_type, 5000 + g, 'count',
|
||||
'GOOGLE_HEALTH'::measurement_source,
|
||||
timestamp '2026-06-02 12:00:00' + (g * interval '1 day'), now(), now(), 1
|
||||
FROM generate_series(0, 59, 4) g`,
|
||||
USER,
|
||||
);
|
||||
await prisma.$executeRawUnsafe(
|
||||
`INSERT INTO measurements (id, user_id, type, value, unit, source, measured_at, created_at, updated_at, sync_version)
|
||||
SELECT 'rh' || g, $1, 'RESTING_HEART_RATE'::measurement_type, 55 + (g % 9), 'bpm',
|
||||
'WITHINGS'::measurement_source,
|
||||
timestamp '2026-06-02 22:30:00' + (g * interval '1 day'), now(), now(), 1
|
||||
FROM generate_series(0, 59) g`,
|
||||
USER,
|
||||
);
|
||||
}, 120_000);
|
||||
|
||||
describe("cycle crosstab day values (#1023)", () => {
|
||||
it("per-source day aggregates give the raw rows' day values for every metric", async () => {
|
||||
const raw = await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId: USER,
|
||||
deletedAt: null,
|
||||
type: { in: PHASE_CROSSTAB_METRIC_TYPES },
|
||||
measuredAt: { gte: SINCE },
|
||||
},
|
||||
select: {
|
||||
type: true,
|
||||
value: true,
|
||||
measuredAt: true,
|
||||
source: true,
|
||||
deviceType: true,
|
||||
},
|
||||
});
|
||||
const agg = await readSourceDayAggregates({
|
||||
userId: USER,
|
||||
types: PHASE_CROSSTAB_METRIC_TYPES,
|
||||
since: SINCE,
|
||||
timeZone: "Europe/Berlin",
|
||||
});
|
||||
const aggRows: CrossMetricMeasurement[] = agg.map((a) => ({
|
||||
type: a.type,
|
||||
value: a.sum,
|
||||
count: a.n,
|
||||
measuredAt: a.firstAt,
|
||||
source: a.source,
|
||||
deviceType: a.deviceType,
|
||||
}));
|
||||
|
||||
expect(raw.length).toBeGreaterThan(40_000);
|
||||
expect(aggRows.length).toBeLessThan(500);
|
||||
|
||||
for (const type of PHASE_CROSSTAB_METRIC_TYPES) {
|
||||
const expected = metricDayMap(raw, type, null);
|
||||
const actual = metricDayMap(aggRows, type, null);
|
||||
expect([...actual.keys()].sort(), type).toEqual(
|
||||
[...expected.keys()].sort(),
|
||||
);
|
||||
for (const [day, value] of expected) {
|
||||
expect(actual.get(day), `${type} ${day}`).toBeCloseTo(value, 9);
|
||||
}
|
||||
}
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Issue #1026 — a daily total must land on the same calendar day whichever
|
||||
* chart range reads it.
|
||||
*
|
||||
* The 7-day chart pulls raw rows and buckets them by the user's own calendar
|
||||
* day. The 30-day chart reads the daily series from the rollup tier, whose
|
||||
* DAY buckets are cut at UTC midnight, and the chart then labels each bucket's
|
||||
* UTC-midnight start in the user's zone. East of UTC a sample shortly after
|
||||
* local midnight was counted on the previous day; west of UTC every bucket
|
||||
* was labelled one day early. Steps, active energy, flights and distance are
|
||||
* summed per day, so the drift showed up as whole totals moving between
|
||||
* neighbouring days when the range changed.
|
||||
*
|
||||
* This file seeds cumulative samples right next to local midnight for a
|
||||
* positive, a half-hour and a negative offset, warms the rollup tier the way
|
||||
* the live instance has it, and asserts that the daily series (both the
|
||||
* rollup-backed read and the live aggregate) buckets by the user's day.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
import {
|
||||
readDailySeries,
|
||||
readLiveBuckets,
|
||||
} from "@/lib/measurements/daily-series-read";
|
||||
import { recomputeUserRollups } from "@/lib/rollups/measurement-rollups";
|
||||
import { invalidateUserTimezone } from "@/lib/tz/resolver";
|
||||
import { userDayKey } from "@/lib/tz/format";
|
||||
import { localHmAsUtc } from "@/lib/tz/local-day";
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
});
|
||||
|
||||
const DAY = new Date("2026-09-10T12:00:00Z");
|
||||
const NEXT_DAY = new Date("2026-09-11T12:00:00Z");
|
||||
|
||||
async function seed(tz: string): Promise<string> {
|
||||
const prisma = getPrismaClient();
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
username: `tz-${tz.replace(/\W/g, "-").toLowerCase()}`,
|
||||
email: `tz-${tz.replace(/\W/g, "-").toLowerCase()}@example.test`,
|
||||
role: "USER",
|
||||
timezone: tz,
|
||||
},
|
||||
});
|
||||
invalidateUserTimezone(user.id);
|
||||
const rows = [
|
||||
// Just after local midnight: belongs to DAY.
|
||||
{ at: localHmAsUtc(DAY, tz, 0, 20), value: 100 },
|
||||
// Just before the next local midnight: still DAY.
|
||||
{ at: localHmAsUtc(DAY, tz, 23, 40), value: 1000 },
|
||||
// A daily total stamped at the start of the next local day.
|
||||
{ at: localHmAsUtc(NEXT_DAY, tz, 0, 0), value: 7 },
|
||||
{ at: localHmAsUtc(NEXT_DAY, tz, 12, 0), value: 3 },
|
||||
];
|
||||
await prisma.measurement.createMany({
|
||||
data: rows.map((r) => ({
|
||||
userId: user.id,
|
||||
type: "ACTIVITY_STEPS" as const,
|
||||
value: r.value,
|
||||
unit: "count",
|
||||
measuredAt: r.at,
|
||||
source: "APPLE_HEALTH" as const,
|
||||
})),
|
||||
});
|
||||
return user.id;
|
||||
}
|
||||
|
||||
function byLocalDay(
|
||||
rows: Array<{ measuredAt: string; value: number }>,
|
||||
tz: string,
|
||||
): Record<string, number> {
|
||||
const out: Record<string, number> = {};
|
||||
for (const r of rows) {
|
||||
const key = userDayKey(new Date(r.measuredAt), tz);
|
||||
out[key] = (out[key] ?? 0) + r.value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const EXPECTED = { "2026-09-10": 1100, "2026-09-11": 10 };
|
||||
|
||||
describe("daily series buckets by the user's calendar day (#1026)", () => {
|
||||
for (const tz of ["Europe/Berlin", "Asia/Kolkata", "America/New_York"]) {
|
||||
it(`chart daily read agrees with the local day (${tz})`, async () => {
|
||||
const userId = await seed(tz);
|
||||
const from = new Date("2026-09-05T00:00:00Z");
|
||||
const to = new Date("2026-09-15T00:00:00Z");
|
||||
// Warm the rollup tier exactly as the write hook / backfill does.
|
||||
await recomputeUserRollups(userId, {
|
||||
types: ["ACTIVITY_STEPS"],
|
||||
granularities: ["DAY"],
|
||||
from,
|
||||
to,
|
||||
});
|
||||
const rows = await readDailySeries({
|
||||
userId,
|
||||
type: "ACTIVITY_STEPS",
|
||||
from,
|
||||
to,
|
||||
priorityJson: null,
|
||||
});
|
||||
expect(byLocalDay(rows, tz)).toEqual(EXPECTED);
|
||||
});
|
||||
|
||||
it(`live daily aggregate agrees with the local day (${tz})`, async () => {
|
||||
const userId = await seed(tz);
|
||||
const rows = await readLiveBuckets({
|
||||
userId,
|
||||
type: "ACTIVITY_STEPS",
|
||||
from: new Date("2026-09-05T00:00:00Z"),
|
||||
to: new Date("2026-09-15T00:00:00Z"),
|
||||
cap: 366,
|
||||
priorityJson: null,
|
||||
grain: "daily",
|
||||
timeZone: tz,
|
||||
});
|
||||
expect(byLocalDay(rows, tz)).toEqual(EXPECTED);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* #1023 — a status card over a dense heart-rate stream, measured.
|
||||
*
|
||||
* A single account syncing heart rate from a watch killed the worker within
|
||||
* half an hour: the pulse status card's recent-window read was bounded in
|
||||
* time (91 days) but not in rows, so every refresh materialised the whole
|
||||
* stream as objects, and the read itself ran past the statement timeout.
|
||||
* The read now folds per day in Postgres.
|
||||
*
|
||||
* The fixture is one reading a minute for 91 days, 131 040 rows, seeded in
|
||||
* one `INSERT … generate_series`. Each half of the memory test reads the
|
||||
* same window: the per-day fold the card now uses, and the raw `findMany` it
|
||||
* used to issue. Without the second half the budget could be any number and
|
||||
* the first half would still pass. Every reading of the heap is taken after a
|
||||
* forced collection, so what is compared is what each read HOLDS; the
|
||||
* transient figure (taken before collecting) is reported alongside.
|
||||
*
|
||||
* The parity case pins that the SQL fold and the in-memory fold the unit
|
||||
* tests use agree on real Postgres: same days in a non-UTC zone, same
|
||||
* segment edges, same plausibility filter.
|
||||
*/
|
||||
import v8 from "node:v8";
|
||||
import vm from "node:vm";
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
import { buildGradedSeriesWithRollups } from "@/lib/insights/graded-series";
|
||||
import { readDayAggregates } from "@/lib/measurements/day-aggregates";
|
||||
import { foldDayAggregates } from "@/lib/measurements/__tests__/fake-day-aggregates";
|
||||
import { localHmAsUtc } from "@/lib/tz/local-day";
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const prisma = getPrismaClient();
|
||||
const DENSE_USER = "dense-pulse-owner";
|
||||
const PARITY_USER = "dense-parity-owner";
|
||||
const DAYS = 91;
|
||||
const ROWS = DAYS * 24 * 60;
|
||||
const NOW = new Date("2026-09-20T18:00:00.000Z");
|
||||
|
||||
const forceGc = ((): (() => void) => {
|
||||
v8.setFlagsFromString("--expose-gc");
|
||||
const gc = vm.runInNewContext("gc") as () => void;
|
||||
v8.setFlagsFromString("--no-expose-gc");
|
||||
return gc;
|
||||
})();
|
||||
|
||||
function liveHeapBytes(): number {
|
||||
forceGc();
|
||||
forceGc();
|
||||
return process.memoryUsage().heapUsed;
|
||||
}
|
||||
|
||||
const mb = (bytes: number): string => (bytes / 1024 / 1024).toFixed(1);
|
||||
|
||||
function report(line: string): void {
|
||||
process.stderr.write(`[dense-read] ${line}\n`);
|
||||
}
|
||||
|
||||
/** Run `read`, returning what it holds, its transient peak and its time. */
|
||||
async function measure<T>(read: () => Promise<T>): Promise<{
|
||||
result: T;
|
||||
heldBytes: number;
|
||||
transientBytes: number;
|
||||
ms: number;
|
||||
}> {
|
||||
const before = liveHeapBytes();
|
||||
const t0 = performance.now();
|
||||
const result = await read();
|
||||
const ms = performance.now() - t0;
|
||||
const transientBytes = process.memoryUsage().heapUsed - before;
|
||||
const heldBytes = liveHeapBytes() - before;
|
||||
return { result, heldBytes, transientBytes, ms };
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
await truncateAllTables(prisma);
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
id: DENSE_USER,
|
||||
username: DENSE_USER,
|
||||
timezone: "Europe/Berlin",
|
||||
},
|
||||
});
|
||||
await prisma.$executeRawUnsafe(
|
||||
`INSERT INTO measurements (
|
||||
id, user_id, type, value, unit, source, measured_at,
|
||||
created_at, updated_at, sync_version)
|
||||
SELECT
|
||||
'dp' || lpad(g::text, 8, '0'),
|
||||
$1,
|
||||
'PULSE'::measurement_type,
|
||||
55 + (g % 50),
|
||||
'bpm',
|
||||
'GOOGLE_HEALTH'::measurement_source,
|
||||
($2::timestamptz AT TIME ZONE 'UTC') - (g * interval '1 minute'),
|
||||
now(), now(), 1
|
||||
FROM generate_series(1, $3::int) g`,
|
||||
DENSE_USER,
|
||||
NOW.toISOString(),
|
||||
ROWS,
|
||||
);
|
||||
await prisma.$executeRawUnsafe(`ANALYZE measurements`);
|
||||
}, 300_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await truncateAllTables(prisma);
|
||||
});
|
||||
|
||||
describe("pulse status over a dense stream (#1023)", () => {
|
||||
it("builds the graded series without materialising the stream", async () => {
|
||||
const graded = await measure(() =>
|
||||
buildGradedSeriesWithRollups(DENSE_USER, "PULSE", NOW),
|
||||
);
|
||||
const series = graded.result;
|
||||
// Every reading in the 91-day window is accounted for, one bucket per day
|
||||
// or week, not one object per reading. The oldest reading sits exactly 91
|
||||
// days back, which the graded split files under the older slice.
|
||||
const counted = [...series.recent, ...series.weekly].reduce(
|
||||
(sum, b) => sum + b.n,
|
||||
0,
|
||||
);
|
||||
expect(counted).toBe(ROWS - 1);
|
||||
expect(series.recent.length).toBeLessThanOrEqual(22);
|
||||
expect(series.weekly.length).toBeLessThanOrEqual(12);
|
||||
|
||||
const raw = await measure(() =>
|
||||
prisma.measurement.findMany({
|
||||
where: {
|
||||
userId: DENSE_USER,
|
||||
type: "PULSE",
|
||||
deletedAt: null,
|
||||
measuredAt: { gte: new Date(NOW.getTime() - DAYS * 86_400_000) },
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: { measuredAt: true, value: true },
|
||||
}),
|
||||
);
|
||||
expect(raw.result.length).toBe(ROWS);
|
||||
|
||||
report(
|
||||
`graded series (per-day fold, cold tier): held ${mb(graded.heldBytes)} MB, ` +
|
||||
`transient ${mb(graded.transientBytes)} MB, ${graded.ms.toFixed(0)} ms`,
|
||||
);
|
||||
report(
|
||||
`raw 91-day findMany (the old read): held ${mb(raw.heldBytes)} MB, ` +
|
||||
`transient ${mb(raw.transientBytes)} MB, ${raw.ms.toFixed(0)} ms, ` +
|
||||
`${raw.result.length} rows`,
|
||||
);
|
||||
|
||||
// The fold holds a few kilobytes; the raw read holds the stream. The
|
||||
// transient figure is the one that killed the worker: the old builder
|
||||
// held little once it returned but allocated the whole stream on the
|
||||
// way (62 MB on this fixture, against 1.6 MB for the fold).
|
||||
expect(graded.heldBytes).toBeLessThan(2 * 1024 * 1024);
|
||||
expect(graded.transientBytes).toBeLessThan(12 * 1024 * 1024);
|
||||
expect(raw.heldBytes).toBeGreaterThan(20 * Math.max(graded.heldBytes, 1));
|
||||
}, 120_000);
|
||||
|
||||
it("the SQL fold agrees with the in-memory fold on real Postgres", async () => {
|
||||
const tz = "Asia/Kolkata";
|
||||
await prisma.user.create({
|
||||
data: { id: PARITY_USER, username: PARITY_USER, timezone: tz },
|
||||
});
|
||||
const day = new Date("2026-09-10T12:00:00Z");
|
||||
const edge = new Date(NOW.getTime() - 21 * 86_400_000);
|
||||
const rows = [
|
||||
{ at: localHmAsUtc(day, tz, 0, 5), value: 61 },
|
||||
{ at: localHmAsUtc(day, tz, 23, 55), value: 99 },
|
||||
{ at: localHmAsUtc(day, tz, 12, 0), value: 111_287_531 }, // impossible
|
||||
{ at: edge, value: 70 },
|
||||
{ at: new Date(edge.getTime() + 1), value: 72 },
|
||||
{ at: new Date(NOW.getTime() - 200 * 86_400_000), value: 58 },
|
||||
];
|
||||
await prisma.measurement.createMany({
|
||||
data: rows.map((r, i) => ({
|
||||
id: `parity-${i}`,
|
||||
userId: PARITY_USER,
|
||||
type: "PULSE" as const,
|
||||
value: r.value,
|
||||
unit: "bpm",
|
||||
source: "MANUAL" as const,
|
||||
measuredAt: r.at,
|
||||
})),
|
||||
});
|
||||
const opts = {
|
||||
since: new Date(NOW.getTime() - 400 * 86_400_000),
|
||||
until: NOW,
|
||||
timeZone: tz,
|
||||
valueRange: { min: 20, max: 300 },
|
||||
segmentStarts: [edge, new Date(NOW.getTime() - 91 * 86_400_000)],
|
||||
};
|
||||
const sql = await readDayAggregates({
|
||||
userId: PARITY_USER,
|
||||
type: "PULSE",
|
||||
...opts,
|
||||
});
|
||||
const memory = foldDayAggregates(
|
||||
rows.map((r) => ({ measuredAt: r.at, value: r.value })),
|
||||
opts,
|
||||
);
|
||||
expect(sql).toEqual(memory);
|
||||
// Sanity: the impossible value was dropped and the edge row split off.
|
||||
expect(sql.reduce((n, r) => n + r.n, 0)).toBe(5);
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,7 @@
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { userDayKey } from "@/lib/tz/format";
|
||||
|
||||
import { cookieJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
@@ -128,11 +129,15 @@ describe("GET /api/measurements?aggregate=… (real Postgres)", () => {
|
||||
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"),
|
||||
// Buckets are the account's own calendar days (Europe/Berlin, the
|
||||
// column default), keyed by the instant of local midnight (#1026).
|
||||
const may1 = json.data.measurements.find(
|
||||
(m) =>
|
||||
userDayKey(new Date(m.measuredAt), "Europe/Berlin") === "2026-05-01",
|
||||
);
|
||||
const may2 = json.data.measurements.find((m) =>
|
||||
m.measuredAt.startsWith("2026-05-02"),
|
||||
const may2 = json.data.measurements.find(
|
||||
(m) =>
|
||||
userDayKey(new Date(m.measuredAt), "Europe/Berlin") === "2026-05-02",
|
||||
);
|
||||
expect(may1).toBeDefined();
|
||||
expect(may1!.count).toBe(2);
|
||||
|
||||
@@ -28,6 +28,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
process.env.ENCRYPTION_KEY ??=
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
import { invalidateServerDefaultTimezone } from "@/lib/tz/resolver";
|
||||
import { cookieJar, headerJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
@@ -166,6 +167,36 @@ describe("per-user timezone — Pacific/Auckland end-to-end", () => {
|
||||
expect(body.data?.timezone).toBe("Pacific/Auckland");
|
||||
});
|
||||
|
||||
// A stored zone the server cannot use falls back to the instance
|
||||
// default for every server-side day cut. `/api/auth/me` reports that
|
||||
// same resolved zone, so a client (the charts' day buckets among them)
|
||||
// never cuts days in a different zone than the server did.
|
||||
it("GET /api/auth/me reports the resolved zone when the stored one is unusable", async () => {
|
||||
const user = await seedAucklandUser();
|
||||
const prisma = getPrismaClient();
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { timezone: "" },
|
||||
});
|
||||
await prisma.appSettings.upsert({
|
||||
where: { id: "singleton" },
|
||||
create: { id: "singleton", defaultUserTimezone: "Asia/Kolkata" },
|
||||
update: { defaultUserTimezone: "Asia/Kolkata" },
|
||||
});
|
||||
invalidateServerDefaultTimezone();
|
||||
try {
|
||||
const { GET } = await import("@/app/api/auth/me/route");
|
||||
const res = await GET();
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { timezone?: string } | null;
|
||||
};
|
||||
expect(body.data?.timezone).toBe("Asia/Kolkata");
|
||||
} finally {
|
||||
invalidateServerDefaultTimezone();
|
||||
}
|
||||
});
|
||||
|
||||
it("PUT /api/auth/me/timezone rejects an invalid IANA zone with 422", async () => {
|
||||
await seedAucklandUser();
|
||||
|
||||
|
||||
Reference in new issue
Block a user