chore(release): v1.4.40 — comprehensive Critical+High architecture closure plus iOS PB30 enablement

Eleven touch-disjoint waves landed on develop plus a six-axis QA
reconcile round before tagging. See CHANGELOG.md for the user-facing
prose; this commit message documents the wave + report inventory for
release-management traceability.

Waves shipped (each with a phase report under .planning/):

- phase-W-POOL-v1440-report.md — p-limit(4) wrapper around the
  analytics 15-way fan-out + pg.Pool max raised 10 -> 20. Closes the
  v1.4.39 empirical-trace finding #1 (Prisma pool starvation).
- phase-W-DELETED-v1440-report.md — 11 reader-tier helpers + 3
  integration contracts close audit Critical Finding #3 (soft-delete
  invisibility) across dashboard, analytics, summaries, rollup tiers,
  and the Coach snapshot path.
- phase-W-INSIGHTS-v1440-report.md — mood-rollup swap on the three
  /api/insights/{features,targets,cards} routes plus the
  compliance-rollup hook gap closure for bulk-projection mints; the
  post-QA reconcile patch closes the six remaining measurement.findMany
  sites in the cluster that still leaked tombstoned rows.
- phase-W-INFRA-v1440-report.md — rollup helper umbrella consolidation
  under src/lib/rollups/* with zero orphan re-exports; lint, typecheck,
  knip, and 93/93 rollup tests green post-restructure.
- phase-W-RSC-v1440-report.md — per-tile Suspense boundaries plus the
  queryKey factory-bypass walker test (extended to comparison/ in the
  reconcile pass).
- phase-W-PRIVACY-v1440-report.md — bilingual /privacy page with 17
  paired-section tests pinning the 9 SB-3 disclosure requirements.
- phase-W-AASA-v1440-report.md — /.well-known/apple-app-site-association
  endpoint pinned to S8WDX4W5KX.dev.healthlog.app with app-ID parity
  assertions guarding against split rotation.
- phase-W-APNS-NOTIFY-v1440-report.md — MEDICATION_REMINDER
  time-sensitive APNs payload (priority 10 + interruption-level)
  scoped via a parameterised test that pins all 6 other event-types
  do not bypass Focus. AP-2 .p8 install gates real delivery.
- phase-W-CONSENT-v1440-report.md — AI consent receipts CRUD with
  append-only invariant pinned in test; revoke-then-regrant leaves
  both rows; idempotent DELETE returns 200 + receipt: null.
- phase-W-WMY-WIRE-v1440-report.md — three previously-dead WMY readers
  (week/month/year) wired through summaries-slice and
  health-score-fast-path with a linear-composability parity test
  pinning the cross-consumer routing contract.
- phase-W-GHOSTS-v1440-report.md — ~1 177 lines of dead code removed
  across 9 atomic commits; startOfUtcDay + wallClockInTz consolidation
  eliminates a sibling-drift risk; knip whitelist trimmed to three
  files pending follow-up.

QA reconcile patches applied per the v1.4.40 QA round (six reports
under .planning/round-v1440-QA-*-findings.md):

- senior C1 — dashboard-suspense-boundaries regex aligned with the
  post-W-INFRA Thread 2 userTimezone-local lift.
- senior H1 — soft-delete filter added to the six remaining
  insights measurement.findMany sites.
- specialist H1 — Promise.allSettled around bulk-projection
  compliance recompute on both /api/medications/intake and
  /api/dashboard/summary.
- ux H1 — compare-toggle migrated to queryKeys.dashboardWidgets
  factory; walker test extended to src/components/comparison/.
- security M1 — consent artefact 64 KB cap enforced via
  Buffer.byteLength (UTF-8 bytes, not UTF-16 code units).

Quality gates green at tag: pnpm typecheck, pnpm lint, pnpm knip
--include files,dependencies,binaries,unlisted, pnpm test --run
(4726 passing / 1 skipped). Tests 4525 -> 4726 unit + 1 skipped over
the v1.4.39 -> v1.4.40 span.

User-facing prose: see CHANGELOG.md.
This commit is contained in:
Marc-André Bombeck committed 2026-05-21 12:48:02 +02:00
1 parent f9996552ed
commit 1114cf7e63
146 files changed
+8207 -2279

No files matched your search

+64
View File
@@ -0,0 +1,64 @@
/**
* v1.4.40 W-POOL — pool-config contract for `src/lib/db.ts`.
*
* The v1.4.39 empirical cold-mount trace
* (`.planning/round-v1439-empirical-trace.md` § B2) traced the
* Wave-B/C dashboard-query stall to the Prisma `pg.Pool` falling
* back to the library default ceiling of 10 connections. W-POOL
* pinned the ceiling to 20 via `getPoolMax()`; this test prevents a
* future refactor from silently dropping the override and re-introducing
* the saturation regression on a power-user cold mount.
*
* The test only exercises the `getPoolMax()` env-resolver. Constructing
* a real `PrismaClient` requires a live `DATABASE_URL`, which would
* make this an integration test instead of a unit test — the helper
* isolation is intentional.
*/
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { getPoolMax } from "../db";
describe("getPoolMax", () => {
const originalEnv = process.env.DATABASE_POOL_MAX;
beforeEach(() => {
delete process.env.DATABASE_POOL_MAX;
});
afterEach(() => {
if (originalEnv === undefined) {
delete process.env.DATABASE_POOL_MAX;
} else {
process.env.DATABASE_POOL_MAX = originalEnv;
}
});
it("defaults to 20 when DATABASE_POOL_MAX is unset", () => {
expect(getPoolMax()).toBe(20);
});
it("honours DATABASE_POOL_MAX when it parses to a positive int", () => {
process.env.DATABASE_POOL_MAX = "30";
expect(getPoolMax()).toBe(30);
});
it("falls back to 20 on a malformed DATABASE_POOL_MAX", () => {
process.env.DATABASE_POOL_MAX = "not-a-number";
expect(getPoolMax()).toBe(20);
});
it("falls back to 20 on a non-positive DATABASE_POOL_MAX", () => {
process.env.DATABASE_POOL_MAX = "0";
expect(getPoolMax()).toBe(20);
process.env.DATABASE_POOL_MAX = "-5";
expect(getPoolMax()).toBe(20);
});
it("never returns the library default of 10 on the happy path", () => {
// The whole point of the W-POOL change. If anybody removes the
// `max:` config from `createPrismaClient` and reverts to the
// library default, this assertion stops the regression before the
// next saturated cold mount.
expect(getPoolMax()).toBeGreaterThanOrEqual(20);
});
});
-13
View File
@@ -1,7 +1,6 @@
import { describe, it, expect } from "vitest";
import {
mgdlToMmol,
mmolToMgdl,
convertGlucose,
resolveGlucoseUnit,
thresholdMetricForContext,
@@ -14,18 +13,6 @@ describe("glucose conversion", () => {
expect(mgdlToMmol(70)).toBeCloseTo(3.9, 1); // hypoglycemia threshold
});
it("converts mmol/L to mg/dL rounded to integer", () => {
expect(mmolToMgdl(5.5)).toBe(99);
expect(mmolToMgdl(7.0)).toBe(126);
});
it("roundtrips within 1 mg/dL", () => {
for (const mgdl of [70, 99, 100, 126, 140, 200, 300]) {
const back = mmolToMgdl(mgdlToMmol(mgdl));
expect(Math.abs(back - mgdl)).toBeLessThanOrEqual(1);
}
});
it("convertGlucose dispatches to the right unit", () => {
expect(convertGlucose(100, "mg/dL")).toBe(100);
expect(convertGlucose(100, "mmol/L")).toBe(5.5);
+161
View File
@@ -1,3 +1,5 @@
import { readFileSync, readdirSync, statSync } from "node:fs";
import { join } from "node:path";
import { describe, it, expect, vi } from "vitest";
import {
queryKeys,
@@ -34,6 +36,51 @@ describe("queryKeys factory", () => {
expect(queryKeys.analytics()).toEqual(["analytics"]);
expect(queryKeys.analytics(undefined)).toEqual(["analytics"]);
});
// v1.4.40 W-RSC — the four new factory entries from this wave each
// need a byte-stable contract so the audit-cited drift cases stay
// pinned. A future rename of any of these literals would land a
// matching test failure rather than silently breaking cache layout.
it("exposes the authMe shape so useAuth doesn't drift back to a bare literal", () => {
expect(queryKeys.authMe()).toEqual(["auth", "me"]);
});
it("exposes the userThresholds shape for Settings + targets", () => {
expect(queryKeys.userThresholds()).toEqual(["user", "thresholds"]);
});
it("packs chartData params into a stable tuple prefixed by chart-data", () => {
const key = queryKeys.chartData(
"WEIGHT",
"raw",
"no-bmi",
"Europe/Berlin",
"2026-04-01T00:00:00.000Z",
"2026-05-01T00:00:00.000Z",
);
expect(key[0]).toBe("chart-data");
expect(key.length).toBe(7);
// Numeric bmiDivisor is allowed (BMI charts pass `heightCm * heightCm`).
const bmiKey = queryKeys.chartData(
"WEIGHT",
"bmi",
30625,
"Europe/Berlin",
"2026-04-01T00:00:00.000Z",
"2026-05-01T00:00:00.000Z",
);
expect(bmiKey[3]).toBe(30625);
});
it("prefixes the dashboard medication-compliance key under one root", () => {
expect(queryKeys.dashboardMedicationCompliance(7)).toEqual([
"dashboard-medication-compliance",
7,
]);
expect(queryKeys.dashboardMedicationCompliance(30)[0]).toBe(
"dashboard-medication-compliance",
);
});
});
describe("dependent-key bundles", () => {
@@ -48,6 +95,16 @@ describe("dependent-key bundles", () => {
);
});
// v1.4.40 W-RSC — chart-data prefix now lives in the bundle so a
// fresh measurement evicts every per-chart daily-aggregate cache.
// Pre-fix the chart row stayed 60 s stale after a measurement save
// (audit-C2). Pin the bundle membership so a refactor of the chart
// queryKey factory can't silently drop the dependency.
it("measurementDependentKeys bundles the chart-data prefix (v1.4.40)", () => {
const keyStrings = measurementDependentKeys.map((k) => JSON.stringify(k));
expect(keyStrings).toContain(JSON.stringify(["chart-data"]));
});
it("moodDependentKeys bundle covers mood + analytics + targets", () => {
const keyStrings = moodDependentKeys.map((k) => JSON.stringify(k));
expect(keyStrings).toContain(JSON.stringify(["mood-entries"]));
@@ -63,6 +120,110 @@ describe("dependent-key bundles", () => {
JSON.stringify(["gamification", "achievements"]),
);
});
// v1.4.40 W-RSC — dashboard-medication-compliance prefix now lives in
// the bundle so an intake POST refreshes the dashboard chart in
// lockstep with the per-medication compliance-chart-inline tile
// (audit-L4).
it("medicationDependentKeys bundles the dashboard-medication-compliance prefix (v1.4.40)", () => {
const keyStrings = medicationDependentKeys.map((k) => JSON.stringify(k));
expect(keyStrings).toContain(JSON.stringify(["dashboard-medication-compliance"]));
});
});
/**
* v1.4.40 W-RSC — factory-bypass guard.
*
* Walks every `.ts` / `.tsx` file in `src/components/charts` and
* `src/app/page.tsx` (the dashboard + chart files we own in this wave)
* and fails if a literal `queryKey: [...]` slipped past the factory.
* Acts as a lint-style enforcement check in the absence of a custom
* ESLint rule — keeps the CI gate cheap and the failure message
* pointed at the exact file the contributor needs to fix.
*
* The audit (`.planning/round-v1439-arch-qa-frontend.md` §H1) found
* 154 bare queryKey sites at v1.4.39.3. We intentionally scope this
* guard tight to the dashboard + chart files this wave touched so a
* future wave can extend the directory list as it migrates the
* remaining sites — opt-in expansion beats a giant red CI on
* landing.
*/
describe("queryKey factory enforcement", () => {
function listFiles(dir: string): string[] {
const entries = readdirSync(dir);
const out: string[] = [];
for (const name of entries) {
const full = join(dir, name);
const st = statSync(full);
if (st.isDirectory()) {
if (name === "__tests__" || name === "node_modules") continue;
out.push(...listFiles(full));
} else if (name.endsWith(".ts") || name.endsWith(".tsx")) {
out.push(full);
}
}
return out;
}
const repoRoot = join(__dirname, "..", "..", "..");
// Files this wave migrated to the factory. Extend this list as
// future waves migrate their own surface; the audit-H1 long tail
// (admin/, settings/, medications/, integrations) stays out of the
// guard's scope until a follow-up wave routes those keys.
const guardedRoots = [
join(repoRoot, "src", "components", "charts"),
join(repoRoot, "src", "components", "comparison"),
join(repoRoot, "src", "app", "page.tsx"),
join(repoRoot, "src", "hooks", "use-auth.ts"),
];
function collect(): string[] {
const all: string[] = [];
for (const root of guardedRoots) {
const st = statSync(root);
if (st.isDirectory()) {
all.push(...listFiles(root));
} else {
all.push(root);
}
}
return all;
}
it("no guarded file declares a bare-literal `queryKey:` (factory enforcement)", () => {
const offenders: Array<{ file: string; line: number; snippet: string }> = [];
const files = collect();
for (const file of files) {
const text = readFileSync(file, "utf8");
const lines = text.split("\n");
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// Match `queryKey:` followed by a `[` on the same line. We
// explicitly allow `queryKey: queryKeys.<entry>(…)` patterns
// and any indirect identifier the call site passes through
// (a `key` local, a `listKey` constant, etc.) so the guard
// only flags the literal-array shape that drove audit-H1.
const match = line.match(/queryKey\s*:\s*\[/);
if (!match) continue;
// Strip trailing comments + JSX comments before deciding.
const trimmed = line.replace(/\/\/.*$/, "").trim();
if (!/queryKey\s*:\s*\[/.test(trimmed)) continue;
offenders.push({
file: file.slice(repoRoot.length + 1),
line: i + 1,
snippet: trimmed,
});
}
}
expect(
offenders,
`Factory bypass — every \`queryKey:\` in guarded files must go through \`queryKeys.<entry>()\`. ` +
`Offenders:\n${offenders
.map((o) => ` ${o.file}:${o.line} ${o.snippet}`)
.join("\n")}`,
).toEqual([]);
});
});
describe("invalidateKeys", () => {
+1
View File
@@ -497,6 +497,7 @@ async function buildCoachSnapshotImpl(
userId,
type: { in: wantedTypes as never[] },
measuredAt: { gte: cutoff },
deletedAt: null,
},
orderBy: { measuredAt: "asc" },
select: { type: true, value: true, measuredAt: true },
@@ -1,31 +0,0 @@
import { describe, it, expect } from "vitest";
import { berlinDayKey } from "../berlin-day";
/**
* v1.4.22 W5 reconcile (Code-MED-3) — pin that the helper buckets a
* 23:30-Berlin reading on Tuesday into the Tuesday key, regardless of
* UTC offset (CET / CEST, DST).
*/
describe("berlinDayKey", () => {
it("buckets a 23:30-Berlin reading on Tuesday into Tuesday's key (CEST)", () => {
// 2025-10-25T21:30:00Z = 2025-10-25T23:30 CEST (Saturday before DST end)
expect(berlinDayKey(new Date("2025-10-25T21:30:00Z"))).toBe("2025-10-25");
});
it("buckets a 23:30-Berlin reading after DST end into the right Berlin day", () => {
// 2025-10-26 is the DST end day in Europe/Berlin (CET takes over).
// 2025-10-26T22:30:00Z = 2025-10-26T23:30 CET — still Sunday in Berlin.
expect(berlinDayKey(new Date("2025-10-26T22:30:00Z"))).toBe("2025-10-26");
});
it("crosses the UTC-midnight boundary in Berlin's favour", () => {
// 2026-05-09T22:30:00Z falls on 2026-05-10T00:30 CEST — Berlin says
// Sunday, UTC says Saturday. The helper must follow Berlin.
expect(berlinDayKey(new Date("2026-05-09T22:30:00Z"))).toBe("2026-05-10");
});
it("returns YYYY-MM-DD format (sortable, padded)", () => {
expect(berlinDayKey(new Date("2026-01-09T12:00:00Z"))).toBe("2026-01-09");
expect(berlinDayKey(new Date("2026-12-09T12:00:00Z"))).toBe("2026-12-09");
});
});
@@ -19,7 +19,7 @@ vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
}));
vi.mock("@/lib/measurements/rollup-coverage", () => ({
vi.mock("@/lib/rollups/measurement-coverage", () => ({
isFullyCovered: vi.fn(),
probeRollupCoverage: vi.fn(),
}));
@@ -29,7 +29,7 @@ import { annotate } from "@/lib/logging/context";
import {
isFullyCovered,
probeRollupCoverage,
} from "@/lib/measurements/rollup-coverage";
} from "@/lib/rollups/measurement-coverage";
import { computeBpInTargetFastPath } from "../bp-in-target-fast-path";
const MEASUREMENT_FIND_MANY = prisma.measurement.findMany as unknown as ReturnType<typeof vi.fn>;
@@ -22,7 +22,7 @@ vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
}));
vi.mock("@/lib/measurements/rollup-coverage", () => ({
vi.mock("@/lib/rollups/measurement-coverage", () => ({
isFullyCovered: vi.fn(),
probeRollupCoverage: vi.fn(),
}));
@@ -32,7 +32,7 @@ import { annotate } from "@/lib/logging/context";
import {
isFullyCovered,
probeRollupCoverage,
} from "@/lib/measurements/rollup-coverage";
} from "@/lib/rollups/measurement-coverage";
import {
CORRELATION_WINDOW_DAYS,
computeCorrelationHypothesesFastPath,
@@ -1,10 +1,6 @@
import { describe, it, expect } from "vitest";
import type { DataPoint } from "../trends";
import {
pairByTimestamp,
pearsonCorrelation,
weeklyAverages,
} from "../correlations";
import { pairByTimestamp, pearsonCorrelation } from "../correlations";
function makePoints(values: number[], startDaysAgo = 30): DataPoint[] {
const now = Date.now();
@@ -107,57 +103,3 @@ describe("pearsonCorrelation", () => {
});
});
describe("weeklyAverages", () => {
it("returns empty for empty input", () => {
expect(weeklyAverages([])).toEqual([]);
});
it("groups data points by ISO week", () => {
const data = makePoints(
[70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83],
14,
);
const result = weeklyAverages(data);
// 14 days = 2-3 weeks
expect(result.length).toBeGreaterThanOrEqual(2);
expect(result.length).toBeLessThanOrEqual(3);
});
it("averages values within same week", () => {
const now = new Date();
// Find the Monday of current week
const day = now.getDay();
const monday = new Date(now);
monday.setDate(now.getDate() - day + (day === 0 ? -6 : 1));
monday.setHours(12, 0, 0, 0);
const tuesday = new Date(monday);
tuesday.setDate(monday.getDate() + 1);
const data: DataPoint[] = [
{ date: monday, value: 70 },
{ date: tuesday, value: 80 },
];
const result = weeklyAverages(data);
expect(result).toHaveLength(1);
expect(result[0].value).toBe(75);
});
// v3 audit caught a TZ bug: a Sunday-evening Berlin reading (e.g. 22:00
// local = 21:00 UTC in summer / 21:00 UTC in winter via DST) bucketed
// into next week on UTC servers because `Date.getDay()` was system-local.
// The Berlin-TZ-aware implementation puts the same point into THIS week.
it("buckets Sunday-evening Berlin into the same ISO week as the preceding Monday", () => {
// 2024-08-04 is a Sunday; 22:00 Berlin = 20:00 UTC (CEST). The Monday
// of the same Berlin week is 2024-07-29.
const sundayEveningBerlin = new Date("2024-08-04T20:00:00.000Z");
const mondayMidday = new Date("2024-07-29T11:00:00.000Z");
const result = weeklyAverages([
{ date: mondayMidday, value: 70 },
{ date: sundayEveningBerlin, value: 90 },
]);
expect(result).toHaveLength(1);
expect(result[0].value).toBe(80); // (70 + 90) / 2
expect(result[0].date.toISOString()).toBe("2024-07-29T00:00:00.000Z");
});
});
@@ -22,7 +22,7 @@ vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
}));
vi.mock("@/lib/measurements/rollup-coverage", () => ({
vi.mock("@/lib/rollups/measurement-coverage", () => ({
isFullyCovered: vi.fn(),
probeRollupCoverage: vi.fn(),
}));
@@ -32,7 +32,7 @@ import { annotate } from "@/lib/logging/context";
import {
isFullyCovered,
probeRollupCoverage,
} from "@/lib/measurements/rollup-coverage";
} from "@/lib/rollups/measurement-coverage";
import { computeUserHealthScoreFastPath } from "../health-score-fast-path";
const MEASUREMENT_FIND_MANY = prisma.measurement.findMany as unknown as ReturnType<typeof vi.fn>;
@@ -102,6 +102,11 @@ describe("computeUserHealthScoreFastPath", () => {
computedAt: now,
},
]);
// v1.4.40 W-WMY-WIRE — the parallel long-window read fires
// alongside the DAY-bucket read; mock its `findMany` to return
// a coverage miss so the annotate carries `weightLongWindow:
// null` and the score shape is unchanged.
ROLLUP_FIND_MANY.mockResolvedValueOnce([]);
// Weight source-attribution read (narrow 2-column).
MEASUREMENT_FIND_MANY
.mockResolvedValueOnce([
@@ -147,6 +152,170 @@ describe("computeUserHealthScoreFastPath", () => {
});
});
/**
* v1.4.40 W-WMY-WIRE — pin the long-window weight wiring through
* `readBestGranularityRollups`. The score shape is unchanged; the
* weightLongWindow annotate makes the rollup-tier consumption
* verifiable in production wide-events.
*/
describe("long-window weight wiring (W-WMY-WIRE)", () => {
it("surfaces a MONTH-granularity weightLongWindow mean on the rollup branch", async () => {
const coverage = new Map<string, boolean>([["WEIGHT", true]]);
FULLY_COVERED.mockReturnValue(true);
PROBE.mockResolvedValue(coverage);
const now = new Date("2026-05-17T12:00:00.000Z");
// DAY-bucket read (trailing 37 days) — single bucket, irrelevant
// for the long-window assertion.
ROLLUP_FIND_MANY.mockResolvedValueOnce([
{
bucketStart: new Date("2026-05-10T00:00:00.000Z"),
count: 1,
mean: 82.5,
minValue: 82.5,
maxValue: 82.5,
sd: null,
slope: null,
r2: null,
computedAt: now,
},
]);
// readBestGranularityRollups(WEIGHT, 365) — first call is the
// MONTH probe (floor 181 d). Two MONTH buckets, count-weighted
// mean = (12*82 + 18*80) / 30 = 80.8.
ROLLUP_FIND_MANY.mockResolvedValueOnce([
{
bucketStart: new Date("2025-08-01T00:00:00.000Z"),
count: 12,
mean: 82,
minValue: 80,
maxValue: 84,
sd: 1,
slope: 0,
r2: 0,
sumValue: null,
},
{
bucketStart: new Date("2025-09-01T00:00:00.000Z"),
count: 18,
mean: 80,
minValue: 78,
maxValue: 83,
sd: 1,
slope: 0,
r2: 0,
sumValue: null,
},
]);
// Source attribution + BP-SYS attribution reads.
MEASUREMENT_FIND_MANY.mockResolvedValueOnce([
{
measuredAt: new Date("2026-05-10T08:00:00.000Z"),
source: "WITHINGS",
},
]).mockResolvedValueOnce([]);
await computeUserHealthScoreFastPath({
userId: "user-longwindow",
bpInTargetPct: 85,
heightCm: 178,
now,
coverage,
});
const calls = ANNOTATE.mock.calls.map((c) => c[0]);
const pathCall = calls.find(
(c) => c?.meta?.healthScore?.path === "rollup",
);
expect(pathCall).toBeDefined();
const longWindow = pathCall?.meta?.healthScore?.weightLongWindow;
expect(longWindow).not.toBeNull();
expect(longWindow?.granularity).toBe("MONTH");
expect(longWindow?.buckets).toBe(2);
// Σ(count*mean) / Σcount = (12*82 + 18*80) / 30 = 80.8 → rounded.
expect(longWindow?.mean).toBeCloseTo(80.8, 5);
});
it("emits weightLongWindow:null when the WMY tier carries no buckets", async () => {
const coverage = new Map<string, boolean>([["WEIGHT", true]]);
FULLY_COVERED.mockReturnValue(true);
PROBE.mockResolvedValue(coverage);
const now = new Date("2026-05-17T12:00:00.000Z");
// DAY-bucket read.
ROLLUP_FIND_MANY.mockResolvedValueOnce([
{
bucketStart: new Date("2026-05-10T00:00:00.000Z"),
count: 1,
mean: 82.5,
minValue: 82.5,
maxValue: 82.5,
sd: null,
slope: null,
r2: null,
computedAt: now,
},
]);
// Long-window read — every granularity returns empty (router
// walks MONTH → WEEK → DAY; 365 d skips YEAR floor).
ROLLUP_FIND_MANY.mockResolvedValue([]);
MEASUREMENT_FIND_MANY.mockResolvedValueOnce([
{
measuredAt: new Date("2026-05-10T08:00:00.000Z"),
source: "WITHINGS",
},
]).mockResolvedValueOnce([]);
await computeUserHealthScoreFastPath({
userId: "user-no-longwindow",
bpInTargetPct: 85,
heightCm: 178,
now,
coverage,
});
const calls = ANNOTATE.mock.calls.map((c) => c[0]);
const pathCall = calls.find(
(c) => c?.meta?.healthScore?.path === "rollup",
);
expect(pathCall?.meta?.healthScore?.weightLongWindow).toBeNull();
});
it("omits the long-window read on the live fallback branch", async () => {
const coverage = new Map<string, boolean>([["WEIGHT", false]]);
FULLY_COVERED.mockReturnValue(false);
PROBE.mockResolvedValue(coverage);
const now = new Date("2026-05-17T12:00:00.000Z");
// Weight live read + BP source attribution.
MEASUREMENT_FIND_MANY.mockResolvedValueOnce([
{
measuredAt: new Date("2026-05-10T08:00:00.000Z"),
value: 82.5,
source: "MANUAL",
},
]).mockResolvedValueOnce([]);
await computeUserHealthScoreFastPath({
userId: "user-live-nowmy",
bpInTargetPct: 80,
heightCm: 178,
now,
coverage,
});
// The live branch never issues the long-window probe — guards
// against accidentally double-reading on cold mounts where the
// raw weight findMany is already doing the work.
expect(ROLLUP_FIND_MANY).not.toHaveBeenCalled();
const calls = ANNOTATE.mock.calls.map((c) => c[0]);
const pathCall = calls.find(
(c) => c?.meta?.healthScore?.path === "live",
);
expect(pathCall?.meta?.healthScore?.weightLongWindow).toBeNull();
});
});
describe("live fallback — WEIGHT not covered", () => {
it("reads raw weight rows and pins path:live", async () => {
const coverage = new Map<string, boolean>([
@@ -291,7 +291,164 @@ describe("computeSummariesSlice", () => {
// probe + narrow aggregate + latests + rollup GROUP BY
// (v1.4.37.2 — the prior `findMany` is gone). No heavy aggregate.
expect(RAW).toHaveBeenCalledTimes(4);
expect(ROLLUP_FIND_MANY).toHaveBeenCalledTimes(0);
// v1.4.40 W-WMY-WIRE — the year-ago baseline probe runs
// `readBestGranularityRollups(userId, type, 395)` per
// type-with-data via `prisma.measurementRollup.findMany`. The
// 395-day window skips the YEAR floor (731 d) and walks MONTH
// → WEEK → DAY on full coverage miss; the default mock returns
// `[]` so all three reachable tiers probe.
expect(ROLLUP_FIND_MANY).toHaveBeenCalledTimes(3);
});
});
/**
* v1.4.40 W-WMY-WIRE — pin the wiring of the WMY readers into the
* slim slice. The pre-v1.4.40 shape hardcoded `avg30LastYear` to
* `null`; we now populate it from `readBestGranularityRollups` when
* the YEAR / MONTH / WEEK / DAY tier carries buckets that overlap
* the `[now-395d, now-365d)` slice.
*/
describe("year-over-year wiring (avg30LastYear)", () => {
it("populates avg30LastYear from MONTH buckets that overlap the year-ago slice", async () => {
// Coverage probe shows WEIGHT covered → rollup happy path.
RAW.mockResolvedValueOnce([{ type: "WEIGHT", has_buckets: true }])
.mockResolvedValueOnce([
{
type: "WEIGHT",
avg7: 82,
avg30: 82.5,
slope7: 0,
r2_7: 0,
slope30: 0,
r2_30: 0,
slope90: 0,
r2_90: 0,
},
])
.mockResolvedValueOnce([
{ type: "WEIGHT", value: 82.7, measured_at: new Date() },
])
.mockResolvedValueOnce([
{ type: "WEIGHT", count: 5, min: 82, max: 84, mean: 83 },
]);
// Year-ago slice MONTH bucket — `bucketStart` placed 380 days
// ago so it falls inside `[now-395d, now-365d)`. Single bucket
// (count=10, mean=85) → weighted mean = 85.
const yearAgoBucketStart = new Date(
Date.now() - 380 * 24 * 60 * 60 * 1000,
);
ROLLUP_FIND_MANY.mockResolvedValueOnce([
{
bucketStart: yearAgoBucketStart,
count: 10,
mean: 85,
sd: 1,
slope: 0,
r2: 0,
sumValue: null,
minValue: 83,
maxValue: 87,
},
]);
const result = await computeSummariesSlice("user-yoy");
expect(result.summaries.WEIGHT.avg30LastYear).toBe(85);
// Router asked MONTH first (395 > 181, 395 < 731 → skip YEAR).
expect(ROLLUP_FIND_MANY.mock.calls[0][0].where.granularity).toBe("MONTH");
});
it("leaves avg30LastYear null when no bucket overlaps the year-ago slice", async () => {
RAW.mockResolvedValueOnce([{ type: "WEIGHT", has_buckets: true }])
.mockResolvedValueOnce([
{
type: "WEIGHT",
avg7: 82,
avg30: 82.5,
slope7: 0,
r2_7: 0,
slope30: 0,
r2_30: 0,
slope90: 0,
r2_90: 0,
},
])
.mockResolvedValueOnce([
{ type: "WEIGHT", value: 82.7, measured_at: new Date() },
])
.mockResolvedValueOnce([
{ type: "WEIGHT", count: 5, min: 82, max: 84, mean: 83 },
]);
// MONTH bucket placed 30 days ago — inside the YEAR / MONTH /
// WEEK 395-day window the router asks for, but outside the
// `[now-395d, now-365d)` slice. The helper returns null.
const recentBucket = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
ROLLUP_FIND_MANY.mockResolvedValueOnce([
{
bucketStart: recentBucket,
count: 10,
mean: 82,
sd: 1,
slope: 0,
r2: 0,
sumValue: null,
minValue: 80,
maxValue: 84,
},
]);
const result = await computeSummariesSlice("user-recent");
expect(result.summaries.WEIGHT.avg30LastYear).toBeNull();
});
it("leaves avg30LastYear null when every granularity misses (no coverage)", async () => {
RAW.mockResolvedValueOnce([{ type: "WEIGHT", has_buckets: true }])
.mockResolvedValueOnce([
{
type: "WEIGHT",
avg7: 82,
avg30: 82.5,
slope7: 0,
r2_7: 0,
slope30: 0,
r2_30: 0,
slope90: 0,
r2_90: 0,
},
])
.mockResolvedValueOnce([
{ type: "WEIGHT", value: 82.7, measured_at: new Date() },
])
.mockResolvedValueOnce([
{ type: "WEIGHT", count: 5, min: 82, max: 84, mean: 83 },
]);
// Default mock returns `[]` for every findMany call → router
// walks YEAR → MONTH → WEEK → DAY and gives up.
ROLLUP_FIND_MANY.mockResolvedValue([]);
const result = await computeSummariesSlice("user-empty-yoy");
expect(result.summaries.WEIGHT.avg30LastYear).toBeNull();
// 395d window skips YEAR (floor 731 d) → MONTH → WEEK → DAY,
// three reachable tiers all probed on full coverage miss.
expect(ROLLUP_FIND_MANY).toHaveBeenCalledTimes(3);
});
it("only probes types that actually have data in the current window", async () => {
// Coverage probe + rollup GROUP BY both report zero rows → no
// types-with-data, so the year-ago probe must NOT fan out.
RAW.mockResolvedValueOnce([])
.mockResolvedValueOnce([])
.mockResolvedValueOnce([])
.mockResolvedValueOnce([]);
await computeSummariesSlice("user-no-data");
expect(ROLLUP_FIND_MANY).not.toHaveBeenCalled();
});
});
});
@@ -1,6 +1,5 @@
import { describe, it, expect } from "vitest";
import {
movingAverage,
trendSlope,
detectAnomalies,
summarize,
@@ -15,27 +14,6 @@ function makePoints(values: number[], startDaysAgo = 30): DataPoint[] {
}));
}
describe("movingAverage", () => {
it("returns empty for empty input", () => {
expect(movingAverage([], 7)).toEqual([]);
});
it("calculates moving average over window", () => {
const data = makePoints([70, 71, 72, 73, 74, 75, 76], 7);
const result = movingAverage(data, 3);
expect(result).toHaveLength(7);
// Last point should be avg of last 3 days
expect(result[result.length - 1].value).toBeCloseTo(75, 0);
});
it("handles single data point", () => {
const data = makePoints([100], 1);
const result = movingAverage(data, 7);
expect(result).toHaveLength(1);
expect(result[0].value).toBe(100);
});
});
describe("trendSlope", () => {
it("returns null for insufficient data", () => {
expect(trendSlope([], 7)).toBeNull();
-35
View File
@@ -1,35 +0,0 @@
/**
* v1.4.22 W5 reconcile (Code-MED-1) — shared Berlin-day-key helper.
*
* The CLAUDE.md timezone convention is "Europe/Berlin for display,
* UTC in database". The dashboard analytics route used a private
* `berlinDayKey()` (defined in `src/app/api/analytics/route.ts:330`)
* to bucket measurements by Berlin calendar day. The targets route's
* `sparklinePoints()` was bucketing by UTC `YYYY-MM-DD` — a 23:30
* Berlin reading on Tuesday landed in Wednesday's bucket, drifting
* the sparkline 1 day on cross-DST boundaries.
*
* Lift the helper into `src/lib/analytics/` so every display surface
* shares the same bucket contract.
*/
const BERLIN_DATE_PARTS = new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/Berlin",
year: "numeric",
month: "2-digit",
day: "2-digit",
});
/**
* Format a `Date` as a `YYYY-MM-DD` string anchored to Europe/Berlin
* calendar day. Used as a stable key for daily aggregation so a
* 23:30-Berlin reading on Tuesday lands in Tuesday's bucket
* regardless of UTC offset (CET / CEST, DST).
*/
export function berlinDayKey(d: Date): string {
const parts = BERLIN_DATE_PARTS.formatToParts(d);
const y = parts.find((p) => p.type === "year")?.value ?? "1970";
const m = parts.find((p) => p.type === "month")?.value ?? "01";
const day = parts.find((p) => p.type === "day")?.value ?? "01";
return `${y}-${m}-${day}`;
}
+3 -3
View File
@@ -52,11 +52,11 @@
*/
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import { readRollupBuckets } from "@/lib/measurements/rollups";
import { readRollupBuckets } from "@/lib/rollups/measurement-rollups";
import {
probeRollupCoverage,
type RollupCoverageMap,
} from "@/lib/measurements/rollup-coverage";
} from "@/lib/rollups/measurement-coverage";
import { isNearUtc } from "@/lib/tz/resolver";
import {
computeBpInTargetWindows,
@@ -432,7 +432,7 @@ async function fetchSeriesChunked(
let cursorId: string | undefined;
for (let page = 0; page < 1000; page++) {
const chunk = await prisma.measurement.findMany({
where: { userId, type, measuredAt: { gte: since } },
where: { userId, type, measuredAt: { gte: since }, deletedAt: null },
orderBy: [{ measuredAt: "asc" }, { id: "asc" }],
select: { id: true, measuredAt: true, value: true },
take: CHUNK,
+3 -3
View File
@@ -45,11 +45,11 @@
*/
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import { readRollupBuckets } from "@/lib/measurements/rollups";
import { readRollupBuckets } from "@/lib/rollups/measurement-rollups";
import {
probeRollupCoverage,
type RollupCoverageMap,
} from "@/lib/measurements/rollup-coverage";
} from "@/lib/rollups/measurement-coverage";
import {
correlateBpCompliance,
correlateMoodPulse,
@@ -342,7 +342,7 @@ async function fetchSeriesChunked(
let cursorId: string | undefined;
for (let page = 0; page < 1000; page++) {
const chunk = await prisma.measurement.findMany({
where: { userId, type, measuredAt: { gte: since } },
where: { userId, type, measuredAt: { gte: since }, deletedAt: null },
orderBy: [{ measuredAt: "asc" }, { id: "asc" }],
select: { id: true, measuredAt: true, value: true },
take: CHUNK,
-66
View File
@@ -105,69 +105,3 @@ export function pearsonCorrelation(
return { r, strength, n };
}
const BERLIN_DATE_PARTS = new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/Berlin",
year: "numeric",
month: "2-digit",
day: "2-digit",
weekday: "short",
});
/**
* Aggregate data into weekly averages, ISO-Monday-based, in Europe/Berlin.
*
* v3 audit caught a TZ bug here: the previous implementation used
* `Date.getDay()` / `Date.getDate()` (system local) so on a UTC server, a
* Sunday-evening Berlin reading bucketed into the next week. We now derive
* year/month/day/weekday in Berlin via Intl.DateTimeFormat and rebuild the
* Monday key from there. The returned `date` for each bucket is the Monday
* UTC midnight — sortable, comparable across DST.
*/
export function weeklyAverages(data: DataPoint[]): DataPoint[] {
if (data.length === 0) return [];
const sorted = [...data].sort((a, b) => a.date.getTime() - b.date.getTime());
const weeks = new Map<string, { sum: number; count: number; date: Date }>();
const WEEKDAY_INDEX: Record<string, number> = {
Mon: 1,
Tue: 2,
Wed: 3,
Thu: 4,
Fri: 5,
Sat: 6,
Sun: 7,
};
for (const point of sorted) {
const parts = BERLIN_DATE_PARTS.formatToParts(point.date);
const yearStr = parts.find((p) => p.type === "year")?.value ?? "1970";
const monthStr = parts.find((p) => p.type === "month")?.value ?? "01";
const dayStr = parts.find((p) => p.type === "day")?.value ?? "01";
const weekdayStr = parts.find((p) => p.type === "weekday")?.value ?? "Mon";
const isoWeekday = WEEKDAY_INDEX[weekdayStr] ?? 1;
// Days to subtract to land on Monday of the same ISO week in Berlin.
const offsetDays = isoWeekday - 1;
const dayUtc = new Date(`${yearStr}-${monthStr}-${dayStr}T00:00:00.000Z`);
const monday = new Date(dayUtc.getTime() - offsetDays * 86_400_000);
const key = monday.toISOString().slice(0, 10);
const existing = weeks.get(key);
if (existing) {
existing.sum += point.value;
existing.count++;
} else {
weeks.set(key, { sum: point.value, count: 1, date: monday });
}
}
return Array.from(weeks.values())
.sort((a, b) => a.date.getTime() - b.date.getTime())
.map((w) => ({
date: w.date,
value: Math.round((w.sum / w.count) * 100) / 100,
}));
}
+67 -9
View File
@@ -40,11 +40,12 @@
*/
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import { readRollupBuckets } from "@/lib/measurements/rollups";
import { readRollupBuckets } from "@/lib/rollups/measurement-rollups";
import {
probeRollupCoverage,
type RollupCoverageMap,
} from "@/lib/measurements/rollup-coverage";
} from "@/lib/rollups/measurement-coverage";
import { readBestGranularityRollups } from "@/lib/rollups/measurement-read-wmy";
import { calculateCompliance } from "./compliance";
import {
computeHealthScore,
@@ -159,19 +160,38 @@ export async function computeUserHealthScoreFastPath(
let weightSeriesPrev30d: Array<{ date: string; kg: number }>;
let weightSourcesIn30d: ReadonlyArray<ContributingSource>;
let latestWeightAsOf: string | null;
// v1.4.40 W-WMY-WIRE — long-window weight baseline served from the
// WEEK / MONTH / YEAR rollup tier. Used for the diagnostic annotate
// so operators can verify the WMY readers actually serve production
// traffic instead of sitting as dead write amplification (see
// `.planning/round-v1438-perf-analysis.md` §2 + §5 P6). The score
// shape stays unchanged; the long-window mean is annotate-only so
// the v1.5 Coach drawer can opt into it later without breaking the
// current contract.
let weightLongWindowGranularity: string | null = null;
let weightLongWindowMean: number | null = null;
let weightLongWindowBucketCount = 0;
if (weightCovered) {
// Rollup-fast-path — per-day MEAN weight from `measurement_rollups`.
// Linear regression on per-day means produces a slope equivalent
// to per-event regression for a series with consistent sampling
// cadence (one weigh-in per day is the canonical pattern).
const dayBuckets = await readRollupBuckets(
userId,
"WEIGHT",
"DAY",
prevSince30d,
now,
);
//
// v1.4.40 W-WMY-WIRE — the long-window weight read runs in
// parallel with the canonical DAY-bucket read so the wall-clock
// cost stays flat. `readBestGranularityRollups(..., 365)` routes
// the trailing-year window through MONTH (floor 181 d) by default
// — typically 12 monthly buckets vs the ~365 DAY rows the live
// path would scan. count / mean are linearly composable across
// MONTH buckets so the derived mean is mathematically equivalent
// to the per-row average over the same year-long window.
// Coverage-fallback in the helper means a tenant with WEEK or DAY
// coverage but no MONTH buckets still resolves to a usable mean.
const [dayBuckets, longWindow] = await Promise.all([
readRollupBuckets(userId, "WEIGHT", "DAY", prevSince30d, now),
readBestGranularityRollups(userId, "WEIGHT", 365),
]);
weightSeriesLast30d = dayBuckets
.filter((b) => b.bucketStart >= since30d)
.map((b) => ({
@@ -184,6 +204,19 @@ export async function computeUserHealthScoreFastPath(
date: b.bucketStart.toISOString(),
kg: b.mean,
}));
if (longWindow && longWindow.rows.length > 0) {
weightLongWindowGranularity = longWindow.granularity;
weightLongWindowBucketCount = longWindow.rows.length;
let totalCount = 0;
let weighted = 0;
for (const row of longWindow.rows) {
totalCount += row.count;
weighted += row.count * row.mean;
}
if (totalCount > 0) {
weightLongWindowMean = weighted / totalCount;
}
}
// Source attribution still needs the raw rows — only the raw table
// carries the `source` enum. Pull the narrow 30-day window with a
// 2-column projection so the round-trip stays minimal.
@@ -192,6 +225,7 @@ export async function computeUserHealthScoreFastPath(
userId,
type: "WEIGHT",
measuredAt: { gte: since30d, lte: now },
deletedAt: null,
},
select: { measuredAt: true, source: true },
orderBy: { measuredAt: "asc" },
@@ -206,6 +240,7 @@ export async function computeUserHealthScoreFastPath(
userId,
type: "WEIGHT",
measuredAt: { gte: prevSince30d, lte: now },
deletedAt: null,
},
select: { value: true, measuredAt: true, source: true },
orderBy: { measuredAt: "asc" },
@@ -235,6 +270,7 @@ export async function computeUserHealthScoreFastPath(
userId,
type: "BLOOD_PRESSURE_SYS",
measuredAt: { gte: since30d, lte: now },
deletedAt: null,
},
select: { measuredAt: true, source: true },
orderBy: { measuredAt: "asc" },
@@ -335,6 +371,14 @@ export async function computeUserHealthScoreFastPath(
score: null,
reason: "no_components_available",
path: weightCovered ? "rollup" : "live",
weightLongWindow:
weightLongWindowMean !== null
? {
mean: Math.round(weightLongWindowMean * 100) / 100,
granularity: weightLongWindowGranularity,
buckets: weightLongWindowBucketCount,
}
: null,
},
},
});
@@ -403,6 +447,20 @@ export async function computeUserHealthScoreFastPath(
band: result.band,
delta: result.delta,
path: weightCovered ? "rollup" : "live",
// v1.4.40 W-WMY-WIRE — the long-window weight baseline drawn
// from the WEEK / MONTH / YEAR rollup tier. `null` when the
// user lacks long-tail coverage or when the live fallback
// branch ran (no probe issued). Surfaces the granularity the
// router landed on so operators can see "rollup tier served
// the year-long mean from MONTH buckets" in the wide-event.
weightLongWindow:
weightLongWindowMean !== null
? {
mean: Math.round(weightLongWindowMean * 100) / 100,
granularity: weightLongWindowGranularity,
buckets: weightLongWindowBucketCount,
}
: null,
},
},
});
+143 -3
View File
@@ -59,16 +59,18 @@ import { prisma } from "@/lib/db";
import type { DataSummary } from "@/lib/analytics/trends";
import { measurementTypeEnum } from "@/lib/validations/measurement";
import { annotate } from "@/lib/logging/context";
import { ensureUserRollupsFresh } from "@/lib/measurements/rollups";
import { ensureUserRollupsFresh } from "@/lib/rollups/measurement-rollups";
import {
isFullyCovered,
probeRollupCoverage,
} from "@/lib/measurements/rollup-coverage";
} from "@/lib/rollups/measurement-coverage";
import {
aggregateWmyBuckets,
readBestGranularityRollups,
type RollupBucketRow,
} from "@/lib/measurements/rollup-read-wmy";
} from "@/lib/rollups/measurement-read-wmy";
const DAY_MS = 24 * 60 * 60 * 1000;
/**
* Heavy aggregate row — used on the cold-mount fallback path where the
@@ -301,6 +303,7 @@ async function computeFromRollups(userId: string): Promise<SummariesSlice> {
)::double precision AS r2_90
FROM measurements m
WHERE m."user_id" = ${userId}
AND m."deleted_at" IS NULL
GROUP BY m."type"
`,
prisma.$queryRaw<LatestRow[]>`
@@ -310,6 +313,7 @@ async function computeFromRollups(userId: string): Promise<SummariesSlice> {
m."measured_at" AS measured_at
FROM measurements m
WHERE m."user_id" = ${userId}
AND m."deleted_at" IS NULL
ORDER BY m."type", m."measured_at" DESC
`,
// v1.4.37.2 hotfix — the v1.4.35 implementation read EVERY DAY
@@ -388,10 +392,12 @@ async function computeFromRollups(userId: string): Promise<SummariesSlice> {
let totalRows = 0;
let typeCount = 0;
const typesWithData: string[] = [];
for (const row of dayBuckets) {
if (!row.count || row.count <= 0) continue;
typeCount += 1;
totalRows += row.count;
typesWithData.push(row.type);
const narrow = narrowByType.get(row.type);
const latest = latestByType.get(row.type) ?? null;
summaries[row.type] = {
@@ -411,6 +417,21 @@ async function computeFromRollups(userId: string): Promise<SummariesSlice> {
};
}
// v1.4.40 W-WMY-WIRE — populate `avg30LastYear` per type from the
// WMY rollup tier. Only types with data in the current window are
// probed so we don't pay the per-type round-trip on the long tail
// of unlogged measurement enums. Types without YEAR/MONTH coverage
// surface as `null` (existing behaviour) — additive only.
const avg30LastYearMap = await computeAvg30LastYearMap(userId, typesWithData);
let yearOverYearTypeCount = 0;
for (const [type, value] of avg30LastYearMap.entries()) {
if (value === null) continue;
const summary = summaries[type];
if (!summary) continue;
summary.avg30LastYear = round2(value);
yearOverYearTypeCount += 1;
}
annotate({
action: { name: "analytics.get.slim" },
meta: {
@@ -419,6 +440,7 @@ async function computeFromRollups(userId: string): Promise<SummariesSlice> {
row_count: totalRows,
type_count: typeCount,
path: "rollup",
year_over_year_types: yearOverYearTypeCount,
},
},
},
@@ -487,6 +509,7 @@ async function computeFromLiveAggregate(
)::double precision AS r2_90
FROM measurements m
WHERE m."user_id" = ${userId}
AND m."deleted_at" IS NULL
GROUP BY m."type"
`,
prisma.$queryRaw<LatestRow[]>`
@@ -496,6 +519,7 @@ async function computeFromLiveAggregate(
m."measured_at" AS measured_at
FROM measurements m
WHERE m."user_id" = ${userId}
AND m."deleted_at" IS NULL
ORDER BY m."type", m."measured_at" DESC
`,
]);
@@ -531,9 +555,11 @@ async function computeFromLiveAggregate(
}
let totalRows = 0;
const typesWithData: string[] = [];
for (const row of aggregates) {
const count = Number(row.count);
totalRows += count;
typesWithData.push(row.type);
const latest = latestByType.get(row.type) ?? null;
summaries[row.type] = {
count,
@@ -552,6 +578,23 @@ async function computeFromLiveAggregate(
};
}
// v1.4.40 W-WMY-WIRE — even on the live-aggregate fallback (DAY
// coverage incomplete), the YEAR / MONTH rollup tier may still
// carry the year-ago baseline because the boot-time backfill mints
// all granularities once per user. Probing here means a partial-
// coverage cold mount still surfaces `avg30LastYear` whenever the
// long-tail tier is populated, instead of waiting for the next
// refresh cycle.
const avg30LastYearMap = await computeAvg30LastYearMap(userId, typesWithData);
let yearOverYearTypeCount = 0;
for (const [type, value] of avg30LastYearMap.entries()) {
if (value === null) continue;
const summary = summaries[type];
if (!summary) continue;
summary.avg30LastYear = round2(value);
yearOverYearTypeCount += 1;
}
annotate({
action: { name: "analytics.get.slim" },
meta: {
@@ -560,6 +603,7 @@ async function computeFromLiveAggregate(
row_count: totalRows,
type_count: aggregates.length,
path: "live",
year_over_year_types: yearOverYearTypeCount,
},
},
},
@@ -660,3 +704,99 @@ export async function computeLongWindowSummary(
sum: aggregate.sum,
};
}
/**
* v1.4.40 W-WMY-WIRE — year-ago 30-day baseline per type, served from
* the WEEK / MONTH / YEAR rollup tier.
*
* Why this helper exists
* ----------------------
* `DataSummary.avg30LastYear` is the mean over `[now-395d, now-365d)` —
* the 30-day window starting 365 days ago. The dashboard tile-strip's
* delta callout uses it to narrate "current avg vs same time last
* year". Up to v1.4.39 the slim slice (and the comprehensive
* aggregator's rollup branch) hardcoded the field to `null` because
* the 90-day windowed `$queryRaw` cannot reach back a year, and the
* live `measurements` table walk for a 395-day per-type window is
* exactly the cold-path cost we're trying to avoid.
*
* The WMY rollup tier already carries the per-bucket stats this
* window needs. `readBestGranularityRollups(userId, type, 395)`
* routes the 395-day window through YEAR (if covered) → MONTH → WEEK
* → DAY and returns the buckets whose `bucketStart` falls inside the
* window. We then keep only the buckets that overlap the
* `[now-395d, now-365d)` slice and compose `count / mean` linearly to
* produce the year-ago baseline.
*
* Compositional contract
* ----------------------
* `count / mean` are linearly composable across any granularity, so
* the year-ago mean derived from MONTH or YEAR buckets is
* mathematically equivalent to the per-row average over the same
* window. The bucket-overlap filter is conservative: we include only
* buckets whose `bucketStart` is strictly inside the slice. On a
* YEAR-granularity routing this collapses to at most one bucket and
* approximates the 30-day mean by the surrounding yearly average —
* acceptable for a UI hint that explicitly narrates "last year".
*
* Coverage-miss policy
* --------------------
* Returns `null` when `readBestGranularityRollups` returns null (no
* coverage at any granularity) OR when no buckets overlap the
* year-ago slice. The caller leaves the dashboard field as `null`,
* which the UI already handles as "no comparison available".
*/
async function computeAvg30LastYearForType(
userId: string,
type: MeasurementType,
): Promise<number | null> {
const resolved = await readBestGranularityRollups(userId, type, 395);
if (!resolved) return null;
const now = Date.now();
const sliceStart = now - 395 * DAY_MS;
const sliceEnd = now - 365 * DAY_MS;
const overlapping = resolved.rows.filter((row) => {
const t = row.bucketStart.getTime();
return t >= sliceStart && t < sliceEnd;
});
if (overlapping.length === 0) return null;
let totalCount = 0;
let weighted = 0;
for (const row of overlapping) {
totalCount += row.count;
weighted += row.count * row.mean;
}
if (totalCount === 0) return null;
return weighted / totalCount;
}
/**
* v1.4.40 W-WMY-WIRE — fan out `computeAvg30LastYearForType` across
* the types the caller actually surfaces. Runs the per-type WMY reads
* in parallel so the long-tail of types doesn't serialise into a
* per-type round-trip stack.
*
* Returns a `type → mean | null` map; types with no coverage at any
* granularity (or no buckets in the year-ago slice) map to `null` so
* the caller can blanket-assign without per-type null-checking.
*/
async function computeAvg30LastYearMap(
userId: string,
types: ReadonlyArray<string>,
): Promise<Map<string, number | null>> {
const out = new Map<string, number | null>();
if (types.length === 0) return out;
const results = await Promise.all(
types.map(async (type) => {
const value = await computeAvg30LastYearForType(
userId,
type as MeasurementType,
);
return [type, value] as const;
}),
);
for (const [type, value] of results) {
out.set(type, value);
}
return out;
}
-27
View File
@@ -8,33 +8,6 @@ export interface DataPoint {
value: number;
}
// ── Moving Average ───────────────────────────────────────
export function movingAverage(
data: DataPoint[],
windowDays: number,
): DataPoint[] {
if (data.length === 0) return [];
const sorted = [...data].sort((a, b) => a.date.getTime() - b.date.getTime());
const result: DataPoint[] = [];
const windowMs = windowDays * 24 * 60 * 60 * 1000;
for (const point of sorted) {
const windowStart = point.date.getTime() - windowMs;
const windowPoints = sorted.filter(
(p) =>
p.date.getTime() > windowStart &&
p.date.getTime() <= point.date.getTime(),
);
const avg =
windowPoints.reduce((sum, p) => sum + p.value, 0) / windowPoints.length;
result.push({ date: point.date, value: Math.round(avg * 100) / 100 });
}
return result;
}
// ── Trend Slope ──────────────────────────────────────────
export interface TrendSlope {
+123
View File
@@ -0,0 +1,123 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
consentReceipt: {
create: vi.fn(),
findFirst: vi.fn(),
findMany: vi.fn(),
update: vi.fn(),
},
},
}));
import { prisma } from "@/lib/db";
import {
createReceipt,
latestActiveReceipt,
latestActiveReceiptsByKind,
revokeLatest,
} from "../receipts";
beforeEach(() => {
vi.resetAllMocks();
});
function row(overrides: Partial<Record<string, unknown>> = {}) {
return {
id: "rcpt-1",
userId: "user-1",
kind: "ai_full",
artefact: "PDF…",
signedAt: new Date("2026-05-18T10:00:00.000Z"),
revokedAt: null,
createdAt: new Date("2026-05-18T10:00:01.000Z"),
...overrides,
};
}
describe("createReceipt", () => {
it("inserts a row scoped to (userId, kind) with the artefact + signedAt", async () => {
vi.mocked(prisma.consentReceipt.create).mockResolvedValue(row() as never);
const signedAt = new Date("2026-05-18T10:00:00.000Z");
const result = await createReceipt("user-1", "ai_full", "PDF…", signedAt);
expect(prisma.consentReceipt.create).toHaveBeenCalledWith({
data: {
userId: "user-1",
kind: "ai_full",
artefact: "PDF…",
signedAt,
},
});
expect(result.id).toBe("rcpt-1");
});
});
describe("latestActiveReceipt", () => {
it("filters by user + kind + revokedAt:null and orders by createdAt desc", async () => {
vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue(row() as never);
const result = await latestActiveReceipt("user-1", "ai_full");
expect(prisma.consentReceipt.findFirst).toHaveBeenCalledWith({
where: { userId: "user-1", kind: "ai_full", revokedAt: null },
orderBy: { createdAt: "desc" },
});
expect(result?.id).toBe("rcpt-1");
});
it("returns null when no active receipt exists", async () => {
vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue(null);
const result = await latestActiveReceipt("user-1", "ai_full");
expect(result).toBeNull();
});
});
describe("latestActiveReceiptsByKind", () => {
it("returns the freshest row per kind (first-wins over createdAt desc)", async () => {
vi.mocked(prisma.consentReceipt.findMany).mockResolvedValue([
row({ id: "fresh-coach", kind: "ai_coach" }),
row({ id: "fresh-full", kind: "ai_full" }),
// Older `ai_full` row — must be ignored because we already saw
// the freshest one above (the mock list is pre-sorted desc).
row({ id: "older-full", kind: "ai_full" }),
] as never);
const result = await latestActiveReceiptsByKind("user-1");
expect(result.ai_full?.id).toBe("fresh-full");
expect(result.ai_coach?.id).toBe("fresh-coach");
expect(result.ai_insights_only).toBeUndefined();
});
});
describe("revokeLatest", () => {
it("flips revokedAt on the latest active row and returns the updated row", async () => {
vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue(row() as never);
vi.mocked(prisma.consentReceipt.update).mockResolvedValue(
row({ revokedAt: new Date("2026-05-18T11:00:00.000Z") }) as never,
);
const now = new Date("2026-05-18T11:00:00.000Z");
const result = await revokeLatest("user-1", "ai_full", now);
expect(prisma.consentReceipt.update).toHaveBeenCalledWith({
where: { id: "rcpt-1" },
data: { revokedAt: now },
});
expect(result?.revokedAt).toEqual(now);
});
it("returns null without writing when no active receipt exists", async () => {
vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue(null);
const result = await revokeLatest("user-1", "ai_full");
expect(result).toBeNull();
expect(prisma.consentReceipt.update).not.toHaveBeenCalled();
});
});
+105
View File
@@ -0,0 +1,105 @@
/**
* v1.4.40 SB-10 — AI consent receipts CRUD helper.
*
* Append-only audit trail. Every grant + revoke mints a fresh row;
* no updates, no deletes outside of cascade-on-user-delete. The
* `revokeLatest` helper writes the current row's `revokedAt` and
* returns it — that single field flip is the only mutation the
* helper performs. A subsequent re-grant inserts a new row, so the
* historical chain stays intact for legal review.
*
* Source of truth for "is AI active right now?" is
* `latestActiveReceipt(userId, kind)` returning non-null. Reader
* routes never inspect older rows.
*/
import { prisma } from "@/lib/db";
import type { ConsentReceiptModel } from "@/generated/prisma/models/ConsentReceipt";
import type { ConsentKind } from "@/lib/validations/consent";
export type ConsentReceipt = ConsentReceiptModel;
/**
* Insert a fresh consent receipt for (userId, kind). Always appends;
* never collides with an earlier row. The caller has already
* Zod-validated `artefact` (≤ 64 KB) and `signedAt` (ISO-8601).
*/
export async function createReceipt(
userId: string,
kind: ConsentKind,
artefact: string,
signedAt: Date,
): Promise<ConsentReceipt> {
return prisma.consentReceipt.create({
data: {
userId,
kind,
artefact,
signedAt,
},
});
}
/**
* Return the most recent non-revoked receipt for (userId, kind), or
* `null` if none exists. Ordering is by `createdAt DESC` — the same
* index direction declared on the schema, so the planner walks the
* compound index forward without a sort.
*
* "Non-revoked" = `revokedAt IS NULL`. A revoked row never resurrects;
* a new grant must mint a new row.
*/
export async function latestActiveReceipt(
userId: string,
kind: ConsentKind,
): Promise<ConsentReceipt | null> {
return prisma.consentReceipt.findFirst({
where: { userId, kind, revokedAt: null },
orderBy: { createdAt: "desc" },
});
}
/**
* Return the latest non-revoked receipt across every kind for the
* user, keyed by kind. Used by `GET /api/consent/ai/latest` when the
* caller omits the `?kind` query param.
*/
export async function latestActiveReceiptsByKind(
userId: string,
): Promise<Partial<Record<ConsentKind, ConsentReceipt>>> {
const rows = await prisma.consentReceipt.findMany({
where: { userId, revokedAt: null },
orderBy: { createdAt: "desc" },
});
// First-wins reduce over `createdAt DESC`. `findMany` returns every
// historical grant for the user; we keep the freshest per kind.
const out: Partial<Record<ConsentKind, ConsentReceipt>> = {};
for (const r of rows) {
const k = r.kind as ConsentKind;
if (!(k in out)) out[k] = r;
}
return out;
}
/**
* Mark the latest active receipt for (userId, kind) as revoked.
* Returns the updated row, or `null` if no active receipt exists.
*
* Implementation note: a two-step "read latest → update by id" is
* race-tolerant for the single-user revoke flow (iOS toggle is a
* per-device action; no concurrent revoke from a second device is
* plausible inside the same millisecond). If two devices race we end
* up writing `revokedAt` to the same row twice with identical
* timestamps — semantically idempotent.
*/
export async function revokeLatest(
userId: string,
kind: ConsentKind,
now: Date = new Date(),
): Promise<ConsentReceipt | null> {
const latest = await latestActiveReceipt(userId, kind);
if (!latest) return null;
return prisma.consentReceipt.update({
where: { id: latest.id },
data: { revokedAt: now },
});
}
+31
View File
@@ -5,9 +5,40 @@ const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
/**
* v1.4.40 W-POOL — Prisma `pg.Pool` ceiling raised from the library
* default of 10 → 20.
*
* The v1.4.39 empirical cold-mount trace
* (`.planning/round-v1439-empirical-trace.md` § B2) showed thick
* `/api/analytics` holding ≥ 8 of the 10 default pool slots for
* 6.5 s on Marc's 347k-row tenant, starving every other Wave-B and
* Wave-C useQuery fan-out for the duration. Coolify's Postgres
* container runs the stock `max_connections` 100 ceiling, so a
* 20-slot Node-side pool sits well under the server's hard cap with
* plenty of headroom for concurrent power-users (≤ 5 simultaneous
* tenants × 20 = 100). Complements the W-POOL `p-limit(4)` cap on
* the analytics fan-out itself — the bounded concurrency keeps any
* single analytics call to ≤ 4 slots, so the remaining 16 stay
* available for every other dashboard query.
*
* Env-overridable via `DATABASE_POOL_MAX` so an operator running
* with a smaller Postgres `max_connections` can dial down without a
* code change.
*/
export function getPoolMax(): number {
const raw = process.env.DATABASE_POOL_MAX;
if (raw) {
const parsed = Number.parseInt(raw, 10);
if (Number.isFinite(parsed) && parsed > 0) return parsed;
}
return 20;
}
function createPrismaClient() {
const adapter = new PrismaPg({
connectionString: process.env.DATABASE_URL!,
max: getPoolMax(),
});
return new PrismaClient({ adapter });
}
-4
View File
@@ -15,10 +15,6 @@ export function mgdlToMmol(mgdl: number): number {
return Math.round((mgdl / MGDL_PER_MMOL) * 10) / 10;
}
export function mmolToMgdl(mmol: number): number {
return Math.round(mmol * MGDL_PER_MMOL);
}
export function convertGlucose(value: number, to: GlucoseUnit): number {
return to === "mmol/L" ? mgdlToMmol(value) : Math.round(value);
}
@@ -1,69 +0,0 @@
/**
* v1.4.27 B6 — `format.dateShort` / `timeShort` / `dateTime` ordering.
*
* Each shipped locale carries a `format.*` namespace that documents
* its native date-pattern ordering as i18n strings. The actual
* formatting still routes through `Intl.*` via
* `src/lib/format-locale.ts`; the format keys exist so downstream
* surfaces (PDF export, email templates, CSV docstrings) can render
* the date hint without a JS context.
*
* This smoke test pins the ordering per locale so a copy-paste
* regression that flips DD/MM to MM/DD on the French bundle would
* fail fast.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
const MESSAGES = join(__dirname, "../../../../messages");
function loadFormat(locale: string): {
dateShort: string;
timeShort: string;
dateTime: string;
} {
const bundle = JSON.parse(
readFileSync(join(MESSAGES, `${locale}.json`), "utf8"),
);
const fmt = bundle.format as
| { dateShort?: string; timeShort?: string; dateTime?: string }
| undefined;
if (!fmt || !fmt.dateShort || !fmt.timeShort || !fmt.dateTime) {
throw new Error(`Locale ${locale} is missing the format namespace`);
}
return fmt as { dateShort: string; timeShort: string; dateTime: string };
}
describe("locale-native format ordering", () => {
it("DE renders day-month-year with dot separators", () => {
const fmt = loadFormat("de");
expect(fmt.dateShort).toBe("{day}.{month}.{year}");
expect(fmt.dateTime).toBe("{day}.{month}.{year} {hour}:{minute}");
expect(fmt.timeShort).toBe("{hour}:{minute}");
});
it("EN renders month-day-year with slash separators", () => {
const fmt = loadFormat("en");
expect(fmt.dateShort).toBe("{month}/{day}/{year}");
expect(fmt.dateTime).toBe("{month}/{day}/{year} {hour}:{minute}");
expect(fmt.timeShort).toBe("{hour}:{minute}");
});
it.each([["fr"], ["es"], ["it"]])(
"%s renders day-month-year with slash separators (native ordering)",
(locale) => {
const fmt = loadFormat(locale);
expect(fmt.dateShort).toBe("{day}/{month}/{year}");
expect(fmt.dateTime).toBe("{day}/{month}/{year} {hour}:{minute}");
expect(fmt.timeShort).toBe("{hour}:{minute}");
},
);
it("PL renders day-month-year with dot separators (native ordering)", () => {
const fmt = loadFormat("pl");
expect(fmt.dateShort).toBe("{day}.{month}.{year}");
expect(fmt.dateTime).toBe("{day}.{month}.{year} {hour}:{minute}");
expect(fmt.timeShort).toBe("{hour}:{minute}");
});
});
+28 -2
View File
@@ -5,12 +5,28 @@ vi.mock("@/lib/db", () => ({
user: { findUnique: vi.fn() },
measurement: { findMany: vi.fn() },
measurementRollup: { findMany: vi.fn() },
moodEntry: { findMany: vi.fn() },
moodEntry: { findMany: vi.fn(), findFirst: vi.fn() },
moodEntryRollup: { findMany: vi.fn(), findFirst: vi.fn() },
medication: { findMany: vi.fn() },
medicationIntakeEvent: { findMany: vi.fn() },
$queryRawUnsafe: vi.fn(),
},
}));
// Stub the mood-rollup warm-up so test runs don't fire the real
// recompute aggregate. The warm-up is fire-and-forget on the route.
vi.mock("@/lib/rollups/mood-rollups", async () => {
const actual = await vi.importActual<typeof import("@/lib/rollups/mood-rollups")>(
"@/lib/rollups/mood-rollups",
);
return {
...actual,
ensureUserMoodRollupsFresh: vi
.fn()
.mockResolvedValue({ recomputed: false }),
};
});
vi.mock("@/lib/medication-category", () => ({
getMedicationCategories: vi.fn(async () => ({})),
}));
@@ -27,7 +43,14 @@ const prismaMock = prisma as unknown as {
user: { findUnique: ReturnType<typeof vi.fn> };
measurement: { findMany: ReturnType<typeof vi.fn> };
measurementRollup: { findMany: ReturnType<typeof vi.fn> };
moodEntry: { findMany: ReturnType<typeof vi.fn> };
moodEntry: {
findMany: ReturnType<typeof vi.fn>;
findFirst: ReturnType<typeof vi.fn>;
};
moodEntryRollup: {
findMany: ReturnType<typeof vi.fn>;
findFirst: ReturnType<typeof vi.fn>;
};
medication: { findMany: ReturnType<typeof vi.fn> };
medicationIntakeEvent: { findMany: ReturnType<typeof vi.fn> };
};
@@ -58,6 +81,9 @@ beforeEach(() => {
prismaMock.measurement.findMany.mockResolvedValue([]);
prismaMock.measurementRollup.findMany.mockResolvedValue([]);
prismaMock.moodEntry.findMany.mockResolvedValue([]);
prismaMock.moodEntry.findFirst.mockResolvedValue(null);
prismaMock.moodEntryRollup.findMany.mockResolvedValue([]);
prismaMock.moodEntryRollup.findFirst.mockResolvedValue(null);
prismaMock.medication.findMany.mockResolvedValue([]);
prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([]);
});
+12 -3
View File
@@ -100,12 +100,12 @@
import { prisma } from "@/lib/db";
import type { DataSummary } from "@/lib/analytics/trends";
import { annotate } from "@/lib/logging/context";
import { ensureUserRollupsFresh } from "@/lib/measurements/rollups";
import { aggregateBuckets } from "@/lib/measurements/rollup-read";
import { ensureUserRollupsFresh } from "@/lib/rollups/measurement-rollups";
import { aggregateBuckets } from "@/lib/rollups/measurement-read";
import {
isFullyCovered,
probeRollupCoverage,
} from "@/lib/measurements/rollup-coverage";
} from "@/lib/rollups/measurement-coverage";
/**
* Heavy aggregate row — count/min/max/mean alongside the non-composable
@@ -310,6 +310,7 @@ async function fetchBpRawRows(
userId,
type: { in: ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"] },
measuredAt: { gte: ninetyDaysAgo },
deletedAt: null,
},
orderBy: { measuredAt: "asc" },
select: { type: true, measuredAt: true, value: true },
@@ -348,6 +349,7 @@ async function buildFromRollups(
FROM measurements m
WHERE m."user_id" = ${userId}
AND m."measured_at" >= ${ninetyDaysAgo}
AND m."deleted_at" IS NULL
GROUP BY m."type"
)
SELECT
@@ -414,6 +416,7 @@ async function buildFromRollups(
JOIN window_stats ws ON ws."type" = m."type"
WHERE m."user_id" = ${userId}
AND m."measured_at" >= ${ninetyDaysAgo}
AND m."deleted_at" IS NULL
GROUP BY m."type", ws.stddev_value
`),
timeSubquery(timings, "latests", () => prisma.$queryRaw<LatestRow[]>`
@@ -424,6 +427,7 @@ async function buildFromRollups(
FROM measurements m
WHERE m."user_id" = ${userId}
AND m."measured_at" >= ${ninetyDaysAgo}
AND m."deleted_at" IS NULL
ORDER BY m."type", m."measured_at" DESC
`),
// v1.4.35 — DAY rollup buckets for the trailing 90 days, every
@@ -450,6 +454,7 @@ async function buildFromRollups(
FROM measurements m
WHERE m."user_id" = ${userId}
AND m."measured_at" >= ${ninetyDaysAgo}
AND m."deleted_at" IS NULL
`),
]);
@@ -536,6 +541,7 @@ async function buildFromLiveAggregate(
FROM measurements m
WHERE m."user_id" = ${userId}
AND m."measured_at" >= ${ninetyDaysAgo}
AND m."deleted_at" IS NULL
GROUP BY m."type"
)
SELECT
@@ -602,6 +608,7 @@ async function buildFromLiveAggregate(
JOIN window_stats ws ON ws."type" = m."type"
WHERE m."user_id" = ${userId}
AND m."measured_at" >= ${ninetyDaysAgo}
AND m."deleted_at" IS NULL
GROUP BY m."type", ws.stddev_value
`),
timeSubquery(timings, "latests", () => prisma.$queryRaw<LatestRow[]>`
@@ -612,6 +619,7 @@ async function buildFromLiveAggregate(
FROM measurements m
WHERE m."user_id" = ${userId}
AND m."measured_at" >= ${ninetyDaysAgo}
AND m."deleted_at" IS NULL
ORDER BY m."type", m."measured_at" DESC
`),
// Buckets may exist for some types even when the COUNT probe came
@@ -672,6 +680,7 @@ async function buildFromLiveAggregate(
FROM measurements m
WHERE m."user_id" = ${userId}
AND m."measured_at" >= ${ninetyDaysAgo}
AND m."deleted_at" IS NULL
`,
);
firstMeasurementAt = firstRows[0]?.first_at
+111 -27
View File
@@ -15,7 +15,11 @@ import {
type CorrelationResult,
} from "@/lib/analytics/correlations";
import { getMedicationCategories } from "@/lib/medication-category";
import { readRollupBuckets } from "@/lib/measurements/rollups";
import { readRollupBuckets } from "@/lib/rollups/measurement-rollups";
import {
ensureUserMoodRollupsFresh,
readMoodDayRollups,
} from "@/lib/rollups/mood-rollups";
import type {
MeasurementType,
RollupGranularity,
@@ -373,8 +377,8 @@ export async function extractFeatures(
: null;
const measurements = await prisma.measurement.findMany({
where: sinceCutoff
? { userId, measuredAt: { gte: sinceCutoff } }
: { userId },
? { userId, measuredAt: { gte: sinceCutoff }, deletedAt: null }
: { userId, deletedAt: null },
orderBy: { measuredAt: "asc" },
});
@@ -583,24 +587,104 @@ export async function extractFeatures(
}
// Mood
const moodEntries = await prisma.moodEntry.findMany({
where: { userId },
orderBy: { moodLoggedAt: "asc" },
});
//
// v1.4.40 — swap the unbounded `prisma.moodEntry.findMany` for the
// persistent mood-rollup DAY tier (audit Critical Finding #2). The
// feature block downstream consumes:
// - `avg7` / `avg30` over recent rollup means
// - `trend30` from first-half vs second-half of last 30 days
// - `latest` = newest individual entry score (one bounded row)
// - `totalEntries` = sum of rollup `count` across all DAY rows
// - `oldest` / `newest` = first and last rollup `bucketStart`
// - `moodPoints` for cross-metric correlations (one per-day mean
// DataPoint — same resolution as the legacy raw-score series for
// single-entry-per-day power users, slightly different on
// multi-entry days, which is the rollup-tier semantic the
// v1.4.39 `/api/mood/analytics` already shipped)
//
// Coverage-fallback: when raw entries exist but the rollup tier is
// still empty (legacy account before boot-time backfill caught up),
// we fall back to a bounded 1-year raw walk and fire the warm-up so
// the next request lands on the rollup tier. Same posture as
// `/api/mood/analytics`.
void ensureUserMoodRollupsFresh(userId);
// Five-year window mirrors the mood-rollup writer default; covers
// every realistic user history span without an unbounded scan.
const moodSince = new Date(Date.now() - 5 * 365 * 24 * 60 * 60 * 1000);
const moodRollupDayRows = await readMoodDayRollups(userId, moodSince);
if (moodEntries.length > 0) {
type MoodDayPoint = {
measuredAt: Date;
value: number;
count: number;
};
let moodDailyPoints: MoodDayPoint[] = [];
let moodLatestScore: number | null = null;
let moodTotalEntries = 0;
if (moodRollupDayRows.length > 0) {
moodDailyPoints = moodRollupDayRows.map((r) => ({
measuredAt: r.bucketStart,
value: r.mean,
count: r.count,
}));
moodTotalEntries = moodRollupDayRows.reduce((s, r) => s + r.count, 0);
// Latest score = newest individual entry (one bounded row).
const latestEntry = await prisma.moodEntry.findFirst({
where: { userId },
orderBy: { moodLoggedAt: "desc" },
select: { score: true },
});
moodLatestScore = latestEntry?.score ?? null;
} else {
// Coverage-fallback. Bounded 1-year walk (instead of the legacy
// unbounded `findMany`) so even a cache miss is capped.
const oneYearAgo = new Date(Date.now() - 365 * 24 * 60 * 60 * 1000);
const moodEntriesRaw = await prisma.moodEntry.findMany({
where: { userId, moodLoggedAt: { gte: oneYearAgo } },
orderBy: { moodLoggedAt: "asc" },
select: { score: true, moodLoggedAt: true, date: true },
});
if (moodEntriesRaw.length > 0) {
// Bucket per `date` (TZ-anchored YYYY-MM-DD label, same key the
// rollup tier would emit for Berlin-anchored single-entry days)
// so the fallback shape matches the rollup-tier shape.
const byDay = new Map<string, { sum: number; count: number; ts: Date }>();
for (const e of moodEntriesRaw) {
const k = e.date;
const cur = byDay.get(k) ?? { sum: 0, count: 0, ts: e.moodLoggedAt };
cur.sum += e.score;
cur.count += 1;
// Pin the bucket's measuredAt to the latest entry's timestamp
// — close enough for the trailing-window filters below.
if (e.moodLoggedAt > cur.ts) cur.ts = e.moodLoggedAt;
byDay.set(k, cur);
}
moodDailyPoints = Array.from(byDay.values())
.map((b) => ({
measuredAt: b.ts,
value: b.sum / b.count,
count: b.count,
}))
.sort((a, b) => a.measuredAt.getTime() - b.measuredAt.getTime());
moodTotalEntries = moodEntriesRaw.length;
moodLatestScore = moodEntriesRaw[moodEntriesRaw.length - 1].score;
}
}
if (moodDailyPoints.length > 0) {
const moodNow = Date.now();
const last7 = moodEntries.filter(
(e) => moodNow - e.moodLoggedAt.getTime() < 7 * 24 * 60 * 60 * 1000,
const last7 = moodDailyPoints.filter(
(e) => moodNow - e.measuredAt.getTime() < 7 * 24 * 60 * 60 * 1000,
);
const last30 = moodEntries.filter(
(e) => moodNow - e.moodLoggedAt.getTime() < 30 * 24 * 60 * 60 * 1000,
const last30 = moodDailyPoints.filter(
(e) => moodNow - e.measuredAt.getTime() < 30 * 24 * 60 * 60 * 1000,
);
const avg = (entries: typeof moodEntries) =>
entries.length > 0
const avg = (points: MoodDayPoint[]) =>
points.length > 0
? Math.round(
(entries.reduce((s, e) => s + e.score, 0) / entries.length) * 100,
(points.reduce((s, e) => s + e.value, 0) / points.length) * 100,
) / 100
: null;
@@ -608,10 +692,10 @@ export async function extractFeatures(
let trend30: "improving" | "declining" | "stable" | null = null;
if (last30.length >= 4) {
const firstHalf = last30.filter(
(e) => moodNow - e.moodLoggedAt.getTime() >= 15 * 24 * 60 * 60 * 1000,
(e) => moodNow - e.measuredAt.getTime() >= 15 * 24 * 60 * 60 * 1000,
);
const secondHalf = last30.filter(
(e) => moodNow - e.moodLoggedAt.getTime() < 15 * 24 * 60 * 60 * 1000,
(e) => moodNow - e.measuredAt.getTime() < 15 * 24 * 60 * 60 * 1000,
);
if (firstHalf.length >= 2 && secondHalf.length >= 2) {
const avgFirst = avg(firstHalf)!;
@@ -623,25 +707,25 @@ export async function extractFeatures(
}
}
const oldest = moodEntries[0].moodLoggedAt;
const newest = moodEntries[moodEntries.length - 1].moodLoggedAt;
const oldest = moodDailyPoints[0].measuredAt;
const newest = moodDailyPoints[moodDailyPoints.length - 1].measuredAt;
const spanDays = Math.round(
(newest.getTime() - oldest.getTime()) / (24 * 60 * 60 * 1000),
);
const avgDaysBetween =
moodEntries.length > 1
? Math.round((spanDays / (moodEntries.length - 1)) * 10) / 10
moodTotalEntries > 1
? Math.round((spanDays / (moodTotalEntries - 1)) * 10) / 10
: null;
features.mood = {
scale: "1=LAUSIG, 2=SCHLECHT, 3=OKAY, 4=GUT, 5=SUPER_GUT",
avg7: avg(last7),
avg30: avg(last30),
latest: moodEntries[moodEntries.length - 1].score,
latest: moodLatestScore,
trend30,
totalEntries: moodEntries.length,
totalEntries: moodTotalEntries,
coverage: {
count: moodEntries.length,
count: moodTotalEntries,
spanDays,
avgDaysBetween,
oldestDaysAgo: Math.round(
@@ -659,9 +743,9 @@ export async function extractFeatures(
const sysPoints = toDataPoints(sysData);
const diaPoints = toDataPoints(diaData);
const pulsePoints = toDataPoints(pulseData);
const moodPoints: DataPoint[] = moodEntries.map((e) => ({
date: e.moodLoggedAt,
value: e.score,
const moodPoints: DataPoint[] = moodDailyPoints.map((e) => ({
date: e.measuredAt,
value: e.value,
}));
const computeCorr = (a: DataPoint[], b: DataPoint[]) =>
+1
View File
@@ -74,6 +74,7 @@ export async function detectGlp1Plateau(
userId,
type: "WEIGHT",
measuredAt: { gte: windowStart },
deletedAt: null,
},
orderBy: { measuredAt: "asc" },
select: { value: true, measuredAt: true },
+1
View File
@@ -158,6 +158,7 @@ export async function generatePulseStatusForUser(
where: {
userId,
type: "PULSE",
deletedAt: null,
},
orderBy: { measuredAt: "desc" },
take: 365,
+1 -1
View File
@@ -36,7 +36,7 @@ import {
type ImportJobProgress,
type ImportJobResult,
} from "@/lib/measurements/import-apple-health-export";
import { recomputeUserRollups } from "@/lib/measurements/rollups";
import { recomputeUserRollups } from "@/lib/rollups/measurement-rollups";
/** Queue name for the Apple Health export ingest. */
export const APPLE_HEALTH_IMPORT_QUEUE = "apple-health-import";
+3 -47
View File
@@ -66,7 +66,7 @@ import {
recomputeUserRollups,
type RollupFullBackfillPayload,
type RollupRecomputePayload,
} from "@/lib/measurements/rollups";
} from "@/lib/rollups/measurement-rollups";
import {
MOOD_ROLLUP_FULL_BACKFILL_QUEUE,
MOOD_ROLLUP_FULL_BACKFILL_CONCURRENCY,
@@ -76,7 +76,7 @@ import {
recomputeUserMoodRollups,
type MoodRollupFullBackfillPayload,
type MoodRollupRecomputePayload,
} from "@/lib/mood/rollups";
} from "@/lib/rollups/mood-rollups";
import {
MEDICATION_COMPLIANCE_BACKFILL_QUEUE,
MEDICATION_COMPLIANCE_BACKFILL_CONCURRENCY,
@@ -84,7 +84,7 @@ import {
recomputeUserMedicationCompliance,
enqueueBootTimeMedicationComplianceBackfill,
type MedicationComplianceBackfillPayload,
} from "@/lib/medications/compliance-rollups";
} from "@/lib/rollups/medication-compliance-rollups";
import {
drainPerSampleCumulative,
DRAIN_CUMULATIVE_CUTOFF_HOURS,
@@ -161,7 +161,6 @@ const BMI_STATUS_CRON = "20 2 * * *"; // daily at 02:20
const MEDICATION_COMPLIANCE_STATUS_QUEUE =
"insights-medication-compliance-status";
const MEDICATION_COMPLIANCE_STATUS_CRON = "25 2 * * *"; // daily at 02:25
const TELEGRAM_CLEANUP_QUEUE = "telegram-message-cleanup";
const MOODLOG_SYNC_QUEUE = "moodlog-sync";
const MOODLOG_SYNC_CRON = "30 * * * *"; // every hour at :30
const DATA_BACKUP_QUEUE = "data-backup";
@@ -278,12 +277,6 @@ interface MedicationComplianceStatusPayload {
triggeredAt: string;
}
interface TelegramCleanupPayload {
userId: string;
chatId: string;
messageId: number;
}
interface MoodLogSyncPayload {
triggeredAt: string;
}
@@ -1091,37 +1084,6 @@ async function handleMedicationComplianceStatusGenerate(
);
}
/**
* Delete a Telegram message after a 24h delay.
* Scheduled by the Telegram sender when a notification is sent.
*/
async function handleTelegramCleanup(jobs: Job<TelegramCleanupPayload>[]) {
await withBackgroundEvent("job.telegram_cleanup", async (evt) => {
const prisma = getWorkerPrisma();
let deleted = 0;
for (const job of jobs) {
try {
const { userId, chatId, messageId } = job.data;
const user = await prisma.user.findFirst({
where: { id: userId, telegramBotToken: { not: null } },
select: { telegramBotToken: true },
});
if (user?.telegramBotToken) {
const botToken = decrypt(user.telegramBotToken);
await deleteMessage(botToken, chatId, messageId);
deleted++;
}
} catch (err) {
evt.addWarning(`Failed to delete message: ${err}`);
}
}
evt.setBackground({
task_name: "job.telegram_cleanup",
result: { deleted, total: jobs.length },
});
});
}
/**
* Fallback polling for moodLog data.
* Syncs mood entries for all users with moodLog enabled.
@@ -1669,7 +1631,6 @@ export async function startReminderWorker() {
PULSE_STATUS_QUEUE,
BMI_STATUS_QUEUE,
MEDICATION_COMPLIANCE_STATUS_QUEUE,
TELEGRAM_CLEANUP_QUEUE,
MOODLOG_SYNC_QUEUE,
DATA_BACKUP_QUEUE,
RATE_LIMIT_CLEANUP_QUEUE,
@@ -1829,11 +1790,6 @@ export async function startReminderWorker() {
{ localConcurrency: 1 },
handleMedicationComplianceStatusGenerate,
);
await boss.work<TelegramCleanupPayload>(
TELEGRAM_CLEANUP_QUEUE,
{ localConcurrency: 1 },
handleTelegramCleanup,
);
await boss.work<MoodLogSyncPayload>(
MOODLOG_SYNC_QUEUE,
{ localConcurrency: 1 },
+4 -63
View File
@@ -28,6 +28,7 @@
*/
import { parseScheduleRecurrence } from "@/lib/medication-schedule";
import { wallClockInTz } from "@/lib/tz/wall-clock";
export interface ScheduleLike {
/** "HH:mm" 24h, user-tz reference (per existing schema). */
@@ -78,69 +79,9 @@ const WEEK_MS = 7 * DAY_MS;
*/
const PAIR_RADIUS_MS = 12 * 60 * 60 * 1000;
interface WallClockParts {
year: number;
month: number; // 1-12
day: number;
hour: number;
minute: number;
second: number;
weekday: number; // 0 = Sunday
}
const WEEKDAY_MAP: Record<string, number> = {
Sun: 0,
Mon: 1,
Tue: 2,
Wed: 3,
Thu: 4,
Fri: 5,
Sat: 6,
};
/**
* Compute the wall-clock parts of `date` interpreted in the user's
* timezone. When `tz` is omitted, falls back to the system-local
* representation so the legacy single-tz callers keep their existing
* shape (the v1.4.25 W19e callers were system-local by construction).
*/
function wallClockInTz(date: Date, tz: string | undefined): WallClockParts {
if (!tz) {
return {
year: date.getFullYear(),
month: date.getMonth() + 1,
day: date.getDate(),
hour: date.getHours(),
minute: date.getMinutes(),
second: date.getSeconds(),
weekday: date.getDay(),
};
}
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: tz,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
weekday: "short",
}).formatToParts(date);
const get = (type: Intl.DateTimeFormatPartTypes): string =>
parts.find((p) => p.type === type)?.value ?? "0";
let hour = Number(get("hour"));
if (hour === 24) hour = 0;
return {
year: Number(get("year")),
month: Number(get("month")),
day: Number(get("day")),
hour,
minute: Number(get("minute")),
second: Number(get("second")),
weekday: WEEKDAY_MAP[get("weekday")] ?? 0,
};
}
// WallClockParts + wallClockInTz live in `@/lib/tz/wall-clock` as the
// canonical helper — see the file-level comment there for the v1.4.40
// consolidation rationale.
/**
* Compute the UTC offset (in minutes) of `tz` at the given instant.
+1 -1
View File
@@ -7,7 +7,7 @@ import {
recordSyncFailure,
recordSyncSuccess,
} from "@/lib/integrations/status";
import { recomputeUserMoodRollups } from "@/lib/mood/rollups";
import { recomputeUserMoodRollups } from "@/lib/rollups/mood-rollups";
/**
* Sync mood entries from a user's moodLog instance.
@@ -41,6 +41,8 @@ vi.mock("@parse/node-apn", () => {
public payload: unknown;
public category: string | undefined;
public mutableContent: boolean | undefined;
public interruptionLevel: string | undefined;
public priority: number | undefined;
constructor() {
notificationCtorMock();
}
@@ -466,6 +468,72 @@ describe("sendViaApns — dispatcher fan-out", () => {
expect(note.payload.scheduledAt).toBe("2026-05-17T20:00:00.000Z");
});
it("MEDICATION_REMINDER sets interruption-level=time-sensitive + priority=10", async () => {
// SB-5 v1.4.40 — medication reminders are the one category the user
// explicitly opts into Focus-bypass for. The aps payload must carry
// BOTH `interruption-level: time-sensitive` AND `apns-priority: 10`
// per Apple's push best-practices doc; otherwise APNs may downgrade
// delivery and the Focus-bypass intent is silently lost.
vi.mocked(prisma.device.findMany).mockResolvedValueOnce([
{ id: "d1", apnsToken: "tok-a", apnsEnvironment: "sandbox" },
] as never);
sendMock.mockResolvedValueOnce({ sent: [{ device: "tok-a" }], failed: [] });
await sendViaApns("u-1", {
title: "Take Mounjaro",
message: "10mg, weekly dose",
eventType: "MEDICATION_REMINDER",
});
const note = sendMock.mock.calls[0][0];
expect(note.interruptionLevel).toBe("time-sensitive");
expect(note.priority).toBe(10);
});
it.each([
"MOOD_REMINDER",
"MEASUREMENT_ANOMALY",
"COMPLIANCE_LOW",
"WITHINGS_SYNC_FAILED",
"SYSTEM_ALERT",
"PERSONAL_RECORD",
])("eventType %s does NOT set interruption-level (Focus respected)", async (eventType) => {
// Every non-MEDICATION_REMINDER event-type must omit the
// interruption-level so the system default (`active`) lets Focus
// modes — Sleep, Do-Not-Disturb, Personal — silence the alert as
// the user expects.
vi.mocked(prisma.device.findMany).mockResolvedValueOnce([
{ id: "d1", apnsToken: "tok-a", apnsEnvironment: "sandbox" },
] as never);
sendMock.mockResolvedValueOnce({ sent: [{ device: "tok-a" }], failed: [] });
await sendViaApns("u-1", {
title: "t",
message: "m",
eventType,
});
const note = sendMock.mock.calls[0][0];
expect(note.interruptionLevel).toBeUndefined();
// priority too — only opt in for time-sensitive deliveries.
expect(note.priority).toBeUndefined();
});
it("forwards explicit interruptionLevel on sendApnsPush", async () => {
// Lower-level entry should round-trip the explicit flag — caller
// controls the level when bypassing `sendViaApns`.
setEnv(VALID_ENV);
sendMock.mockResolvedValueOnce({ sent: [{ device: "abc" }], failed: [] });
await sendApnsPush({
deviceToken: "abc",
environment: "sandbox",
payload: {
alert: { title: "t", body: "b" },
interruptionLevel: "time-sensitive",
priority: 10,
},
});
const note = sendMock.mock.calls[0][0];
expect(note.interruptionLevel).toBe("time-sensitive");
expect(note.priority).toBe(10);
});
it("forwards explicit category override on sendApnsPush", async () => {
// Lower-level entry — directly setting `payload.category` must
// propagate to `note.category` so callers that bypass `sendViaApns`
+58
View File
@@ -59,6 +59,26 @@ export interface ApnsPayload {
* roundtrip.
*/
mutableContent?: boolean;
/**
* APNs `aps.interruption-level` (iOS 15+). Controls whether the alert
* bypasses Focus modes:
* - `passive` — silent, no banner; lives in Notification Center.
* - `active` — default; banner + sound; honours Focus.
* - `time-sensitive` — breaks through Focus modes (per-app opt-in by
* the user on iOS); used for things the user explicitly asked to be
* interrupted for. **Restricted to MEDICATION_REMINDER** in the
* dispatcher; other categories must NOT bypass Focus.
* - `critical` — entitlement-gated, ignores DND completely; not
* used by HealthLog.
* Omit for the iOS default (`active`).
*/
interruptionLevel?: "passive" | "active" | "time-sensitive" | "critical";
/**
* APNs `apns-priority` header. `10` = immediate delivery (required for
* time-sensitive interruption-level per Apple's push best-practices),
* `5` = power-conserving. Omit to let node-apn default to `10`.
*/
priority?: 5 | 10;
}
export interface ApnsSendInput {
@@ -269,6 +289,30 @@ export async function sendApnsPush(
// unlocks the iOS Notification Service Extension hook.
note.mutableContent = true;
}
if (input.payload.interruptionLevel) {
// node-apn's `interruptionLevel` setter writes through to
// `aps.interruption-level`. iOS 15+ reads it to decide whether the
// alert breaks through Focus modes — `time-sensitive` is the level
// that does so, gated on a per-app toggle the user grants on
// install. Older iOS versions ignore the key, so this is safe to
// send unconditionally.
//
// The cast mirrors the `category` setter above — node-apn 8.1's
// d.ts omits the public setter (the runtime setter on
// `apsProperties.js` writes the value through to
// `aps.interruption-level` unchanged), so we assign via a typed
// cast rather than poking `note.aps` directly. Forward-compatible
// with future d.ts fixes.
(note as unknown as { interruptionLevel: string }).interruptionLevel =
input.payload.interruptionLevel;
}
if (typeof input.payload.priority === "number") {
// node-apn writes this through to the `apns-priority` HTTP header.
// Apple's push best-practices doc requires priority 10 alongside
// `time-sensitive` interruption-level — otherwise APNs may downgrade
// delivery and the Focus-bypass intent is lost.
note.priority = input.payload.priority;
}
if (input.collapseId) {
note.collapseId = input.collapseId;
}
@@ -386,6 +430,14 @@ export async function sendViaApns(
const env =
device.apnsEnvironment === "production" ? "production" : "sandbox";
// SB-5 v1.4.40 — only MEDICATION_REMINDER opts in to Focus-bypass.
// The user explicitly asked the server to nudge them about a dose;
// a missed dose is the textbook "time-sensitive" use case Apple
// sanctions. Every other event-type (mood nudges, anomaly alerts,
// PRs, system messages) must stay on the default `active` level so
// Focus modes — including Sleep — silence them as the user expects.
const isTimeSensitive = payload.eventType === "MEDICATION_REMINDER";
const result = await sendApnsPush({
deviceToken: device.apnsToken,
environment: env,
@@ -407,6 +459,12 @@ export async function sendViaApns(
// no-op on today's binary but unblocks NSE work without a
// server-side change.
mutableContent: true,
...(isTimeSensitive
? {
interruptionLevel: "time-sensitive" as const,
priority: 10 as const,
}
: {}),
},
collapseId: payload.eventType,
});
+77 -2
View File
@@ -11,7 +11,24 @@ import type { QueryClient, QueryKey } from "@tanstack/react-query";
export const queryKeys = {
auth: () => ["auth"] as const,
/**
* v1.4.40 W-RSC — the `useAuth` hook's `["auth", "me"]` shape was a
* recurring source of factory drift (audit H1 — "`useAuth` uses
* `["auth", "me"]` but `queryKeys.auth()` returns `["auth"]`"). Both
* shapes share the `["auth"]` prefix so existing prefix-invalidations
* still match, but the centralised name makes the call site
* obviously factory-routed.
*/
authMe: () => ["auth", "me"] as const,
authRegistrationStatus: () => ["auth", "registration-status"] as const,
/**
* v1.4.40 W-RSC — Settings → AI surfaces and the targets editor
* subscribe to the user-thresholds API. Centralise the key so a
* future rename (e.g. `["user","limits"]`) doesn't drift across the
* three call sites (settings/thresholds-editor-section,
* settings/ai-section, targets/target-edit-sheet).
*/
userThresholds: () => ["user", "thresholds"] as const,
measurements: () => ["measurements"] as const,
moodEntries: () => ["mood-entries"] as const,
@@ -56,9 +73,20 @@ export const queryKeys = {
medicationDetail: (id: string) => ["medications", id] as const,
medicationComplianceChart: (medicationId: string) =>
["compliance-chart-inline", medicationId] as const,
/**
* v1.4.40 W-RSC — the dashboard-level compliance chart (aggregate
* across every scheduled medication) was a bare `["medication-
* compliance-chart", days]` key; route it through the factory so
* `medicationDependentKeys` invalidates it on intake-mutation just
* like the per-medication compliance-chart-inline tile. `days` is the
* range (7 / 30 / 90); kept as the only param so the prefix
* `["dashboard-medication-compliance"]` invalidates every range at
* once.
*/
dashboardMedicationCompliance: (days: number) =>
["dashboard-medication-compliance", days] as const,
medicationPhaseConfig: (medicationId: string) =>
["phase-config", medicationId] as const,
medicationIntakeSummary: () => ["medications", "intake-summary"] as const,
gamificationAchievements: () => ["gamification", "achievements"] as const,
@@ -107,11 +135,51 @@ export const queryKeys = {
* immediately.
*/
sourcePriority: () => ["auth", "source-priority"] as const,
/**
* v1.4.40 W-RSC — per-chart daily-aggregate fetch from the dashboard
* + insights chart row. Pre-fix the key was bare `["chart-data", …]`
* across the codebase, which excluded it from
* `measurementDependentKeys` and left chart caches stale for up to
* 60 s after a measurement save (audit-C2). Routing through the
* factory pulls every variant under a single
* `["chart-data"]` invalidation prefix so a mutation refreshes the
* tile strip + the chart row in lockstep.
*
* The shape carries the heavy parameter list because the chart query
* is bounded by metric set, value mode, BMI divisor, timezone, and
* fetch window; the factory packs those into a single tuple to keep
* the cache layout byte-identical with the pre-v1.4.40 layout.
*/
chartData: (
types: string,
valueMode: string,
bmiDivisor: string | number,
timezone: string,
fromIso: string,
toIso: string,
) =>
[
"chart-data",
types,
valueMode,
bmiDivisor,
timezone,
fromIso,
toIso,
] as const,
};
/**
* Keys that should be invalidated when a measurement is created, updated or
* deleted. Kept here so dashboards, insights, and targets always stay in sync.
*
* v1.4.40 W-RSC — `["chart-data"]` prefix now lives in the bundle so a
* fresh measurement evicts every per-chart daily-aggregate cache. The
* prefix matches every key returned from `queryKeys.chartData(…)` via
* TanStack's hierarchical-prefix semantics — adding a measurement now
* refreshes the tile strip *and* the chart row in lockstep instead of
* leaving the chart row 60 s stale (audit C2).
*/
export const measurementDependentKeys = [
queryKeys.measurements(),
@@ -119,6 +187,7 @@ export const measurementDependentKeys = [
queryKeys.insightsRoot(),
queryKeys.insightsTargets(),
queryKeys.gamificationAchievements(),
["chart-data"] as const,
];
/**
@@ -135,14 +204,20 @@ export const moodDependentKeys = [
/**
* Keys invalidated when medications change (CRUD or intake).
*
* v1.4.40 W-RSC — the dashboard's aggregate compliance chart now
* rides the factory under `dashboardMedicationCompliance`. The prefix
* `["dashboard-medication-compliance"]` lands in the bundle so an
* intake POST refreshes the chart immediately rather than waiting for
* `staleTime` (audit L4).
*/
export const medicationDependentKeys = [
queryKeys.medications(),
queryKeys.analytics(),
queryKeys.insightsRoot(),
queryKeys.insightsTargets(),
queryKeys.medicationIntakeSummary(),
queryKeys.gamificationAchievements(),
["dashboard-medication-compliance"] as const,
];
/**
@@ -24,9 +24,8 @@ import { prisma } from "@/lib/db";
import {
isCumulativeType,
readCumulativeDaySums,
readCumulativeDaySumsBatch,
resolveBucketSum,
} from "../rollup-read-cumulative";
} from "../measurement-read-cumulative";
beforeEach(() => {
mocks.findMany.mockReset();
@@ -112,64 +111,6 @@ describe("readCumulativeDaySums", () => {
});
});
describe("readCumulativeDaySumsBatch", () => {
it("groups by type and seeds empty arrays for missing types", async () => {
const since = new Date("2026-04-15T00:00:00.000Z");
mocks.findMany.mockResolvedValueOnce([
{
type: "ACTIVITY_STEPS",
bucketStart: new Date("2026-04-15T00:00:00.000Z"),
sumValue: 8120,
count: 4,
mean: 2030,
},
{
type: "ACTIVITY_STEPS",
bucketStart: new Date("2026-04-16T00:00:00.000Z"),
sumValue: 12480,
count: 5,
mean: 2496,
},
{
type: "FLIGHTS_CLIMBED",
bucketStart: new Date("2026-04-15T00:00:00.000Z"),
sumValue: 22,
count: 3,
mean: 7.33,
},
]);
const out = await readCumulativeDaySumsBatch(
"user-1",
["ACTIVITY_STEPS", "FLIGHTS_CLIMBED", "TIME_IN_DAYLIGHT"],
since,
);
expect(out.get("ACTIVITY_STEPS")).toHaveLength(2);
expect(out.get("FLIGHTS_CLIMBED")).toHaveLength(1);
// Type with zero rollup rows still surfaces as an empty array so
// the caller can avoid a `.get(...) ?? []` fallback per branch.
expect(out.get("TIME_IN_DAYLIGHT")).toEqual([]);
// Single findMany over the IN-list — replaces the per-type loop
// that drove the A2 cold path on Marc's tenant.
expect(prisma.measurementRollup.findMany).toHaveBeenCalledTimes(1);
const args = mocks.findMany.mock.calls[0][0];
expect(args.where.type).toEqual({
in: ["ACTIVITY_STEPS", "FLIGHTS_CLIMBED", "TIME_IN_DAYLIGHT"],
});
});
it("returns an empty map without querying when types is empty", async () => {
const out = await readCumulativeDaySumsBatch(
"user-1",
[],
new Date("2026-04-15T00:00:00.000Z"),
);
expect(out.size).toBe(0);
expect(prisma.measurementRollup.findMany).not.toHaveBeenCalled();
});
});
describe("resolveBucketSum", () => {
it("returns sumValue when populated", () => {
expect(
@@ -41,7 +41,7 @@ import {
readWeekRollups,
readYearRollups,
type RollupBucketRow,
} from "../rollup-read-wmy";
} from "../measurement-read-wmy";
const { findMany } = mocks;
@@ -290,3 +290,73 @@ describe("aggregateWmyBuckets", () => {
expect(aggregateWmyBuckets(rows).sum).toBe(100);
});
});
/**
* v1.4.40 W-WMY-WIRE — granularity-routing parity across the v1.4.40
* consumers (summaries-slice + health-score-fast-path). Both consumers
* now call `readBestGranularityRollups` for their long-window probes;
* this pins:
* - 90 d → DAY (every coarser floor too high — DAY is canonical
* for the trailing quarter regardless of consumer),
* - 365 d → MONTH (clears MONTH floor 181 d, short of YEAR floor
* 731 d) — the granularity health-score-fast-path's weight long-
* window read and summaries-slice's year-ago baseline both
* consume,
* - 1095 d → YEAR (clears the 731-day YEAR floor) — the v1.5
* multi-year trend card target.
*
* The parity assertion ensures the routing contract is a single
* source of truth: any future floor adjustment forces both consumers
* to re-pin in lock-step rather than silently diverging.
*/
describe("readBestGranularityRollups — cross-consumer routing parity", () => {
it("pins the 90 / 365 / 1095 day routing targets the consumers depend on", async () => {
// Three sequential probes with non-empty results so the router
// stops at the first matching tier per window.
findMany
.mockResolvedValueOnce([bucket("2026-02-15T00:00:00.000Z")]) // 90d → DAY
.mockResolvedValueOnce([bucket("2025-08-01T00:00:00.000Z")]) // 365d → MONTH
.mockResolvedValueOnce([bucket("2024-01-01T00:00:00.000Z")]); // 1095d → YEAR
const ninety = await readBestGranularityRollups("user", "WEIGHT", 90);
const yearLong = await readBestGranularityRollups("user", "WEIGHT", 365);
const threeYear = await readBestGranularityRollups("user", "WEIGHT", 1095);
expect(ninety?.granularity).toBe("DAY");
expect(yearLong?.granularity).toBe("MONTH");
expect(threeYear?.granularity).toBe("YEAR");
// Each consumer-relevant window only fires one round-trip on the
// happy path; the per-tier walk is reserved for coverage-miss
// scenarios already pinned above.
expect(findMany).toHaveBeenCalledTimes(3);
expect(findMany.mock.calls[0][0].where.granularity).toBe("DAY");
expect(findMany.mock.calls[1][0].where.granularity).toBe("MONTH");
expect(findMany.mock.calls[2][0].where.granularity).toBe("YEAR");
});
it("aggregates MONTH buckets to a byte-identical mean compared to the underlying DAY buckets", async () => {
// The compositional contract `count / mean` are linearly
// composable across granularities — pin it by simulating "MONTH
// routing for a 365-day window" vs "DAY routing for the same
// window" against the same underlying counts/means and asserting
// the count-weighted mean agrees.
//
// MONTH bucket (consolidated): count=30, mean=80
// DAY buckets (per-day refresh): count=10/mean=82 + count=20/mean=79
// ⇒ DAY-derived mean = (10*82 + 20*79) / 30 = 80.0
// Both must agree numerically — the routing helper's choice is a
// performance optimisation, not a math change.
const monthBuckets: RollupBucketRow[] = [
bucket("2025-08-01T00:00:00.000Z", { count: 30, mean: 80 }),
];
const dayBuckets: RollupBucketRow[] = [
bucket("2025-08-05T00:00:00.000Z", { count: 10, mean: 82 }),
bucket("2025-08-20T00:00:00.000Z", { count: 20, mean: 79 }),
];
const monthAgg = aggregateWmyBuckets(monthBuckets);
const dayAgg = aggregateWmyBuckets(dayBuckets);
expect(monthAgg.count).toBe(dayAgg.count);
expect(monthAgg.mean).toBeCloseTo(dayAgg.mean ?? Number.NaN, 5);
});
});
@@ -8,7 +8,7 @@
*/
import { describe, expect, it } from "vitest";
import { aggregateBuckets, type DailyMeanRow } from "../rollup-read";
import { aggregateBuckets, type DailyMeanRow } from "../measurement-read";
const dayA: DailyMeanRow = {
day: new Date("2026-05-08T00:00:00.000Z"),
@@ -89,7 +89,7 @@ import {
enqueueRollupRecompute,
ensureUserRollupsFresh,
recomputeBucketsForMeasurement,
} from "../rollups";
} from "../measurement-rollups";
beforeEach(() => {
queryRaw.mockReset();
@@ -56,7 +56,7 @@ import {
enqueueBootTimeMedicationComplianceBackfill,
enqueueUserMedicationComplianceBackfill,
MEDICATION_COMPLIANCE_BACKFILL_QUEUE,
} from "../compliance-rollups";
} from "../medication-compliance-rollups";
const {
intakeFindMany,
@@ -83,7 +83,7 @@ import {
enqueueMoodRollupRecompute,
ensureUserMoodRollupsFresh,
recomputeMoodBucketsForEntry,
} from "../rollups";
} from "../mood-rollups";
beforeEach(() => {
queryRaw.mockReset();
@@ -55,6 +55,7 @@ export async function probeRollupCoverage(
SELECT DISTINCT "type"
FROM measurements
WHERE user_id = ${userId}
AND "deleted_at" IS NULL
) m
LEFT JOIN measurement_rollups r
ON r.user_id = ${userId}
@@ -69,19 +70,6 @@ export async function probeRollupCoverage(
return coverage;
}
/**
* Returns the set of types the user has logged that do NOT yet have
* DAY-rollup coverage. The bucket-fresh read paths use this to decide
* which types still need the live aggregator branch.
*/
export function typesMissingCoverage(coverage: RollupCoverageMap): string[] {
const missing: string[] = [];
for (const [type, hasBuckets] of coverage.entries()) {
if (!hasBuckets) missing.push(type);
}
return missing;
}
/**
* Convenience — `true` when the user has at least one measurement and
* every type with measurements also has DAY-bucket coverage. The read
@@ -30,7 +30,7 @@
*/
import { prisma } from "@/lib/db";
import type { MeasurementType } from "@/generated/prisma/client";
import { CUMULATIVE_HK_TYPES } from "./apple-health-mapping";
import { CUMULATIVE_HK_TYPES } from "@/lib/measurements/apple-health-mapping";
/**
* Cumulative measurement types the rollup `sum_value` column serves.
@@ -108,53 +108,6 @@ export async function readCumulativeDaySums(
}));
}
/**
* Batch read DAY buckets for several cumulative types in one round
* trip. Eliminates the per-type chunked-findMany loop in
* `/api/analytics` A2 for the five highest-row-count metrics on
* Marc's tenant.
*
* Returns a Map keyed on `MeasurementType` so the caller can route
* each type's rows back into the per-metric branch without scanning
* the flat list.
*/
export async function readCumulativeDaySumsBatch(
userId: string,
types: readonly CumulativeType[],
since: Date,
): Promise<Map<CumulativeType, CumulativeDaySumRow[]>> {
if (types.length === 0) return new Map();
const rows = await prisma.measurementRollup.findMany({
where: {
userId,
type: { in: types as unknown as MeasurementType[] },
granularity: "DAY",
bucketStart: { gte: since },
},
orderBy: [{ type: "asc" }, { bucketStart: "asc" }],
select: {
type: true,
bucketStart: true,
sumValue: true,
count: true,
mean: true,
},
});
const out = new Map<CumulativeType, CumulativeDaySumRow[]>();
for (const t of types) out.set(t, []);
for (const row of rows) {
const bucket = out.get(row.type as CumulativeType);
if (!bucket) continue;
bucket.push({
bucketStart: row.bucketStart,
sumValue: row.sumValue,
count: row.count,
mean: row.mean,
});
}
return out;
}
/**
* Resolve a per-bucket cumulative total. Reads `sumValue` when the
* v1.4.39 writer has populated it; falls back to `mean * count` for
@@ -21,7 +21,7 @@
* the v1.4.34.1 / 4.5 aggregator survives.
*/
import type { MeasurementType } from "@/generated/prisma/client";
import { readRollupBuckets } from "./rollups";
import { readRollupBuckets } from "@/lib/rollups/measurement-rollups";
export interface DailyMeanRow {
day: Date;
@@ -31,6 +31,7 @@
import { prisma } from "@/lib/db";
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
import { annotate } from "@/lib/logging/context";
import { startOfUtcDay } from "@/lib/tz/start-of-utc-day";
import type {
MeasurementType,
RollupGranularity,
@@ -319,7 +320,7 @@ export async function isRollupFresh(
newestComputedAt: Date | null,
): Promise<boolean> {
const newest = await prisma.measurement.findFirst({
where: { userId, type },
where: { userId, type, deletedAt: null },
orderBy: { measuredAt: "desc" },
select: { measuredAt: true, updatedAt: true },
});
@@ -403,6 +404,7 @@ async function runRollupAggregate(input: {
AND m."type" IN (${typeList})
AND m."measured_at" >= $2
AND m."measured_at" < $3
AND m."deleted_at" IS NULL
GROUP BY m."type", ${dateTrunc}
`;
return prisma.$queryRawUnsafe<RollupRow[]>(
@@ -435,6 +437,7 @@ async function runRollupAggregate(input: {
WHERE m."user_id" = $1
AND m."measured_at" >= $2
AND m."measured_at" < $3
AND m."deleted_at" IS NULL
GROUP BY m."type", ${dateTrunc}
`;
return prisma.$queryRawUnsafe<RollupRow[]>(
@@ -516,17 +519,6 @@ async function persistRollupRows(
return touched;
}
/**
* Truncate a `Date` to the start of its UTC day. The rollup table
* stores TIMESTAMPTZ values so we anchor on UTC to keep the bucket
* key deterministic across timezone-shifting consumers.
*/
function startOfUtcDay(d: Date): Date {
return new Date(
Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), 0, 0, 0, 0),
);
}
/**
* Span of the bucket containing `measuredAt` at the given granularity.
* Postgres `date_trunc` is the canonical truncator; we mirror its
@@ -656,7 +648,7 @@ async function ensureUserRollupsFreshImpl(
select: { computedAt: true },
}),
prisma.measurement.findFirst({
where: { userId },
where: { userId, deletedAt: null },
orderBy: { updatedAt: "desc" },
select: { updatedAt: true, measuredAt: true },
}),
@@ -775,6 +767,7 @@ export async function enqueueBootTimeRollupBackfill(): Promise<{
m."type",
date_trunc('day', m."measured_at") AS bucket_start
FROM measurements m
WHERE m."deleted_at" IS NULL
) mt
LEFT JOIN measurement_rollups r
ON r."user_id" = mt."user_id"
@@ -26,6 +26,7 @@ import {
isValidTimezone,
userDayKey,
} from "@/lib/tz/format";
import { wallClockInTz } from "@/lib/tz/wall-clock";
/**
* pg-boss queue name for the boot-time medication compliance backfill.
@@ -65,30 +66,6 @@ export interface ComplianceBucket {
*/
type PrismaLike = typeof prisma | Prisma.TransactionClient;
/**
* Compute the wall-clock components of `date` interpreted in `tz`.
* Same shape as the cadence helper's `wallClockInTz`, lifted here so
* the compliance module doesn't depend on a sibling-folder helper.
*/
function wallClockInTz(
date: Date,
tz: string,
): { year: number; month: number; day: number } {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: tz,
year: "numeric",
month: "2-digit",
day: "2-digit",
}).formatToParts(date);
const get = (type: Intl.DateTimeFormatPartTypes): string =>
parts.find((p) => p.type === type)?.value ?? "0";
return {
year: Number(get("year")),
month: Number(get("month")),
day: Number(get("day")),
};
}
/**
* UTC offset (in minutes) of `tz` at the given instant. Positive east
* of UTC. Honours DST via `Intl.DateTimeFormat`.
@@ -40,6 +40,7 @@
import { prisma } from "@/lib/db";
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
import { annotate } from "@/lib/logging/context";
import { startOfUtcDay } from "@/lib/tz/start-of-utc-day";
import type {
Prisma,
PrismaClient,
@@ -611,18 +612,6 @@ async function persistMoodRollupRows(
return touched;
}
/**
* Truncate a `Date` to the start of its UTC day. The rollup table
* stores TIMESTAMPTZ values so we anchor on UTC to keep the bucket
* key deterministic across timezone-shifting consumers — same
* convention as `measurement-rollups`.
*/
function startOfUtcDay(d: Date): Date {
return new Date(
Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), 0, 0, 0, 0),
);
}
/**
* Span of the bucket containing `moodLoggedAt` at the given
* granularity. Mirrors `measurement-rollups` so the WEEK / MONTH /
+6 -6
View File
@@ -121,7 +121,7 @@ export type FormatInUserTzShape =
* `DISPLAY_TIMEZONE`.
*
* - "date" — `2026-05-11` (ISO date in the user's zone). Used as a
* day-bucket key parallel to the legacy `berlinDayKey()`.
* stable day-bucket key for per-user aggregation.
*/
export function formatInUserTz(
date: Date,
@@ -151,11 +151,11 @@ export function formatInUserTz(
}
/**
* Day-bucket key in the user's timezone. Mirrors `berlinDayKey()` but
* parameterised. Used as a stable Map key for daily aggregation so a
* 23:30-local reading lands on today's bucket regardless of UTC
* offset. The legacy `berlinDayKey()` stays for surfaces that are
* not yet user-scoped (admin tables, audit log viewer).
* Day-bucket key in the user's timezone. Used as a stable Map key for
* daily aggregation so a 23:30-local reading lands on today's bucket
* regardless of UTC offset. Every display surface in v1.4.40 anchors
* here — the legacy Berlin-only `berlinDayKey()` was retired in that
* release.
*/
export function userDayKey(date: Date, tz: string): string {
return formatInUserTz(date, tz, "date");
+20
View File
@@ -0,0 +1,20 @@
/**
* UTC-anchored start-of-day truncation shared by the measurement +
* mood rollup writers.
*
* Both rollup tables store TIMESTAMPTZ bucket starts. Anchoring on the
* UTC calendar day (rather than the user's local day) keeps the bucket
* key deterministic across timezone-shifting consumers — the same
* convention `date_trunc('day', measured_at AT TIME ZONE 'UTC')` would
* produce on the read side. The display surfaces re-bucket into the
* user's timezone via `userDayKey()` from `./format`.
*
* v1.4.40 W-GHOSTS consolidated two byte-identical local copies
* (`src/lib/measurements/rollups.ts` + `src/lib/mood/rollups.ts`) into
* this module so future writers cannot drift independently.
*/
export function startOfUtcDay(d: Date): Date {
return new Date(
Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), 0, 0, 0, 0),
);
}
+84
View File
@@ -0,0 +1,84 @@
/**
* Wall-clock-in-timezone helper shared by the medication scheduling
* + compliance-rollup paths.
*
* Decomposes a `Date` instant into the year/month/day/hour/minute/
* second/weekday tuple an observer in `tz` would read off a wall
* clock at that moment. Honours DST because `Intl.DateTimeFormat`
* does.
*
* When `tz` is omitted the helper falls back to the host's system-
* local representation so the v1.4.25 W19e callers that pre-date the
* per-user timezone work keep their original shape.
*
* v1.4.40 W-GHOSTS consolidated two file-local copies
* (`compliance-rollups.ts` returning `{year,month,day}` only +
* `scheduling/cadence.ts` returning the full tuple) into this single
* canonical export. The cross-tz ±3 h guard hinges on these helpers
* staying in lock-step; keeping them as separate definitions made
* silent drift a real failure mode.
*/
export interface WallClockParts {
year: number;
/** 1–12 (January = 1, December = 12). */
month: number;
/** 1–31. */
day: number;
/** 0–23 (24 normalised to 0 to match Date.getHours() semantics). */
hour: number;
minute: number;
second: number;
/** 0 = Sunday … 6 = Saturday. */
weekday: number;
}
const WEEKDAY_MAP: Record<string, number> = {
Sun: 0,
Mon: 1,
Tue: 2,
Wed: 3,
Thu: 4,
Fri: 5,
Sat: 6,
};
export function wallClockInTz(
date: Date,
tz: string | undefined,
): WallClockParts {
if (!tz) {
return {
year: date.getFullYear(),
month: date.getMonth() + 1,
day: date.getDate(),
hour: date.getHours(),
minute: date.getMinutes(),
second: date.getSeconds(),
weekday: date.getDay(),
};
}
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: tz,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
weekday: "short",
}).formatToParts(date);
const get = (type: Intl.DateTimeFormatPartTypes): string =>
parts.find((p) => p.type === type)?.value ?? "0";
let hour = Number(get("hour"));
if (hour === 24) hour = 0;
return {
year: Number(get("year")),
month: Number(get("month")),
day: Number(get("day")),
hour,
minute: Number(get("minute")),
second: Number(get("second")),
weekday: WEEKDAY_MAP[get("weekday")] ?? 0,
};
}
+70
View File
@@ -0,0 +1,70 @@
import { z } from "zod/v4";
/**
* v1.4.40 SB-10 — AI consent receipts (App-Store Guideline 5.1.2(i) +
* GDPR Art. 7 audit trail).
*
* The discriminator covers the three consent surfaces the iOS client
* collects independently:
* - `ai_full` full assistant + on-device personalisation
* - `ai_insights_only` read-only Insights generation (no Coach)
* - `ai_coach` chat-style Coach access
*
* Keeping each surface as its own row means the legal team can prove
* which exact scope the user agreed to without parsing the signed
* artefact server-side. The server stays opaque to the artefact's
* format — we accept either a base64-encoded PDF or a signed JWT, the
* audit trail *is* the storage plus the `signedAt` timestamp.
*/
export const consentKindEnum = z.enum([
"ai_full",
"ai_insights_only",
"ai_coach",
]);
export type ConsentKind = z.infer<typeof consentKindEnum>;
/**
* 64 KB upper bound on the artefact. A signed PDF receipt sits well
* under 32 KB; a signed JWT is a few hundred bytes. The cap exists so
* a misbehaving client (or a malicious one) can't fill the audit table
* with multi-megabyte rows.
*
* v1.4.40 security M1 — the cap is enforced via `Buffer.byteLength(...,
* "utf8")` because `z.string().max()` counts JavaScript string units
* (UTF-16 code units), not bytes. A UTF-8 artefact full of multi-byte
* code points (CJK, emoji) would otherwise be allowed past the 64 KB
* row budget — the database stores bytes, not code units, and the
* audit-table guarantee is byte-bounded.
*/
const ARTEFACT_MAX_BYTES = 64 * 1024;
export const consentPostBody = z.object({
kind: consentKindEnum,
artefact: z
.string()
.min(1, "artefact must not be empty")
.refine(
(value) => Buffer.byteLength(value, "utf8") <= ARTEFACT_MAX_BYTES,
{ message: "artefact exceeds 64 KB cap (UTF-8 byte length)" },
),
signedAt: z.iso
.datetime({ offset: true })
.transform((s) => new Date(s)),
});
export type ConsentPostBody = z.infer<typeof consentPostBody>;
/**
* Query schema for both `/api/consent/ai/latest` GET and DELETE.
*
* Omitting `kind` on GET returns the latest active receipt per kind
* (a `Record<ConsentKind, …>` shape). Omitting it on DELETE revokes
* the latest receipt across all three kinds — handy for the iOS
* "deaktivieren alle KI-Funktionen" master toggle.
*/
export const consentLatestQuery = z.object({
kind: consentKindEnum.optional(),
});
export type ConsentLatestQuery = z.infer<typeof consentLatestQuery>;
@@ -49,10 +49,10 @@ vi.mock("@/lib/logging/context", () => ({
// measurement rollup writer so the dashboard chart's `source=rollup`
// fast-path sees the new buckets. The mock collects the call args so
// the regression test below can assert the hook fired.
vi.mock("@/lib/measurements/rollups", async () => {
vi.mock("@/lib/rollups/measurement-rollups", async () => {
const actual = await vi.importActual<
typeof import("@/lib/measurements/rollups")
>("@/lib/measurements/rollups");
typeof import("@/lib/rollups/measurement-rollups")
>("@/lib/rollups/measurement-rollups");
return {
...actual,
recomputeBucketsForMeasurement: vi.fn().mockResolvedValue(undefined),
@@ -65,7 +65,7 @@ import {
recordSyncFailure,
recordSyncSuccess,
} from "@/lib/integrations/status";
import { recomputeBucketsForMeasurement } from "@/lib/measurements/rollups";
import { recomputeBucketsForMeasurement } from "@/lib/rollups/measurement-rollups";
import { fetchWithingsActivity, syncUserActivity } from "../sync-activity";
@@ -48,10 +48,10 @@ vi.mock("@/lib/logging/context", () => ({
// rollup tier for each touched SLEEP_DURATION day so the dashboard
// chart's `source=rollup` fast-path sees the new segments. The mock
// surfaces the call args for the regression test below.
vi.mock("@/lib/measurements/rollups", async () => {
vi.mock("@/lib/rollups/measurement-rollups", async () => {
const actual = await vi.importActual<
typeof import("@/lib/measurements/rollups")
>("@/lib/measurements/rollups");
typeof import("@/lib/rollups/measurement-rollups")
>("@/lib/rollups/measurement-rollups");
return {
...actual,
recomputeBucketsForMeasurement: vi.fn().mockResolvedValue(undefined),
@@ -64,7 +64,7 @@ import {
recordSyncFailure,
recordSyncSuccess,
} from "@/lib/integrations/status";
import { recomputeBucketsForMeasurement } from "@/lib/measurements/rollups";
import { recomputeBucketsForMeasurement } from "@/lib/rollups/measurement-rollups";
import {
fetchWithingsSleep,
+1 -1
View File
@@ -41,7 +41,7 @@ import { getUnitForType } from "@/lib/validations/measurement";
import {
collapseToTypeDayKeys,
recomputeBucketsForMeasurement,
} from "@/lib/measurements/rollups";
} from "@/lib/rollups/measurement-rollups";
import { hasActivityScope } from "./client";
import {
+1 -1
View File
@@ -39,7 +39,7 @@ import { getUnitForType } from "@/lib/validations/measurement";
import {
collapseToTypeDayKeys,
recomputeBucketsForMeasurement,
} from "@/lib/measurements/rollups";
} from "@/lib/rollups/measurement-rollups";
import { hasActivityScope } from "./client";
import {
+1 -1
View File
@@ -21,7 +21,7 @@ import {
import {
collapseToTypeDayKeys,
recomputeBucketsForMeasurement,
} from "@/lib/measurements/rollups";
} from "@/lib/rollups/measurement-rollups";
/**
* Build the callback URL handed to Withings at `Notify.subscribe` time.