mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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:
8 files changed
+426
-44
No files matched your search
@@ -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,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
@@ -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
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user