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

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

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

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

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

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

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

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

No files matched your search

@@ -1,31 +0,0 @@
import { describe, it, expect } from "vitest";
import { berlinDayKey } from "../berlin-day";
/**
* v1.4.22 W5 reconcile (Code-MED-3) — pin that the helper buckets a
* 23:30-Berlin reading on Tuesday into the Tuesday key, regardless of
* UTC offset (CET / CEST, DST).
*/
describe("berlinDayKey", () => {
it("buckets a 23:30-Berlin reading on Tuesday into Tuesday's key (CEST)", () => {
// 2025-10-25T21:30:00Z = 2025-10-25T23:30 CEST (Saturday before DST end)
expect(berlinDayKey(new Date("2025-10-25T21:30:00Z"))).toBe("2025-10-25");
});
it("buckets a 23:30-Berlin reading after DST end into the right Berlin day", () => {
// 2025-10-26 is the DST end day in Europe/Berlin (CET takes over).
// 2025-10-26T22:30:00Z = 2025-10-26T23:30 CET — still Sunday in Berlin.
expect(berlinDayKey(new Date("2025-10-26T22:30:00Z"))).toBe("2025-10-26");
});
it("crosses the UTC-midnight boundary in Berlin's favour", () => {
// 2026-05-09T22:30:00Z falls on 2026-05-10T00:30 CEST — Berlin says
// Sunday, UTC says Saturday. The helper must follow Berlin.
expect(berlinDayKey(new Date("2026-05-09T22:30:00Z"))).toBe("2026-05-10");
});
it("returns YYYY-MM-DD format (sortable, padded)", () => {
expect(berlinDayKey(new Date("2026-01-09T12:00:00Z"))).toBe("2026-01-09");
expect(berlinDayKey(new Date("2026-12-09T12:00:00Z"))).toBe("2026-12-09");
});
});
@@ -19,7 +19,7 @@ vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
}));
vi.mock("@/lib/measurements/rollup-coverage", () => ({
vi.mock("@/lib/rollups/measurement-coverage", () => ({
isFullyCovered: vi.fn(),
probeRollupCoverage: vi.fn(),
}));
@@ -29,7 +29,7 @@ import { annotate } from "@/lib/logging/context";
import {
isFullyCovered,
probeRollupCoverage,
} from "@/lib/measurements/rollup-coverage";
} from "@/lib/rollups/measurement-coverage";
import { computeBpInTargetFastPath } from "../bp-in-target-fast-path";
const MEASUREMENT_FIND_MANY = prisma.measurement.findMany as unknown as ReturnType<typeof vi.fn>;
@@ -22,7 +22,7 @@ vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
}));
vi.mock("@/lib/measurements/rollup-coverage", () => ({
vi.mock("@/lib/rollups/measurement-coverage", () => ({
isFullyCovered: vi.fn(),
probeRollupCoverage: vi.fn(),
}));
@@ -32,7 +32,7 @@ import { annotate } from "@/lib/logging/context";
import {
isFullyCovered,
probeRollupCoverage,
} from "@/lib/measurements/rollup-coverage";
} from "@/lib/rollups/measurement-coverage";
import {
CORRELATION_WINDOW_DAYS,
computeCorrelationHypothesesFastPath,
@@ -1,10 +1,6 @@
import { describe, it, expect } from "vitest";
import type { DataPoint } from "../trends";
import {
pairByTimestamp,
pearsonCorrelation,
weeklyAverages,
} from "../correlations";
import { pairByTimestamp, pearsonCorrelation } from "../correlations";
function makePoints(values: number[], startDaysAgo = 30): DataPoint[] {
const now = Date.now();
@@ -107,57 +103,3 @@ describe("pearsonCorrelation", () => {
});
});
describe("weeklyAverages", () => {
it("returns empty for empty input", () => {
expect(weeklyAverages([])).toEqual([]);
});
it("groups data points by ISO week", () => {
const data = makePoints(
[70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83],
14,
);
const result = weeklyAverages(data);
// 14 days = 2-3 weeks
expect(result.length).toBeGreaterThanOrEqual(2);
expect(result.length).toBeLessThanOrEqual(3);
});
it("averages values within same week", () => {
const now = new Date();
// Find the Monday of current week
const day = now.getDay();
const monday = new Date(now);
monday.setDate(now.getDate() - day + (day === 0 ? -6 : 1));
monday.setHours(12, 0, 0, 0);
const tuesday = new Date(monday);
tuesday.setDate(monday.getDate() + 1);
const data: DataPoint[] = [
{ date: monday, value: 70 },
{ date: tuesday, value: 80 },
];
const result = weeklyAverages(data);
expect(result).toHaveLength(1);
expect(result[0].value).toBe(75);
});
// v3 audit caught a TZ bug: a Sunday-evening Berlin reading (e.g. 22:00
// local = 21:00 UTC in summer / 21:00 UTC in winter via DST) bucketed
// into next week on UTC servers because `Date.getDay()` was system-local.
// The Berlin-TZ-aware implementation puts the same point into THIS week.
it("buckets Sunday-evening Berlin into the same ISO week as the preceding Monday", () => {
// 2024-08-04 is a Sunday; 22:00 Berlin = 20:00 UTC (CEST). The Monday
// of the same Berlin week is 2024-07-29.
const sundayEveningBerlin = new Date("2024-08-04T20:00:00.000Z");
const mondayMidday = new Date("2024-07-29T11:00:00.000Z");
const result = weeklyAverages([
{ date: mondayMidday, value: 70 },
{ date: sundayEveningBerlin, value: 90 },
]);
expect(result).toHaveLength(1);
expect(result[0].value).toBe(80); // (70 + 90) / 2
expect(result[0].date.toISOString()).toBe("2024-07-29T00:00:00.000Z");
});
});
@@ -22,7 +22,7 @@ vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
}));
vi.mock("@/lib/measurements/rollup-coverage", () => ({
vi.mock("@/lib/rollups/measurement-coverage", () => ({
isFullyCovered: vi.fn(),
probeRollupCoverage: vi.fn(),
}));
@@ -32,7 +32,7 @@ import { annotate } from "@/lib/logging/context";
import {
isFullyCovered,
probeRollupCoverage,
} from "@/lib/measurements/rollup-coverage";
} from "@/lib/rollups/measurement-coverage";
import { computeUserHealthScoreFastPath } from "../health-score-fast-path";
const MEASUREMENT_FIND_MANY = prisma.measurement.findMany as unknown as ReturnType<typeof vi.fn>;
@@ -102,6 +102,11 @@ describe("computeUserHealthScoreFastPath", () => {
computedAt: now,
},
]);
// v1.4.40 W-WMY-WIRE — the parallel long-window read fires
// alongside the DAY-bucket read; mock its `findMany` to return
// a coverage miss so the annotate carries `weightLongWindow:
// null` and the score shape is unchanged.
ROLLUP_FIND_MANY.mockResolvedValueOnce([]);
// Weight source-attribution read (narrow 2-column).
MEASUREMENT_FIND_MANY
.mockResolvedValueOnce([
@@ -147,6 +152,170 @@ describe("computeUserHealthScoreFastPath", () => {
});
});
/**
* v1.4.40 W-WMY-WIRE — pin the long-window weight wiring through
* `readBestGranularityRollups`. The score shape is unchanged; the
* weightLongWindow annotate makes the rollup-tier consumption
* verifiable in production wide-events.
*/
describe("long-window weight wiring (W-WMY-WIRE)", () => {
it("surfaces a MONTH-granularity weightLongWindow mean on the rollup branch", async () => {
const coverage = new Map<string, boolean>([["WEIGHT", true]]);
FULLY_COVERED.mockReturnValue(true);
PROBE.mockResolvedValue(coverage);
const now = new Date("2026-05-17T12:00:00.000Z");
// DAY-bucket read (trailing 37 days) — single bucket, irrelevant
// for the long-window assertion.
ROLLUP_FIND_MANY.mockResolvedValueOnce([
{
bucketStart: new Date("2026-05-10T00:00:00.000Z"),
count: 1,
mean: 82.5,
minValue: 82.5,
maxValue: 82.5,
sd: null,
slope: null,
r2: null,
computedAt: now,
},
]);
// readBestGranularityRollups(WEIGHT, 365) — first call is the
// MONTH probe (floor 181 d). Two MONTH buckets, count-weighted
// mean = (12*82 + 18*80) / 30 = 80.8.
ROLLUP_FIND_MANY.mockResolvedValueOnce([
{
bucketStart: new Date("2025-08-01T00:00:00.000Z"),
count: 12,
mean: 82,
minValue: 80,
maxValue: 84,
sd: 1,
slope: 0,
r2: 0,
sumValue: null,
},
{
bucketStart: new Date("2025-09-01T00:00:00.000Z"),
count: 18,
mean: 80,
minValue: 78,
maxValue: 83,
sd: 1,
slope: 0,
r2: 0,
sumValue: null,
},
]);
// Source attribution + BP-SYS attribution reads.
MEASUREMENT_FIND_MANY.mockResolvedValueOnce([
{
measuredAt: new Date("2026-05-10T08:00:00.000Z"),
source: "WITHINGS",
},
]).mockResolvedValueOnce([]);
await computeUserHealthScoreFastPath({
userId: "user-longwindow",
bpInTargetPct: 85,
heightCm: 178,
now,
coverage,
});
const calls = ANNOTATE.mock.calls.map((c) => c[0]);
const pathCall = calls.find(
(c) => c?.meta?.healthScore?.path === "rollup",
);
expect(pathCall).toBeDefined();
const longWindow = pathCall?.meta?.healthScore?.weightLongWindow;
expect(longWindow).not.toBeNull();
expect(longWindow?.granularity).toBe("MONTH");
expect(longWindow?.buckets).toBe(2);
// Σ(count*mean) / Σcount = (12*82 + 18*80) / 30 = 80.8 → rounded.
expect(longWindow?.mean).toBeCloseTo(80.8, 5);
});
it("emits weightLongWindow:null when the WMY tier carries no buckets", async () => {
const coverage = new Map<string, boolean>([["WEIGHT", true]]);
FULLY_COVERED.mockReturnValue(true);
PROBE.mockResolvedValue(coverage);
const now = new Date("2026-05-17T12:00:00.000Z");
// DAY-bucket read.
ROLLUP_FIND_MANY.mockResolvedValueOnce([
{
bucketStart: new Date("2026-05-10T00:00:00.000Z"),
count: 1,
mean: 82.5,
minValue: 82.5,
maxValue: 82.5,
sd: null,
slope: null,
r2: null,
computedAt: now,
},
]);
// Long-window read — every granularity returns empty (router
// walks MONTH → WEEK → DAY; 365 d skips YEAR floor).
ROLLUP_FIND_MANY.mockResolvedValue([]);
MEASUREMENT_FIND_MANY.mockResolvedValueOnce([
{
measuredAt: new Date("2026-05-10T08:00:00.000Z"),
source: "WITHINGS",
},
]).mockResolvedValueOnce([]);
await computeUserHealthScoreFastPath({
userId: "user-no-longwindow",
bpInTargetPct: 85,
heightCm: 178,
now,
coverage,
});
const calls = ANNOTATE.mock.calls.map((c) => c[0]);
const pathCall = calls.find(
(c) => c?.meta?.healthScore?.path === "rollup",
);
expect(pathCall?.meta?.healthScore?.weightLongWindow).toBeNull();
});
it("omits the long-window read on the live fallback branch", async () => {
const coverage = new Map<string, boolean>([["WEIGHT", false]]);
FULLY_COVERED.mockReturnValue(false);
PROBE.mockResolvedValue(coverage);
const now = new Date("2026-05-17T12:00:00.000Z");
// Weight live read + BP source attribution.
MEASUREMENT_FIND_MANY.mockResolvedValueOnce([
{
measuredAt: new Date("2026-05-10T08:00:00.000Z"),
value: 82.5,
source: "MANUAL",
},
]).mockResolvedValueOnce([]);
await computeUserHealthScoreFastPath({
userId: "user-live-nowmy",
bpInTargetPct: 80,
heightCm: 178,
now,
coverage,
});
// The live branch never issues the long-window probe — guards
// against accidentally double-reading on cold mounts where the
// raw weight findMany is already doing the work.
expect(ROLLUP_FIND_MANY).not.toHaveBeenCalled();
const calls = ANNOTATE.mock.calls.map((c) => c[0]);
const pathCall = calls.find(
(c) => c?.meta?.healthScore?.path === "live",
);
expect(pathCall?.meta?.healthScore?.weightLongWindow).toBeNull();
});
});
describe("live fallback — WEIGHT not covered", () => {
it("reads raw weight rows and pins path:live", async () => {
const coverage = new Map<string, boolean>([
@@ -291,7 +291,164 @@ describe("computeSummariesSlice", () => {
// probe + narrow aggregate + latests + rollup GROUP BY
// (v1.4.37.2 — the prior `findMany` is gone). No heavy aggregate.
expect(RAW).toHaveBeenCalledTimes(4);
expect(ROLLUP_FIND_MANY).toHaveBeenCalledTimes(0);
// v1.4.40 W-WMY-WIRE — the year-ago baseline probe runs
// `readBestGranularityRollups(userId, type, 395)` per
// type-with-data via `prisma.measurementRollup.findMany`. The
// 395-day window skips the YEAR floor (731 d) and walks MONTH
// → WEEK → DAY on full coverage miss; the default mock returns
// `[]` so all three reachable tiers probe.
expect(ROLLUP_FIND_MANY).toHaveBeenCalledTimes(3);
});
});
/**
* v1.4.40 W-WMY-WIRE — pin the wiring of the WMY readers into the
* slim slice. The pre-v1.4.40 shape hardcoded `avg30LastYear` to
* `null`; we now populate it from `readBestGranularityRollups` when
* the YEAR / MONTH / WEEK / DAY tier carries buckets that overlap
* the `[now-395d, now-365d)` slice.
*/
describe("year-over-year wiring (avg30LastYear)", () => {
it("populates avg30LastYear from MONTH buckets that overlap the year-ago slice", async () => {
// Coverage probe shows WEIGHT covered → rollup happy path.
RAW.mockResolvedValueOnce([{ type: "WEIGHT", has_buckets: true }])
.mockResolvedValueOnce([
{
type: "WEIGHT",
avg7: 82,
avg30: 82.5,
slope7: 0,
r2_7: 0,
slope30: 0,
r2_30: 0,
slope90: 0,
r2_90: 0,
},
])
.mockResolvedValueOnce([
{ type: "WEIGHT", value: 82.7, measured_at: new Date() },
])
.mockResolvedValueOnce([
{ type: "WEIGHT", count: 5, min: 82, max: 84, mean: 83 },
]);
// Year-ago slice MONTH bucket — `bucketStart` placed 380 days
// ago so it falls inside `[now-395d, now-365d)`. Single bucket
// (count=10, mean=85) → weighted mean = 85.
const yearAgoBucketStart = new Date(
Date.now() - 380 * 24 * 60 * 60 * 1000,
);
ROLLUP_FIND_MANY.mockResolvedValueOnce([
{
bucketStart: yearAgoBucketStart,
count: 10,
mean: 85,
sd: 1,
slope: 0,
r2: 0,
sumValue: null,
minValue: 83,
maxValue: 87,
},
]);
const result = await computeSummariesSlice("user-yoy");
expect(result.summaries.WEIGHT.avg30LastYear).toBe(85);
// Router asked MONTH first (395 > 181, 395 < 731 → skip YEAR).
expect(ROLLUP_FIND_MANY.mock.calls[0][0].where.granularity).toBe("MONTH");
});
it("leaves avg30LastYear null when no bucket overlaps the year-ago slice", async () => {
RAW.mockResolvedValueOnce([{ type: "WEIGHT", has_buckets: true }])
.mockResolvedValueOnce([
{
type: "WEIGHT",
avg7: 82,
avg30: 82.5,
slope7: 0,
r2_7: 0,
slope30: 0,
r2_30: 0,
slope90: 0,
r2_90: 0,
},
])
.mockResolvedValueOnce([
{ type: "WEIGHT", value: 82.7, measured_at: new Date() },
])
.mockResolvedValueOnce([
{ type: "WEIGHT", count: 5, min: 82, max: 84, mean: 83 },
]);
// MONTH bucket placed 30 days ago — inside the YEAR / MONTH /
// WEEK 395-day window the router asks for, but outside the
// `[now-395d, now-365d)` slice. The helper returns null.
const recentBucket = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
ROLLUP_FIND_MANY.mockResolvedValueOnce([
{
bucketStart: recentBucket,
count: 10,
mean: 82,
sd: 1,
slope: 0,
r2: 0,
sumValue: null,
minValue: 80,
maxValue: 84,
},
]);
const result = await computeSummariesSlice("user-recent");
expect(result.summaries.WEIGHT.avg30LastYear).toBeNull();
});
it("leaves avg30LastYear null when every granularity misses (no coverage)", async () => {
RAW.mockResolvedValueOnce([{ type: "WEIGHT", has_buckets: true }])
.mockResolvedValueOnce([
{
type: "WEIGHT",
avg7: 82,
avg30: 82.5,
slope7: 0,
r2_7: 0,
slope30: 0,
r2_30: 0,
slope90: 0,
r2_90: 0,
},
])
.mockResolvedValueOnce([
{ type: "WEIGHT", value: 82.7, measured_at: new Date() },
])
.mockResolvedValueOnce([
{ type: "WEIGHT", count: 5, min: 82, max: 84, mean: 83 },
]);
// Default mock returns `[]` for every findMany call → router
// walks YEAR → MONTH → WEEK → DAY and gives up.
ROLLUP_FIND_MANY.mockResolvedValue([]);
const result = await computeSummariesSlice("user-empty-yoy");
expect(result.summaries.WEIGHT.avg30LastYear).toBeNull();
// 395d window skips YEAR (floor 731 d) → MONTH → WEEK → DAY,
// three reachable tiers all probed on full coverage miss.
expect(ROLLUP_FIND_MANY).toHaveBeenCalledTimes(3);
});
it("only probes types that actually have data in the current window", async () => {
// Coverage probe + rollup GROUP BY both report zero rows → no
// types-with-data, so the year-ago probe must NOT fan out.
RAW.mockResolvedValueOnce([])
.mockResolvedValueOnce([])
.mockResolvedValueOnce([])
.mockResolvedValueOnce([]);
await computeSummariesSlice("user-no-data");
expect(ROLLUP_FIND_MANY).not.toHaveBeenCalled();
});
});
});
@@ -1,6 +1,5 @@
import { describe, it, expect } from "vitest";
import {
movingAverage,
trendSlope,
detectAnomalies,
summarize,
@@ -15,27 +14,6 @@ function makePoints(values: number[], startDaysAgo = 30): DataPoint[] {
}));
}
describe("movingAverage", () => {
it("returns empty for empty input", () => {
expect(movingAverage([], 7)).toEqual([]);
});
it("calculates moving average over window", () => {
const data = makePoints([70, 71, 72, 73, 74, 75, 76], 7);
const result = movingAverage(data, 3);
expect(result).toHaveLength(7);
// Last point should be avg of last 3 days
expect(result[result.length - 1].value).toBeCloseTo(75, 0);
});
it("handles single data point", () => {
const data = makePoints([100], 1);
const result = movingAverage(data, 7);
expect(result).toHaveLength(1);
expect(result[0].value).toBe(100);
});
});
describe("trendSlope", () => {
it("returns null for insufficient data", () => {
expect(trendSlope([], 7)).toBeNull();
-35
View File
@@ -1,35 +0,0 @@
/**
* v1.4.22 W5 reconcile (Code-MED-1) — shared Berlin-day-key helper.
*
* The CLAUDE.md timezone convention is "Europe/Berlin for display,
* UTC in database". The dashboard analytics route used a private
* `berlinDayKey()` (defined in `src/app/api/analytics/route.ts:330`)
* to bucket measurements by Berlin calendar day. The targets route's
* `sparklinePoints()` was bucketing by UTC `YYYY-MM-DD` — a 23:30
* Berlin reading on Tuesday landed in Wednesday's bucket, drifting
* the sparkline 1 day on cross-DST boundaries.
*
* Lift the helper into `src/lib/analytics/` so every display surface
* shares the same bucket contract.
*/
const BERLIN_DATE_PARTS = new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/Berlin",
year: "numeric",
month: "2-digit",
day: "2-digit",
});
/**
* Format a `Date` as a `YYYY-MM-DD` string anchored to Europe/Berlin
* calendar day. Used as a stable key for daily aggregation so a
* 23:30-Berlin reading on Tuesday lands in Tuesday's bucket
* regardless of UTC offset (CET / CEST, DST).
*/
export function berlinDayKey(d: Date): string {
const parts = BERLIN_DATE_PARTS.formatToParts(d);
const y = parts.find((p) => p.type === "year")?.value ?? "1970";
const m = parts.find((p) => p.type === "month")?.value ?? "01";
const day = parts.find((p) => p.type === "day")?.value ?? "01";
return `${y}-${m}-${day}`;
}
+3 -3
View File
@@ -52,11 +52,11 @@
*/
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import { readRollupBuckets } from "@/lib/measurements/rollups";
import { readRollupBuckets } from "@/lib/rollups/measurement-rollups";
import {
probeRollupCoverage,
type RollupCoverageMap,
} from "@/lib/measurements/rollup-coverage";
} from "@/lib/rollups/measurement-coverage";
import { isNearUtc } from "@/lib/tz/resolver";
import {
computeBpInTargetWindows,
@@ -432,7 +432,7 @@ async function fetchSeriesChunked(
let cursorId: string | undefined;
for (let page = 0; page < 1000; page++) {
const chunk = await prisma.measurement.findMany({
where: { userId, type, measuredAt: { gte: since } },
where: { userId, type, measuredAt: { gte: since }, deletedAt: null },
orderBy: [{ measuredAt: "asc" }, { id: "asc" }],
select: { id: true, measuredAt: true, value: true },
take: CHUNK,
+3 -3
View File
@@ -45,11 +45,11 @@
*/
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import { readRollupBuckets } from "@/lib/measurements/rollups";
import { readRollupBuckets } from "@/lib/rollups/measurement-rollups";
import {
probeRollupCoverage,
type RollupCoverageMap,
} from "@/lib/measurements/rollup-coverage";
} from "@/lib/rollups/measurement-coverage";
import {
correlateBpCompliance,
correlateMoodPulse,
@@ -342,7 +342,7 @@ async function fetchSeriesChunked(
let cursorId: string | undefined;
for (let page = 0; page < 1000; page++) {
const chunk = await prisma.measurement.findMany({
where: { userId, type, measuredAt: { gte: since } },
where: { userId, type, measuredAt: { gte: since }, deletedAt: null },
orderBy: [{ measuredAt: "asc" }, { id: "asc" }],
select: { id: true, measuredAt: true, value: true },
take: CHUNK,
-66
View File
@@ -105,69 +105,3 @@ export function pearsonCorrelation(
return { r, strength, n };
}
const BERLIN_DATE_PARTS = new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/Berlin",
year: "numeric",
month: "2-digit",
day: "2-digit",
weekday: "short",
});
/**
* Aggregate data into weekly averages, ISO-Monday-based, in Europe/Berlin.
*
* v3 audit caught a TZ bug here: the previous implementation used
* `Date.getDay()` / `Date.getDate()` (system local) so on a UTC server, a
* Sunday-evening Berlin reading bucketed into the next week. We now derive
* year/month/day/weekday in Berlin via Intl.DateTimeFormat and rebuild the
* Monday key from there. The returned `date` for each bucket is the Monday
* UTC midnight — sortable, comparable across DST.
*/
export function weeklyAverages(data: DataPoint[]): DataPoint[] {
if (data.length === 0) return [];
const sorted = [...data].sort((a, b) => a.date.getTime() - b.date.getTime());
const weeks = new Map<string, { sum: number; count: number; date: Date }>();
const WEEKDAY_INDEX: Record<string, number> = {
Mon: 1,
Tue: 2,
Wed: 3,
Thu: 4,
Fri: 5,
Sat: 6,
Sun: 7,
};
for (const point of sorted) {
const parts = BERLIN_DATE_PARTS.formatToParts(point.date);
const yearStr = parts.find((p) => p.type === "year")?.value ?? "1970";
const monthStr = parts.find((p) => p.type === "month")?.value ?? "01";
const dayStr = parts.find((p) => p.type === "day")?.value ?? "01";
const weekdayStr = parts.find((p) => p.type === "weekday")?.value ?? "Mon";
const isoWeekday = WEEKDAY_INDEX[weekdayStr] ?? 1;
// Days to subtract to land on Monday of the same ISO week in Berlin.
const offsetDays = isoWeekday - 1;
const dayUtc = new Date(`${yearStr}-${monthStr}-${dayStr}T00:00:00.000Z`);
const monday = new Date(dayUtc.getTime() - offsetDays * 86_400_000);
const key = monday.toISOString().slice(0, 10);
const existing = weeks.get(key);
if (existing) {
existing.sum += point.value;
existing.count++;
} else {
weeks.set(key, { sum: point.value, count: 1, date: monday });
}
}
return Array.from(weeks.values())
.sort((a, b) => a.date.getTime() - b.date.getTime())
.map((w) => ({
date: w.date,
value: Math.round((w.sum / w.count) * 100) / 100,
}));
}
+67 -9
View File
@@ -40,11 +40,12 @@
*/
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import { readRollupBuckets } from "@/lib/measurements/rollups";
import { readRollupBuckets } from "@/lib/rollups/measurement-rollups";
import {
probeRollupCoverage,
type RollupCoverageMap,
} from "@/lib/measurements/rollup-coverage";
} from "@/lib/rollups/measurement-coverage";
import { readBestGranularityRollups } from "@/lib/rollups/measurement-read-wmy";
import { calculateCompliance } from "./compliance";
import {
computeHealthScore,
@@ -159,19 +160,38 @@ export async function computeUserHealthScoreFastPath(
let weightSeriesPrev30d: Array<{ date: string; kg: number }>;
let weightSourcesIn30d: ReadonlyArray<ContributingSource>;
let latestWeightAsOf: string | null;
// v1.4.40 W-WMY-WIRE — long-window weight baseline served from the
// WEEK / MONTH / YEAR rollup tier. Used for the diagnostic annotate
// so operators can verify the WMY readers actually serve production
// traffic instead of sitting as dead write amplification (see
// `.planning/round-v1438-perf-analysis.md` §2 + §5 P6). The score
// shape stays unchanged; the long-window mean is annotate-only so
// the v1.5 Coach drawer can opt into it later without breaking the
// current contract.
let weightLongWindowGranularity: string | null = null;
let weightLongWindowMean: number | null = null;
let weightLongWindowBucketCount = 0;
if (weightCovered) {
// Rollup-fast-path — per-day MEAN weight from `measurement_rollups`.
// Linear regression on per-day means produces a slope equivalent
// to per-event regression for a series with consistent sampling
// cadence (one weigh-in per day is the canonical pattern).
const dayBuckets = await readRollupBuckets(
userId,
"WEIGHT",
"DAY",
prevSince30d,
now,
);
//
// v1.4.40 W-WMY-WIRE — the long-window weight read runs in
// parallel with the canonical DAY-bucket read so the wall-clock
// cost stays flat. `readBestGranularityRollups(..., 365)` routes
// the trailing-year window through MONTH (floor 181 d) by default
// — typically 12 monthly buckets vs the ~365 DAY rows the live
// path would scan. count / mean are linearly composable across
// MONTH buckets so the derived mean is mathematically equivalent
// to the per-row average over the same year-long window.
// Coverage-fallback in the helper means a tenant with WEEK or DAY
// coverage but no MONTH buckets still resolves to a usable mean.
const [dayBuckets, longWindow] = await Promise.all([
readRollupBuckets(userId, "WEIGHT", "DAY", prevSince30d, now),
readBestGranularityRollups(userId, "WEIGHT", 365),
]);
weightSeriesLast30d = dayBuckets
.filter((b) => b.bucketStart >= since30d)
.map((b) => ({
@@ -184,6 +204,19 @@ export async function computeUserHealthScoreFastPath(
date: b.bucketStart.toISOString(),
kg: b.mean,
}));
if (longWindow && longWindow.rows.length > 0) {
weightLongWindowGranularity = longWindow.granularity;
weightLongWindowBucketCount = longWindow.rows.length;
let totalCount = 0;
let weighted = 0;
for (const row of longWindow.rows) {
totalCount += row.count;
weighted += row.count * row.mean;
}
if (totalCount > 0) {
weightLongWindowMean = weighted / totalCount;
}
}
// Source attribution still needs the raw rows — only the raw table
// carries the `source` enum. Pull the narrow 30-day window with a
// 2-column projection so the round-trip stays minimal.
@@ -192,6 +225,7 @@ export async function computeUserHealthScoreFastPath(
userId,
type: "WEIGHT",
measuredAt: { gte: since30d, lte: now },
deletedAt: null,
},
select: { measuredAt: true, source: true },
orderBy: { measuredAt: "asc" },
@@ -206,6 +240,7 @@ export async function computeUserHealthScoreFastPath(
userId,
type: "WEIGHT",
measuredAt: { gte: prevSince30d, lte: now },
deletedAt: null,
},
select: { value: true, measuredAt: true, source: true },
orderBy: { measuredAt: "asc" },
@@ -235,6 +270,7 @@ export async function computeUserHealthScoreFastPath(
userId,
type: "BLOOD_PRESSURE_SYS",
measuredAt: { gte: since30d, lte: now },
deletedAt: null,
},
select: { measuredAt: true, source: true },
orderBy: { measuredAt: "asc" },
@@ -335,6 +371,14 @@ export async function computeUserHealthScoreFastPath(
score: null,
reason: "no_components_available",
path: weightCovered ? "rollup" : "live",
weightLongWindow:
weightLongWindowMean !== null
? {
mean: Math.round(weightLongWindowMean * 100) / 100,
granularity: weightLongWindowGranularity,
buckets: weightLongWindowBucketCount,
}
: null,
},
},
});
@@ -403,6 +447,20 @@ export async function computeUserHealthScoreFastPath(
band: result.band,
delta: result.delta,
path: weightCovered ? "rollup" : "live",
// v1.4.40 W-WMY-WIRE — the long-window weight baseline drawn
// from the WEEK / MONTH / YEAR rollup tier. `null` when the
// user lacks long-tail coverage or when the live fallback
// branch ran (no probe issued). Surfaces the granularity the
// router landed on so operators can see "rollup tier served
// the year-long mean from MONTH buckets" in the wide-event.
weightLongWindow:
weightLongWindowMean !== null
? {
mean: Math.round(weightLongWindowMean * 100) / 100,
granularity: weightLongWindowGranularity,
buckets: weightLongWindowBucketCount,
}
: null,
},
},
});
+143 -3
View File
@@ -59,16 +59,18 @@ import { prisma } from "@/lib/db";
import type { DataSummary } from "@/lib/analytics/trends";
import { measurementTypeEnum } from "@/lib/validations/measurement";
import { annotate } from "@/lib/logging/context";
import { ensureUserRollupsFresh } from "@/lib/measurements/rollups";
import { ensureUserRollupsFresh } from "@/lib/rollups/measurement-rollups";
import {
isFullyCovered,
probeRollupCoverage,
} from "@/lib/measurements/rollup-coverage";
} from "@/lib/rollups/measurement-coverage";
import {
aggregateWmyBuckets,
readBestGranularityRollups,
type RollupBucketRow,
} from "@/lib/measurements/rollup-read-wmy";
} from "@/lib/rollups/measurement-read-wmy";
const DAY_MS = 24 * 60 * 60 * 1000;
/**
* Heavy aggregate row — used on the cold-mount fallback path where the
@@ -301,6 +303,7 @@ async function computeFromRollups(userId: string): Promise<SummariesSlice> {
)::double precision AS r2_90
FROM measurements m
WHERE m."user_id" = ${userId}
AND m."deleted_at" IS NULL
GROUP BY m."type"
`,
prisma.$queryRaw<LatestRow[]>`
@@ -310,6 +313,7 @@ async function computeFromRollups(userId: string): Promise<SummariesSlice> {
m."measured_at" AS measured_at
FROM measurements m
WHERE m."user_id" = ${userId}
AND m."deleted_at" IS NULL
ORDER BY m."type", m."measured_at" DESC
`,
// v1.4.37.2 hotfix — the v1.4.35 implementation read EVERY DAY
@@ -388,10 +392,12 @@ async function computeFromRollups(userId: string): Promise<SummariesSlice> {
let totalRows = 0;
let typeCount = 0;
const typesWithData: string[] = [];
for (const row of dayBuckets) {
if (!row.count || row.count <= 0) continue;
typeCount += 1;
totalRows += row.count;
typesWithData.push(row.type);
const narrow = narrowByType.get(row.type);
const latest = latestByType.get(row.type) ?? null;
summaries[row.type] = {
@@ -411,6 +417,21 @@ async function computeFromRollups(userId: string): Promise<SummariesSlice> {
};
}
// v1.4.40 W-WMY-WIRE — populate `avg30LastYear` per type from the
// WMY rollup tier. Only types with data in the current window are
// probed so we don't pay the per-type round-trip on the long tail
// of unlogged measurement enums. Types without YEAR/MONTH coverage
// surface as `null` (existing behaviour) — additive only.
const avg30LastYearMap = await computeAvg30LastYearMap(userId, typesWithData);
let yearOverYearTypeCount = 0;
for (const [type, value] of avg30LastYearMap.entries()) {
if (value === null) continue;
const summary = summaries[type];
if (!summary) continue;
summary.avg30LastYear = round2(value);
yearOverYearTypeCount += 1;
}
annotate({
action: { name: "analytics.get.slim" },
meta: {
@@ -419,6 +440,7 @@ async function computeFromRollups(userId: string): Promise<SummariesSlice> {
row_count: totalRows,
type_count: typeCount,
path: "rollup",
year_over_year_types: yearOverYearTypeCount,
},
},
},
@@ -487,6 +509,7 @@ async function computeFromLiveAggregate(
)::double precision AS r2_90
FROM measurements m
WHERE m."user_id" = ${userId}
AND m."deleted_at" IS NULL
GROUP BY m."type"
`,
prisma.$queryRaw<LatestRow[]>`
@@ -496,6 +519,7 @@ async function computeFromLiveAggregate(
m."measured_at" AS measured_at
FROM measurements m
WHERE m."user_id" = ${userId}
AND m."deleted_at" IS NULL
ORDER BY m."type", m."measured_at" DESC
`,
]);
@@ -531,9 +555,11 @@ async function computeFromLiveAggregate(
}
let totalRows = 0;
const typesWithData: string[] = [];
for (const row of aggregates) {
const count = Number(row.count);
totalRows += count;
typesWithData.push(row.type);
const latest = latestByType.get(row.type) ?? null;
summaries[row.type] = {
count,
@@ -552,6 +578,23 @@ async function computeFromLiveAggregate(
};
}
// v1.4.40 W-WMY-WIRE — even on the live-aggregate fallback (DAY
// coverage incomplete), the YEAR / MONTH rollup tier may still
// carry the year-ago baseline because the boot-time backfill mints
// all granularities once per user. Probing here means a partial-
// coverage cold mount still surfaces `avg30LastYear` whenever the
// long-tail tier is populated, instead of waiting for the next
// refresh cycle.
const avg30LastYearMap = await computeAvg30LastYearMap(userId, typesWithData);
let yearOverYearTypeCount = 0;
for (const [type, value] of avg30LastYearMap.entries()) {
if (value === null) continue;
const summary = summaries[type];
if (!summary) continue;
summary.avg30LastYear = round2(value);
yearOverYearTypeCount += 1;
}
annotate({
action: { name: "analytics.get.slim" },
meta: {
@@ -560,6 +603,7 @@ async function computeFromLiveAggregate(
row_count: totalRows,
type_count: aggregates.length,
path: "live",
year_over_year_types: yearOverYearTypeCount,
},
},
},
@@ -660,3 +704,99 @@ export async function computeLongWindowSummary(
sum: aggregate.sum,
};
}
/**
* v1.4.40 W-WMY-WIRE — year-ago 30-day baseline per type, served from
* the WEEK / MONTH / YEAR rollup tier.
*
* Why this helper exists
* ----------------------
* `DataSummary.avg30LastYear` is the mean over `[now-395d, now-365d)` —
* the 30-day window starting 365 days ago. The dashboard tile-strip's
* delta callout uses it to narrate "current avg vs same time last
* year". Up to v1.4.39 the slim slice (and the comprehensive
* aggregator's rollup branch) hardcoded the field to `null` because
* the 90-day windowed `$queryRaw` cannot reach back a year, and the
* live `measurements` table walk for a 395-day per-type window is
* exactly the cold-path cost we're trying to avoid.
*
* The WMY rollup tier already carries the per-bucket stats this
* window needs. `readBestGranularityRollups(userId, type, 395)`
* routes the 395-day window through YEAR (if covered) → MONTH → WEEK
* → DAY and returns the buckets whose `bucketStart` falls inside the
* window. We then keep only the buckets that overlap the
* `[now-395d, now-365d)` slice and compose `count / mean` linearly to
* produce the year-ago baseline.
*
* Compositional contract
* ----------------------
* `count / mean` are linearly composable across any granularity, so
* the year-ago mean derived from MONTH or YEAR buckets is
* mathematically equivalent to the per-row average over the same
* window. The bucket-overlap filter is conservative: we include only
* buckets whose `bucketStart` is strictly inside the slice. On a
* YEAR-granularity routing this collapses to at most one bucket and
* approximates the 30-day mean by the surrounding yearly average —
* acceptable for a UI hint that explicitly narrates "last year".
*
* Coverage-miss policy
* --------------------
* Returns `null` when `readBestGranularityRollups` returns null (no
* coverage at any granularity) OR when no buckets overlap the
* year-ago slice. The caller leaves the dashboard field as `null`,
* which the UI already handles as "no comparison available".
*/
async function computeAvg30LastYearForType(
userId: string,
type: MeasurementType,
): Promise<number | null> {
const resolved = await readBestGranularityRollups(userId, type, 395);
if (!resolved) return null;
const now = Date.now();
const sliceStart = now - 395 * DAY_MS;
const sliceEnd = now - 365 * DAY_MS;
const overlapping = resolved.rows.filter((row) => {
const t = row.bucketStart.getTime();
return t >= sliceStart && t < sliceEnd;
});
if (overlapping.length === 0) return null;
let totalCount = 0;
let weighted = 0;
for (const row of overlapping) {
totalCount += row.count;
weighted += row.count * row.mean;
}
if (totalCount === 0) return null;
return weighted / totalCount;
}
/**
* v1.4.40 W-WMY-WIRE — fan out `computeAvg30LastYearForType` across
* the types the caller actually surfaces. Runs the per-type WMY reads
* in parallel so the long-tail of types doesn't serialise into a
* per-type round-trip stack.
*
* Returns a `type → mean | null` map; types with no coverage at any
* granularity (or no buckets in the year-ago slice) map to `null` so
* the caller can blanket-assign without per-type null-checking.
*/
async function computeAvg30LastYearMap(
userId: string,
types: ReadonlyArray<string>,
): Promise<Map<string, number | null>> {
const out = new Map<string, number | null>();
if (types.length === 0) return out;
const results = await Promise.all(
types.map(async (type) => {
const value = await computeAvg30LastYearForType(
userId,
type as MeasurementType,
);
return [type, value] as const;
}),
);
for (const [type, value] of results) {
out.set(type, value);
}
return out;
}
-27
View File
@@ -8,33 +8,6 @@ export interface DataPoint {
value: number;
}
// ── Moving Average ───────────────────────────────────────
export function movingAverage(
data: DataPoint[],
windowDays: number,
): DataPoint[] {
if (data.length === 0) return [];
const sorted = [...data].sort((a, b) => a.date.getTime() - b.date.getTime());
const result: DataPoint[] = [];
const windowMs = windowDays * 24 * 60 * 60 * 1000;
for (const point of sorted) {
const windowStart = point.date.getTime() - windowMs;
const windowPoints = sorted.filter(
(p) =>
p.date.getTime() > windowStart &&
p.date.getTime() <= point.date.getTime(),
);
const avg =
windowPoints.reduce((sum, p) => sum + p.value, 0) / windowPoints.length;
result.push({ date: point.date, value: Math.round(avg * 100) / 100 });
}
return result;
}
// ── Trend Slope ──────────────────────────────────────────
export interface TrendSlope {