mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +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
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user