mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
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:
1 parent
f9996552ed
commit
1114cf7e63
146 files changed
+8207
-2279
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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}");
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
|
||||
@@ -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
@@ -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[]) =>
|
||||
|
||||
@@ -74,6 +74,7 @@ export async function detectGlp1Plateau(
|
||||
userId,
|
||||
type: "WEIGHT",
|
||||
measuredAt: { gte: windowStart },
|
||||
deletedAt: null,
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: { value: true, measuredAt: true },
|
||||
|
||||
@@ -158,6 +158,7 @@ export async function generatePulseStatusForUser(
|
||||
where: {
|
||||
userId,
|
||||
type: "PULSE",
|
||||
deletedAt: null,
|
||||
},
|
||||
orderBy: { measuredAt: "desc" },
|
||||
take: 365,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
@@ -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,
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
+1
-60
@@ -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(
|
||||
+71
-1
@@ -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);
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -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"),
|
||||
+1
-1
@@ -89,7 +89,7 @@ import {
|
||||
enqueueRollupRecompute,
|
||||
ensureUserRollupsFresh,
|
||||
recomputeBucketsForMeasurement,
|
||||
} from "../rollups";
|
||||
} from "../measurement-rollups";
|
||||
|
||||
beforeEach(() => {
|
||||
queryRaw.mockReset();
|
||||
+1
-1
@@ -56,7 +56,7 @@ import {
|
||||
enqueueBootTimeMedicationComplianceBackfill,
|
||||
enqueueUserMedicationComplianceBackfill,
|
||||
MEDICATION_COMPLIANCE_BACKFILL_QUEUE,
|
||||
} from "../compliance-rollups";
|
||||
} from "../medication-compliance-rollups";
|
||||
|
||||
const {
|
||||
intakeFindMany,
|
||||
+1
-1
@@ -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
|
||||
+1
-48
@@ -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
|
||||
File renamed without changes.
@@ -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"
|
||||
+1
-24
@@ -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 /
|
||||
@@ -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");
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user