diff --git a/CHANGELOG.md b/CHANGELOG.md index 3cfdc5807..3b83046cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 0998fe392..8eae52a6f 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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. diff --git a/package.json b/package.json index 4cba5ff7c..081084681 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/public/sw.js b/public/sw.js index d672582af..845fd4148 100644 --- a/public/sw.js +++ b/public/sw.js @@ -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/*` diff --git a/src/app/api/insights/comprehensive/__tests__/route.test.ts b/src/app/api/insights/comprehensive/__tests__/route.test.ts index 9e69f49d6..5f2a4eedd 100644 --- a/src/app/api/insights/comprehensive/__tests__/route.test.ts +++ b/src/app/api/insights/comprehensive/__tests__/route.test.ts @@ -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 + ).mockResolvedValue([moodRollupDay("2026-07-10T00:00:00.000Z", 4)]); + (buildComprehensiveAggregate as ReturnType).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 + ).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).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 + ).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).mockResolvedValue( + [{ measuredAt: new Date("2026-07-09T22:30:00Z"), value: 58 }], + ); + (buildComprehensiveAggregate as ReturnType).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 + ).mockResolvedValue([moodRollupDay("2026-07-10T00:00:00.000Z", 4)]); + (buildComprehensiveAggregate as ReturnType).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 + ).mockResolvedValue(aggregate); + ( + prisma.medication.findMany as ReturnType + ).mockResolvedValue([medication]); + ( + prisma.medicationIntakeEvent.findMany as ReturnType + ).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(); + }); +}); diff --git a/src/app/api/insights/comprehensive/route.ts b/src/app/api/insights/comprehensive/route.ts index 91f3bbfed..772cb16e5 100644 --- a/src/app/api/insights/comprehensive/route.ts +++ b/src/app/api/insights/comprehensive/route.ts @@ -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(); 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(); 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(); + 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`. diff --git a/src/lib/insights/__tests__/comprehensive-aggregator.test.ts b/src/lib/insights/__tests__/comprehensive-aggregator.test.ts index d7a8e6f34..07c377a9a 100644 --- a/src/lib/insights/__tests__/comprehensive-aggregator.test.ts +++ b/src/lib/insights/__tests__/comprehensive-aggregator.test.ts @@ -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. diff --git a/src/lib/insights/comprehensive-aggregator.ts b/src/lib/insights/comprehensive-aggregator.ts index 2968a8885..c96c1b736 100644 --- a/src/lib/insights/comprehensive-aggregator.ts +++ b/src/lib/insights/comprehensive-aggregator.ts @@ -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( ` 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,