Merge pull request #619 from MBombeck/release/v1.32.17

release/v1.32.17 — pair mood correlations on the local logging day (v1.32.12 regression)
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-07-24 12:31:59 +02:00
commit 6a0bec7553
8 files changed
+426 -44

No files matched your search

+19
View File
@@ -2,6 +2,25 @@
## [Unreleased]
## [1.32.17] — 2026-07-24
The mood correlations on the Insights page could line up a day off. Your mood
is dated by the calendar day you logged it in your own timezone, but the
metrics it was matched against were still grouped by the UTC day. If you live
away from UTC, an evening reading slid onto the next UTC day, so a mood and a
reading from the same evening were paired as if a day apart. A regression from
v1.32.12, when the mood side moved to local days on its own.
- **Mood now pairs with metrics from the same local day.** The blood pressure,
weight, and resting pulse series that mood is correlated against are grouped
by the day you logged them in your own timezone, so both sides of the pairing
share one calendar. An evening entry no longer drifts onto the next day.
- **Non-UTC users get the correct same-day figures.** For anyone west of UTC an
evening reading used to pair with the wrong day's mood, turning a same-day
relationship into a one-day-apart one. Those correlations and their scatter
charts now reflect the day the data was actually recorded. UTC users see no
change.
## [1.32.16] — 2026-07-24
Changing which tiles and charts appear on your dashboard could get undone if
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.32.16
version: 1.32.17
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.32.16",
"version": "1.32.17",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "PolyForm-Noncommercial-1.0.0",
"homepage": "https://healthlog.dev",
+1 -1
View File
@@ -36,7 +36,7 @@ try {
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
const CACHE_VERSION =
(typeof self !== "undefined" && self.__APP_VERSION__) ||
/* @sw-version-fallback */ "v1.32.16";
/* @sw-version-fallback */ "v1.32.17";
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
@@ -93,6 +93,7 @@ import { getSession } from "@/lib/auth/session";
import { prisma } from "@/lib/db";
import { buildComprehensiveAggregate } from "@/lib/insights/comprehensive-aggregator";
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
import { getMedicationCategories } from "@/lib/medication-category";
import { __resetAllCachesForTests, caches } from "@/lib/cache/server-cache";
const SESSION_OK = {
@@ -157,6 +158,7 @@ describe("GET /api/insights/comprehensive — envelope shape", () => {
{
summaries: {},
bpRawRows: { sys: [], dia: [] },
weightRawRows: [],
dailyByType: {},
firstMeasurementAt: null,
totalMeasurements: 0,
@@ -229,6 +231,7 @@ describe("GET /api/insights/comprehensive — envelope shape", () => {
},
},
bpRawRows: { sys: [], dia: [] },
weightRawRows: [],
dailyByType: {},
firstMeasurementAt: new Date(),
totalMeasurements: 1,
@@ -285,6 +288,7 @@ describe("GET /api/insights/comprehensive — envelope shape", () => {
sys: [{ measuredAt: at, value: 125 }],
dia: [{ measuredAt: at, value: 75 }],
},
weightRawRows: [],
dailyByType: {},
firstMeasurementAt: at,
totalMeasurements: 2,
@@ -324,6 +328,7 @@ describe("GET /api/insights/comprehensive — envelope shape", () => {
sys: [{ measuredAt: new Date("2026-05-14T21:50:00Z"), value: 125 }],
dia: [{ measuredAt: new Date("2026-05-14T22:10:00Z"), value: 75 }],
},
weightRawRows: [],
dailyByType: {},
firstMeasurementAt: new Date("2026-05-14T21:50:00Z"),
totalMeasurements: 2,
@@ -350,6 +355,7 @@ describe("GET /api/insights/comprehensive — envelope shape", () => {
{
summaries: {},
bpRawRows: { sys: [], dia: [] },
weightRawRows: [],
dailyByType: {},
firstMeasurementAt: new Date(),
totalMeasurements: 0,
@@ -409,6 +415,7 @@ describe("GET /api/insights/comprehensive — envelope shape", () => {
{
summaries: {},
bpRawRows: { sys: [], dia: [] },
weightRawRows: [],
dailyByType: {},
firstMeasurementAt: new Date(),
totalMeasurements: 0,
@@ -456,6 +463,7 @@ describe("GET /api/insights/comprehensive — envelope shape", () => {
{
summaries: {},
bpRawRows: { sys: [], dia: [] },
weightRawRows: [],
dailyByType: {
WEIGHT: days.map((d, i) => ({ day: d, value: 80 + i })),
BLOOD_PRESSURE_SYS: days.map((d, i) => ({
@@ -491,6 +499,7 @@ describe("GET /api/insights/comprehensive — stale-while-revalidate marker", ()
const EMPTY_AGGREGATE = {
summaries: {},
bpRawRows: { sys: [], dia: [] },
weightRawRows: [],
dailyByType: {},
firstMeasurementAt: null,
totalMeasurements: 0,
@@ -543,3 +552,278 @@ describe("GET /api/insights/comprehensive — stale-while-revalidate marker", ()
expect(freshBody.data.revalidating).toBe(false);
});
});
// v1.32.17 (R1) — mood × metric correlations must pair on the LOCAL
// logging day. The mood series has been local-day-keyed since v1.32.12;
// the three metric partners (sys BP, weight, resting pulse) were still
// UTC-keyed, so for non-UTC users an evening reading landed a day off and
// paired with the wrong mood entry. These tests run under CI's TZ=UTC and
// carry tz-explicit fixtures so the assertions are host-TZ-independent.
describe("GET /api/insights/comprehensive — mood × metric local-day pairing (v1.32.17)", () => {
type MoodBody = {
data: {
moodBpScatterData: Array<{ mood: number; sysBP: number }>;
moodWeightScatterData: Array<{ mood: number; weight: number }>;
moodPulseScatterData: Array<{ mood: number; pulse: number }>;
scatterData: Array<{ weight: number; sysBP: number }>;
weightBpCorrelation: unknown;
bpMedicationCorrelation: unknown;
};
};
function moodRollupDay(midnightZ: string, mean: number, count = 1) {
return {
userId: "user-comp-1",
granularity: "DAY",
bucketStart: new Date(midnightZ),
count,
mean,
minScore: mean,
maxScore: mean,
sd: null,
computedAt: new Date(),
};
}
function sessionWithTz(tz: string) {
vi.mocked(getSession).mockResolvedValue({
...SESSION_OK,
user: { ...SESSION_OK.user, timezone: tz },
} as never);
}
it("Berlin: a mood + BP logged the same local evening now pair (previously dropped)", async () => {
sessionWithTz("Europe/Berlin");
// Mood logged 00:30 local on 2026-07-10 → rollup bucketStart is the
// canonical local day at UTC midnight.
(
prisma.moodEntryRollup.findMany as ReturnType<typeof vi.fn>
).mockResolvedValue([moodRollupDay("2026-07-10T00:00:00.000Z", 4)]);
(buildComprehensiveAggregate as ReturnType<typeof vi.fn>).mockResolvedValue(
{
summaries: {},
// 22:35Z = 00:35 Berlin on Jul 10 → local day 2026-07-10, but UTC
// day 2026-07-09. The old UTC `dailyByType` key (below) would never
// match the mood's local 2026-07-10 → the pair was dropped.
bpRawRows: {
sys: [{ measuredAt: new Date("2026-07-09T22:35:00Z"), value: 128 }],
dia: [],
},
weightRawRows: [],
dailyByType: {
BLOOD_PRESSURE_SYS: [{ day: "2026-07-09", value: 128 }],
},
firstMeasurementAt: new Date("2026-07-09T22:35:00Z"),
totalMeasurements: 1,
},
);
const res = await callGet(makeReq());
const body = (await res.json()) as MoodBody;
// Derived from the raw row in the user's tz → pairs on local 2026-07-10.
expect(body.data.moodBpScatterData).toEqual([{ mood: 4, sysBP: 128 }]);
});
it("New York: the lag-1 offset is gone — each pair couples mood(D) with the metric logged local day D", async () => {
sessionWithTz("America/New_York");
(
prisma.moodEntryRollup.findMany as ReturnType<typeof vi.fn>
).mockResolvedValue([
moodRollupDay("2026-07-10T00:00:00.000Z", 2),
moodRollupDay("2026-07-11T00:00:00.000Z", 3),
moodRollupDay("2026-07-12T00:00:00.000Z", 4),
]);
(buildComprehensiveAggregate as ReturnType<typeof vi.fn>).mockResolvedValue(
{
summaries: {},
// Each sys row is at (D+1)T01:00Z = 21:00 local on day D (EDT UTC-4).
// Old UTC code keyed each on D+1 and paired it with mood(D+1) — a
// lag-1 correlation sold as same-day. Local derivation keys on D.
bpRawRows: {
sys: [
{ measuredAt: new Date("2026-07-11T01:00:00Z"), value: 120 },
{ measuredAt: new Date("2026-07-12T01:00:00Z"), value: 130 },
{ measuredAt: new Date("2026-07-13T01:00:00Z"), value: 140 },
],
dia: [],
},
weightRawRows: [],
dailyByType: {},
firstMeasurementAt: new Date("2026-07-11T01:00:00Z"),
totalMeasurements: 3,
},
);
const res = await callGet(makeReq());
const body = (await res.json()) as MoodBody;
// mood(07-10)=2 ↔ 120 (logged local 07-10), not 130 (the old +1 offset).
expect(body.data.moodBpScatterData).toEqual([
{ mood: 2, sysBP: 120 },
{ mood: 3, sysBP: 130 },
{ mood: 4, sysBP: 140 },
]);
});
it("Berlin: mood × weight and mood × resting pulse also pair on the local day (all three partners moved)", async () => {
sessionWithTz("Europe/Berlin");
(
prisma.moodEntryRollup.findMany as ReturnType<typeof vi.fn>
).mockResolvedValue([moodRollupDay("2026-07-10T00:00:00.000Z", 4)]);
// Resting-HR row at 00:30 local Jul 10 (= 22:30Z Jul 9).
(prisma.measurement.findMany as ReturnType<typeof vi.fn>).mockResolvedValue(
[{ measuredAt: new Date("2026-07-09T22:30:00Z"), value: 58 }],
);
(buildComprehensiveAggregate as ReturnType<typeof vi.fn>).mockResolvedValue(
{
summaries: {},
bpRawRows: { sys: [], dia: [] },
// Weight row at 00:30 local Jul 10 (= 22:30Z Jul 9).
weightRawRows: [
{ measuredAt: new Date("2026-07-09T22:30:00Z"), value: 80.5 },
],
dailyByType: {},
firstMeasurementAt: new Date("2026-07-09T22:30:00Z"),
totalMeasurements: 2,
},
);
const res = await callGet(makeReq());
const body = (await res.json()) as MoodBody;
expect(body.data.moodWeightScatterData).toEqual([
{ mood: 4, weight: 80.5 },
]);
expect(body.data.moodPulseScatterData).toEqual([{ mood: 4, pulse: 58 }]);
});
it("UTC user: pairing is byte-identical to the pre-fix UTC-slice join (invariance)", async () => {
sessionWithTz("UTC");
(
prisma.moodEntryRollup.findMany as ReturnType<typeof vi.fn>
).mockResolvedValue([moodRollupDay("2026-07-10T00:00:00.000Z", 4)]);
(buildComprehensiveAggregate as ReturnType<typeof vi.fn>).mockResolvedValue(
{
summaries: {},
// Under UTC, `userDayKey` degenerates to the ISO slice, so the raw
// derivation reproduces exactly what the old `dailyByType` join gave.
bpRawRows: {
sys: [{ measuredAt: new Date("2026-07-10T08:00:00Z"), value: 122 }],
dia: [],
},
weightRawRows: [],
dailyByType: {
BLOOD_PRESSURE_SYS: [{ day: "2026-07-10", value: 122 }],
},
firstMeasurementAt: new Date("2026-07-10T08:00:00Z"),
totalMeasurements: 1,
},
);
const res = await callGet(makeReq());
const body = (await res.json()) as MoodBody;
expect(body.data.moodBpScatterData).toEqual([{ mood: 4, sysBP: 122 }]);
});
it("metric × metric guard: weight × BP and BP-med continuity are byte-identical across timezones (UTC joins untouched)", async () => {
// R1 must NOT re-key the two internally-consistent UTC joins
// (weight × BP, BP-med continuity) — those migrate with the rollup
// tier in R12. Both read the UTC `dailyByType` / UTC `scheduledFor`
// slice, so their output must be invariant to the caller's timezone.
// Run the SAME fixture under UTC then America/New_York and deep-equal.
const days = [
"2026-05-01",
"2026-05-02",
"2026-05-03",
"2026-05-04",
"2026-05-05",
];
const aggregate = {
summaries: {},
bpRawRows: { sys: [], dia: [] },
weightRawRows: [],
dailyByType: {
WEIGHT: days.map((d, i) => ({ day: d, value: 80 + i })),
BLOOD_PRESSURE_SYS: days.map((d, i) => ({
day: d,
value: 120 + i * 2,
})),
},
firstMeasurementAt: new Date(),
totalMeasurements: 10,
};
const medication = {
id: "med-bp",
name: "Amlodipine",
dose: "5mg",
active: true,
asNeeded: false,
createdAt: new Date("2026-04-01T00:00:00Z"),
schedules: [
{
id: "sch-1",
windowStart: "07:00",
windowEnd: "08:00",
timesOfDay: ["07:00"],
daysOfWeek: null,
rrule: null,
rollingIntervalDays: null,
reminderGraceMinutes: null,
scheduleType: "SCHEDULED",
cyclicOnWeeks: null,
cyclicOffWeeks: null,
doseWindows: [],
},
],
scheduleRevisions: [],
pauseEras: [],
};
// Intake taken on the first three days, missed on the last two → a
// non-degenerate continuity series.
const intakeEvents = days.slice(0, 3).map((d) => ({
medicationId: "med-bp",
userId: "user-comp-1",
deletedAt: null,
scheduledFor: new Date(`${d}T07:00:00Z`),
takenAt: new Date(`${d}T07:05:00Z`),
skipped: false,
}));
function primeMocks() {
(
buildComprehensiveAggregate as ReturnType<typeof vi.fn>
).mockResolvedValue(aggregate);
(
prisma.medication.findMany as ReturnType<typeof vi.fn>
).mockResolvedValue([medication]);
(
prisma.medicationIntakeEvent.findMany as ReturnType<typeof vi.fn>
).mockResolvedValue(intakeEvents);
vi.mocked(getMedicationCategories).mockResolvedValue({
"med-bp": "BLOOD_PRESSURE",
});
}
sessionWithTz("UTC");
primeMocks();
const utcBody = (await (await callGet(makeReq())).json()) as MoodBody;
// Fresh cache so the second run recomputes rather than serving the
// cached UTC body under the same user id.
__resetAllCachesForTests();
sessionWithTz("America/New_York");
primeMocks();
const nyBody = (await (await callGet(makeReq())).json()) as MoodBody;
expect(nyBody.data.scatterData).toEqual(utcBody.data.scatterData);
expect(nyBody.data.weightBpCorrelation).toEqual(
utcBody.data.weightBpCorrelation,
);
expect(nyBody.data.bpMedicationCorrelation).toEqual(
utcBody.data.bpMedicationCorrelation,
);
// And the guard is meaningful only if these joins actually produced
// output to compare.
expect(utcBody.data.scatterData).toHaveLength(5);
expect(utcBody.data.bpMedicationCorrelation).not.toBeNull();
});
});
+92 -39
View File
@@ -36,6 +36,7 @@ import {
ensureUserMoodRollupsFresh,
readMoodDayRollups,
} from "@/lib/rollups/mood-rollups";
import { userDayKey } from "@/lib/tz/format";
export const dynamic = "force-dynamic";
@@ -136,13 +137,17 @@ export async function buildComprehensiveResponse(user: AuthedUser) {
// warm-up so the next request lands on the rollup tier. Same posture
// as `/api/mood/analytics`.
//
// The bucket key shape change is documented in the route-parity test
// for `/api/mood/analytics`: rollup `bucketStart` is UTC-anchored
// YYYY-MM-DD; the legacy `MoodEntry.date` column is TZ-anchored
// (write-time). For Berlin tenants whose mood log timestamps don't
// straddle the UTC boundary the two labels agree on every realistic
// entry — the v1.5 per-user-tz bucketing closes the residual DST
// edge.
// Since v1.32.12 the mood rollup writer mints `bucketStart` from the
// canonical local `MoodEntry.date` label at UTC midnight, so
// `.toISOString().slice(0, 10)` recovers the user's LOCAL calendar day —
// byte-identical to the live fallback's `entry.date`. Both mood paths
// therefore emit local-day keys. The mood × metric pairing partners
// below (sys BP, weight, resting pulse) are consequently derived in the
// user's tz (v1.32.17) so both join sides share ONE local-day key space.
// The measurement-rollup tier itself still buckets UTC (`dailyByType`)
// and migrates to local in a documented follow-up release; the two
// internally-consistent UTC joins (weight × BP, BP-med continuity) are
// left on it deliberately (see their call sites below).
void ensureUserMoodRollupsFresh(userId);
const moodRollupDayRows = await readMoodDayRollups(userId, ninetyDaysAgo);
let dailyMoodEntries: Array<{ day: string; value: number }>;
@@ -247,6 +252,13 @@ export async function buildComprehensiveResponse(user: AuthedUser) {
// 24-hour default tolerance — daily-mean buckets are the
// SQL-side equivalent and the directive explicitly accepts the
// small semantic shift to bound the raw-row footprint).
//
// v1.32.17 — this pair stays on the UTC `dailyByType` series: BOTH
// sides come from `dailyByType` (weight × sys), so they share one UTC
// day-key space and are internally consistent. Re-keying it to local
// is R12's read-swap (it migrates with the rollup tier), NOT this
// hotfix — doing it here would mean per-request raw re-aggregation
// without the local-bucketed rollup behind it.
const weightDaily = aggregate.dailyByType.WEIGHT ?? [];
const sysDaily = aggregate.dailyByType.BLOOD_PRESSURE_SYS ?? [];
const weightBpPairs: PairedPoint[] = joinDailyByDay(weightDaily, sysDaily);
@@ -257,14 +269,26 @@ export async function buildComprehensiveResponse(user: AuthedUser) {
}));
// ── Correlations: mood × {sys BP, weight, pulse} ─────────
// Daily-key matches the legacy `buildMoodMetricPairs` exactly —
// mood is already per-day in the legacy code and we just align the
// metric side to the same daily bucket.
const moodBpPairs = pairMoodWithDaily(dailyMoodEntries, sysDaily);
// v1.32.17 — the mood series is LOCAL-day-keyed (since v1.32.12, see the
// rollup note above). `pairMoodWithDaily` joins on an EXACT YYYY-MM-DD
// key, so the metric partners MUST also be local-day-keyed or every
// evening logger west of UTC gets a lag-1 pairing sold as same-day.
// Derive the sys / weight partners as exact local-day means from the raw
// rows already on the request path (canonical-source deduped in the
// aggregator), NOT from the UTC `dailyByType` — a UTC-day MEAN cannot be
// relabeled into a local-day mean. `round2` mirrors the aggregator's
// `ROUND(AVG, 2)` so correlation thresholds don't drift.
const sysDailyLocal = deriveDailyLocalMeans(aggregate.bpRawRows.sys, userTz);
const weightDailyLocal = deriveDailyLocalMeans(
aggregate.weightRawRows,
userTz,
);
const moodBpPairs = pairMoodWithDaily(dailyMoodEntries, sysDailyLocal);
const moodBpCorrelation = pearsonCorrelation(moodBpPairs);
const moodBpScatterData = moodBpPairs.map((p) => ({ mood: p.a, sysBP: p.b }));
const moodWeightPairs = pairMoodWithDaily(dailyMoodEntries, weightDaily);
const moodWeightPairs = pairMoodWithDaily(dailyMoodEntries, weightDailyLocal);
const moodWeightCorrelation = pearsonCorrelation(moodWeightPairs);
const moodWeightScatterData = moodWeightPairs.map((p) => ({
mood: p.a,
@@ -278,23 +302,16 @@ export async function buildComprehensiveResponse(user: AuthedUser) {
// clean RESTING_HEART_RATE rows; only pull RAW PULSE (for the
// low-percentile daily proxy) when the user has no resting rows — the
// proxy needs the per-day distribution, which the day-mean discards.
// Day keys follow this route's UTC-bucket convention (the per-user-tz
// bucketing is the documented v1.5 follow-up) so the resting series pairs
// against the same UTC day keys as the mood series.
//
// v1.30.3 (QA F8) — investigated switching this to `userDayKey(d, userTz)`
// to match the intraday page's resting-proxy bucketing (`71d7ca78c`), but
// `dailyMoodEntries` below (this series' ONLY pairing partner, joined by
// an exact YYYY-MM-DD key in `pairMoodWithDaily`) is itself UTC-anchored —
// it reads `moodRollupDayRows[].bucketStart.toISOString().slice(0, 10)`,
// a real UTC aggregation boundary, not just a display label. Moving ONLY
// this side to the user's tz would not fix anything: it would swap
// "evening readings shift a day relative to the true local day" for
// "evening readings mismatch their mood pairing partner, which used to
// line up precisely because both sides shared the same UTC convention."
// A genuine fix needs the mood-rollup tier itself to bucket per-user-tz
// (the same v1.5 follow-up the comment above already flags) — out of
// scope for this pass. Left UTC on both sides intentionally.
// v1.32.17 — key this resting series on `userDayKey(d, userTz)`. Its ONLY
// pairing partner is `dailyMoodEntries`, which is LOCAL-day-keyed since
// v1.32.12 (the mood rollup writer now mints `bucketStart` from the
// canonical local `MoodEntry.date` label, not a UTC truncation). Keeping
// UTC here would offset every evening logger west of UTC by one day and
// sell a lag-1 correlation as same-day. This is exactly the flip the
// v1.30.3 F8 note said becomes correct once the mood side goes local —
// it now has. (Unlike the mood side, this series builds from raw rows in
// this route, so it re-keys directly with no rollup-tier change.)
const restingPulseRows = await prisma.measurement.findMany({
where: {
userId,
@@ -316,15 +333,15 @@ export async function buildComprehensiveResponse(user: AuthedUser) {
select: { measuredAt: true, value: true },
})
: [];
const utcDayKey = (d: Date) => d.toISOString().slice(0, 10);
const localDayKey = (d: Date) => userDayKey(d, userTz);
const { series: restingPulseSeries } = resolveRestingPulseSeries({
restingSamples: restingPulseRows,
pulseSamples: rawPulseRowsForResting,
dayKeyOf: utcDayKey,
dayKeyOf: localDayKey,
});
const restingByDay = new Map<string, { sum: number; count: number }>();
for (const s of restingPulseSeries) {
const key = utcDayKey(s.measuredAt);
const key = localDayKey(s.measuredAt);
const cur = restingByDay.get(key) ?? { sum: 0, count: 0 };
cur.sum += s.value;
cur.count += 1;
@@ -458,14 +475,16 @@ export async function buildComprehensiveResponse(user: AuthedUser) {
if (expectedBpIntakesPerDay > 0) {
// Daily systolic means (already keyed YYYY-MM-DD) from the SQL pass.
// v1.30.3 (QA F8) — `sysDaily` is UTC `date_trunc`-bucketed by the
// aggregator (`comprehensive-aggregator.ts`), so `takenByDay` below
// MUST key on the same UTC day, not the user's own tz — pairing the
// two on different day spaces would silently drop pairs rather than
// fix anything. Making this whole correlation user-tz-consistent
// needs the aggregator's `dailyByType` to bucket in the user's tz
// first (a bigger, already-flagged follow-up); until then this stays
// UTC to match its only pairing partner.
// v1.32.17 — `sysDaily` (UTC `date_trunc` from the aggregator's
// `dailyByType`) and `takenByDay` below (intake `scheduledFor`
// truncated to UTC) both live in the SAME UTC day-key space and are
// internally consistent with each other — this join is NOT broken and
// stays as is. Only the mood pairings above moved to a separately
// derived local-day series (their partner, the mood series, is
// local-day-keyed). This correlation migrates to local when the
// measurement-rollup tier buckets per-user tz (R12), not in this hotfix
// — re-keying it here without the local-bucketed rollup would mean
// per-request raw re-aggregation.
const sysByDay = new Map<string, number>();
for (const row of sysDaily) {
sysByDay.set(row.day, row.value);
@@ -565,6 +584,40 @@ export async function buildComprehensiveResponse(user: AuthedUser) {
// Helpers
// ─────────────────────────────────────────────────────────────
/**
* v1.32.17 — derive exact local-day mean series from raw measurement rows,
* keyed by the user's timezone via `userDayKey`. The mood series is
* local-day-keyed (since v1.32.12), and `pairMoodWithDaily` joins on an
* exact YYYY-MM-DD key, so its metric partners must be local-day-keyed too.
* A UTC-day mean (from `dailyByType`) cannot be relabeled into a local-day
* mean, so the mean is recomputed here from raw rows already on the request
* path. `userDayKey` formats via a memoised `Intl.DateTimeFormat` — no
* offset arithmetic — so DST-transition days (23 h / 25 h) bucket correctly
* and the result is host-TZ-independent (CI runs UTC). `round2` mirrors the
* aggregator's `ROUND(AVG, 2)` so correlation thresholds don't drift.
* Returns `{ day, value }[]` sorted ASC by day, the shape
* `pairMoodWithDaily` expects.
*/
function deriveDailyLocalMeans(
rows: Array<{ measuredAt: Date; value: number }>,
tz: string,
): Array<{ day: string; value: number }> {
const byDay = new Map<string, { sum: number; count: number }>();
for (const r of rows) {
const key = userDayKey(r.measuredAt, tz);
const cur = byDay.get(key) ?? { sum: 0, count: 0 };
cur.sum += r.value;
cur.count += 1;
byDay.set(key, cur);
}
return Array.from(byDay.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([day, stats]) => ({
day,
value: Math.round((stats.sum / stats.count) * 100) / 100,
}));
}
/**
* Daily-key inner join between two daily-aggregated series. Returns
* `PairedPoint` shapes compatible with `pearsonCorrelation`.
@@ -416,14 +416,25 @@ describe("buildComprehensiveAggregate", () => {
// already-collapsed sys + dia rows in raw SQL snake_case shape; the
// aggregator partitions by type in JS so the bpRawRows.sys / .dia
// byte-shape stays identical.
// v1.32.17 — WEIGHT now rides this same canonical pull so the route
// can derive a local-day mood × weight series. It partitions into a
// separate `weightRawRows` field and must not disturb sys / dia.
.mockResolvedValueOnce([
{ type: "BLOOD_PRESSURE_SYS", measured_at: measuredAt, value: 120 },
{ type: "BLOOD_PRESSURE_DIA", measured_at: measuredAt, value: 80 },
{ type: "WEIGHT", measured_at: measuredAt, value: 81.4 },
]);
const result = await buildComprehensiveAggregate("user-bp");
expect(result.bpRawRows.sys).toEqual([{ measuredAt, value: 120 }]);
expect(result.bpRawRows.dia).toEqual([{ measuredAt, value: 80 }]);
// WEIGHT partitions into its own field; sys / dia stay byte-identical.
expect(result.weightRawRows).toEqual([{ measuredAt, value: 81.4 }]);
// The widened pull selects WEIGHT alongside the BP types in one query.
const bpPullSql = UNSAFE.mock.calls
.map((c) => String(c[0]))
.find((sql) => sql.includes("'WEIGHT'"));
expect(bpPullSql).toBeTruthy();
// The BP pull must route through the canonical-source subquery (the
// collapse fires in SQL); pin the marker so a revert to a plain findMany
// (double-counting overlapping sources) fails here.
+17 -2
View File
@@ -192,6 +192,12 @@ export interface ComprehensiveAggregate {
sys: Array<{ measuredAt: Date; value: number }>;
dia: Array<{ measuredAt: Date; value: number }>;
};
/** Raw weight rows over the same 90-day window, same canonical-source
* dedup as `bpRawRows` (they ride one query). Weight is sparse
* (~1-2 rows/day). v1.32.17 derives the mood × weight correlation from
* these local-day-keyed means instead of the UTC `dailyByType.WEIGHT`,
* so the mood join partner shares the mood series' local-day key space. */
weightRawRows: Array<{ measuredAt: Date; value: number }>;
/** Daily means per (type, day) for the correlation pairings. The
* route filters down to the types it needs (WEIGHT, BLOOD_PRESSURE_SYS,
* PULSE) when joining against mood / weight / bp. */
@@ -343,8 +349,12 @@ async function fetchBpRawRows(
): Promise<{
sys: Array<{ measuredAt: Date; value: number }>;
dia: Array<{ measuredAt: Date; value: number }>;
weight: Array<{ measuredAt: Date; value: number }>;
}> {
void ninetyDaysAgo;
// v1.32.17 — WEIGHT rides this same canonical-dedup pull (one extra
// sparse type, no new round-trip) so the route can derive a local-day
// mood × weight series without re-scanning raw rows.
const rows = await prisma.$queryRawUnsafe<BpRawRow[]>(
`
SELECT
@@ -352,22 +362,25 @@ async function fetchBpRawRows(
m."measured_at" AS measured_at,
m."value"::double precision AS value
FROM ${canonicalMeasurementsFrom(rankUnqualified, "90 days")}
WHERE m."type" IN ('BLOOD_PRESSURE_SYS', 'BLOOD_PRESSURE_DIA')
WHERE m."type" IN ('BLOOD_PRESSURE_SYS', 'BLOOD_PRESSURE_DIA', 'WEIGHT')
ORDER BY m."measured_at" ASC
`,
userId,
);
const sys: Array<{ measuredAt: Date; value: number }> = [];
const dia: Array<{ measuredAt: Date; value: number }> = [];
const weight: Array<{ measuredAt: Date; value: number }> = [];
for (const r of rows) {
const measuredAt = new Date(r.measured_at);
if (r.type === "BLOOD_PRESSURE_SYS") {
sys.push({ measuredAt, value: Number(r.value) });
} else if (r.type === "BLOOD_PRESSURE_DIA") {
dia.push({ measuredAt, value: Number(r.value) });
} else if (r.type === "WEIGHT") {
weight.push({ measuredAt, value: Number(r.value) });
}
}
return { sys, dia };
return { sys, dia, weight };
}
/**
@@ -610,6 +623,7 @@ async function buildFromRollups(
return {
summaries,
bpRawRows,
weightRawRows: bpRawRows.weight,
dailyByType: buildDailyByType(bucketsByType),
firstMeasurementAt,
totalMeasurements,
@@ -833,6 +847,7 @@ async function buildFromLiveAggregate(
return {
summaries,
bpRawRows,
weightRawRows: bpRawRows.weight,
dailyByType: buildDailyByType(bucketsByType),
firstMeasurementAt,
totalMeasurements,