From d51ba9f134d5bc295d16b3d5d53460c2a2a3f436 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 26 Sep 2026 15:37:10 +0200 Subject: [PATCH 01/15] fix(rollups): choose the canonical source per day inside weekly, monthly and yearly buckets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rollup writer keeps one row per bucket and source. Collapsing the stored WEEK / MONTH / YEAR rows picked one source for the whole bucket, so a month of manual blood-pressure readings plus one synced reading reported the synced day alone, and a step month that switched devices lost every day the losing device carried. The live weekly and monthly chart fold made the same pick. Every coarse bucket is now folded from the canonical DAY buckets: the source ladder picks one source per day, then the week, month or year counts every one of its days. Spread, slope and r² are composed from the summed day accumulators. The live fold picks per local day before folding into weeks and months. Ties between sources without a ladder rank break alphabetically on the SQL paths too, matching the rollup reader. --- src/lib/analytics/source-rank-sql.ts | 2 +- src/lib/insights/__tests__/features.test.ts | 83 ++-- .../insights/__tests__/metric-status.test.ts | 3 +- src/lib/measurements/daily-series-read.ts | 64 +-- .../__tests__/measurement-read-wmy.test.ts | 450 +++++++++--------- src/lib/rollups/measurement-read-wmy.ts | 316 +++++------- src/lib/rollups/measurement-read.ts | 234 +++++++++ src/lib/rollups/measurement-rollups.ts | 64 +-- .../rollup-per-day-canonical.test.ts | 286 +++++++++++ 9 files changed, 968 insertions(+), 534 deletions(-) create mode 100644 tests/integration/rollup-per-day-canonical.test.ts diff --git a/src/lib/analytics/source-rank-sql.ts b/src/lib/analytics/source-rank-sql.ts index f675c7a12..664b98170 100644 --- a/src/lib/analytics/source-rank-sql.ts +++ b/src/lib/analytics/source-rank-sql.ts @@ -147,7 +147,7 @@ export function canonicalMeasurementsCte( WHERE "user_id" = $1 AND "deleted_at" IS NULL ${sinceInner} - ORDER BY "type", date_trunc('day', "measured_at"), (${rankUnqualified}), "source" + ORDER BY "type", date_trunc('day', "measured_at"), (${rankUnqualified}), "source"::text ) c ON c.t = mm."type" AND c.d = date_trunc('day', mm."measured_at") diff --git a/src/lib/insights/__tests__/features.test.ts b/src/lib/insights/__tests__/features.test.ts index 261810cf2..2fa796a92 100644 --- a/src/lib/insights/__tests__/features.test.ts +++ b/src/lib/insights/__tests__/features.test.ts @@ -78,19 +78,43 @@ const dayMs = 24 * 60 * 60 * 1000; const TZ_USER = "user-tz"; function rollupRow(daysAgo: number, mean: number, count: number) { + const now = new Date(); return { - bucketStart: new Date(Date.now() - daysAgo * dayMs), + // A stored DAY bucket starts at UTC midnight. + bucketStart: new Date( + Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()) - + daysAgo * dayMs, + ), + source: "MANUAL", count, mean, minValue: mean, maxValue: mean, + sumValue: count * mean, sd: 0, slope: 0, r2: 0, + sumX: null, + sumXy: null, + sumXx: null, + sumYy: null, computedAt: new Date(), }; } +/** Keep the rows a DAY read's `bucketStart` window would return. */ +function inWindow( + rows: ReturnType[], + where: { bucketStart: { gte: Date; lt?: Date } }, +) { + return rows.filter( + (r) => + r.bucketStart >= where.bucketStart.gte && + (where.bucketStart.lt === undefined || + r.bucketStart < where.bucketStart.lt), + ); +} + beforeEach(() => { vi.resetAllMocks(); prismaMock.user.findUnique.mockResolvedValue({ @@ -122,22 +146,25 @@ describe("extractFeatures — v1.4.36 W3 bucketed payload", () => { }); it("attaches DAY / WEEK / MONTH buckets from measurement_rollups when includeRaw=true", async () => { - // Two WEIGHT DAY buckets in the 0-90d window and one MONTH bucket - // in the 365-1825d window. The reader is called once per - // (type, granularity) combination — return data only for the two - // we care about, empty for the rest. + // Two WEIGHT DAY buckets in the 0-90d window and one day in the + // 365-1825d window, which surfaces as a MONTH bucket. Every window is + // folded from the DAY tier, so the mock answers DAY reads by window. + const weightDays = [ + rollupRow(10, 82.5, 2), + rollupRow(20, 82.7, 1), + rollupRow(400, 85.1, 28), + ]; prismaMock.measurementRollup.findMany.mockImplementation( - async (args: { where: { type: string; granularity: string } }) => { - if (args.where.type === "WEIGHT" && args.where.granularity === "DAY") { - return [rollupRow(10, 82.5, 2), rollupRow(20, 82.7, 1)]; - } - if ( - args.where.type === "WEIGHT" && - args.where.granularity === "MONTH" - ) { - return [rollupRow(400, 85.1, 28)]; - } - return []; + async (args: { + where: { + type: string; + granularity: string; + bucketStart: { gte: Date; lt?: Date }; + }; + }) => { + if (args.where.type !== "WEIGHT") return []; + expect(args.where.granularity).toBe("DAY"); + return inWindow(weightDays, args.where); }, ); @@ -188,19 +215,17 @@ describe("extractFeatures — v1.4.36 W3 bucketed payload", () => { { timeout: 30_000 }, async () => { // Fabricate an absurdly long bucket list so the JSON dump crosses - // the ceiling. ~200 KB per series × 28 series ≈ ~5.6 MB. - const giant = new Array(200_000).fill(null).map((_, i) => ({ - bucketStart: new Date(Date.now() - i * dayMs), - count: i, - mean: i, - minValue: i, - maxValue: i, - sd: 0, - slope: 0, - r2: 0, - computedAt: new Date(), - })); - prismaMock.measurementRollup.findMany.mockResolvedValue(giant); + // the ceiling: every DAY read answers 200 000 distinct buckets inside + // its own window, one second apart. + prismaMock.measurementRollup.findMany.mockImplementation( + async (args: { where: { bucketStart: { gte: Date } } }) => + new Array(200_000).fill(null).map((_, i) => ({ + ...rollupRow(0, i, i), + bucketStart: new Date( + args.where.bucketStart.gte.getTime() + i * 1000, + ), + })), + ); await expect(extractFeatures("user-1", true)).rejects.toThrow( FeaturesPayloadTooLargeError, diff --git a/src/lib/insights/__tests__/metric-status.test.ts b/src/lib/insights/__tests__/metric-status.test.ts index 091f2cf74..d118a93ec 100644 --- a/src/lib/insights/__tests__/metric-status.test.ts +++ b/src/lib/insights/__tests__/metric-status.test.ts @@ -62,7 +62,8 @@ vi.mock("@/lib/tz/resolver", async () => { return { ...actual, resolveUserTimezone: vi.fn() }; }); -vi.mock("@/lib/rollups/measurement-read", () => ({ +vi.mock("@/lib/rollups/measurement-read", async (importOriginal) => ({ + ...(await importOriginal()), loadUserSourcePriority: vi.fn(), })); diff --git a/src/lib/measurements/daily-series-read.ts b/src/lib/measurements/daily-series-read.ts index 0ce67d26f..945345dd4 100644 --- a/src/lib/measurements/daily-series-read.ts +++ b/src/lib/measurements/daily-series-read.ts @@ -159,9 +159,10 @@ const TRUNC_UNIT: Record, string> = { * the bucket's own day for every offset, including half-hour and negative * ones, and across a DST change. * - * Per bucket the overlapping sources collapse to the ladder-canonical one - * before the fold (the same pick the rollup reader makes), cumulative types - * SUM and every other type averages. The result set is at most `cap` rows, + * Per local day the overlapping sources collapse to the ladder-canonical one + * (the same pick the rollup reader makes), and the days then fold into the + * bucket: cumulative types SUM and every other type averages over every + * reading of the canonical days. The result set is at most `cap` rows, * however dense the underlying stream: the fold runs in Postgres. * * `type` narrows to one type; `null` folds every type in the window (the @@ -190,9 +191,7 @@ export async function readLiveBuckets(opts: { const cumulativeList = [...CUMULATIVE_HK_TYPES] .map((t) => `'${t}'`) .join(","); - const aggregator = Prisma.raw( - `(CASE WHEN m."type"::text IN (${cumulativeList}) THEN SUM(m."value") ELSE AVG(m."value") END)::double precision`, - ); + const isCumulative = Prisma.raw(`c."type"::text IN (${cumulativeList})`); const rankRaw = Prisma.raw( buildSourceRankCase(priorityJson, 'p."type"', 'p."source"'), ); @@ -202,14 +201,14 @@ export async function readLiveBuckets(opts: { // measured_at is a UTC wall-clock timestamp: pin it to UTC, re-read it in // the user's zone and truncate there. // - // Fold first, pick second. Every source is folded per bucket in one hashed - // pass (a few rows per day, however dense the stream), and only then does - // each bucket keep its ladder-canonical source. The fold of the canonical - // source's rows is exactly the fold of that source's group, so the result - // is the same as collapsing the rows first; what changes is the cost. A - // year of per-minute heart rate is half a million rows, and ranking them - // before the fold sorted them twice (about 0.55 s); folding first never - // sorts more than one row per source per day. + // The canonical source is chosen per local DAY, whatever the grain: each + // source is folded per day in one hashed pass (a few rows per day, however + // dense the stream), each day keeps its ladder-canonical source, and only + // then are the days folded into the week or month. Picking the source per + // week or month instead dropped every day the losing source carried, so a + // month of manual readings plus one synced reading reported one day. + // Folding first and picking second is the same result as collapsing the + // raw rows first; it just never sorts more than one row per source per day. const buckets = await prisma.$queryRaw< Array<{ type: string; @@ -224,8 +223,8 @@ export async function readLiveBuckets(opts: { SELECT m."type", m."source", - date_trunc(${unit}, (m."measured_at" AT TIME ZONE 'UTC') AT TIME ZONE ${timeZone}) AS d, - ${aggregator} AS avg, + date_trunc('day', (m."measured_at" AT TIME ZONE 'UTC') AT TIME ZONE ${timeZone}) AS day, + SUM(m."value")::double precision AS total, COUNT(*)::int AS cnt, MIN(m."value") AS min_value, MAX(m."value") AS max_value @@ -238,20 +237,31 @@ export async function readLiveBuckets(opts: { GROUP BY m."type", m."source", 3 ), canon AS ( - SELECT DISTINCT ON (p."type", p.d) - p."type", p.d, p.avg, p.cnt, p.min_value, p.max_value + SELECT DISTINCT ON (p."type", p.day) + p."type", p.day, p.total, p.cnt, p.min_value, p.max_value FROM per_source p - ORDER BY p."type", p.d, (${rankRaw}), p."source" + ORDER BY p."type", p.day, (${rankRaw}), p."source"::text + ), + folded AS ( + SELECT + c."type", + date_trunc(${unit}, c.day) AS d, + (CASE WHEN ${isCumulative} THEN SUM(c.total) ELSE SUM(c.total) / SUM(c.cnt) END)::double precision AS avg, + SUM(c.cnt)::int AS cnt, + MIN(c.min_value) AS min_value, + MAX(c.max_value) AS max_value + FROM canon c + GROUP BY c."type", 2 ) SELECT - c."type"::text AS type, - c.d AT TIME ZONE ${timeZone} AS bucket_start, - c.avg, - c.cnt, - c.min_value, - c.max_value - FROM canon c - ORDER BY c.d ASC + f."type"::text AS type, + f.d AT TIME ZONE ${timeZone} AS bucket_start, + f.avg, + f.cnt, + f.min_value, + f.max_value + FROM folded f + ORDER BY f.d ASC LIMIT ${cap} `; return buckets.map((b) => { diff --git a/src/lib/rollups/__tests__/measurement-read-wmy.test.ts b/src/lib/rollups/__tests__/measurement-read-wmy.test.ts index 39d0e3f0d..31dd66dbb 100644 --- a/src/lib/rollups/__tests__/measurement-read-wmy.test.ts +++ b/src/lib/rollups/__tests__/measurement-read-wmy.test.ts @@ -5,13 +5,12 @@ * * `prisma.measurementRollup.findMany` is mocked at the module level * so the test pins: - * - each per-granularity reader filters by the correct - * `granularity` literal, + * - every granularity is folded from the canonical DAY buckets (the + * source is chosen per DAY, never once per week / month / year), * - empty rollups return `null` so the caller can branch on * coverage miss, - * - the auto-router walks YEAR → MONTH → WEEK → DAY and stops at - * the first granularity whose floor the window clears AND which - * has coverage, + * - the auto-router picks the coarsest granularity whose floor the + * window clears, * - `aggregateWmyBuckets` composes `count / min / max / mean / sum` * linearly across coarser buckets (the same compositional * contract `rollup-read.ts:aggregateBuckets` carries for DAY). @@ -86,7 +85,69 @@ afterEach(() => { vi.restoreAllMocks(); }); +/** One stored DAY rollup row, per source, as `findMany` returns it. */ +function dayRow( + day: string, + source: string, + count: number, + mean: number, + extra: Partial<{ minValue: number; maxValue: number }> = {}, +) { + const bucketStart = new Date(`${day}T00:00:00.000Z`); + // Accumulators for `count` readings of `mean` at the day's midday. + const x = bucketStart.getTime() / DAY_MS_ROWS - 18262 + 0.5; + return { + bucketStart, + source, + count, + mean, + minValue: extra.minValue ?? mean, + maxValue: extra.maxValue ?? mean, + sumValue: count * mean, + sd: 0, + slope: null, + r2: null, + sumX: count * x, + sumXy: count * x * mean, + sumXx: count * x * x, + sumYy: count * mean * mean, + computedAt: new Date("2026-06-01T00:00:00.000Z"), + }; +} +const DAY_MS_ROWS = 86_400_000; + +/** `n` consecutive days from `start`, one row each. */ +function days( + start: string, + n: number, + source: string, + count: number, + mean: number, +) { + const out = []; + const t0 = new Date(`${start}T00:00:00.000Z`).getTime(); + for (let i = 0; i < n; i++) { + out.push( + dayRow( + new Date(t0 + i * DAY_MS_ROWS).toISOString().slice(0, 10), + source, + count, + mean, + ), + ); + } + return out; +} + describe("readBestGranularityRollups", () => { + beforeEach(() => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date("2026-01-15T12:00:00.000Z")); + }); + afterEach(() => { + vi.useRealTimers(); + }); + it("returns null on a non-positive window", async () => { expect(await readBestGranularityRollups("user", "WEIGHT", 0)).toBeNull(); expect(await readBestGranularityRollups("user", "WEIGHT", -10)).toBeNull(); @@ -94,65 +155,111 @@ describe("readBestGranularityRollups", () => { expect(findMany).not.toHaveBeenCalled(); }); - it("routes a 90-day window to DAY (every coarser floor is too low)", async () => { - findMany.mockResolvedValueOnce([bucket("2026-04-01T00:00:00.000Z")]); - const result = await readBestGranularityRollups("user", "WEIGHT", 90); - expect(result).not.toBeNull(); - expect(result?.granularity).toBe("DAY"); - expect(findMany).toHaveBeenCalledTimes(1); - expect(findMany.mock.calls[0][0].where.granularity).toBe("DAY"); - }); + it.each([ + [90, "DAY"], + [365, "MONTH"], + [540, "MONTH"], + [1095, "YEAR"], + [120, "WEEK"], + ] as const)( + "routes a %i-day window to %s, folded from the DAY tier", + async (windowDays, granularity) => { + findMany.mockResolvedValueOnce([dayRow("2025-12-20", "MANUAL", 1, 80)]); + const result = await readBestGranularityRollups( + "user", + "WEIGHT", + windowDays, + ); + expect(result?.granularity).toBe(granularity); + expect(findMany).toHaveBeenCalledTimes(1); + expect(findMany.mock.calls[0][0].where.granularity).toBe("DAY"); + }, + ); - it("routes a 365-day window to MONTH (clears the 62-day MONTH floor)", async () => { - findMany.mockResolvedValueOnce([bucket("2026-01-01T00:00:00.000Z")]); - const result = await readBestGranularityRollups("user", "WEIGHT", 365); - expect(result).not.toBeNull(); - expect(result?.granularity).toBe("MONTH"); - expect(findMany).toHaveBeenCalledTimes(1); - expect(findMany.mock.calls[0][0].where.granularity).toBe("MONTH"); - }); - - it("routes a 1095-day window to YEAR (clears the 730-day YEAR floor)", async () => { - findMany.mockResolvedValueOnce([bucket("2024-01-01T00:00:00.000Z")]); - const result = await readBestGranularityRollups("user", "WEIGHT", 1095); - expect(result).not.toBeNull(); - expect(result?.granularity).toBe("YEAR"); - expect(findMany).toHaveBeenCalledTimes(1); - expect(findMany.mock.calls[0][0].where.granularity).toBe("YEAR"); - }); - - it("falls back to MONTH when YEAR has no coverage", async () => { - // 1095-day window asks for YEAR first; coverage miss → MONTH. - findMany - .mockResolvedValueOnce([]) // YEAR miss - .mockResolvedValueOnce([bucket("2024-01-01T00:00:00.000Z")]); // MONTH hit - - const result = await readBestGranularityRollups("user", "WEIGHT", 1095); - - expect(result?.granularity).toBe("MONTH"); - expect(findMany.mock.calls[0][0].where.granularity).toBe("YEAR"); - expect(findMany.mock.calls[1][0].where.granularity).toBe("MONTH"); - }); - - it("falls all the way through to DAY when every coarser tier misses", async () => { - findMany - .mockResolvedValueOnce([]) // YEAR miss - .mockResolvedValueOnce([]) // MONTH miss - .mockResolvedValueOnce([]) // WEEK miss - .mockResolvedValueOnce([bucket("2024-01-01T00:00:00.000Z")]); // DAY hit - - const result = await readBestGranularityRollups("user", "WEIGHT", 1095); - - expect(result?.granularity).toBe("DAY"); - expect(findMany).toHaveBeenCalledTimes(4); - }); - - it("returns null when no granularity carries any coverage", async () => { + it("returns null when the window holds no DAY buckets", async () => { findMany.mockResolvedValue([]); const result = await readBestGranularityRollups("user", "WEIGHT", 1095); expect(result).toBeNull(); - // YEAR, MONTH, WEEK, DAY all probed. - expect(findMany).toHaveBeenCalledTimes(4); + expect(findMany).toHaveBeenCalledTimes(1); + }); + + // The canonical source is a per-DAY decision. Collapsing the stored + // per-source MONTH rows picked WITHINGS for the whole month (it leads the + // blood-pressure ladder) and reported its one reading: count 1, mean 120. + it("keeps every manual day of a month that also carries one synced day", async () => { + findMany.mockResolvedValueOnce([ + ...days("2025-11-01", 20, "MANUAL", 1, 150), + dayRow("2025-11-25", "WITHINGS", 1, 120), + ]); + const result = await readBestGranularityRollups( + "user", + "BLOOD_PRESSURE_SYS", + 365, + ); + expect(result?.granularity).toBe("MONTH"); + expect(result?.rows).toHaveLength(1); + expect(result?.rows[0].count).toBe(21); + expect(result?.rows[0].mean).toBeCloseTo((20 * 150 + 120) / 21, 9); + expect(result?.rows[0].minValue).toBe(120); + expect(result?.rows[0].maxValue).toBe(150); + }); + + it("sums a step month whose days switch device, one source per day", async () => { + findMany.mockResolvedValueOnce([ + ...days("2025-11-01", 10, "FITBIT", 1, 8000), + ...days("2025-11-11", 20, "APPLE_HEALTH", 1, 8000), + ]); + const result = await readBestGranularityRollups( + "user", + "ACTIVITY_STEPS", + 365, + ); + expect(aggregateWmyBuckets(result?.rows ?? []).sum).toBe(240_000); + }); + + it("still keeps one source on a day both sources carry", async () => { + findMany.mockResolvedValueOnce([ + dayRow("2025-11-03", "APPLE_HEALTH", 1, 9000), + dayRow("2025-11-03", "FITBIT", 1, 7000), + dayRow("2025-11-04", "FITBIT", 1, 7000), + ]); + const result = await readBestGranularityRollups( + "user", + "ACTIVITY_STEPS", + 365, + ); + expect(result?.rows[0].sumValue).toBe(16_000); + expect(result?.rows[0].count).toBe(2); + }); + + it("cuts weeks on the ISO Monday in UTC", async () => { + findMany.mockResolvedValueOnce([ + dayRow("2025-11-02", "MANUAL", 1, 1), // Sunday → week of 2025-10-27 + dayRow("2025-11-03", "MANUAL", 1, 2), // Monday → week of 2025-11-03 + dayRow("2025-11-09", "MANUAL", 1, 3), // Sunday → week of 2025-11-03 + ]); + const result = await readBestGranularityRollups("user", "WEIGHT", 120); + expect(result?.granularity).toBe("WEEK"); + expect(result?.rows.map((r) => r.bucketStart.toISOString())).toEqual([ + "2025-10-27T00:00:00.000Z", + "2025-11-03T00:00:00.000Z", + ]); + expect(result?.rows.map((r) => r.count)).toEqual([1, 2]); + }); + + it("composes the bucket's spread and slope from the day accumulators", async () => { + // Three days of one reading each: 70, 72, 74 → slope 2 per day, + // population sd sqrt(8/3), r² 1. + findMany.mockResolvedValueOnce([ + dayRow("2025-11-03", "MANUAL", 1, 70), + dayRow("2025-11-04", "MANUAL", 1, 72), + dayRow("2025-11-05", "MANUAL", 1, 74), + ]); + const result = await readBestGranularityRollups("user", "WEIGHT", 365); + const month = result?.rows[0]; + expect(month?.slope).toBeCloseTo(2, 6); + expect(month?.r2).toBeCloseTo(1, 6); + expect(month?.sd).toBeCloseTo(Math.sqrt(8 / 3), 6); }); }); @@ -232,30 +339,6 @@ describe("aggregateWmyBuckets", () => { * A floor adjustment must update this single routing contract. */ describe("readBestGranularityRollups — cross-consumer routing parity", () => { - it("pins the 90 / 365 / 1095 day routing targets the consumers depend on", async () => { - // Three sequential probes with non-empty results so the router - // stops at the first matching tier per window. - findMany - .mockResolvedValueOnce([bucket("2026-02-15T00:00:00.000Z")]) // 90d → DAY - .mockResolvedValueOnce([bucket("2025-08-01T00:00:00.000Z")]) // 365d → MONTH - .mockResolvedValueOnce([bucket("2024-01-01T00:00:00.000Z")]); // 1095d → YEAR - - const ninety = await readBestGranularityRollups("user", "WEIGHT", 90); - const yearLong = await readBestGranularityRollups("user", "WEIGHT", 365); - const threeYear = await readBestGranularityRollups("user", "WEIGHT", 1095); - - expect(ninety?.granularity).toBe("DAY"); - expect(yearLong?.granularity).toBe("MONTH"); - expect(threeYear?.granularity).toBe("YEAR"); - // Each consumer-relevant window only fires one round-trip on the - // happy path; the per-tier walk is reserved for coverage-miss - // scenarios already pinned above. - expect(findMany).toHaveBeenCalledTimes(3); - expect(findMany.mock.calls[0][0].where.granularity).toBe("DAY"); - expect(findMany.mock.calls[1][0].where.granularity).toBe("MONTH"); - expect(findMany.mock.calls[2][0].where.granularity).toBe("YEAR"); - }); - it("aggregates MONTH buckets to a byte-identical mean compared to the underlying DAY buckets", async () => { // The compositional contract `count / mean` are linearly // composable across granularities — pin it by simulating "MONTH @@ -313,15 +396,14 @@ describe("readTieredRollupSeries", () => { expect(findMany).not.toHaveBeenCalled(); }); - it("reads the MONTH tier for a multi-year window and spans the whole history", async () => { - // 10-year window → MONTH tier. The fixture carries buckets from the - // OLDEST month through the most recent — the result must keep the - // oldest bucket, proving no recent-slice truncation. - const months: RollupBucketRow[] = []; + it("folds a multi-year window into MONTH buckets and spans the whole history", async () => { + // Ten years of first-of-January readings → MONTH tier; the oldest + // bucket survives (no recent-slice truncation). + const rows = []; for (let y = 2017; y <= 2026; y++) { - months.push(bucket(`${y}-01-01T00:00:00.000Z`, { count: 12, mean: 80 })); + rows.push(dayRow(`${y}-01-01`, "MANUAL", 12, 80)); } - findMany.mockResolvedValueOnce(months); + findMany.mockResolvedValueOnce(rows); const result = await readTieredRollupSeries({ userId: "u", @@ -329,28 +411,24 @@ describe("readTieredRollupSeries", () => { ...win(3650), }); - expect(result).not.toBeNull(); expect(result?.granularity).toBe("MONTH"); - expect(findMany.mock.calls[0][0].where.granularity).toBe("MONTH"); - // Whole-history coverage: the earliest 2017 bucket survives. + expect(findMany).toHaveBeenCalledTimes(1); + expect(findMany.mock.calls[0][0].where.granularity).toBe("DAY"); expect(result?.rows[0].measuredAt).toBe("2017-01-01T00:00:00.000Z"); expect(result?.rows.at(-1)?.measuredAt).toBe("2026-01-01T00:00:00.000Z"); expect(result?.rows.length).toBe(10); }); // v1.26.0 SEAM-N2 — a historic window entirely in the past must bound the - // rollup read on BOTH ends. Pre-fix the reader anchored on `now − windowDays` - // and would have queried `bucketStart >= now − 730d` (mid-2024 onward), - // returning recent buckets for a 2020–2022 ask. - it("bounds the rollup read on BOTH ends for a historic past window", async () => { + // rollup read on BOTH ends. + it("bounds the DAY read on BOTH ends for a historic past window", async () => { const from = new Date("2020-01-01T00:00:00.000Z"); const to = new Date("2022-01-01T00:00:00.000Z"); - const inWindow: RollupBucketRow[] = [ - bucket("2020-02-01T00:00:00.000Z", { count: 12, mean: 80 }), - bucket("2021-06-01T00:00:00.000Z", { count: 12, mean: 81 }), - bucket("2021-12-01T00:00:00.000Z", { count: 12, mean: 82 }), - ]; - findMany.mockResolvedValueOnce(inWindow); + findMany.mockResolvedValueOnce([ + dayRow("2020-02-01", "MANUAL", 12, 80), + dayRow("2021-06-01", "MANUAL", 12, 81), + dayRow("2021-12-01", "MANUAL", 12, 82), + ]); const result = await readTieredRollupSeries({ userId: "u", @@ -359,60 +437,20 @@ describe("readTieredRollupSeries", () => { to, }); - // The `bucketStart` filter carries BOTH the requested lower AND upper - // bound — not a trailing "to now" slice. - const where = findMany.mock.calls[0][0].where; - expect(where.bucketStart).toEqual({ gte: from, lte: to }); - // Every returned bucket_start falls inside the requested window. + expect(findMany.mock.calls[0][0].where.bucketStart).toEqual({ + gte: from, + lte: to, + }); for (const row of result?.rows ?? []) { const t = new Date(row.measuredAt).getTime(); expect(t).toBeGreaterThanOrEqual(from.getTime()); expect(t).toBeLessThanOrEqual(to.getTime()); } - // Tier width is 2 years (731 days) → still the MONTH tier for >730 d. - expect(result?.granularity).toBe("MONTH"); - }); - - // The web-chart "All" path passes `to ≈ now`; prove that case still bounds - // on both ends (no regression) — the historic fix must not perturb it. - it("bounds a current-window (to = now) read on both ends too", async () => { - findMany.mockResolvedValueOnce([bucket("2026-01-01T00:00:00.000Z")]); - const { from, to } = win(3650); - await readTieredRollupSeries({ userId: "u", type: "WEIGHT", from, to }); - expect(findMany.mock.calls[0][0].where.bucketStart).toEqual({ - gte: from, - lte: to, - }); - }); - - it("reads the WEEK tier for a 1–2 year window", async () => { - findMany.mockResolvedValueOnce([bucket("2025-08-04T00:00:00.000Z")]); - const result = await readTieredRollupSeries({ - userId: "u", - type: "WEIGHT", - ...win(540), - }); + // Data span ≈ 22 months → WEEK (the request alone would call for MONTH). expect(result?.granularity).toBe("WEEK"); - expect(findMany.mock.calls[0][0].where.granularity).toBe("WEEK"); }); - it("falls FINER (MONTH → WEEK → DAY) when the target tier has no coverage", async () => { - findMany - .mockResolvedValueOnce([]) // MONTH miss - .mockResolvedValueOnce([]) // WEEK miss - .mockResolvedValueOnce([bucket("2024-03-01T00:00:00.000Z")]); // DAY hit - const result = await readTieredRollupSeries({ - userId: "u", - type: "WEIGHT", - ...win(3650), - }); - expect(result?.granularity).toBe("DAY"); - expect(findMany.mock.calls[0][0].where.granularity).toBe("MONTH"); - expect(findMany.mock.calls[1][0].where.granularity).toBe("WEEK"); - expect(findMany.mock.calls[2][0].where.granularity).toBe("DAY"); - }); - - it("returns null when no tier at or below the target carries coverage", async () => { + it("returns null when the window holds no DAY buckets", async () => { findMany.mockResolvedValue([]); const result = await readTieredRollupSeries({ userId: "u", @@ -420,25 +458,20 @@ describe("readTieredRollupSeries", () => { ...win(3650), }); expect(result).toBeNull(); - // MONTH, WEEK, DAY all probed (never coarser than the target). - expect(findMany).toHaveBeenCalledTimes(3); + expect(findMany).toHaveBeenCalledTimes(1); }); it("surfaces the cumulative summed total for SUM metrics and drops the spread", async () => { findMany.mockResolvedValueOnce([ - bucket("2025-01-01T00:00:00.000Z", { - count: 30, - mean: 8000, - sumValue: 240_000, - minValue: 0, - maxValue: 20_000, - }), + ...days("2024-01-01", 30, "APPLE_HEALTH", 1, 8000), + dayRow("2026-01-01", "APPLE_HEALTH", 1, 5000), ]); const result = await readTieredRollupSeries({ userId: "u", type: "ACTIVITY_STEPS", ...win(3650), }); + expect(result?.granularity).toBe("MONTH"); expect(result?.rows[0].value).toBe(240_000); expect(result?.rows[0].minValue).toBeUndefined(); expect(result?.rows[0].maxValue).toBeUndefined(); @@ -446,19 +479,17 @@ describe("readTieredRollupSeries", () => { it("carries the count-weighted mean + spread for spot metrics", async () => { findMany.mockResolvedValueOnce([ - bucket("2025-01-01T00:00:00.000Z", { - count: 12, - mean: 81.5, - minValue: 78, - maxValue: 85, - }), + dayRow("2024-01-02", "MANUAL", 2, 80, { minValue: 78, maxValue: 82 }), + dayRow("2024-01-03", "MANUAL", 1, 84, { minValue: 84, maxValue: 85 }), + dayRow("2026-01-01", "MANUAL", 1, 80), ]); const result = await readTieredRollupSeries({ userId: "u", type: "WEIGHT", ...win(3650), }); - expect(result?.rows[0].value).toBe(81.5); + expect(result?.rows[0].value).toBeCloseTo((2 * 80 + 84) / 3, 9); + expect(result?.rows[0].count).toBe(3); expect(result?.rows[0].minValue).toBe(78); expect(result?.rows[0].maxValue).toBe(85); }); @@ -466,93 +497,40 @@ describe("readTieredRollupSeries", () => { // v1.37.29 — the tier keys off the ACTUAL data span, not the requested // window width. The "All" tab always requests ~3650 days, so pre-fix a // record whose history spans four months came back as four MONTH means - // while the chart captioned them from the real span — "weekly average" - // over monthly buckets. + // while the chart captioned them from the real span. describe("span refinement", () => { - it("re-reads at the tier the real span calls for (4 months of data on an All request → DAY)", async () => { - const months = [ - bucket("2026-02-01T00:00:00.000Z"), - bucket("2026-03-01T00:00:00.000Z"), - bucket("2026-04-01T00:00:00.000Z"), - bucket("2026-05-01T00:00:00.000Z"), - ]; - const days = [ - bucket("2026-02-03T00:00:00.000Z"), - bucket("2026-03-14T00:00:00.000Z"), - bucket("2026-04-09T00:00:00.000Z"), - bucket("2026-05-28T00:00:00.000Z"), - ]; - findMany.mockResolvedValueOnce(months).mockResolvedValueOnce(days); - + it("serves DAY rows for four months of data on an All request", async () => { + findMany.mockResolvedValueOnce([ + dayRow("2026-02-03", "MANUAL", 1, 80), + dayRow("2026-03-14", "MANUAL", 1, 80), + dayRow("2026-04-09", "MANUAL", 1, 80), + dayRow("2026-05-28", "MANUAL", 1, 80), + ]); const result = await readTieredRollupSeries({ userId: "u", type: "WEIGHT", ...win(3650), }); - - // Span of the MONTH rows ≈ 89 days → the display ladder wants DAY, - // and the refinement jumps straight there (no WEEK stop-over). - expect(findMany.mock.calls[0][0].where.granularity).toBe("MONTH"); - expect(findMany.mock.calls[1][0].where.granularity).toBe("DAY"); - expect(findMany).toHaveBeenCalledTimes(2); expect(result?.granularity).toBe("DAY"); expect(result?.rows).toHaveLength(4); expect(result?.rows[0].measuredAt).toBe("2026-02-03T00:00:00.000Z"); }); - it("refines to WEEK for a span between one and two years", async () => { - const months = [ - bucket("2025-01-01T00:00:00.000Z"), - bucket("2026-05-01T00:00:00.000Z"), - ]; - const weeks = [ - bucket("2024-12-30T00:00:00.000Z"), - bucket("2026-04-27T00:00:00.000Z"), - ]; - findMany.mockResolvedValueOnce(months).mockResolvedValueOnce(weeks); - + it("serves WEEK rows for a span between one and two years", async () => { + findMany.mockResolvedValueOnce([ + dayRow("2025-01-01", "MANUAL", 1, 80), + dayRow("2026-05-01", "MANUAL", 1, 80), + ]); const result = await readTieredRollupSeries({ userId: "u", type: "WEIGHT", ...win(3650), }); - - expect(findMany.mock.calls[1][0].where.granularity).toBe("WEEK"); expect(result?.granularity).toBe("WEEK"); - }); - - it("keeps the coarser rows when the finer tier has no coverage", async () => { - const months = [ - bucket("2026-01-01T00:00:00.000Z"), - bucket("2026-05-01T00:00:00.000Z"), - ]; - findMany.mockResolvedValueOnce(months).mockResolvedValueOnce([]); - - const result = await readTieredRollupSeries({ - userId: "u", - type: "WEIGHT", - ...win(3650), - }); - - expect(result?.granularity).toBe("MONTH"); - expect(result?.rows).toHaveLength(2); - }); - - it("does not probe when the rows already span the requested tier", async () => { - const months = [ - bucket("2018-01-01T00:00:00.000Z"), - bucket("2026-05-01T00:00:00.000Z"), - ]; - findMany.mockResolvedValueOnce(months); - - const result = await readTieredRollupSeries({ - userId: "u", - type: "WEIGHT", - ...win(3650), - }); - - expect(findMany).toHaveBeenCalledTimes(1); - expect(result?.granularity).toBe("MONTH"); + expect(result?.rows.map((r) => r.measuredAt)).toEqual([ + "2024-12-30T00:00:00.000Z", + "2026-04-27T00:00:00.000Z", + ]); }); }); }); diff --git a/src/lib/rollups/measurement-read-wmy.ts b/src/lib/rollups/measurement-read-wmy.ts index 5121ce427..92a40afc5 100644 --- a/src/lib/rollups/measurement-read-wmy.ts +++ b/src/lib/rollups/measurement-read-wmy.ts @@ -1,71 +1,47 @@ /** - * v1.4.39 W-WMY — WEEK / MONTH / YEAR rollup readers. + * WEEK / MONTH / YEAR readers over the measurement rollup tier. * - * Background — the read-side gap - * ------------------------------ - * Every measurement write fans out via `recomputeBucketsForMeasurement` - * into a synchronous DAY upsert plus pg-boss-queued WEEK / MONTH / YEAR - * recomputes. The boot-time backfill (`enqueueBootTimeRollupBackfill`) - * mints the full WEEK / MONTH / YEAR history once per uncovered user. - * - * As of v1.4.38 the only reader that consults `measurement_rollups` - * (`rollup-read.ts`, plus the per-fast-path branches in - * `summaries-slice`, `comprehensive-aggregator`, etc.) caps at DAY and - * a trailing 90-day window. The WEEK / MONTH / YEAR buckets sit in - * Postgres as pure write amplification — populated every write, - * surfaced nowhere on read. The v1.4.38 perf audit - * (`.planning/round-v1438-perf-analysis.md` §2 + §5 P6) calls this out - * as the largest unused investment in the rollup tier. - * - * What this module adds - * --------------------- * Helpers that read the trailing N-bucket window for a single - * `(userId, type)` pair at a chosen granularity, plus an auto-router - * that picks the largest granularity that still resolves the requested - * window. The shape returned is identical to `readRollupBuckets` so - * callers can interleave WEEK / MONTH / YEAR rows with DAY rows - * downstream without branching on the source granularity. + * `(userId, type)` pair at a chosen granularity, plus an auto-router that + * picks the largest granularity that still resolves the requested window. + * The shape returned is identical to `readRollupBuckets` so callers can + * interleave WEEK / MONTH / YEAR rows with DAY rows downstream without + * branching on the source granularity. + * + * Built from the DAY tier + * ----------------------- + * Every coarse bucket is folded from the canonical DAY buckets + * (`readCanonicalRollupBuckets`): the source-priority ladder picks one + * source per DAY, and the week, month or year then counts every one of its + * days. The stored per-source WEEK / MONTH / YEAR rows cannot express that: + * collapsing them picks one source for the whole bucket, so a month of + * manual readings plus one synced reading reported only the synced day. * * Compositional contract * ---------------------- - * `count / min / max / mean / sumValue` are linearly composable across - * any granularity — aggregating these stats over WEEK buckets returns - * the same numbers as aggregating the underlying DAY buckets or the - * raw measurements. `sd / slope / r2` are NOT linearly composable; the - * routed callers either fall back to live SQL for those or accept the - * per-bucket stat as-is. The auto-router is a granularity selector - * only — it does not attempt to reconstruct slope across coarser - * buckets. + * `count / min / max / mean / sumValue` compose exactly across days. + * `sd / slope / r2` are composed from the summed regression accumulators, + * which is exact over the canonical rows too; a bucket with any day that + * lacks the accumulators reports them as `null`. * * Coverage-miss semantics * ----------------------- - * Each reader returns `null` when the requested granularity yields - * zero rows for `(userId, type, since)`. The caller decides whether - * that is a real "no data" case (the user has not logged this type in - * the window) or a coverage miss the boot-backfill / worker has not - * caught up on. The default policy is to fall back to a finer - * granularity (MONTH → WEEK → DAY) before giving up — see - * `readBestGranularityRollups`. - * - * Scope vs. `rollup-read.ts` - * -------------------------- - * `rollup-read.ts` carries the existing DAY-only aggregator. This - * module is deliberately separate so the WEEK / MONTH / YEAR wiring - * is reviewable in isolation and so the v1.4.39 W-SUM agent's - * concurrent edits to the writer side don't collide with the reader - * additions. + * Each reader returns `null` when the window holds no DAY buckets for + * `(userId, type)`. The caller decides whether that is a real "no data" + * case or a coverage miss the boot backfill has not caught up on, and + * usually falls through to live SQL. */ import type { MeasurementType, RollupGranularity, } from "@/generated/prisma/client"; -import { prisma } from "@/lib/db"; import { CUMULATIVE_HK_TYPES } from "@/lib/measurements/apple-health-mapping"; import { annotate } from "@/lib/logging/context"; import { - collapseRollupRowsBySource, + foldCanonicalDays, loadUserSourcePriority, + readCanonicalRollupBuckets, } from "@/lib/rollups/measurement-read"; /** @@ -125,11 +101,7 @@ const GRANULARITY_FLOORS: Array<{ /** * Pick the largest granularity that can still resolve the requested - * `windowDays` window — WEEK for >14 d, MONTH for >62 d, YEAR for - * >730 d. Falls back to a finer granularity on coverage miss so a - * user with WEEK / MONTH coverage but no YEAR buckets (e.g. a tenant - * who joined less than two years ago) still gets a usable trend - * series. + * `windowDays` window (see `GRANULARITY_FLOORS`) and read it. * * Returns the granularity the helper resolved against plus the row * shape the internal `readGranularity` reader produces. `null` when @@ -153,28 +125,25 @@ export async function readBestGranularityRollups( userPriorityJson !== undefined ? userPriorityJson : await loadUserSourcePriority(userId); - // Walk the floors from coarsest to finest and return the first - // granularity whose floor the window clears AND which has coverage - // for `(userId, type, since)`. The `if rows == null` fall-through - // is what makes the helper resilient to partial coverage. - for (const floor of GRANULARITY_FLOORS) { - if (windowDays < floor.minWindowDays) continue; - const rows = await readGranularity( - userId, - type, - floor.granularity, - since, - // Trailing-window semantics: no upper bound. This router serves the - // "last N days to now" probes (summaries-slice / health-score); the - // requested-window bounding lives on `readTieredRollupSeries`. - null, - priority, - ); - if (rows && rows.length > 0) { - return { granularity: floor.granularity, rows }; - } - } - return null; + // Every tier is folded from the same canonical DAY buckets, so a tier + // either has coverage or none has: pick the coarsest floor the window + // clears and read it once. + const floor = GRANULARITY_FLOORS.find((f) => windowDays >= f.minWindowDays); + if (!floor) return null; + const rows = await readGranularity( + userId, + type, + floor.granularity, + since, + // Trailing-window semantics: no upper bound. This router serves the + // "last N days to now" probes (summaries-slice / health-score); the + // requested-window bounding lives on `readTieredRollupSeries`. + null, + priority, + ); + return rows && rows.length > 0 + ? { granularity: floor.granularity, rows } + : null; } /** @@ -225,9 +194,17 @@ export function aggregateWmyBuckets(rows: RollupBucketRow[]): { } /** - * Internal — shared `findMany` projection. Returns `null` when the - * window has zero rows so the caller can branch on coverage miss - * without a separate count round-trip. + * Internal — one type's canonical buckets at `granularity`, built from the + * DAY tier (`readCanonicalRollupBuckets`: canonical source per DAY, then the + * fold). Returns `null` when the window has no rows so the caller can branch + * on a coverage miss without a separate count round-trip. + * + * When `to` is supplied the window is bounded on BOTH ends (`bucketStart` in + * `[from, to]`), so a caller asking for an arbitrary historic window reads + * the buckets INSIDE it rather than the trailing "to now" slice, the same + * contract the DAY-tier `readRollup` and the live-SQL fallback keep. + * `to === null` keeps the trailing-window semantics for the + * `readBestGranularityRollups` router. */ async function readGranularity( userId: string, @@ -237,43 +214,17 @@ async function readGranularity( to: Date | null, userPriorityJson: unknown, ): Promise { - // Bounded `findMany`: `(userId, type, granularity, bucketStart, source)` - // is the composite primary key so the planner picks the index path - // every time. When `to` is supplied the `bucketStart` filter is bounded - // on BOTH ends (`gte from` AND `lte to`) so a caller asking for an - // arbitrary historic window — `[2020-01-01, 2022-01-01]` — reads the - // buckets INSIDE that window rather than the trailing "to now" slice. - // This mirrors the DAY-tier `readRollup` in `daily-series-read.ts` - // (`bucketStart: { gte: from, lte: to }`) so the tiered read reproduces - // the same window contract the live-SQL fallback bounds on both ends. - // `to === null` keeps the legacy trailing-window semantics for the - // `readBestGranularityRollups` router. - const rows = await prisma.measurementRollup.findMany({ - where: { - userId, - type, - granularity, - bucketStart: to === null ? { gte: from } : { gte: from, lte: to }, - }, - orderBy: { bucketStart: "asc" }, - select: { - bucketStart: true, - // v1.11.1 — source drives the per-bucket canonical collapse below. - source: true, - count: true, - mean: true, - sd: true, - slope: true, - r2: true, - sumValue: true, - minValue: true, - maxValue: true, - }, + const rows = await readCanonicalRollupBuckets({ + userId, + type, + granularity, + from, + to, + toInclusive: true, + userPriorityJson, }); if (rows.length === 0) return null; - // v1.11.1 — collapse overlapping sources to the ladder-canonical reading - // per bucket, then drop the source column from the normalised shape. - return collapseRollupRowsBySource(rows, type, userPriorityJson).map((r) => ({ + return rows.map((r) => ({ bucketStart: r.bucketStart, count: r.count, mean: r.mean, @@ -318,9 +269,6 @@ export interface TieredSeriesRow { * ladders are not identical — this one may be finer (DAY rows for a * 91–365 d span; the client folds them into the weeks it captions) — * and a test pins the never-coarser direction across every span. - * - * The finer fallback in `readTieredRollupSeries` still rescues a tenant - * whose coarse buckets the worker has not minted yet. */ export function pickRollupGranularityForWindow( windowDays: number, @@ -330,7 +278,7 @@ export function pickRollupGranularityForWindow( return "DAY"; } -/** DAY → WEEK → MONTH → YEAR ordering for the finer-fallback walk. */ +/** DAY → WEEK → MONTH → YEAR, finest first. */ const TIER_ORDER: RollupGranularity[] = ["DAY", "WEEK", "MONTH", "YEAR"]; /** @@ -354,12 +302,9 @@ const TIER_ORDER: RollupGranularity[] = ["DAY", "WEEK", "MONTH", "YEAR"]; * range band on spot metrics; cumulative metrics (steps, energy, * distance) surface the bucket's summed total and drop the spread. * - * Coverage handling: the target tier is read first; on a coverage miss - * (the boot backfill / worker has not minted the coarse buckets yet) the - * reader walks FINER (MONTH → WEEK → DAY) so a tenant still gets a usable - * series rather than an empty chart. Returns `null` only when no tier at - * or below the target carries any buckets for the window — the caller - * then falls through to its live-SQL path. + * Coverage handling: the window's canonical DAY buckets are read once and + * folded into the chosen tier. Returns `null` when the window holds no DAY + * buckets; the caller then falls through to its live-SQL path. * * The result is NOT capped — the tier selection bounds the row count * (≤ ~104 weeks for the WEEK tier, ≤ ~12 months/year for MONTH). If a @@ -397,77 +342,58 @@ export async function readTieredRollupSeries(opts: { : await loadUserSourcePriority(userId); const target = pickRollupGranularityForWindow(windowDays); - // Walk the target tier first, then finer (never coarser — coarser would - // drop detail the chart can render). Stop at the first tier with coverage. - const targetIdx = TIER_ORDER.indexOf(target); - for (let i = targetIdx; i >= 0; i--) { - let granularity = TIER_ORDER[i]; - let rows = await readGranularity( - userId, + const days = await readCanonicalRollupBuckets({ + userId, + type, + granularity: "DAY", + from, + to, + toInclusive: true, + userPriorityJson: priority, + }); + if (days.length === 0) return null; + // Refine the tier by the ACTUAL data span, not the requested window. The + // "Alle" tab always requests ~3650 days, so keying the tier off the request + // width alone handed EVERY account MONTH buckets: a record whose history + // spans four months came back as four monthly means while the client + // (which captions from the real span) labelled them "weekly average". The + // tier is never coarser than the request calls for. + const spanDays = Math.ceil( + (days[days.length - 1].bucketStart.getTime() - + days[0].bucketStart.getTime()) / + 86_400_000, + ); + const refined = pickRollupGranularityForWindow(Math.max(1, spanDays)); + const granularity = + TIER_ORDER.indexOf(refined) < TIER_ORDER.indexOf(target) ? refined : target; + // Fold the window's own days; a leading bucket that starts before `from` + // would hold only part of its days, so it is left out as the stored coarse + // rows were. + const rows = foldCanonicalDays(days, granularity).filter( + (b) => b.bucketStart.getTime() >= from.getTime(), + ); + if (rows.length === 0) return null; + const useSum = CUMULATIVE_HK_TYPES.has(type); + annotate({ + action: { name: "measurement.list" }, + meta: { + total: rows.length, type, + aggregate: "tiered", granularity, - from, - to, - priority, - ); - if (rows && rows.length > 0) { - // Refine the tier by the ACTUAL data span, not the requested window. - // The "Alle" tab always requests ~3650 days, so keying the tier off - // the request width alone handed EVERY account MONTH buckets — a - // record whose history spans four months came back as four monthly - // means while the client (which captions from the real span) labelled - // them "weekly average". Re-reading at the tier the real span calls - // for returns the resolution the chart will actually caption. A - // coverage miss on the finer tier keeps the coarser rows — finer - // tiers are the base tiers the workers mint first, so in practice - // this probe only fires when the refinement is genuinely available. - // The walk is strictly finer-only, so it terminates. - for (;;) { - const spanDays = Math.ceil( - (rows[rows.length - 1].bucketStart.getTime() - - rows[0].bucketStart.getTime()) / - 86_400_000, - ); - const refined = pickRollupGranularityForWindow(Math.max(1, spanDays)); - if (TIER_ORDER.indexOf(refined) >= TIER_ORDER.indexOf(granularity)) { - break; - } - const finerRows = await readGranularity( - userId, - type, - refined, - from, - to, - priority, - ); - if (!finerRows || finerRows.length === 0) break; - granularity = refined; - rows = finerRows; - } - const useSum = CUMULATIVE_HK_TYPES.has(type); - annotate({ - action: { name: "measurement.list" }, - meta: { - total: rows.length, - type, - aggregate: "tiered", - granularity, - target_granularity: target, - source: "rollup", - }, - }); - return { - granularity, - rows: rows.map((r) => ({ - type, - value: useSum ? (r.sumValue ?? r.mean * r.count) : r.mean, - measuredAt: r.bucketStart.toISOString(), - count: r.count, - minValue: useSum ? undefined : r.minValue, - maxValue: useSum ? undefined : r.maxValue, - })), - }; - } - } - return null; + target_granularity: target, + source: "rollup", + }, + }); + return { + granularity, + rows: rows.map((r) => ({ + type, + value: useSum ? (r.sumValue ?? r.mean * r.count) : r.mean, + measuredAt: r.bucketStart.toISOString(), + count: r.count, + minValue: useSum ? undefined : r.minValue, + maxValue: useSum ? undefined : r.maxValue, + })), + }; } diff --git a/src/lib/rollups/measurement-read.ts b/src/lib/rollups/measurement-read.ts index bffad4ad5..93c377028 100644 --- a/src/lib/rollups/measurement-read.ts +++ b/src/lib/rollups/measurement-read.ts @@ -23,9 +23,11 @@ import type { MeasurementSource, MeasurementType, + RollupGranularity, } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; import { metricKeyForType } from "@/lib/measurements/cumulative-day-sum"; +import { startOfUtcDay } from "@/lib/tz/start-of-utc-day"; import { getSourceLadder, parseSourcePriority, @@ -412,3 +414,235 @@ export function aggregateBuckets(rows: DailyMeanRow[]): { mean: sumWeighted / totalCount, }; } + +const DAY_MS = 86_400_000; + +/** + * Start of the UTC bucket containing `at` at the given granularity. The same + * cut Postgres `date_trunc(, measured_at)` makes on the rollup writer's + * UTC wall-clock `measured_at` (weeks are ISO weeks starting on Monday). + */ +export function utcBucketStart(at: Date, granularity: RollupGranularity): Date { + switch (granularity) { + case "DAY": + return startOfUtcDay(at); + case "WEEK": { + const day = startOfUtcDay(at); + // getUTCDay(): Sunday = 0 … Saturday = 6; ISO weeks start on Monday. + const mondayOffset = (day.getUTCDay() + 6) % 7; + return new Date(day.getTime() - mondayOffset * DAY_MS); + } + case "MONTH": + return new Date(Date.UTC(at.getUTCFullYear(), at.getUTCMonth(), 1)); + case "YEAR": + return new Date(Date.UTC(at.getUTCFullYear(), 0, 1)); + } +} + +/** Start of the UTC bucket after the one containing `at`. */ +export function utcBucketEnd(at: Date, granularity: RollupGranularity): Date { + const start = utcBucketStart(at, granularity); + switch (granularity) { + case "DAY": + return new Date(start.getTime() + DAY_MS); + case "WEEK": + return new Date(start.getTime() + 7 * DAY_MS); + case "MONTH": + return new Date( + Date.UTC(start.getUTCFullYear(), start.getUTCMonth() + 1, 1), + ); + case "YEAR": + return new Date(Date.UTC(start.getUTCFullYear() + 1, 0, 1)); + } +} + +/** One canonical-source bucket, at any granularity. */ +export interface CanonicalRollupBucket { + bucketStart: Date; + count: number; + mean: number; + minValue: number; + maxValue: number; + sumValue: number | null; + sd: number | null; + slope: number | null; + r2: number | null; + sumX: number | null; + sumXy: number | null; + sumXx: number | null; + sumYy: number | null; + computedAt: Date; +} + +/** + * Fold canonical DAY buckets (one row per day, already source-collapsed) + * into buckets of `granularity`. + * + * The canonical source is a per-DAY decision: a week, month or year in which + * the user logged most days by hand and one day from a device holds readings + * from both, and each of those days counts. Collapsing the stored per-source + * WEEK / MONTH / YEAR rows instead picked ONE source for the whole bucket, so + * a month of manual blood-pressure readings plus a single synced one reported + * the synced day alone, and a step history that switched devices mid-month + * lost every day logged by the losing device. + * + * `count`, `min`, `max`, `sum` and the count-weighted `mean` compose exactly. + * `sd`, `slope` and `r2` are composed from the summed regression accumulators + * (`composeRegression`); a bucket with any day lacking them reports `null`. + * DAY input at DAY granularity passes through unchanged. + */ +export function foldCanonicalDays( + days: ReadonlyArray, + granularity: RollupGranularity, +): CanonicalRollupBucket[] { + if (granularity === "DAY") return days.map((d) => ({ ...d })); + const groups = new Map(); + for (const d of days) { + const key = utcBucketStart(d.bucketStart, granularity).getTime(); + const slot = groups.get(key); + if (slot) slot.push(d); + else groups.set(key, [d]); + } + const out: CanonicalRollupBucket[] = []; + for (const [key, members] of [...groups.entries()].sort( + (a, b) => a[0] - b[0], + )) { + let count = 0; + let sumY = 0; + let min = Infinity; + let max = -Infinity; + let computedAt = members[0].computedAt; + let accumulatorsComplete = true; + let sumX = 0; + let sumXy = 0; + let sumXx = 0; + let sumYy = 0; + for (const m of members) { + count += m.count; + sumY += m.sumValue ?? m.mean * m.count; + if (m.minValue < min) min = m.minValue; + if (m.maxValue > max) max = m.maxValue; + if (m.computedAt.getTime() > computedAt.getTime()) { + computedAt = m.computedAt; + } + if ( + m.sumX === null || + m.sumXy === null || + m.sumXx === null || + m.sumYy === null + ) { + accumulatorsComplete = false; + } else { + sumX += m.sumX; + sumXy += m.sumXy; + sumXx += m.sumXx; + sumYy += m.sumYy; + } + } + if (count === 0) continue; + const mean = sumY / count; + const regression = accumulatorsComplete + ? composeRegression([ + { count, mean, sumValue: sumY, sumX, sumXy, sumXx, sumYy }, + ]) + : { slope: null, r2: null, sdPop: null }; + out.push({ + bucketStart: new Date(key), + count, + mean, + minValue: min, + maxValue: max, + sumValue: sumY, + // A single reading has no spread; STDDEV_POP of one value is 0. + sd: count === 1 && accumulatorsComplete ? 0 : regression.sdPop, + slope: regression.slope, + r2: regression.r2, + sumX: accumulatorsComplete ? sumX : null, + sumXy: accumulatorsComplete ? sumXy : null, + sumXx: accumulatorsComplete ? sumXx : null, + sumYy: accumulatorsComplete ? sumYy : null, + computedAt, + }); + } + return out; +} + +/** + * Read one type's canonical rollup buckets at `granularity`. + * + * Every granularity is built from the DAY tier: the day's rows are collapsed + * to the ladder-canonical source first (`collapseRollupRowsBySource`), then + * folded into weeks, months or years (`foldCanonicalDays`). The stored + * per-source WEEK / MONTH / YEAR rows are not read, because the canonical + * source cannot be chosen once per coarse bucket. + * + * A bucket is returned when its start lies in the window: `bucketStart >= + * from`, and `< to` (or `<= to` with `toInclusive`). `to === null` leaves the + * window open towards now. A returned bucket always carries all of its days, + * including days past `to`, the way the stored coarse rows did. + */ +export async function readCanonicalRollupBuckets(opts: { + userId: string; + type: MeasurementType; + granularity: RollupGranularity; + from: Date; + to: Date | null; + toInclusive?: boolean; + /** The user's source-priority blob; `undefined` loads it on demand. */ + userPriorityJson?: unknown; +}): Promise { + const { userId, type, granularity, from, to } = opts; + // The DAY read reaches the end of the last bucket that can start inside + // the window, so that bucket folds complete. + const dayUpper = + to === null + ? undefined + : granularity === "DAY" + ? to + : utcBucketEnd(to, granularity); + const dayRows = await prisma.measurementRollup.findMany({ + where: { + userId, + type, + granularity: "DAY", + bucketStart: + dayUpper === undefined + ? { gte: from } + : granularity === "DAY" && opts.toInclusive + ? { gte: from, lte: dayUpper } + : { gte: from, lt: dayUpper }, + }, + orderBy: { bucketStart: "asc" }, + }); + if (dayRows.length === 0) return []; + const priority = + opts.userPriorityJson !== undefined + ? opts.userPriorityJson + : await loadUserSourcePriority(userId); + const canonicalDays = collapseRollupRowsBySource(dayRows, type, priority).map( + (r): CanonicalRollupBucket => ({ + bucketStart: r.bucketStart, + count: r.count, + mean: r.mean, + minValue: r.minValue, + maxValue: r.maxValue, + sumValue: r.sumValue, + sd: r.sd, + slope: r.slope, + r2: r.r2, + sumX: r.sumX, + sumXy: r.sumXy, + sumXx: r.sumXx, + sumYy: r.sumYy, + computedAt: r.computedAt, + }), + ); + const fromMs = from.getTime(); + const toMs = to?.getTime() ?? null; + return foldCanonicalDays(canonicalDays, granularity).filter((b) => { + const start = b.bucketStart.getTime(); + if (start < fromMs) return false; + if (toMs === null) return true; + return opts.toInclusive ? start <= toMs : start < toMs; + }); +} diff --git a/src/lib/rollups/measurement-rollups.ts b/src/lib/rollups/measurement-rollups.ts index c2aa7c74d..6afdac3d4 100644 --- a/src/lib/rollups/measurement-rollups.ts +++ b/src/lib/rollups/measurement-rollups.ts @@ -38,9 +38,10 @@ import { prisma } from "@/lib/db"; import { getGlobalBoss } from "@/lib/jobs/boss-instance"; import { annotate } from "@/lib/logging/context"; import { - collapseRollupRowsBySource, - loadUserSourcePriority, + readCanonicalRollupBuckets, REGRESSION_X_ORIGIN_DAYS, + utcBucketEnd, + utcBucketStart, } from "@/lib/rollups/measurement-read"; import { startOfUtcDay } from "@/lib/tz/start-of-utc-day"; import type { @@ -354,8 +355,10 @@ export async function enqueueRollupRecompute(input: { * Read rollup rows for `(userId, type, granularity)` in `[from, to)`. * Returns rows sorted ascending by `bucketStart`. * - * v1.11.1 — rows are stored per source; this collapses overlapping sources to - * the ladder-canonical reading so the return shape stays one row per bucket. + * Rows are stored per source. Every granularity is built from the DAY tier, + * collapsed to the ladder-canonical source per DAY and then folded into the + * requested bucket (`readCanonicalRollupBuckets`), so the return shape stays + * one row per bucket and a coarse bucket counts every day it covers. * Pass `userPriorityJson` to avoid a per-call user lookup when the caller * already loaded it (e.g. a loop over many types); omit it to lazy-load. */ @@ -379,20 +382,15 @@ export async function readRollupBuckets( computedAt: Date; }> > { - const rows = await prisma.measurementRollup.findMany({ - where: { - userId, - type, - granularity, - bucketStart: { gte: from, lt: to }, - }, - orderBy: { bucketStart: "asc" }, + const rows = await readCanonicalRollupBuckets({ + userId, + type, + granularity, + from, + to, + userPriorityJson, }); - const priority = - userPriorityJson !== undefined - ? userPriorityJson - : await loadUserSourcePriority(userId); - return collapseRollupRowsBySource(rows, type, priority).map((r) => ({ + return rows.map((r) => ({ bucketStart: r.bucketStart, count: r.count, mean: r.mean, @@ -800,34 +798,10 @@ export function bucketSpan( measuredAt: Date, granularity: RollupGranularity, ): { from: Date; to: Date } { - switch (granularity) { - case "DAY": { - const from = startOfUtcDay(measuredAt); - return { from, to: new Date(from.getTime() + 24 * 60 * 60 * 1000) }; - } - case "WEEK": { - const day = startOfUtcDay(measuredAt); - // Postgres ISO week: Monday is day 1. JS getUTCDay(): Sunday=0..Saturday=6. - const dayOfWeek = day.getUTCDay(); // 0=Sun - const mondayOffset = (dayOfWeek + 6) % 7; // 0 for Mon … 6 for Sun - const from = new Date(day.getTime() - mondayOffset * 24 * 60 * 60 * 1000); - return { from, to: new Date(from.getTime() + 7 * 24 * 60 * 60 * 1000) }; - } - case "MONTH": { - const from = new Date( - Date.UTC(measuredAt.getUTCFullYear(), measuredAt.getUTCMonth(), 1), - ); - const to = new Date( - Date.UTC(measuredAt.getUTCFullYear(), measuredAt.getUTCMonth() + 1, 1), - ); - return { from, to }; - } - case "YEAR": { - const from = new Date(Date.UTC(measuredAt.getUTCFullYear(), 0, 1)); - const to = new Date(Date.UTC(measuredAt.getUTCFullYear() + 1, 0, 1)); - return { from, to }; - } - } + return { + from: utcBucketStart(measuredAt, granularity), + to: utcBucketEnd(measuredAt, granularity), + }; } /** diff --git a/tests/integration/rollup-per-day-canonical.test.ts b/tests/integration/rollup-per-day-canonical.test.ts new file mode 100644 index 000000000..8cc597e26 --- /dev/null +++ b/tests/integration/rollup-per-day-canonical.test.ts @@ -0,0 +1,286 @@ +/** + * Coarse buckets pick the canonical source per DAY, on both read paths. + * + * The rollup writer mints one row per (type, bucket, source). Collapsing the + * stored WEEK / MONTH / YEAR rows picked ONE source for the whole bucket, so a + * month of manual blood-pressure readings plus one synced reading reported the + * synced day alone, and a step month that switched devices lost every day the + * losing device carried. The live weekly / monthly fold made the same pick. + * + * This suite seeds real rows, folds the rollups through the real writer, and + * pins that the rollup readers (`readRollupBuckets`, + * `readBestGranularityRollups`) and the live fold (`readLiveBuckets`) agree + * with each other and with the per-day arithmetic. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { getPrismaClient, truncateAllTables } from "./setup"; +import { + readRollupBuckets, + recomputeUserRollups, +} from "@/lib/rollups/measurement-rollups"; +import { readLiveBuckets } from "@/lib/measurements/daily-series-read"; +import type { MeasurementType } from "@/generated/prisma/client"; + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/jobs/boss-instance", () => ({ + getGlobalBoss: vi.fn(() => null), +})); + +const DAY_MS = 86_400_000; +const FROM = new Date("2025-11-01T00:00:00.000Z"); +const TO = new Date("2025-12-01T00:00:00.000Z"); + +let seq = 0; +async function seedUser() { + seq += 1; + return getPrismaClient().user.create({ + data: { + username: `per-day-canonical-${seq}`, + email: `per-day-canonical-${seq}@example.test`, + role: "USER", + }, + }); +} + +type Row = { + type: MeasurementType; + source: "MANUAL" | "WITHINGS" | "APPLE_HEALTH" | "FITBIT"; + value: number; + at: Date; +}; + +async function seed(userId: string, rows: Row[]) { + await getPrismaClient().measurement.createMany({ + data: rows.map((r) => ({ + userId, + type: r.type, + value: r.value, + unit: r.type === "ACTIVITY_STEPS" ? "count" : "mmHg", + source: r.source, + measuredAt: r.at, + })), + }); + await recomputeUserRollups(userId, { + from: new Date("2025-10-01T00:00:00.000Z"), + to: new Date("2026-01-01T00:00:00.000Z"), + }); +} + +function day(n: number, hour = 8): Date { + return new Date(FROM.getTime() + n * DAY_MS + hour * 3_600_000); +} + +async function live( + userId: string, + type: MeasurementType, + grain: "weekly" | "monthly", +) { + return readLiveBuckets({ + userId, + type, + from: FROM, + to: new Date(TO.getTime() - 1), + cap: 100, + priorityJson: null, + grain, + timeZone: "UTC", + }); +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("coarse buckets — canonical source per day", () => { + it("counts every manual day of a month that also carries one synced day", async () => { + const user = await seedUser(); + const rows: Row[] = []; + for (let i = 0; i < 20; i++) { + rows.push({ + type: "BLOOD_PRESSURE_SYS", + source: "MANUAL", + value: 150, + at: day(i), + }); + } + // WITHINGS leads the blood-pressure ladder; it owns day 24 only. + rows.push({ + type: "BLOOD_PRESSURE_SYS", + source: "WITHINGS", + value: 120, + at: day(24), + }); + await seed(user.id, rows); + + const [month] = await readRollupBuckets( + user.id, + "BLOOD_PRESSURE_SYS", + "MONTH", + FROM, + TO, + ); + expect(month.count).toBe(21); + expect(month.mean).toBeCloseTo((20 * 150 + 120) / 21, 9); + + const [liveMonth] = await live(user.id, "BLOOD_PRESSURE_SYS", "monthly"); + expect(liveMonth.count).toBe(21); + expect(liveMonth.value).toBeCloseTo(month.mean, 9); + }); + + it("sums a step month that switches device, and keeps one source on a shared day", async () => { + const user = await seedUser(); + const rows: Row[] = []; + for (let i = 0; i < 10; i++) { + rows.push({ + type: "ACTIVITY_STEPS", + source: "FITBIT", + value: 8000, + at: day(i), + }); + } + for (let i = 10; i < 30; i++) { + rows.push({ + type: "ACTIVITY_STEPS", + source: "APPLE_HEALTH", + value: 8000, + at: day(i), + }); + } + // Day 12 also carries a Fitbit total; Apple Health leads the ladder. + rows.push({ + type: "ACTIVITY_STEPS", + source: "FITBIT", + value: 9999, + at: day(12), + }); + await seed(user.id, rows); + + const monthly = await readRollupBuckets( + user.id, + "ACTIVITY_STEPS", + "MONTH", + FROM, + TO, + ); + expect(monthly).toHaveLength(1); + expect(monthly[0].count).toBe(30); + expect(monthly[0].mean * monthly[0].count).toBeCloseTo(240_000, 6); + + const [liveMonth] = await live(user.id, "ACTIVITY_STEPS", "monthly"); + expect(liveMonth.value).toBeCloseTo(240_000, 6); + expect(liveMonth.count).toBe(30); + }); + + it("the rollup and live weekly folds agree bucket for bucket", async () => { + const user = await seedUser(); + const rows: Row[] = []; + for (let i = 0; i < 30; i++) { + // Two manual readings on most days, a synced one every fifth day, and + // both sources on every tenth day. + if (i % 5 !== 0 || i % 10 === 0) { + rows.push({ + type: "BLOOD_PRESSURE_SYS", + source: "MANUAL", + value: 130 + (i % 7), + at: day(i, 7), + }); + rows.push({ + type: "BLOOD_PRESSURE_SYS", + source: "MANUAL", + value: 140 - (i % 4), + at: day(i, 20), + }); + } + if (i % 5 === 0) { + rows.push({ + type: "BLOOD_PRESSURE_SYS", + source: "WITHINGS", + value: 118 + i, + at: day(i, 9), + }); + } + } + await seed(user.id, rows); + + const rollupWeeks = await readRollupBuckets( + user.id, + "BLOOD_PRESSURE_SYS", + "WEEK", + new Date("2025-10-27T00:00:00.000Z"), + TO, + ); + const liveWeeks = await readLiveBuckets({ + userId: user.id, + type: "BLOOD_PRESSURE_SYS", + from: new Date("2025-10-27T00:00:00.000Z"), + to: new Date(TO.getTime() - 1), + cap: 100, + priorityJson: null, + grain: "weekly", + timeZone: "UTC", + }); + + expect(liveWeeks.map((w) => w.measuredAt)).toEqual( + rollupWeeks.map((w) => w.bucketStart.toISOString()), + ); + for (let i = 0; i < rollupWeeks.length; i++) { + expect(liveWeeks[i].count).toBe(rollupWeeks[i].count); + expect(liveWeeks[i].value).toBeCloseTo(rollupWeeks[i].mean, 9); + expect(liveWeeks[i].minValue).toBe(rollupWeeks[i].minValue); + expect(liveWeeks[i].maxValue).toBe(rollupWeeks[i].maxValue); + } + // Every day counts once with its canonical source: 24 manual-only days + // with two readings each, and six synced days (WITHINGS leads the ladder, + // so it wins the three days that carry both). + const total = rollupWeeks.reduce((n, w) => n + w.count, 0); + expect(total).toBe(24 * 2 + 6); + }); + + it("breaks a tie between unranked sources alphabetically on both paths", async () => { + const user = await seedUser(); + // GLUCOSE carries no ladder: APPLE_HEALTH sorts before MANUAL by name, + // although MANUAL comes first in the enum's declaration order. + await getPrismaClient().measurement.createMany({ + data: [ + { + userId: user.id, + type: "BLOOD_GLUCOSE", + value: 90, + unit: "mg/dL", + source: "MANUAL", + measuredAt: day(3, 7), + }, + { + userId: user.id, + type: "BLOOD_GLUCOSE", + value: 110, + unit: "mg/dL", + source: "APPLE_HEALTH", + measuredAt: day(3, 9), + }, + ], + }); + await recomputeUserRollups(user.id, { + from: new Date("2025-10-01T00:00:00.000Z"), + to: new Date("2026-01-01T00:00:00.000Z"), + }); + + const [rollupDay] = await readRollupBuckets( + user.id, + "BLOOD_GLUCOSE", + "DAY", + FROM, + TO, + ); + const [liveMonth] = await live(user.id, "BLOOD_GLUCOSE", "monthly"); + expect(rollupDay.mean).toBe(110); + expect(liveMonth.value).toBe(110); + }); +}); From 35f774100690bab2eb501617cfe5161165df1e13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 26 Sep 2026 15:37:17 +0200 Subject: [PATCH 02/15] fix(analytics): all-time and year-ago averages from one source per day Rows older than the five-year rollup window were added to the all-time figures without choosing a source per day, so a vital recorded by two devices counted both on every one of those days. They now keep the ladder-canonical source per day, like the rollup half and the live path. The year-ago 30-day average read the monthly tier and kept whichever month began inside the window, which could be a month off or leave the value empty. It is now the count-weighted mean over exactly the 30 days that start 395 days ago. --- .../__tests__/summaries-slice.test.ts | 176 ++++---------- .../__tests__/summaries-slice.wmy-cap.test.ts | 21 +- src/lib/analytics/summaries-slice.ts | 225 +++++++----------- 3 files changed, 152 insertions(+), 270 deletions(-) diff --git a/src/lib/analytics/__tests__/summaries-slice.test.ts b/src/lib/analytics/__tests__/summaries-slice.test.ts index ce8c10f70..9425ee5cf 100644 --- a/src/lib/analytics/__tests__/summaries-slice.test.ts +++ b/src/lib/analytics/__tests__/summaries-slice.test.ts @@ -412,16 +412,9 @@ describe("computeSummariesSlice", () => { // call is the pre-fold all-time remainder splice. expect(RAW).toHaveBeenCalledTimes(2); expect(UNSAFE).toHaveBeenCalledTimes(3); - // 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. - // - // v1.20.0 F6 — plus the 90-day accumulator findMany the warm path - // now runs to compose slope / r² / sd: 1 accumulator + 3 WMY = 4. - expect(ROLLUP_FIND_MANY).toHaveBeenCalledTimes(4); + // The 90-day accumulator findMany plus one year-ago DAY read per + // type-with-data. + expect(ROLLUP_FIND_MANY).toHaveBeenCalledTimes(2); }); // Watched red: with the pre-fold splice removed from the rollup path @@ -483,28 +476,14 @@ describe("computeSummariesSlice", () => { }); /** - * 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. + * `avg30LastYear` is the mean over the exact 30 UTC days that start 395 + * days ago, read from canonical DAY buckets. */ 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. + function warmPathMocks() { RAW.mockResolvedValueOnce([{ type: "WEIGHT", has_buckets: true }]); UNSAFE.mockResolvedValueOnce([ - { - type: "WEIGHT", - avg7: 82, - avg30: 82.5, - slope7: 0, - r2_7: 0, - slope30: 0, - r2_30: 0, - slope90: 0, - r2_90: 0, - }, + { type: "WEIGHT", avg7: 82, avg30: 82.5, median: 82 }, ]) .mockResolvedValueOnce([ { type: "WEIGHT", value: 82.7, measured_at: new Date() }, @@ -512,120 +491,59 @@ describe("computeSummariesSlice", () => { .mockResolvedValueOnce([ { type: "WEIGHT", count: 5, min: 82, max: 84, mean: 83 }, ]); - - // v1.20.0 F6 — the warm path runs the 90-day accumulator findMany - // first; return empty (slope composes to null, irrelevant to this - // year-over-year assertion) so the year-ago MONTH probe is call #2. + // The 90-day accumulator findMany runs first; empty (slope composes + // to null, irrelevant here) so the year-ago DAY read is call #1. ROLLUP_FIND_MANY.mockResolvedValueOnce([]); - // 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, - ); + } + function dayBucket(daysAgo: number, count: number, mean: number) { + return { + bucketStart: startOfUtcDay( + new Date(Date.now() - daysAgo * 24 * 60 * 60 * 1000), + ), + source: "MANUAL", + count, + mean, + sd: 0, + slope: null, + r2: null, + sumValue: count * mean, + minValue: mean, + maxValue: mean, + sumX: null, + sumXy: null, + sumXx: null, + sumYy: null, + computedAt: new Date(), + }; + } + + it("reads exactly the 30 days that start 395 days ago", async () => { + warmPathMocks(); ROLLUP_FIND_MANY.mockResolvedValueOnce([ - { - bucketStart: yearAgoBucketStart, - count: 10, - mean: 85, - sd: 1, - slope: 0, - r2: 0, - sumValue: null, - minValue: 83, - maxValue: 87, - }, + dayBucket(390, 10, 85), + dayBucket(370, 30, 81), ]); const result = await computeSummariesSlice("user-yoy"); - expect(result.summaries.WEIGHT.avg30LastYear).toBe(85); - // Router asked MONTH first (395 > 181, 395 < 731 → skip YEAR). Call #0 - // is the v1.20.0 F6 accumulator findMany (DAY); the WMY probe is #1. - expect(ROLLUP_FIND_MANY.mock.calls[1][0].where.granularity).toBe("MONTH"); + expect(result.summaries.WEIGHT.avg30LastYear).toBe(82); + const where = ROLLUP_FIND_MANY.mock.calls[1][0].where; + expect(where.granularity).toBe("DAY"); + const DAY = 24 * 60 * 60 * 1000; + expect(where.bucketStart).toEqual({ + gte: startOfUtcDay(new Date(Date.now() - 395 * DAY)), + lt: startOfUtcDay(new Date(Date.now() - 365 * DAY)), + }); }); - it("leaves avg30LastYear null when no bucket overlaps the year-ago slice", async () => { - RAW.mockResolvedValueOnce([{ type: "WEIGHT", has_buckets: true }]); - UNSAFE.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 }, - ]); - - // v1.20.0 F6 — accumulator findMany runs first; empty so the WMY - // probe is call #2. - ROLLUP_FIND_MANY.mockResolvedValueOnce([]); - // 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 }]); - UNSAFE.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. + it("leaves avg30LastYear null when no reading falls in the window", async () => { + warmPathMocks(); 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; plus the - // v1.20.0 F6 accumulator findMany = 4. - expect(ROLLUP_FIND_MANY).toHaveBeenCalledTimes(4); + expect(ROLLUP_FIND_MANY).toHaveBeenCalledTimes(2); }); it("only probes types that actually have data in the current window", async () => { diff --git a/src/lib/analytics/__tests__/summaries-slice.wmy-cap.test.ts b/src/lib/analytics/__tests__/summaries-slice.wmy-cap.test.ts index 53b2018cf..c08caa5d3 100644 --- a/src/lib/analytics/__tests__/summaries-slice.wmy-cap.test.ts +++ b/src/lib/analytics/__tests__/summaries-slice.wmy-cap.test.ts @@ -11,23 +11,30 @@ * This test mirrors the v1.4.40 W-POOL pin * (`src/app/api/analytics/__tests__/route.test.ts` — * "pin concurrency cap and pool ceiling"). It mocks - * `readBestGranularityRollups` with a controllable promise so we can + * `readRollupBuckets` with a controllable promise so we can * observe the in-flight count at the helper boundary and assert it * never exceeds 4 across a 15-type fan-out. */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -vi.mock("@/lib/rollups/measurement-read-wmy", () => ({ - readBestGranularityRollups: vi.fn(), +vi.mock("@/lib/rollups/measurement-rollups", async (importOriginal) => ({ + ...(await importOriginal< + typeof import("@/lib/rollups/measurement-rollups") + >()), + readRollupBuckets: vi.fn(), +})); +vi.mock("@/lib/rollups/measurement-read", async (importOriginal) => ({ + ...(await importOriginal()), + loadUserSourcePriority: vi.fn(async () => null), })); -import { readBestGranularityRollups } from "@/lib/rollups/measurement-read-wmy"; +import { readRollupBuckets } from "@/lib/rollups/measurement-rollups"; import { WMY_FANOUT_CONCURRENCY, computeAvg30LastYearMap, } from "../summaries-slice"; -const READ = readBestGranularityRollups as unknown as ReturnType; +const READ = readRollupBuckets as unknown as ReturnType; beforeEach(() => { READ.mockReset(); @@ -45,7 +52,7 @@ describe("computeAvg30LastYearMap — WMY fan-out cap (v1.4.43)", () => { expect(WMY_FANOUT_CONCURRENCY).toBe(4); }); - it("holds at most 4 concurrent readBestGranularityRollups calls for a 15-type list", async () => { + it("holds at most 4 concurrent rollup reads for a 15-type list", async () => { let inFlight = 0; let peak = 0; const resolvers: Array<() => void> = []; @@ -58,7 +65,7 @@ describe("computeAvg30LastYearMap — WMY fan-out cap (v1.4.43)", () => { inFlight -= 1; // Resolve with `null` — the helper treats no-coverage as // `null` in the output map, which is fine for this test. - resolve(null); + resolve([]); }); }); }); diff --git a/src/lib/analytics/summaries-slice.ts b/src/lib/analytics/summaries-slice.ts index afa1d1a47..11c74e262 100644 --- a/src/lib/analytics/summaries-slice.ts +++ b/src/lib/analytics/summaries-slice.ts @@ -57,6 +57,7 @@ import pLimit from "p-limit"; import type { MeasurementType } from "@/generated/prisma/client"; +import { Prisma } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; import type { DataSummary } from "@/lib/analytics/trends"; @@ -64,9 +65,11 @@ import { measurementTypeEnum } from "@/lib/validations/measurement"; import { annotate } from "@/lib/logging/context"; import { ensureUserRollupsFresh, + readRollupBuckets, ROLLUP_FOLD_WINDOW_MS, } from "@/lib/rollups/measurement-rollups"; import { + aggregateBuckets, collapseRollupRowsBySource, composeWindowedRegression, hasPendingAccumulatorBackfill, @@ -83,7 +86,6 @@ import { probeRollupCoverage, type RollupCoverageMap, } from "@/lib/rollups/measurement-coverage"; -import { readBestGranularityRollups } from "@/lib/rollups/measurement-read-wmy"; import { summarizeSleepNights, type SleepSourceDiscrepancy, @@ -423,6 +425,11 @@ async function computeFromRollups(userId: string): Promise { '"source"', ); const rankM = buildSourceRankCase(priorityJson, 'm."type"', 'm."source"'); + const rankPerSource = buildSourceRankCase( + priorityJson, + 'p."type"', + 'p."source"', + ); // v1.37.19 (A6-10) — where the fold window starts. DAY rollup buckets // only exist inside the trailing `ROLLUP_FOLD_WINDOW_MS` (5 y), so the @@ -514,7 +521,7 @@ async function computeFromRollups(userId: string): Promise { WHERE "user_id" = $1 AND "granularity" = 'DAY' AND "bucket_start" >= $2 - ORDER BY "type", "bucket_start", (${rankUnqualified}), "source" + ORDER BY "type", "bucket_start", (${rankUnqualified}), "source"::text ) SELECT "type"::text AS type, @@ -559,16 +566,15 @@ async function computeFromRollups(userId: string): Promise { sumYy: true, }, }), - // v1.37.19 (A6-10) — all-time remainder OLDER than the fold window. - // One grouped aggregate over the pre-fold rows (index range scan on - // `(user_id, measured_at)`; empty for any account younger than the - // window). Spliced into the per-type figures below so "all-time" - // means all time on the rollup path too. Deliberately without the - // source-priority collapse: pre-fold history predates the rollup - // tier's per-day canonicalisation, and a per-day DISTINCT ON over a - // multi-year span is the cost this splice exists to avoid — for - // count/min/max/mean over years-old data the raw figures are the - // honest ones. + // v1.37.19 (A6-10) — all-time remainder OLDER than the fold window, + // spliced into the per-type figures below so "all-time" means all time + // on the rollup path too (empty for any account younger than the + // window). It keeps one source per (type, day) the way the rollup half + // and the live path do: without it, a vital recorded by two devices + // counted both of them on every pre-fold day. Each source is folded per + // day first (a hash aggregate, a few rows per day however dense the + // stream), each day then keeps its ladder-canonical source, so the + // sort never sees more than one row per source per day. prisma.$queryRaw< Array<{ type: string; @@ -578,17 +584,35 @@ async function computeFromRollups(userId: string): Promise { mean: number; }> >` + WITH per_source AS ( + SELECT + m."type", + m."source", + date_trunc('day', m."measured_at") AS day, + COUNT(*)::int AS cnt, + SUM(m."value")::double precision AS total, + MIN(m."value")::double precision AS min_value, + MAX(m."value")::double precision AS max_value + FROM measurements m + WHERE m."user_id" = ${userId} + AND m."deleted_at" IS NULL + AND m."measured_at" < ${foldSplit} + GROUP BY m."type", m."source", 3 + ), + canon AS ( + SELECT DISTINCT ON (p."type", p.day) + p."type", p.cnt, p.total, p.min_value, p.max_value + FROM per_source p + ORDER BY p."type", p.day, (${Prisma.raw(rankPerSource)}), p."source"::text + ) SELECT - m."type"::text AS type, - COUNT(*)::int AS count, - MIN(m."value")::double precision AS min, - MAX(m."value")::double precision AS max, - AVG(m."value")::double precision AS mean - FROM measurements m - WHERE m."user_id" = ${userId} - AND m."deleted_at" IS NULL - AND m."measured_at" < ${foldSplit} - GROUP BY m."type" + c."type"::text AS type, + SUM(c.cnt)::int AS count, + MIN(c.min_value)::double precision AS min, + MAX(c.max_value)::double precision AS max, + (SUM(c.total) / SUM(c.cnt))::double precision AS mean + FROM canon c + GROUP BY c."type" `, ]); @@ -773,11 +797,10 @@ async function computeFromRollups(userId: string): Promise { }; } - // 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. + // Populate `avg30LastYear` per type from the DAY 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 with no + // reading in the year-ago window surface as `null`. const avg30LastYearMap = await computeAvg30LastYearMap(userId, typesWithData); let yearOverYearTypeCount = 0; for (const [type, value] of avg30LastYearMap.entries()) { @@ -1016,13 +1039,10 @@ 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. + // Even on the live-aggregate fallback (trailing DAY coverage incomplete), + // the year-ago DAY buckets may already exist because the boot-time + // backfill folds the whole history once per user, so a partial-coverage + // cold mount still surfaces `avg30LastYear`. const avg30LastYearMap = await computeAvg30LastYearMap(userId, typesWithData); let yearOverYearTypeCount = 0; for (const [type, value] of avg30LastYearMap.entries()) { @@ -1051,118 +1071,51 @@ async function computeFromLiveAggregate( } /** - * v1.4.39 W-WMY — long-window summary for a single `(userId, type)` - * pair, served from the WEEK / MONTH / YEAR rollup tier when the - * window justifies it. + * Year-ago 30-day baseline per type, served from the DAY rollup tier. * - * Why this helper exists - * ---------------------- - * The slim `computeSummariesSlice` caps its windowed columns at 90 d - * (`slope7 / slope30`). The v1.5 multi-year trend feature - * + the Coach drawer's "history" tile need linearly composable stats - * (count / min / max / mean / sum) over much larger windows — 1 y, - * 2 y, 3 y. Hitting the live `measurements` table for a 3-year span - * walks every row the user has ever logged for that type, which on - * the maintainer's tenant is tens of thousands of rows per type. + * `DataSummary.avg30LastYear` is the mean over the 30 days that start 395 + * days ago, which the dashboard tile uses for "current average vs the same + * time last year". The window is exactly those 30 UTC days (the DAY-tier + * grain), read as canonical-source DAY buckets and composed count-weighted, + * so it is the same per-reading mean `avg30` reports for the last 30 days. * - * The WMY rollup tier already carries the per-bucket stats we need: - * the writer mints WEEK / MONTH / YEAR buckets via pg-boss on every - * measurement write and the boot backfill fills the long tail. This - * helper routes the requested window into the largest granularity - * that still resolves it (via `readBestGranularityRollups`) and - * composes the trailing-window aggregate in JS — typically 3-15 - * bucket rows on a multi-year window, vs the thousands of raw rows - * the live aggregator would scan. + * Up to v1.39.2 this read the MONTH tier and kept the months whose first day + * fell inside the window, so the baseline was a whole calendar month that + * could start up to a month off, or nothing at all when no month began inside + * the window. * - * Compositional caveats - * --------------------- - * `count / min / max / mean / sum` are mathematically exact across - * any granularity (linearly composable). `sd / slope / r2` are NOT - * exact across coarser buckets — this helper deliberately omits them - * so callers cannot accidentally consume stale slope data. The v1.5 - * multi-year trend card derives its slope from the per-bucket `mean` - * series instead. - * - * Coverage-miss policy - * -------------------- - * `readBestGranularityRollups` falls back from YEAR → MONTH → WEEK - * → DAY on per-tier coverage miss. Returns `null` only when the - * user has zero buckets in the entire window — the caller treats - * that as "no data" rather than "stale rollup". The route is - * intentionally NOT wired to live SQL fallback here; the all-time - * aggregate path inside `computeSummariesSlice` already covers the - * "rollup tier empty" cold-mount case, and v1.5's multi-year card - * accepts a `null` "no data yet" state. - */ - -/** - * 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". + * Returns `null` when no reading falls in the window; the UI treats that as + * "no comparison available". */ async function computeAvg30LastYearForType( userId: string, type: MeasurementType, + priorityJson: unknown, + now: number, ): Promise { - 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; + const rows = await readRollupBuckets( + userId, + type, + "DAY", + startOfUtcDay(new Date(now - 395 * DAY_MS)), + startOfUtcDay(new Date(now - 365 * DAY_MS)), + priorityJson, + ); + const composed = aggregateBuckets( + rows.map((r) => ({ + day: r.bucketStart, + count: r.count, + mean: r.mean, + minValue: r.minValue, + maxValue: r.maxValue, + })), + ); + return composed.count > 0 ? composed.mean : null; } /** * v1.4.40 W-WMY-WIRE — fan out `computeAvg30LastYearForType` across - * the types the caller actually surfaces. Runs the per-type WMY reads + * the types the caller actually surfaces. Runs the per-type rollup reads * in parallel so the long-tail of types doesn't serialise into a * per-type round-trip stack. * @@ -1191,12 +1144,16 @@ export async function computeAvg30LastYearMap( const out = new Map(); if (types.length === 0) return out; const limit = pLimit(WMY_FANOUT_CONCURRENCY); + const priorityJson = await loadUserSourcePriority(userId); + const now = Date.now(); const results = await Promise.all( types.map((type) => limit(async () => { const value = await computeAvg30LastYearForType( userId, type as MeasurementType, + priorityJson, + now, ); return [type, value] as const; }), From 148d6c035ff9b8092c71f1a22e2cb48d31a84963 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 26 Sep 2026 15:37:25 +0200 Subject: [PATCH 03/15] fix(dashboard): count the logging streak on local day keys The streak walked back from UTC midnight of today's key and read each instant in the user's zone. West of UTC that instant is still yesterday, so the walk started a day early and the streak came out one day short. It now steps through the day keys themselves. --- src/app/api/dashboard/summary/route.ts | 48 +------------- src/lib/dashboard/__tests__/streak.test.ts | 77 ++++++++++++++++++++++ src/lib/dashboard/streak.ts | 47 +++++++++++++ 3 files changed, 125 insertions(+), 47 deletions(-) create mode 100644 src/lib/dashboard/__tests__/streak.test.ts create mode 100644 src/lib/dashboard/streak.ts diff --git a/src/app/api/dashboard/summary/route.ts b/src/app/api/dashboard/summary/route.ts index 95d65fdbf..084b12087 100644 --- a/src/app/api/dashboard/summary/route.ts +++ b/src/app/api/dashboard/summary/route.ts @@ -67,6 +67,7 @@ import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/resolver"; import { validTimezoneOr } from "@/lib/tz/format"; import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache"; import { buildMedsTodayBlock } from "@/lib/dashboard/meds-today"; +import { computeStreak } from "@/lib/dashboard/streak"; import { readLatestEver, readSparkBuckets, @@ -256,53 +257,6 @@ function trendOf(values: number[]): MetricCard["trend"] { return delta > 0 ? "up" : "down"; } -interface StreakInfo { - currentDays: number; - longest: number; -} - -/** Compute the current logging-day streak (days where any measurement or - * intake event was recorded, in the user's display timezone) plus the - * longest streak in the last `STREAK_WINDOW_DAYS` days. - * - * v1.4.25 W7b — `userTz` parameterises the "today" pivot so a Pacific/ - * Auckland user gets their Auckland-day streak rather than the Berlin - * one. The activity-day Set entries are already produced in the same - * zone by the caller (via `userDayKey`), so the cursor walk only needs - * the same zone here to align. */ -function computeStreak(activityDays: Set, userTz: string): StreakInfo { - if (activityDays.size === 0) return { currentDays: 0, longest: 0 }; - - const sorted = [...activityDays].sort(); - let longest = 1; - let run = 1; - for (let i = 1; i < sorted.length; i++) { - const prev = new Date(`${sorted[i - 1]}T00:00:00.000Z`).getTime(); - const cur = new Date(`${sorted[i]}T00:00:00.000Z`).getTime(); - if (cur - prev === 86_400_000) { - run += 1; - longest = Math.max(longest, run); - } else { - run = 1; - } - } - - // Current streak: walk back from today (user's tz). - const todayKey = userDayKey(new Date(), userTz); - let currentDays = 0; - let cursor = new Date(`${todayKey}T00:00:00.000Z`); - // Allow yesterday's last day to count if today not yet logged. - if (!activityDays.has(userDayKey(cursor, userTz))) { - cursor = new Date(cursor.getTime() - 86_400_000); - } - while (activityDays.has(userDayKey(cursor, userTz))) { - currentDays += 1; - cursor = new Date(cursor.getTime() - 86_400_000); - } - - return { currentDays, longest }; -} - /** v1.4.38 W-F — distinct activity day-keys from the streak window. * The route only needs YYYY-MM-DD strings, so the aggregate runs * `date_trunc('day', m.measured_at AT TIME ZONE $userTz)` and returns diff --git a/src/lib/dashboard/__tests__/streak.test.ts b/src/lib/dashboard/__tests__/streak.test.ts new file mode 100644 index 000000000..8f810c1dd --- /dev/null +++ b/src/lib/dashboard/__tests__/streak.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; + +import { computeStreak } from "../streak"; +import { userDayKey } from "@/lib/tz/format"; + +const ZONES = [ + "America/New_York", + "America/Santiago", + "Pacific/Auckland", + "Pacific/Tongatapu", + "Europe/Berlin", + "UTC", +]; + +/** The last `n` local days ending with today's key in `tz`. */ +function lastDays(now: Date, tz: string, n: number, skipToday = false) { + const keys = new Set(); + const today = userDayKey(now, tz); + const [y, m, d] = today.split("-").map(Number); + for (let i = skipToday ? 1 : 0; i < n + (skipToday ? 1 : 0); i++) { + keys.add(new Date(Date.UTC(y, m - 1, d - i)).toISOString().slice(0, 10)); + } + return keys; +} + +describe("computeStreak", () => { + // Evening in New York is already the next UTC day, and the early hours in + // Auckland or Tonga are still the previous UTC day: both ends of the walk. + const INSTANTS = [ + "2026-03-08T01:30:00.000Z", + "2026-03-08T23:30:00.000Z", + "2026-04-05T13:00:00.000Z", + "2026-09-06T03:30:00.000Z", + "2026-10-25T00:30:00.000Z", + "2026-11-01T06:00:00.000Z", + ]; + + for (const tz of ZONES) { + for (const iso of INSTANTS) { + const now = new Date(iso); + it(`counts three logged days ending today in ${tz} at ${iso}`, () => { + const streak = computeStreak(lastDays(now, tz, 3), tz, now); + expect(streak.currentDays).toBe(3); + expect(streak.longest).toBe(3); + }); + it(`counts from yesterday when today is not logged yet in ${tz} at ${iso}`, () => { + const streak = computeStreak(lastDays(now, tz, 4, true), tz, now); + expect(streak.currentDays).toBe(4); + }); + } + } + + it("breaks the current streak on a gap", () => { + const now = new Date("2026-06-10T20:00:00.000Z"); + const tz = "America/New_York"; + const streak = computeStreak( + new Set([ + "2026-06-10", + "2026-06-09", + "2026-06-07", + "2026-06-06", + "2026-06-05", + ]), + tz, + now, + ); + expect(streak.currentDays).toBe(2); + expect(streak.longest).toBe(3); + }); + + it("returns zeros for no activity", () => { + expect(computeStreak(new Set(), "UTC")).toEqual({ + currentDays: 0, + longest: 0, + }); + }); +}); diff --git a/src/lib/dashboard/streak.ts b/src/lib/dashboard/streak.ts new file mode 100644 index 000000000..13ebd4cfc --- /dev/null +++ b/src/lib/dashboard/streak.ts @@ -0,0 +1,47 @@ +import { shiftDateKey, userDayKey } from "@/lib/tz/format"; + +export interface StreakInfo { + currentDays: number; + longest: number; +} + +/** + * The current logging-day streak (days where any measurement or intake + * event was recorded, in the user's zone) plus the longest streak among + * `activityDays`. + * + * `activityDays` holds `YYYY-MM-DD` keys already cut in `userTz`. The walk + * steps through day KEYS (`shiftDateKey`), never instants: stepping back from + * UTC midnight of today's key read that instant in the user's zone, which + * west of UTC is still yesterday, so the streak started one day early and + * came out one day short. Yesterday counts as the streak's head when today + * has not been logged yet. + */ +export function computeStreak( + activityDays: ReadonlySet, + userTz: string, + now: Date = new Date(), +): StreakInfo { + if (activityDays.size === 0) return { currentDays: 0, longest: 0 }; + + const sorted = [...activityDays].sort(); + let longest = 1; + let run = 1; + for (let i = 1; i < sorted.length; i++) { + if (shiftDateKey(sorted[i - 1], 1) === sorted[i]) { + run += 1; + longest = Math.max(longest, run); + } else { + run = 1; + } + } + + let cursor = userDayKey(now, userTz); + if (!activityDays.has(cursor)) cursor = shiftDateKey(cursor, -1); + let currentDays = 0; + while (activityDays.has(cursor)) { + currentDays += 1; + cursor = shiftDateKey(cursor, -1); + } + return { currentDays, longest }; +} From e1e664be46c231a0d8ea267ae6d23a37d2dbc233 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 26 Sep 2026 15:37:25 +0200 Subject: [PATCH 04/15] fix(health-score): leave today's partial step total out of the activity pillar The activity pillar averaged the 28 days ending today, so any score computed during the day counted a step total that was still growing and pulled the average down. The window is now the 28 completed local days ending yesterday, cut on day keys. --- .../analytics/score/__tests__/pillars.test.ts | 78 +++++++++++++++++-- src/lib/analytics/score/activity.ts | 27 +++++-- src/lib/analytics/score/reader.ts | 20 ++--- 3 files changed, 103 insertions(+), 22 deletions(-) diff --git a/src/lib/analytics/score/__tests__/pillars.test.ts b/src/lib/analytics/score/__tests__/pillars.test.ts index ea150f578..67f173c85 100644 --- a/src/lib/analytics/score/__tests__/pillars.test.ts +++ b/src/lib/analytics/score/__tests__/pillars.test.ts @@ -25,6 +25,16 @@ function days(count: number, value: number) { })); } +/** `count` completed days ending yesterday (activity leaves today out). */ +function completedDays(count: number, value: number) { + return Array.from({ length: count }, (_, index) => ({ + day: new Date(NOW.getTime() - (index + 1) * 86_400_000) + .toISOString() + .slice(0, 10), + value, + })); +} + describe("reference-score pillars", () => { it("requires twelve fresh paired blood-pressure readings", () => { const below = computeBloodPressurePillar({ @@ -174,7 +184,7 @@ describe("reference-score pillars", () => { ...live, asOf: NOW, ageYears: 40, - days: days(20, 10_000), + days: completedDays(20, 10_000), sources: ["APPLE_HEALTH"], }).status, ).toBe("insufficient"); @@ -183,14 +193,14 @@ describe("reference-score pillars", () => { ...live, asOf: NOW, ageYears: 40, - days: days(21, 12_000), + days: completedDays(21, 12_000), sources: ["APPLE_HEALTH"], }); const older = computeActivityPillar({ ...live, asOf: NOW, ageYears: 68, - days: days(21, 8_000), + days: completedDays(21, 8_000), sources: ["APPLE_HEALTH"], }); expect(younger.status).toBe("ok"); @@ -209,7 +219,7 @@ describe("reference-score pillars", () => { asOf: NOW, timezone: "Europe/Berlin", ageYears: 40, - days: days(21, 8_500), + days: completedDays(21, 8_500), sources: ["APPLE_HEALTH"], }; const liveResult = computeActivityPillar({ ...input, source: "live" }); @@ -224,7 +234,7 @@ describe("reference-score pillars", () => { }); it("scores exactly 28 activity calendar keys", () => { - const points = days(29, 10_000); + const points = completedDays(29, 10_000); points[28] = { ...points[28], value: 0 }; const result = computeActivityPillar({ ...live, @@ -237,6 +247,64 @@ describe("reference-score pillars", () => { if (result.status === "ok") expect(result.value.score).toBe(100); }); + it("leaves today's partial step total out of the window", () => { + const withToday = [ + { day: "2026-07-28", value: 400 }, + ...completedDays(21, 10_000), + ]; + const result = computeActivityPillar({ + ...live, + asOf: NOW, + ageYears: 40, + days: withToday, + sources: ["APPLE_HEALTH"], + }); + expect(result.status).toBe("ok"); + if (result.status === "ok") expect(result.value.score).toBe(100); + + // Twenty completed days plus today are not enough days. + expect( + computeActivityPillar({ + ...live, + asOf: NOW, + ageYears: 40, + days: [{ day: "2026-07-28", value: 400 }, ...completedDays(20, 10_000)], + sources: ["APPLE_HEALTH"], + }).status, + ).toBe("insufficient"); + }); + + it.each([ + ["America/New_York", "2026-03-09T03:30:00.000Z", "2026-03-08"], + ["Pacific/Auckland", "2026-04-05T11:00:00.000Z", "2026-04-05"], + ["Pacific/Tongatapu", "2026-07-28T11:30:00.000Z", "2026-07-29"], + ["America/Santiago", "2026-09-06T04:30:00.000Z", "2026-09-06"], + ["Europe/Berlin", "2026-10-25T23:30:00.000Z", "2026-10-26"], + ])("cuts the window on local days in %s", (timezone, iso, today) => { + const asOf = new Date(iso); + const [y, m, d] = today.split("-").map(Number); + const key = (back: number) => + new Date(Date.UTC(y, m - 1, d - back)).toISOString().slice(0, 10); + const window = Array.from({ length: 28 }, (_, i) => ({ + day: key(i + 1), + value: 10_000, + })); + const result = computeActivityPillar({ + source: "live", + readFailed: false, + timezone, + asOf, + ageYears: 40, + days: [{ day: today, value: 0 }, { day: key(29), value: 0 }, ...window], + sources: ["APPLE_HEALTH"], + }); + expect(result.status).toBe("ok"); + if (result.status === "ok") { + expect(result.value.score).toBe(100); + expect(result.coverage.historyDays).toBe(28); + } + }); + it("requires fourteen sleep nights, never penalises long sleep, and adds regularity at 21 nights", () => { const below = computeSleepPillar({ ...live, diff --git a/src/lib/analytics/score/activity.ts b/src/lib/analytics/score/activity.ts index 911710870..4601eb786 100644 --- a/src/lib/analytics/score/activity.ts +++ b/src/lib/analytics/score/activity.ts @@ -1,5 +1,5 @@ import type { Derived } from "@/lib/insights/derived/types"; -import { userDayKey } from "@/lib/tz/format"; +import { shiftDateKey, userDayKey } from "@/lib/tz/format"; import type { ActivityPillarInput, PillarValue } from "./types"; import { @@ -18,6 +18,23 @@ export const OLDER_ADULT_AGE = 60; export const YOUNGER_STEP_PLATEAU = 10_000; export const OLDER_STEP_PLATEAU = 8_000; +/** + * The activity window: the `ACTIVITY_WINDOW_DAYS` completed local days that + * end yesterday, as inclusive `YYYY-MM-DD` keys. Today is left out because + * its step total is still growing: counting it pulled every score computed + * during the day down by a partial day. + */ +export function activityWindowKeys( + asOf: Date, + timezone: string, +): { startDay: string; endDay: string } { + const today = userDayKey(asOf, timezone); + return { + startDay: shiftDateKey(today, -ACTIVITY_WINDOW_DAYS), + endDay: shiftDateKey(today, -1), + }; +} + export function computeActivityPillar( input: ActivityPillarInput, ): Derived { @@ -29,13 +46,9 @@ export function computeActivityPillar( }); } - const asOfDay = userDayKey(input.asOf, input.timezone); - const sinceDay = userDayKey( - new Date(input.asOf.getTime() - (ACTIVITY_WINDOW_DAYS - 1) * 86_400_000), - input.timezone, - ); + const { startDay, endDay } = activityWindowKeys(input.asOf, input.timezone); const points = input.days.filter( - (point) => point.day <= asOfDay && point.day >= sinceDay, + (point) => point.day <= endDay && point.day >= startDay, ); if (points.length < ACTIVITY_MIN_DAYS) { return insufficientPillar({ diff --git a/src/lib/analytics/score/reader.ts b/src/lib/analytics/score/reader.ts index c5eae944c..7f9e80559 100644 --- a/src/lib/analytics/score/reader.ts +++ b/src/lib/analytics/score/reader.ts @@ -23,7 +23,7 @@ import { getAgeFromDateOfBirth } from "@/lib/analytics/pulse-targets"; import { healthScoreNoticeItemKey } from "@/lib/daily/priority-item-key"; import { annotate, getEvent } from "@/lib/logging/context"; -import { ACTIVITY_WINDOW_DAYS } from "./activity"; +import { ACTIVITY_WINDOW_DAYS, activityWindowKeys } from "./activity"; import { SLEEP_WINDOW_DAYS } from "./sleep"; import { attachScoreDelta } from "./composite"; import { @@ -239,9 +239,8 @@ export async function readActivityDays(args: { canonical .filter((row) => { const day = userDayKey(row.measuredAt, args.timezone); - const endDay = userDayKey(args.asOf, args.timezone); - const startDay = userDayKey( - new Date(args.asOf.getTime() - (ACTIVITY_WINDOW_DAYS - 1) * DAY_MS), + const { startDay, endDay } = activityWindowKeys( + args.asOf, args.timezone, ); return day >= startDay && day <= endDay; @@ -461,10 +460,7 @@ function scoreInputsFor(args: { const { asOf, input } = args; const ageYears = getAgeFromDateOfBirth(input.profile.dateOfBirth); const asOfDay = userDayKey(asOf, input.profile.timezone); - const activitySinceDay = userDayKey( - new Date(asOf.getTime() - (ACTIVITY_WINDOW_DAYS - 1) * DAY_MS), - input.profile.timezone, - ); + const activityWindow = activityWindowKeys(asOf, input.profile.timezone); const sleepSinceDay = userDayKey( new Date(asOf.getTime() - (SLEEP_WINDOW_DAYS - 1) * DAY_MS), input.profile.timezone, @@ -548,7 +544,11 @@ function scoreInputsFor(args: { days: args.activity.value.days, sources: uniqueSources( args.activity.value.sourceDays - .filter((row) => row.day >= activitySinceDay && row.day <= asOfDay) + .filter( + (row) => + row.day >= activityWindow.startDay && + row.day <= activityWindow.endDay, + ) .map((row) => row.source), ), }, @@ -629,7 +629,7 @@ export async function computeUserHealthScore( const db = input.prisma ?? prisma; const since = new Date(input.now.getTime() - SCORE_READ_DAYS * DAY_MS); const activitySince = new Date( - input.now.getTime() - (28 + HISTORY_OFFSET_DAYS) * DAY_MS, + input.now.getTime() - (ACTIVITY_WINDOW_DAYS + HISTORY_OFFSET_DAYS) * DAY_MS, ); const [ glucose, From 73e0653b2cc49c7ff0c0e728eb13e6bc22edb432 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 26 Sep 2026 15:41:16 +0200 Subject: [PATCH 05/15] fix(tz): one start-of-day for every zone, including skipped midnights Local day bounds had three implementations. The day-key one read the zone offset at UTC midnight, which on a transition day in zones far from UTC (Pacific/Auckland, Australia/Sydney) is the offset after the change, so the day started an hour off. The instant one converged on local 00:00, which in zones that skip midnight (America/Santiago, America/Havana) does not exist, and returned 23:00 of the previous day. startOfLocalDayKey and localDayWindow in lib/tz/local-day are now the only implementation: the first instant that reads the day, tried under every offset in force around it. A skipped wall clock resolves to the moment the clocks jump, the day-keyed copies in the measurement drains, the compliance rollups and the dashboard batch window are gone, and the noon and mid-hour anchors use the same converge. --- src/app/api/measurements/route.ts | 2 +- .../__tests__/intraday-pulse-io.test.ts | 2 +- src/lib/analytics/intraday-pulse-io.ts | 2 +- src/lib/dashboard/batch-chart-types.ts | 28 ++-- .../drain-per-sample-cumulative.test.ts | 6 +- src/lib/measurements/consolidation-base.ts | 2 +- src/lib/measurements/consolidation-tz.ts | 108 ++-------------- .../dense-intraday-hourly-rebuild.ts | 2 +- .../drain-per-sample-cumulative.ts | 9 +- .../intraday-cumulative-profile.ts | 2 +- .../rollups/medication-compliance-rollups.ts | 71 +---------- src/lib/tz/__tests__/local-day.test.ts | 86 +++++++++++++ src/lib/tz/local-day.ts | 120 +++++++++++++----- src/lib/tz/wall-clock.ts | 28 +++- 14 files changed, 237 insertions(+), 231 deletions(-) diff --git a/src/app/api/measurements/route.ts b/src/app/api/measurements/route.ts index b62d4319f..ccc8742d6 100644 --- a/src/app/api/measurements/route.ts +++ b/src/app/api/measurements/route.ts @@ -34,8 +34,8 @@ import { pickCanonicalSourceRows } from "@/lib/analytics/source-priority"; import { canonicalDailyTimestamp, dayKeyForUserTz, - localDayWindow, } from "@/lib/measurements/drain-per-sample-cumulative"; +import { localDayWindow } from "@/lib/tz/local-day"; import { withIdempotency } from "@/lib/idempotency"; import { isP2002 } from "@/lib/prisma-errors"; import { encryptNote, shapeMeasurementNotes } from "@/lib/crypto/note-cipher"; diff --git a/src/lib/analytics/__tests__/intraday-pulse-io.test.ts b/src/lib/analytics/__tests__/intraday-pulse-io.test.ts index 57450a5b4..b1994537f 100644 --- a/src/lib/analytics/__tests__/intraday-pulse-io.test.ts +++ b/src/lib/analytics/__tests__/intraday-pulse-io.test.ts @@ -32,7 +32,7 @@ import { import { toBerlinDayKey } from "@/lib/tz/resolver"; import { userDayKey } from "@/lib/tz/format"; import { percentile } from "@/lib/insights/strain-score"; -import { localDayWindow } from "@/lib/measurements/consolidation-tz"; +import { localDayWindow } from "@/lib/tz/local-day"; /** Mirrors the private `median()` helper in `intraday-pulse-io.ts`. */ function median(values: number[]): number | null { diff --git a/src/lib/analytics/intraday-pulse-io.ts b/src/lib/analytics/intraday-pulse-io.ts index ec7987b34..d271aeebc 100644 --- a/src/lib/analytics/intraday-pulse-io.ts +++ b/src/lib/analytics/intraday-pulse-io.ts @@ -19,7 +19,7 @@ import { prisma } from "@/lib/db"; import { annotate } from "@/lib/logging/context"; import { percentile } from "@/lib/insights/strain-score"; import { resolveRestingPulseSeries } from "@/lib/analytics/resting-pulse"; -import { localDayWindow } from "@/lib/measurements/consolidation-tz"; +import { localDayWindow } from "@/lib/tz/local-day"; import { userDayKey } from "@/lib/tz/format"; import { BUCKET_MINUTES, diff --git a/src/lib/dashboard/batch-chart-types.ts b/src/lib/dashboard/batch-chart-types.ts index 2267a51db..102d94240 100644 --- a/src/lib/dashboard/batch-chart-types.ts +++ b/src/lib/dashboard/batch-chart-types.ts @@ -25,16 +25,12 @@ * prefetch-off / legacy / fail-soft paths where no prop arrives. * * Import-safe from the server RSC and the client component alike: pure, no - * Prisma / db / `node:*` imports (only the client-safe `@/lib/tz/format` - * Intl helpers and the layout type). + * Prisma / db / `node:*` imports (only the client-safe `@/lib/tz` Intl + * helpers and the layout type). */ import type { DashboardLayout } from "@/lib/dashboard-layout"; -import { - DEFAULT_TIMEZONE, - isValidTimezone, - tzOffsetMinutes, - userDayKey, -} from "@/lib/tz/format"; +import { DEFAULT_TIMEZONE, isValidTimezone, userDayKey } from "@/lib/tz/format"; +import { localDayWindow } from "@/lib/tz/local-day"; /** * Day-span the batched dashboard series (`series-batch`) fetches. Threaded to @@ -97,21 +93,13 @@ export interface BatchWindow { } /** - * End-of-day of `now` in `timezone`, as a UTC instant. Falls back to the - * project default zone for an unusable IANA id. Two-step offset refine so the - * rare DST edge (a transition between the as-if-UTC guess and the true local - * instant) still resolves to the right offset. + * Last millisecond of the local day `now` falls on in `timezone`, as a UTC + * instant. Falls back to the project default zone for an unusable IANA id. */ function endOfLocalDayUtc(now: Date, timezone: string): Date { const safeTz = isValidTimezone(timezone) ? timezone : DEFAULT_TIMEZONE; - const dayKey = userDayKey(now, safeTz); // YYYY-MM-DD in the zone - const [year, month, day] = dayKey.split("-").map(Number); - // Wall-clock 23:59:59.999 treated as-if-UTC, then shifted by the zone offset. - const asIfUtc = Date.UTC(year, month - 1, day, 23, 59, 59, 999); - const offset1 = tzOffsetMinutes(new Date(asIfUtc), safeTz); - const corrected = new Date(asIfUtc - offset1 * 60_000); - const offset2 = tzOffsetMinutes(corrected, safeTz); - return new Date(asIfUtc - offset2 * 60_000); + const { dayEnd } = localDayWindow(userDayKey(now, safeTz), safeTz); + return new Date(dayEnd.getTime() - 1); } /** diff --git a/src/lib/measurements/__tests__/drain-per-sample-cumulative.test.ts b/src/lib/measurements/__tests__/drain-per-sample-cumulative.test.ts index 39da25dc6..2d8962534 100644 --- a/src/lib/measurements/__tests__/drain-per-sample-cumulative.test.ts +++ b/src/lib/measurements/__tests__/drain-per-sample-cumulative.test.ts @@ -14,10 +14,12 @@ import { canonicalDailyTimestamp, dayKeyForUserTz, drainPerSampleCumulative, - localDayWindow, - localStartOfDay, sumBucketValues, } from "../drain-per-sample-cumulative"; +import { + localDayWindow, + startOfLocalDayKey as localStartOfDay, +} from "@/lib/tz/local-day"; import type { PrismaClient } from "@/generated/prisma/client"; describe("dayKeyForUserTz", () => { diff --git a/src/lib/measurements/consolidation-base.ts b/src/lib/measurements/consolidation-base.ts index 382f2335e..43f9d576d 100644 --- a/src/lib/measurements/consolidation-base.ts +++ b/src/lib/measurements/consolidation-base.ts @@ -19,7 +19,7 @@ * supplies its reducer/types and keeps its own public summary shape. * * The timezone day-math helpers (`dayKeyForUserTz`, - * `canonicalDailyTimestamp`, `localStartOfDay`, `localDayWindow`), the + * `canonicalDailyTimestamp`), the * `PerSampleRow` shape, and `CONSOLIDATION_GRACE_CUTOFF_HOURS` all live * in the dependency-free leaf `consolidation-tz.ts`. Both this module * and `drain-per-sample-cumulative.ts` import them from there, so there diff --git a/src/lib/measurements/consolidation-tz.ts b/src/lib/measurements/consolidation-tz.ts index b387505bd..de34efc92 100644 --- a/src/lib/measurements/consolidation-tz.ts +++ b/src/lib/measurements/consolidation-tz.ts @@ -8,8 +8,11 @@ * either of those — it is a dependency-free leaf so the two can both * reach the helpers without forming an import cycle. * - * History: `dayKeyForUserTz`, `canonicalDailyTimestamp`, `localStartOfDay`, - * `localDayWindow`, the grace constant, and the `PerSampleRow` shape used + * Local day bounds (`localDayWindow`, `startOfLocalDayKey`) live in + * `@/lib/tz/local-day`, the one start-of-day implementation. + * + * History: `dayKeyForUserTz`, `canonicalDailyTimestamp`, the grace + * constant, and the `PerSampleRow` shape used * to live in `drain-per-sample-cumulative.ts`. `consolidation-base.ts` * imported them from there while `drain` imported the runner back — a * value-level cycle. Bundled for production (Turbopack merges tightly @@ -25,6 +28,7 @@ import type { MeasurementType } from "@/generated/prisma/client"; // `userDayKey` lives in the pure `@/lib/tz/format` leaf (no Prisma / // node:module pull) so importing it keeps this dependency-free module clean. import { userDayKey } from "@/lib/tz/format"; +import { zonedWallClockToUtc } from "@/lib/tz/wall-clock"; /** * Canonical grace window shared by the cumulative + mean drains. Rows @@ -63,30 +67,6 @@ export function dayKeyForUserTz(date: Date, tz: string): string { return userDayKey(date, tz); } -/** - * Read the IANA-zone offset (in minutes east of UTC) at a given - * instant. Returns 0 for UTC and any zone the shortOffset formatter - * can't resolve (defensive — Node 22's full-icu build covers every - * zone we care about). - */ -function tzOffsetMinutesAt(instant: Date, tz: string): number { - const fmt = new Intl.DateTimeFormat("en-US", { - timeZone: tz, - hour12: false, - hour: "2-digit", - minute: "2-digit", - timeZoneName: "shortOffset", - }); - const parts = fmt.formatToParts(instant); - const tzPart = parts.find((p) => p.type === "timeZoneName")?.value ?? "GMT"; - const match = tzPart.match(/GMT([+-])(\d{1,2})(?::(\d{2}))?/); - if (!match) return 0; - const sign = match[1] === "+" ? 1 : -1; - const hours = Number.parseInt(match[2], 10); - const minutes = match[3] ? Number.parseInt(match[3], 10) : 0; - return sign * (hours * 60 + minutes); -} - /** * Compute the canonical timestamp for a calendar-day key. With a `tz`, * returns the JS-Date instant at the user's local 12:00 noon; without @@ -106,19 +86,11 @@ function tzOffsetMinutesAt(instant: Date, tz: string): number { * correct for every zone within ±12 h. */ export function canonicalDailyTimestamp(dateKey: string, tz?: string): Date { - const utcNoon = new Date(`${dateKey}T12:00:00.000Z`); // No timezone in scope: 12:00 UTC is a safe day-stable anchor for // every zone within ±12 h. - if (!tz) return utcNoon; - // Compute the UTC offset for noon-local of the given day. We don't - // have a lightweight TZ-math library on the server, so the trick is: - // build "12:00 UTC of the day", read what wall-clock that shows in - // the target zone, then shift by the resulting offset. - const offsetMinutes = tzOffsetMinutesAt(utcNoon, tz); - // utcNoon represents 12:00 UTC. The user's local clock reads - // 12:00 + offsetMinutes at that instant. To anchor at local 12:00, - // subtract the offset. - return new Date(utcNoon.getTime() - offsetMinutes * 60 * 1000); + if (!tz) return new Date(`${dateKey}T12:00:00.000Z`); + const [year, month, day] = dateKey.split("-").map(Number); + return zonedWallClockToUtc({ year, month, day, hour: 12, minute: 0 }, tz); } /** @@ -178,64 +150,6 @@ export function canonicalHourlyTimestamp( hour: number, tz: string, ): Date { - const hh = String(hour).padStart(2, "0"); - const guess = new Date(`${dateKey}T${hh}:30:00.000Z`); - const offsetFirst = tzOffsetMinutesAt(guess, tz); - const candidate = new Date(guess.getTime() - offsetFirst * 60 * 1000); - const offsetAtCandidate = tzOffsetMinutesAt(candidate, tz); - if (offsetAtCandidate === offsetFirst) return candidate; - return new Date(guess.getTime() - offsetAtCandidate * 60 * 1000); -} - -/** - * v1.4.37 W10 — Compute the JS-Date instant at the user's local 00:00 - * for a calendar-day key. Robust on DST transitions because the offset - * is read at the UTC-midnight instant of the day, and EU/US DST - * transitions happen at local 02:00 / 03:00 — so the offset at UTC - * midnight is unambiguous on every day of the year. - * - * Used by the W7c drill-down branch to resolve [dayStart, dayEnd) on - * a 23-h spring-forward or 25-h fall-back day; the previous shape - * (`canonicalDailyTimestamp ± 12h`) silently leaked or hid an hour - * of samples on two days per year. - * - * Pair with `localStartOfDay(nextDayKey, tz)` for the right bound so - * the returned window covers the true local-day span — 23 h on - * spring-forward, 24 h on a regular day, 25 h on fall-back. - */ -export function localStartOfDay(dateKey: string, tz: string): Date { - // Anchor at 00:00 UTC of the day; the offset read at that instant - // is the same offset the local clock uses at midnight (DST - // transitions in EU/US happen at 02:00 / 03:00 local, not at the - // 00:00 boundary). For sub-half-hour zones (Asia/Kathmandu UTC+5:45, - // Pacific/Chatham UTC+12:45) the minute component is preserved. - const utcMidnight = new Date(`${dateKey}T00:00:00.000Z`); - const offsetMinutes = tzOffsetMinutesAt(utcMidnight, tz); - // Local 00:00 at this date = UTC midnight - offset. - return new Date(utcMidnight.getTime() - offsetMinutes * 60 * 1000); -} - -/** - * v1.4.37 W10 — Resolve the [dayStart, dayEnd) UTC window for a - * calendar-day key in the user's IANA timezone. The window honours - * DST so the drill-down branch returns the correct 23 / 24 / 25-hour - * span for transition days. - * - * Returns a tuple where `dayEnd` is the local 00:00 of the FOLLOWING - * calendar day — `< dayEnd` is the canonical half-open bound used by - * the route's `measuredAt: { gte: dayStart, lt: dayEnd }` predicate. - */ -export function localDayWindow( - dateKey: string, - tz: string, -): { dayStart: Date; dayEnd: Date } { - const dayStart = localStartOfDay(dateKey, tz); - // Add ONE day to dateKey using UTC arithmetic on a noon anchor (noon - // sidesteps every DST edge case for the calendar increment). Then - // re-extract the ISO date slice — guaranteed to be the next-day key. - const nextUtcNoon = new Date(`${dateKey}T12:00:00.000Z`); - nextUtcNoon.setUTCDate(nextUtcNoon.getUTCDate() + 1); - const nextDateKey = nextUtcNoon.toISOString().slice(0, 10); - const dayEnd = localStartOfDay(nextDateKey, tz); - return { dayStart, dayEnd }; + const [year, month, day] = dateKey.split("-").map(Number); + return zonedWallClockToUtc({ year, month, day, hour, minute: 30 }, tz); } diff --git a/src/lib/measurements/dense-intraday-hourly-rebuild.ts b/src/lib/measurements/dense-intraday-hourly-rebuild.ts index a5cbb7d78..0c49f4c61 100644 --- a/src/lib/measurements/dense-intraday-hourly-rebuild.ts +++ b/src/lib/measurements/dense-intraday-hourly-rebuild.ts @@ -66,9 +66,9 @@ import { import { canonicalDailyTimestamp, canonicalHourlyTimestamp, - localDayWindow, type PerSampleRow, } from "./consolidation-tz"; +import { localDayWindow } from "@/lib/tz/local-day"; import { meanBucketValue } from "./consolidate-daily-mean"; import { DENSE_INTRADAY_RETENTION_DAYS, diff --git a/src/lib/measurements/drain-per-sample-cumulative.ts b/src/lib/measurements/drain-per-sample-cumulative.ts index e0cf58135..e22fb5f5a 100644 --- a/src/lib/measurements/drain-per-sample-cumulative.ts +++ b/src/lib/measurements/drain-per-sample-cumulative.ts @@ -57,8 +57,6 @@ import { CONSOLIDATION_GRACE_CUTOFF_HOURS, canonicalDailyTimestamp, dayKeyForUserTz, - localDayWindow, - localStartOfDay, type PerSampleRow, } from "./consolidation-tz"; import { @@ -73,12 +71,7 @@ import { // keeps resolving them from here. The definitions moved to the // dependency-free leaf `consolidation-tz.ts` to break the init-order // cycle with `consolidation-base.ts`. -export { - canonicalDailyTimestamp, - dayKeyForUserTz, - localDayWindow, - localStartOfDay, -}; +export { canonicalDailyTimestamp, dayKeyForUserTz }; export type { PerSampleRow }; /** Prefix marking an already-collapsed daily-stats row. */ diff --git a/src/lib/measurements/intraday-cumulative-profile.ts b/src/lib/measurements/intraday-cumulative-profile.ts index bb0891bea..6d527017a 100644 --- a/src/lib/measurements/intraday-cumulative-profile.ts +++ b/src/lib/measurements/intraday-cumulative-profile.ts @@ -42,9 +42,9 @@ import { shiftDateKey, userDayKey } from "@/lib/tz/format"; import { dayKeyForUserTz, hourOfDayForUserTz, - localDayWindow, type PerSampleRow, } from "./consolidation-tz"; +import { localDayWindow } from "@/lib/tz/local-day"; /** Hours in a stored profile. Always 24, DST days included — see below. */ export const PROFILE_HOURS = 24; diff --git a/src/lib/rollups/medication-compliance-rollups.ts b/src/lib/rollups/medication-compliance-rollups.ts index 77cbb0822..3fd256272 100644 --- a/src/lib/rollups/medication-compliance-rollups.ts +++ b/src/lib/rollups/medication-compliance-rollups.ts @@ -52,7 +52,7 @@ import { prisma } from "@/lib/db"; import { getGlobalBoss } from "@/lib/jobs/boss-instance"; import { annotate } from "@/lib/logging/context"; import { DEFAULT_TIMEZONE, isValidTimezone, userDayKey } from "@/lib/tz/format"; -import { wallClockInTz } from "@/lib/tz/wall-clock"; +import { localDayWindow, startOfLocalDayKey } from "@/lib/tz/local-day"; /** * pg-boss queue name for the boot-time medication compliance backfill. @@ -84,55 +84,6 @@ export interface ComplianceBucket { taken: number; } -/** - * UTC offset (in minutes) of `tz` at the given instant. Positive east - * of UTC. Honours DST via `Intl.DateTimeFormat`. - */ -function tzOffsetMinutes(date: Date, tz: string): number { - const parts = new Intl.DateTimeFormat("en-US", { - timeZone: tz, - year: "numeric", - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - hour12: false, - }).formatToParts(date); - const get = (type: Intl.DateTimeFormatPartTypes): string => - parts.find((p) => p.type === type)?.value ?? "0"; - let hour = Number(get("hour")); - if (hour === 24) hour = 0; - const asIfUtc = Date.UTC( - Number(get("year")), - Number(get("month")) - 1, - Number(get("day")), - hour, - Number(get("minute")), - Number(get("second")), - ); - return Math.round((asIfUtc - date.getTime()) / 60000); -} - -/** - * The UTC instant corresponding to local-midnight on `dayKey` in `tz`. - * Two-pass convergence handles DST transitions correctly. - */ -function startOfDayUtcInTz(dayKey: string, tz: string): Date { - const [yearStr, monthStr, dayStr] = dayKey.split("-"); - const year = Number(yearStr); - const month = Number(monthStr); - const day = Number(dayStr); - let guess = new Date(Date.UTC(year, month - 1, day, 0, 0, 0, 0)); - for (let i = 0; i < 2; i++) { - const offsetMin = tzOffsetMinutes(guess, tz); - guess = new Date( - Date.UTC(year, month - 1, day, 0, 0, 0, 0) - offsetMin * 60_000, - ); - } - return guess; -} - /** * Resolve a safe tz string — fall back to the server default when the * supplied value is missing or not an IANA zone the host recognises. @@ -176,20 +127,8 @@ export async function recomputeMedicationComplianceForDay( client: MedicationComplianceRollupClient = prisma, ): Promise { const safeTz = safeTimezone(tz); - const start = startOfDayUtcInTz(dayKey, safeTz); - const end = new Date(start.getTime() + 86_400_000); - // Re-derive end via the day-after boundary to honour DST 23 / 25-hour - // days. `start + 86_400_000` is correct for non-transition days; on - // a fall-back day we add one calendar day in the local zone. - const nextDayKey = (() => { - const [y, m, d] = dayKey.split("-").map(Number); - const probe = new Date(Date.UTC(y, m - 1, d, 12, 0, 0)); - const probeNext = new Date(probe.getTime() + 86_400_000); - const parts = wallClockInTz(probeNext, safeTz); - return `${String(parts.year).padStart(4, "0")}-${String(parts.month).padStart(2, "0")}-${String(parts.day).padStart(2, "0")}`; - })(); - const dstSafeEnd = startOfDayUtcInTz(nextDayKey, safeTz); - const windowEnd = dstSafeEnd.getTime() > start.getTime() ? dstSafeEnd : end; + // The local day's own bounds: 23, 24 or 25 hours on DST days. + const { dayStart: start, dayEnd: windowEnd } = localDayWindow(dayKey, safeTz); // v1.4.39 hotfix (QA F-H-02): atomic upsert closes the race window // between the prior `SELECT … aggregate then UPSERT` pattern. Two @@ -397,7 +336,7 @@ export async function hasMedicationComplianceCoverage( new Date(now.getTime() - (days - 1) * 86_400_000), safeTz, ); - const oldestStart = startOfDayUtcInTz(oldestKey, safeTz); + const oldestStart = startOfLocalDayKey(oldestKey, safeTz); // Single SQL aggregate: count DISTINCT rolled days vs DISTINCT // event-days in window. The event side is anchored on the same @@ -456,7 +395,7 @@ export async function recomputeUserMedicationCompliance( new Date(now.getTime() - (days - 1) * 86_400_000), safeTz, ); - const oldestStart = startOfDayUtcInTz(oldestKey, safeTz); + const oldestStart = startOfLocalDayKey(oldestKey, safeTz); // Distinct (medication_id, dayKey) pairs the user has any intake // event for inside the trailing window. `to_char(... AT TIME ZONE diff --git a/src/lib/tz/__tests__/local-day.test.ts b/src/lib/tz/__tests__/local-day.test.ts index 3041677c7..49f28e7c0 100644 --- a/src/lib/tz/__tests__/local-day.test.ts +++ b/src/lib/tz/__tests__/local-day.test.ts @@ -1,9 +1,12 @@ import { describe, expect, it } from "vitest"; import { getUserTodayBounds, + localDayWindow, localHmAsUtc, startOfLocalDayInTz, + startOfLocalDayKey, } from "@/lib/tz/local-day"; +import { shiftDateKey, userDayKey } from "@/lib/tz/format"; import { wallClockInTz, zonedWallClockToUtc } from "@/lib/tz/wall-clock"; const HOUR_MS = 60 * 60 * 1000; @@ -246,3 +249,86 @@ describe("localHmAsUtc — DST transition days", () => { expect(localHm(a, sydney)).toBe("20:00"); }); }); + +describe("startOfLocalDayKey / localDayWindow — every day of the year", () => { + const ZONES = [ + "America/New_York", + "America/Santiago", + "America/Havana", + "Pacific/Auckland", + "Pacific/Tongatapu", + "Australia/Sydney", + "Europe/Berlin", + "Asia/Kathmandu", + "UTC", + ]; + + // The first instant of the day reads the day's key, and the millisecond + // before it reads the previous key: it is exactly where the day begins. + for (const tz of ZONES) { + it(`starts each 2026 day at its first local instant in ${tz}`, () => { + let key = "2026-01-01"; + for (let i = 0; i < 365; i++) { + const { dayStart, dayEnd } = localDayWindow(key, tz); + expect(userDayKey(dayStart, tz), `${tz} ${key}`).toBe(key); + expect( + userDayKey(new Date(dayStart.getTime() - 1), tz), + `${tz} ${key}`, + ).toBe(shiftDateKey(key, -1)); + const hours = (dayEnd.getTime() - dayStart.getTime()) / 3_600_000; + expect(hours).toBeGreaterThanOrEqual(23); + expect(hours).toBeLessThanOrEqual(25); + expect(startOfLocalDayKey(key, tz).getTime()).toBe(dayStart.getTime()); + key = shiftDateKey(key, 1); + } + }); + } + + it.each([ + // Midnight is skipped: the day begins at 01:00 local. + ["America/Santiago", "2026-09-06", "2026-09-06T04:00:00.000Z", 23], + ["America/Havana", "2026-03-08", "2026-03-08T05:00:00.000Z", 23], + // Far from UTC, the offset at UTC midnight is not the offset at local + // midnight on a transition day. + ["Pacific/Auckland", "2026-04-05", "2026-04-04T11:00:00.000Z", 25], + ["Pacific/Auckland", "2026-09-27", "2026-09-26T12:00:00.000Z", 23], + ["Australia/Sydney", "2026-04-05", "2026-04-04T13:00:00.000Z", 25], + ["America/New_York", "2026-11-01", "2026-11-01T04:00:00.000Z", 25], + ["Europe/Berlin", "2026-03-29", "2026-03-28T23:00:00.000Z", 23], + ["Pacific/Tongatapu", "2026-07-01", "2026-06-30T11:00:00.000Z", 24], + ])("%s %s starts at %s and lasts %i h", (tz, key, start, hours) => { + const { dayStart, dayEnd } = localDayWindow(key, tz); + expect(dayStart.toISOString()).toBe(start); + expect((dayEnd.getTime() - dayStart.getTime()) / 3_600_000).toBe(hours); + expect( + startOfLocalDayInTz(new Date(dayStart.getTime() + 3_600_000), tz), + ).toEqual(dayStart); + }); +}); + +describe("zonedWallClockToUtc — skipped wall clocks", () => { + it("reads a skipped midnight as the moment the clocks jump", () => { + expect( + zonedWallClockToUtc( + { year: 2026, month: 9, day: 6, hour: 0, minute: 0 }, + "America/Santiago", + ).toISOString(), + ).toBe("2026-09-06T04:00:00.000Z"); + }); + + it("reads 02:30 on the Berlin spring-forward day as 03:30 summer time", () => { + expect( + zonedWallClockToUtc( + { year: 2026, month: 3, day: 29, hour: 2, minute: 30 }, + "Europe/Berlin", + ).toISOString(), + ).toBe("2026-03-29T01:30:00.000Z"); + }); + + it("agrees with the day start at 00:00 where midnight is skipped", () => { + const ref = new Date("2026-09-06T15:00:00.000Z"); + expect(localHmAsUtc(ref, "America/Santiago", 0, 0)).toEqual( + startOfLocalDayInTz(ref, "America/Santiago"), + ); + }); +}); diff --git a/src/lib/tz/local-day.ts b/src/lib/tz/local-day.ts index 09e778842..ff8dbc79a 100644 --- a/src/lib/tz/local-day.ts +++ b/src/lib/tz/local-day.ts @@ -8,6 +8,7 @@ * user's local calendar day — today's bounds for database queries, * the local weekday, and the UTC instant of a local `hh:mm`. */ +import { shiftDateKey } from "./format"; import { wallClockInTz, zonedWallClockToUtc } from "./wall-clock"; /** @@ -42,15 +43,94 @@ export function getUserTodayBounds( } /** - * UTC instant of local midnight (00:00 wall-clock) on the calendar day - * `instant` falls on in `tz`. DST-safe: derives the zone offset at the - * target local time so the returned instant always represents the user's - * local start-of-day, never UTC midnight of that calendar date. + * The first instant of the local calendar day `year-month-day` in `tz`. * - * Canonical start-of-local-day primitive. The medication-scheduling - * `startOfLocalDay` (cadence) and `startOfDayInTz` (recurrence) route - * through this so the day-floor never drifts between surfaces. When `tz` - * is omitted the host's system-local day is used. + * Usually local 00:00. Where midnight itself is skipped by a DST change + * (America/Santiago, America/Havana, Asia/Beirut move their clocks at + * 00:00), the day starts at the transition instant, 01:00 local. Where + * midnight repeats (a change at 01:00 back to 00:00), it is the first of the + * two midnights. + * + * Candidates are local midnight under every offset the zone observes within + * 14 hours of that date's UTC midnight (any offset in use lies within + * ±14 h); the earliest candidate that already reads the target date is the + * day's start. Reading one offset at UTC midnight instead picked the wrong + * one on transition days in zones far from UTC (Pacific/Auckland) and landed + * a day early where midnight is skipped. + */ +function firstInstantOfLocalDay( + year: number, + month: number, + day: number, + tz: string, +): Date { + const utcMidnight = Date.UTC(year, month - 1, day); + const offsets = new Set(); + for (const delta of [-14, 0, 14]) { + offsets.add(offsetMinutesAt(new Date(utcMidnight + delta * 3_600_000), tz)); + } + let best: number | null = null; + for (const offset of offsets) { + const candidate = utcMidnight - offset * 60_000; + const at = wallClockInTz(new Date(candidate), tz); + if (at.year !== year || at.month !== month || at.day !== day) continue; + if (best === null || candidate < best) best = candidate; + } + if (best !== null) return new Date(best); + // No sampled offset lands on the date (a zone that skipped the whole day, + // such as Pacific/Apia on 2011-12-30): fall back to the converged local + // 00:00, which is the next instant that exists. + return zonedWallClockToUtc({ year, month, day, hour: 0, minute: 0 }, tz); +} + +/** UTC offset of `tz` at `instant`, in minutes east of UTC. */ +function offsetMinutesAt(instant: Date, tz: string): number { + const at = wallClockInTz(instant, tz); + const asIfUtc = Date.UTC( + at.year, + at.month - 1, + at.day, + at.hour, + at.minute, + at.second, + ); + return Math.round((asIfUtc - instant.getTime()) / 60_000); +} + +/** + * The first instant of the local calendar day `dateKey` (`YYYY-MM-DD`) in + * `tz`. The one start-of-day primitive for day keys; see + * {@link firstInstantOfLocalDay} for the DST cases. + */ +export function startOfLocalDayKey(dateKey: string, tz: string): Date { + const [year, month, day] = dateKey.split("-").map(Number); + return firstInstantOfLocalDay(year, month, day, tz); +} + +/** + * The `[dayStart, dayEnd)` UTC window of the local calendar day `dateKey` + * in `tz`: 23, 24 or 25 hours long, whatever the day actually lasts. `dayEnd` + * is the first instant of the following local day, so `measuredAt >= + * dayStart AND measuredAt < dayEnd` selects exactly that day. + */ +export function localDayWindow( + dateKey: string, + tz: string, +): { dayStart: Date; dayEnd: Date } { + return { + dayStart: startOfLocalDayKey(dateKey, tz), + dayEnd: startOfLocalDayKey(shiftDateKey(dateKey, 1), tz), + }; +} + +/** + * The first instant of the local calendar day `instant` falls on in `tz` + * (usually local midnight; see {@link firstInstantOfLocalDay}). + * + * Canonical start-of-local-day primitive for instants. The + * medication-scheduling `startOfLocalDay` (cadence) and `startOfDayInTz` + * (recurrence) route through this so the day-floor never drifts between + * surfaces. When `tz` is omitted the host's system-local day is used. */ export function startOfLocalDayInTz( instant: Date, @@ -68,29 +148,7 @@ export function startOfLocalDayInTz( ); } const parts = wallClockInTz(instant, tz); - // Two-pass converge: treat the wall clock as UTC, then correct by the - // zone offset at that approximate instant. The second pass settles the - // offset across a DST transition for every IANA zone. - let guess = new Date( - Date.UTC(parts.year, parts.month - 1, parts.day, 0, 0, 0, 0), - ); - for (let i = 0; i < 2; i++) { - const at = wallClockInTz(guess, tz); - const asIfUtc = Date.UTC( - at.year, - at.month - 1, - at.day, - at.hour, - at.minute, - at.second, - ); - const offsetMin = Math.round((asIfUtc - guess.getTime()) / 60_000); - guess = new Date( - Date.UTC(parts.year, parts.month - 1, parts.day, 0, 0, 0, 0) - - offsetMin * 60_000, - ); - } - return guess; + return firstInstantOfLocalDay(parts.year, parts.month, parts.day, tz); } /** diff --git a/src/lib/tz/wall-clock.ts b/src/lib/tz/wall-clock.ts index 27db95150..917e8f595 100644 --- a/src/lib/tz/wall-clock.ts +++ b/src/lib/tz/wall-clock.ts @@ -130,7 +130,33 @@ export function zonedWallClockToUtc( const offsetMin = Math.round((guessAsIfUtc - guess.getTime()) / 60_000); guess = new Date(asIfUtc - offsetMin * 60_000); } - return guess; + const settled = wallClockInTz(guess, tz); + if ( + settled.year === parts.year && + settled.month === parts.month && + settled.day === parts.day && + settled.hour === parts.hour && + settled.minute === parts.minute + ) { + return guess; + } + // The wall clock does not exist: a DST change skips it (00:00 on the + // spring-forward day in America/Santiago, 02:30 in Europe/Berlin). Read it + // with the offset in force BEFORE the gap, which lands the same distance + // past the change (00:00 becomes 01:00), the way a wall clock reads when + // it is set forward. The converge alone could settle on the previous day. + let before = Infinity; + for (const delta of [-14, 0, 14]) { + const probe = new Date(asIfUtc + delta * 3_600_000); + const at = wallClockInTz(probe, tz); + const offset = Math.round( + (Date.UTC(at.year, at.month - 1, at.day, at.hour, at.minute, at.second) - + probe.getTime()) / + 60_000, + ); + if (offset < before) before = offset; + } + return new Date(asIfUtc - before * 60_000); } /** From a1d94a33edf1f609287d9b0ef3d0ff445b37a1f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 26 Sep 2026 15:44:24 +0200 Subject: [PATCH 06/15] fix(charts): end the mood and intake heatmaps on the user's today Both heatmaps stepped back from now in 24-hour strides and cut each key in UTC, so in the evening west of UTC, or the morning east of it, the newest cell was a different day from the one the server keyed the readings on. The days now come from today's key in the user's display zone, stepped on keys. --- src/components/charts/compliance-heatmap.tsx | 36 +++++++------------ src/components/charts/mood-heatmap.tsx | 28 ++++++--------- src/lib/charts/__tests__/heatmap-days.test.ts | 35 ++++++++++++++++++ src/lib/charts/heatmap-days.ts | 36 +++++++++++++++++++ 4 files changed, 94 insertions(+), 41 deletions(-) create mode 100644 src/lib/charts/__tests__/heatmap-days.test.ts create mode 100644 src/lib/charts/heatmap-days.ts diff --git a/src/components/charts/compliance-heatmap.tsx b/src/components/charts/compliance-heatmap.tsx index 7eec7a959..cc4a0583b 100644 --- a/src/components/charts/compliance-heatmap.tsx +++ b/src/components/charts/compliance-heatmap.tsx @@ -1,7 +1,12 @@ "use client"; import { useState, useMemo, useEffect, useRef } from "react"; -import { useTranslations, useFormatters } from "@/lib/i18n/context"; +import { + useDisplayTimezone, + useFormatters, + useTranslations, +} from "@/lib/i18n/context"; +import { heatmapDays } from "@/lib/charts/heatmap-days"; interface DailyData { expected: number; @@ -79,6 +84,7 @@ export function ComplianceHeatmap({ }: ComplianceHeatmapProps) { const { t } = useTranslations(); const fmt = useFormatters(); + const timeZone = useDisplayTimezone(); // Day keys are UTC-anchored "YYYY-MM-DD"; format at noon UTC so the // locale-aware renderer never lands a day off in either direction. const formatDay = (dateKey: string) => @@ -175,7 +181,6 @@ export function ComplianceHeatmap({ t("charts.months.nov"), t("charts.months.dec"), ]; - const now = new Date(); const cellList: Array<{ dateKey: string; col: number; @@ -184,34 +189,18 @@ export function ComplianceHeatmap({ data: DailyData; }> = []; - // Build array of dates from oldest to newest - const dates: Date[] = []; - for (let d = days - 1; d >= 0; d--) { - const date = new Date(now.getTime() - d * 24 * 60 * 60 * 1000); - dates.push(date); - } - - // Start from the first date, align to Monday. - // - // v1.4.27 B7 / BL-P4-2 — read every weekday + month boundary off - // the UTC accessor pair (`getUTCDay`, `getUTCMonth`) so the - // computation matches the dateKey, which is also UTC-anchored - // (`toISOString().slice(0, 10)`). Reading in server-tz here would - // shift the Monday-alignment + month-marker placement when the - // SSR pass runs on a server in a non-Berlin timezone. - const firstDate = dates[0]; - const firstDow = (firstDate.getUTCDay() + 6) % 7; // Monday = 0 + // Oldest to newest, one entry per local day in the user's zone. + const dates = heatmapDays(new Date(), timeZone, days); + const firstDow = dates[0]?.dow ?? 0; let col = 0; const markers: Array<{ col: number; label: string }> = []; let lastMonth = -1; for (let i = 0; i < dates.length; i++) { - const date = dates[i]; - const dow = (date.getUTCDay() + 6) % 7; // Monday = 0 + const { dateKey, dow, month } = dates[i]; const currentCol = Math.floor((i + firstDow) / 7); const row = dow; - const dateKey = date.toISOString().slice(0, 10); const data = dailyCompliance[dateKey] ?? { expected: 0, taken: 0, @@ -219,7 +208,6 @@ export function ComplianceHeatmap({ }; // Track month boundaries - const month = date.getUTCMonth(); if (month !== lastMonth) { markers.push({ col: currentCol, label: MONTH_LABELS[month] }); lastMonth = month; @@ -237,7 +225,7 @@ export function ComplianceHeatmap({ } return { cells: cellList, weeks: col + 1, monthMarkers: markers }; - }, [dailyCompliance, days, t]); + }, [dailyCompliance, days, t, timeZone]); const activeDays = cells.filter((cell) => cell.data.expected > 0).length; const summaryLabel = diff --git a/src/components/charts/mood-heatmap.tsx b/src/components/charts/mood-heatmap.tsx index 178064908..959d25d7e 100644 --- a/src/components/charts/mood-heatmap.tsx +++ b/src/components/charts/mood-heatmap.tsx @@ -1,7 +1,12 @@ "use client"; import { useState, useMemo, useEffect, useRef } from "react"; -import { useTranslations, useFormatters } from "@/lib/i18n/context"; +import { + useDisplayTimezone, + useFormatters, + useTranslations, +} from "@/lib/i18n/context"; +import { heatmapDays } from "@/lib/charts/heatmap-days"; import { moodLabelKeyForScore } from "@/lib/mood/labels"; /** @@ -62,6 +67,7 @@ export function MoodHeatmap({ }: MoodHeatmapProps) { const { t } = useTranslations(); const fmt = useFormatters(); + const timeZone = useDisplayTimezone(); // Day keys are UTC-anchored "YYYY-MM-DD"; format at noon UTC so the // locale-aware renderer never lands a day off in either direction. const formatDay = (dateKey: string) => @@ -147,7 +153,6 @@ export function MoodHeatmap({ t("charts.months.nov"), t("charts.months.dec"), ]; - const now = new Date(); const cellList: Array<{ dateKey: string; col: number; @@ -156,30 +161,19 @@ export function MoodHeatmap({ cell: MoodHeatmapCell | null; }> = []; - const dates: Date[] = []; - for (let d = days - 1; d >= 0; d--) { - const date = new Date(now.getTime() - d * 24 * 60 * 60 * 1000); - dates.push(date); - } - - // Monday-align + month markers read off the UTC accessors so the - // grid matches the UTC-anchored dateKey (mirrors compliance-heatmap). - const firstDate = dates[0]; - const firstDow = (firstDate.getUTCDay() + 6) % 7; // Monday = 0 + const dates = heatmapDays(new Date(), timeZone, days); + const firstDow = dates[0]?.dow ?? 0; let col = 0; const markers: Array<{ col: number; label: string }> = []; let lastMonth = -1; for (let i = 0; i < dates.length; i++) { - const date = dates[i]; - const dow = (date.getUTCDay() + 6) % 7; // Monday = 0 + const { dateKey, dow, month } = dates[i]; const currentCol = Math.floor((i + firstDow) / 7); const row = dow; - const dateKey = date.toISOString().slice(0, 10); const cell = cellData[dateKey] ?? null; - const month = date.getUTCMonth(); if (month !== lastMonth) { markers.push({ col: currentCol, label: MONTH_LABELS[month] }); lastMonth = month; @@ -197,7 +191,7 @@ export function MoodHeatmap({ } return { cells: cellList, weeks: col + 1, monthMarkers: markers }; - }, [cellData, days, t]); + }, [cellData, days, t, timeZone]); const loggedDays = cells.filter((cell) => cell.cell !== null).length; const summaryLabel = diff --git a/src/lib/charts/__tests__/heatmap-days.test.ts b/src/lib/charts/__tests__/heatmap-days.test.ts new file mode 100644 index 000000000..dbc0c973b --- /dev/null +++ b/src/lib/charts/__tests__/heatmap-days.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; + +import { heatmapDays } from "../heatmap-days"; + +describe("heatmapDays", () => { + it.each([ + // 20:30 in New York is already the next UTC day. + ["America/New_York", "2026-03-09T00:30:00.000Z", "2026-03-08"], + // 08:00 in Auckland and Tonga is still the previous UTC day. + ["Pacific/Auckland", "2026-04-04T20:00:00.000Z", "2026-04-05"], + ["Pacific/Tongatapu", "2026-07-27T19:00:00.000Z", "2026-07-28"], + ["America/Santiago", "2026-09-06T03:30:00.000Z", "2026-09-05"], + ["Europe/Berlin", "2026-10-25T22:30:00.000Z", "2026-10-25"], + ])("ends on today's key in %s", (tz, iso, today) => { + const days = heatmapDays(new Date(iso), tz, 14); + expect(days).toHaveLength(14); + expect(days.at(-1)?.dateKey).toBe(today); + // Consecutive keys, one per day, no gap or repeat across DST. + for (let i = 1; i < days.length; i++) { + const prev = Date.parse(`${days[i - 1].dateKey}T00:00:00Z`); + const cur = Date.parse(`${days[i].dateKey}T00:00:00Z`); + expect(cur - prev).toBe(86_400_000); + } + }); + + it("reads weekday and month off the key", () => { + const [last] = heatmapDays( + new Date("2026-03-01T03:00:00.000Z"), + "America/New_York", + 1, + ); + // 22:00 on Saturday 28 February in New York. + expect(last).toEqual({ dateKey: "2026-02-28", dow: 5, month: 1 }); + }); +}); diff --git a/src/lib/charts/heatmap-days.ts b/src/lib/charts/heatmap-days.ts new file mode 100644 index 000000000..f769f767a --- /dev/null +++ b/src/lib/charts/heatmap-days.ts @@ -0,0 +1,36 @@ +import { shiftDateKey, userDayKey } from "@/lib/tz/format"; + +/** One calendar-heatmap column entry: a local day and where it sits. */ +export interface HeatmapDay { + /** `YYYY-MM-DD` in the user's zone. */ + dateKey: string; + /** Weekday with Monday = 0 … Sunday = 6. */ + dow: number; + /** Month index 0–11. */ + month: number; +} + +/** + * The last `days` local days in `timeZone`, oldest first, ending with today. + * + * Built from today's key in the user's zone and stepped on keys. The heatmaps + * used to take `now − n·24 h` and cut the key in UTC, so in the evening west + * of UTC (or the morning east of it) "today" was a different day from the one + * the server had keyed the user's readings on, and the newest cell showed the + * wrong day. Weekday and month come from the key itself. + */ +export function heatmapDays( + now: Date, + timeZone: string, + days: number, +): HeatmapDay[] { + const today = userDayKey(now, timeZone); + const out: HeatmapDay[] = []; + for (let back = days - 1; back >= 0; back--) { + const dateKey = shiftDateKey(today, -back); + const [y, m, d] = dateKey.split("-").map(Number); + const utc = new Date(Date.UTC(y, m - 1, d)); + out.push({ dateKey, dow: (utc.getUTCDay() + 6) % 7, month: m - 1 }); + } + return out; +} From 6a422542f141f8be1b53b18ed6bf38be364fd5ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 26 Sep 2026 15:44:25 +0200 Subject: [PATCH 07/15] fix(digest): decide a milestone's day on the user's calendar Today's key, each new record's day and the streak series were UTC days, so through a New York evening a best set that afternoon counted as yesterday's and dropped out of the digest. All three are now the user's own days; the streak series is folded live on local days for this read. --- src/lib/daily/__tests__/load-digest.test.ts | 31 +++++++++++++++++++++ src/lib/daily/load-digest.ts | 19 +++++++------ src/lib/insights/derived/baseline.ts | 12 ++++++-- 3 files changed, 51 insertions(+), 11 deletions(-) diff --git a/src/lib/daily/__tests__/load-digest.test.ts b/src/lib/daily/__tests__/load-digest.test.ts index c52a90995..631dd4b65 100644 --- a/src/lib/daily/__tests__/load-digest.test.ts +++ b/src/lib/daily/__tests__/load-digest.test.ts @@ -63,6 +63,7 @@ import { readDashboardSnapshotCached } from "@/lib/dashboard/snapshot-read"; import { resolveModuleMap } from "@/lib/modules/gate"; import { probeRollupCoverage } from "@/lib/rollups/measurement-coverage"; import { loadIntradayPulse } from "@/lib/analytics/intraday-pulse-io"; +import { readDayMeanSeries } from "@/lib/insights/derived/baseline"; import { __resetAllCachesForTests } from "@/lib/cache/server-cache"; import { invalidateUserMeasurements } from "@/lib/cache/invalidate"; import { PRIORITY_ITEM_KINDS } from "@/lib/daily/priority-item"; @@ -118,6 +119,36 @@ beforeEach(() => { vi.mocked(aiCapabilityToServe).mockResolvedValue(AI_AVAILABLE); }); +describe("loadDailyDigest — milestone day", () => { + // 21:30 on 16 July in New York is already 17 July in UTC. A best set at + // 19:00 local that evening was reached today, on the user's calendar. + const NY_USER = { ...USER, timezone: "America/New_York" } as User; + const NY_EVENING = new Date("2026-07-17T01:30:00.000Z"); + + it("keys today and a new record on the user's own day", async () => { + vi.mocked(prisma.personalRecord.findMany).mockResolvedValueOnce([ + { + metricType: "RESTING_HEART_RATE", + achievedAt: new Date("2026-07-16T23:00:00.000Z"), + }, + ] as never); + + const digest = await loadDailyDigest(NY_USER, NY_EVENING); + + expect(digest.worthALook.some((i) => i.kind === "milestone")).toBe(true); + }); + + it("folds the streak series on the user's days", async () => { + vi.mocked(probeRollupCoverage).mockResolvedValue( + new Map([["RESTING_HEART_RATE", true]]) as never, + ); + await loadDailyDigest(NY_USER, NY_EVENING); + const calls = vi.mocked(readDayMeanSeries).mock.calls; + expect(calls.length).toBeGreaterThan(0); + for (const call of calls) expect(call[5]).toBe("America/New_York"); + }); +}); + describe("loadDailyDigest — S11/S12 extras cache", () => { it("gathers the milestone + tension inputs once, then serves the second poll from cache", async () => { await loadDailyDigest(USER, NOW); diff --git a/src/lib/daily/load-digest.ts b/src/lib/daily/load-digest.ts index b85fb5311..dcfebbdb3 100644 --- a/src/lib/daily/load-digest.ts +++ b/src/lib/daily/load-digest.ts @@ -101,11 +101,6 @@ const MILESTONE_STREAK_WINDOW_DAYS = 30; /** How far back to scan for a just-set personal best (a couple of days is ample). */ const MILESTONE_RECORD_LOOKBACK_MS = 2 * 24 * 60 * 60 * 1000; -/** UTC-ISO day key — the space the streak series + rollup tier already emit. */ -function utcDayKey(date: Date): string { - return date.toISOString().slice(0, 10); -} - /** * S12 — gather today's single freshly-reached milestone (or null) from the * engines that ALREADY exist. Two cheap, fail-soft reads reused verbatim: @@ -117,9 +112,13 @@ function utcDayKey(date: Date): string { */ async function gatherFreshMilestone( userId: string, + timezone: string, now: Date, ): Promise { - const todayKey = utcDayKey(now); + // Every day key here is the user's own calendar day: today's key, the + // streak series (folded on local days) and each record's day. UTC keys + // named tomorrow "today" through a New York evening. + const todayKey = userDayKey(now, timezone); const candidates: Milestone[] = []; const coverage = await probeRollupCoverage(userId).catch(() => null); @@ -133,6 +132,7 @@ async function gatherFreshMilestone( MILESTONE_STREAK_WINDOW_DAYS, now, coverage, + timezone, ); if (points.length === 0) return [] as Milestone[]; const series: StreakPoint[] = points.map((p) => ({ @@ -164,7 +164,10 @@ async function gatherFreshMilestone( }); for (const record of records) { candidates.push( - milestoneFromRecord(record.metricType, utcDayKey(record.achievedAt)), + milestoneFromRecord( + record.metricType, + userDayKey(record.achievedAt, timezone), + ), ); } } catch { @@ -234,7 +237,7 @@ async function loadDailyDigestExtrasCached( // internally (a milestone read-hiccup or a tension-read failure leaves // its own field quiet rather than breaking the other). const [milestone, tensionWindow, sameTime] = await Promise.all([ - gatherFreshMilestone(userId, now), + gatherFreshMilestone(userId, timezone, now), loadIntradayPulse(userId, timezone, todayLocalDate) .then((r) => (r.tension ? { partOfDay: r.tension.partOfDay } : null)) .catch(() => null), diff --git a/src/lib/insights/derived/baseline.ts b/src/lib/insights/derived/baseline.ts index d6319880f..821ab52d6 100644 --- a/src/lib/insights/derived/baseline.ts +++ b/src/lib/insights/derived/baseline.ts @@ -220,6 +220,10 @@ export function buildBaselineBand( * Rollup tier first (DAY-native, `mean` composes); per-type bounded * live-SQL fallback on a coverage miss. Returns the series plus the * provenance source the read resolved against. + * + * Days are UTC days, the DAY rollup grain. A caller that compares the days + * with the user's own calendar (today's key, a record's local day) passes + * `timeZone`; the series is then folded live on the user's local days. */ export async function readDayMeanSeries( userId: string, @@ -227,8 +231,9 @@ export async function readDayMeanSeries( windowDays: number, now: Date, coverage: RollupCoverageMap, + timeZone?: string, ): Promise<{ points: DayMeanPoint[]; source: DerivedProvenanceSource }> { - const hasBuckets = coverage.get(type) === true; + const hasBuckets = timeZone === undefined && coverage.get(type) === true; if (hasBuckets) { // DAY granularity only — the spread invariant forbids composing a @@ -266,13 +271,14 @@ export async function readDayMeanSeries( // stored value the application declares impossible would drag the day's // mean with it and the median would carry it into the band. A day whose // every reading is implausible produces no point at all. Days are UTC - // days, matching the DAY rollup path above. + // days, matching the DAY rollup path above, unless the caller asked for + // the user's own days. const since = new Date(now.getTime() - windowDays * 24 * 60 * 60 * 1000); const days = await readDayAggregates({ userId, type, since, - timeZone: "UTC", + timeZone: timeZone ?? "UTC", valueRange: plausibleMetricRange(type), }); const points = days.map((d) => ({ day: d.day, mean: d.sum / d.n })); From 34f847f12f279a30b7f88be7ac8099b52df6cb58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 26 Sep 2026 15:58:02 +0200 Subject: [PATCH 08/15] fix(integrations): anchor synced daily totals at local noon in the user's zone Withings, Fitbit, Google Health, Polar and Oura wrote each daily total and daily summary at noon UTC of its date. From UTC+12 on (New Zealand all year, Fiji, Tonga, Samoa) noon UTC is already the next local day, so every synced step count, SpO2 or resting-pulse day showed up a day late. The rows now sit at local noon of their date in the user's zone, and the fetch windows run through the user's own today. Fitbit weigh-ins carry a local wall clock without an offset and were read as UTC; they are read in the user's zone now. ExternalIds stay on the calendar date, so the next sync finds each row and moves it in place instead of adding a second one, and the day a row leaves is refolded. Rows inside each integration's re-read window heal on the next sync; Fitbit and Google Health re-read the whole history on reconnect. --- src/lib/fitbit/__tests__/client.test.ts | 50 +++ src/lib/fitbit/__tests__/sync.test.ts | 67 +++- src/lib/fitbit/client.ts | 249 +++++++++----- src/lib/fitbit/sync-activity.ts | 12 +- src/lib/fitbit/sync-core.ts | 32 +- src/lib/fitbit/sync-metrics.ts | 12 +- .../google-health/__tests__/mappers.test.ts | 34 ++ .../__tests__/sync-upsert-resurrect.test.ts | 1 + src/lib/google-health/mappers.ts | 325 +++++++++++------- src/lib/google-health/sync-activity.ts | 13 +- src/lib/google-health/sync-core.ts | 23 +- src/lib/google-health/sync-metrics.ts | 11 +- src/lib/measurements/consolidation-tz.ts | 15 +- src/lib/oura/__tests__/client.test.ts | 20 ++ src/lib/oura/client.ts | 65 ++-- src/lib/oura/sync.ts | 55 ++- src/lib/polar/__tests__/client.test.ts | 25 ++ src/lib/polar/client.ts | 46 ++- src/lib/polar/sync.ts | 19 +- .../withings/__tests__/sync-activity.test.ts | 126 +++++-- src/lib/withings/sync-activity.ts | 121 ++++--- 21 files changed, 947 insertions(+), 374 deletions(-) diff --git a/src/lib/fitbit/__tests__/client.test.ts b/src/lib/fitbit/__tests__/client.test.ts index 7410c17ac..392c818ab 100644 --- a/src/lib/fitbit/__tests__/client.test.ts +++ b/src/lib/fitbit/__tests__/client.test.ts @@ -398,6 +398,56 @@ describe("VO2 max", () => { }); }); +describe("mappers in the user's zone", () => { + it("reads a weigh-in's logged wall clock in the user's zone", () => { + const entry = { + weight: [ + { date: "2026-05-10", time: "07:30:00", weight: 81.4, logId: 7 }, + ], + }; + expect( + mapWeight(entry, "Pacific/Auckland")[0]!.measuredAt.toISOString(), + ).toBe("2026-05-09T19:30:00.000Z"); + expect( + mapWeight(entry, "America/New_York")[0]!.measuredAt.toISOString(), + ).toBe("2026-05-10T11:30:00.000Z"); + // The externalId does not depend on the zone. + expect(mapWeight(entry, "Pacific/Auckland")[0]!.fieldTag).toBe("7:weight"); + }); + + it("keeps a logId-less weigh-in's externalId as earlier versions spelled it", () => { + const out = mapWeight( + { weight: [{ date: "2026-05-10", time: "07:30:00", weight: 81.4 }] }, + "Pacific/Auckland", + ); + expect(out[0]!.fieldTag).toBe("2026-05-10T07:30:00.000Z:weight"); + }); + + it.each([ + ["Pacific/Tongatapu", "2026-05-09T23:00:00.000Z"], + ["Pacific/Auckland", "2026-05-10T00:00:00.000Z"], + ["America/Santiago", "2026-05-10T16:00:00.000Z"], + ["America/New_York", "2026-05-10T16:00:00.000Z"], + ["Europe/Berlin", "2026-05-10T10:00:00.000Z"], + ])( + "anchors a daily total at local noon in %s, on the same day there", + (tz, iso) => { + const [steps] = mapSteps( + { "activities-steps": [{ dateTime: "2026-05-10", value: "8421" }] }, + tz, + ); + expect(steps!.measuredAt.toISOString()).toBe(iso); + expect(steps!.fieldTag).toBe("steps:2026-05-10"); + const [spo2] = mapOxygenSaturation( + [{ dateTime: "2026-05-10", value: { avg: 97 } }], + tz, + ); + expect(spo2!.measuredAt.toISOString()).toBe(iso); + expect(spo2!.fieldTag).toBe("2026-05-10:spo2"); + }, + ); +}); + describe("activity mappers (cumulative daily)", () => { it("maps steps from the string value, preserving a zero, with a day-keyed externalId", () => { const out = mapSteps({ diff --git a/src/lib/fitbit/__tests__/sync.test.ts b/src/lib/fitbit/__tests__/sync.test.ts index 9974e0b50..9d8990a3b 100644 --- a/src/lib/fitbit/__tests__/sync.test.ts +++ b/src/lib/fitbit/__tests__/sync.test.ts @@ -288,7 +288,12 @@ describe("upsertFitbitMeasurements — batched write, tombstones resurrect", () const measuredAt = new Date("2026-05-10T07:00:00.000Z"); // A re-fetched daily summary: the live row already exists. prismaMock.measurement.findMany.mockResolvedValue([ - { id: "m-live", type: "WEIGHT", externalId: "stats:weight:2026-05-10" }, + { + id: "m-live", + type: "WEIGHT", + externalId: "stats:weight:2026-05-10", + measuredAt, + }, ]); const { imported } = await upsertFitbitMeasurements("user1", [ @@ -319,7 +324,12 @@ describe("upsertFitbitMeasurements — batched write, tombstones resurrect", () // probe returns it; planning an insert instead would be dropped silently // by `skipDuplicates` and wedge the key forever. prismaMock.measurement.findMany.mockResolvedValue([ - { id: "m-dead", type: "WEIGHT", externalId: "stats:weight:2026-05-10" }, + { + id: "m-dead", + type: "WEIGHT", + externalId: "stats:weight:2026-05-10", + measuredAt, + }, ]); const { imported } = await upsertFitbitMeasurements("user1", [ @@ -342,11 +352,52 @@ describe("upsertFitbitMeasurements — batched write, tombstones resurrect", () expect(updateArg.data.deletedAt).toBeNull(); }); + it("moves a daily row to its new anchor and refolds the day it left", async () => { + // A step total written at the old noon-UTC anchor, re-fetched for a user + // in Tonga, whose local noon is 23:00 UTC the day before. + const legacy = new Date("2026-05-10T12:00:00.000Z"); + const localNoon = new Date("2026-05-09T23:00:00.000Z"); + prismaMock.measurement.findMany.mockResolvedValue([ + { + id: "m-steps", + type: "ACTIVITY_STEPS", + externalId: "stats:steps:2026-05-10", + value: 8421, + unit: "steps", + measuredAt: legacy, + sleepStage: null, + deletedAt: null, + }, + ]); + + const { touched } = await upsertFitbitMeasurements("user1", [ + { + type: "ACTIVITY_STEPS", + value: 8421, + unit: "steps", + measuredAt: localNoon, + externalId: "stats:steps:2026-05-10", + }, + ]); + + expect(prismaMock.measurement.createManyAndReturn).not.toHaveBeenCalled(); + expect(prismaMock.measurement.update).toHaveBeenCalledTimes(1); + const updateArg = prismaMock.measurement.update.mock.calls[0]![0] as { + data: { measuredAt: Date }; + }; + expect(updateArg.data.measuredAt).toEqual(localNoon); + expect(touched.map((t) => t.measuredAt.toISOString())).toEqual( + expect.arrayContaining([localNoon.toISOString(), legacy.toISOString()]), + ); + }); + it("splits a mixed batch: matched-live → update, unmatched → createMany", async () => { const measuredAt = new Date("2026-05-10T07:00:00.000Z"); - prismaMock.measurement.findMany.mockResolvedValue([ - { id: "m-live", type: "WEIGHT", externalId: "live-key" }, - ]); + prismaMock.measurement.findMany + .mockResolvedValueOnce([ + { id: "m-live", type: "WEIGHT", externalId: "live-key", measuredAt }, + ]) + .mockResolvedValueOnce([]); // natural-key rescue: no twin const { imported } = await upsertFitbitMeasurements("user1", [ { @@ -438,7 +489,7 @@ describe("upsertFitbitMeasurements — write-catch ledger (F-2)", () => { // fresh → create branch. Second findMany is the natural-key rescue probe. prismaMock.measurement.findMany .mockResolvedValueOnce([ - { id: "m-live", type: "WEIGHT", externalId: "live-key" }, + { id: "m-live", type: "WEIGHT", externalId: "live-key", measuredAt }, ]) .mockResolvedValueOnce([]); // natural-key rescue: no twin prismaMock.measurement.createManyAndReturn.mockRejectedValueOnce( @@ -478,8 +529,8 @@ describe("upsertFitbitMeasurements — write-catch ledger (F-2)", () => { // throws; the loop must continue to the second, and the create still runs. prismaMock.measurement.findMany .mockResolvedValueOnce([ - { id: "m-live-1", type: "WEIGHT", externalId: "live-1" }, - { id: "m-live-2", type: "WEIGHT", externalId: "live-2" }, + { id: "m-live-1", type: "WEIGHT", externalId: "live-1", measuredAt }, + { id: "m-live-2", type: "WEIGHT", externalId: "live-2", measuredAt }, ]) .mockResolvedValueOnce([]); // natural-key rescue: no twin prismaMock.measurement.update diff --git a/src/lib/fitbit/client.ts b/src/lib/fitbit/client.ts index 1015e15fa..6521cd8f0 100644 --- a/src/lib/fitbit/client.ts +++ b/src/lib/fitbit/client.ts @@ -39,6 +39,7 @@ import { createHash, randomBytes } from "node:crypto"; import { getEvent } from "@/lib/logging/context"; import { safeFetch } from "@/lib/safe-fetch"; +import { canonicalDailyTimestamp } from "@/lib/measurements/consolidation-tz"; import { zonedWallClockToUtc } from "@/lib/tz/wall-clock"; import { FitbitApiError, classifyFitbitResponse } from "./response-classifier"; @@ -528,15 +529,16 @@ function toFiniteNumber(v: unknown): number | null { } /** - * Parse a Fitbit `YYYY-MM-DD` civil date into a UTC-midday Date. Anchored at - * midday so a timezone shift can't roll the civil day across a boundary. Returns - * null for an unparseable string. + * Validate a Fitbit `YYYY-MM-DD` civil date and return it as a day key, or + * null for an unparseable string. The key names the row's externalId; the + * row's instant is the key's local noon in the user's zone + * (`canonicalDailyTimestamp`), which reads the same calendar day there. + * Noon UTC, the previous anchor, is already the next day from UTC+13 on. */ -function parseCivilDate(dateStr: unknown): Date | null { +function parseCivilKey(dateStr: unknown): string | null { if (typeof dateStr !== "string") return null; const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(dateStr.trim()); - if (!m) return null; - return new Date(Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]), 12)); + return m ? `${m[1]}-${m[2]}-${m[3]}` : null; } /** @@ -584,28 +586,45 @@ interface FitbitBodyLogEntry { fat?: number; } -/** Resolve the measuredAt for a body-log entry from its `date` + `time`. */ -function bodyLogInstant(entry: FitbitBodyLogEntry): Date { +/** + * Resolve the measuredAt for a body-log entry from its `date` + `time`. Both + * are the user's local wall clock (Fitbit logs carry no offset), so they are + * read in the user's zone. Reading them as UTC put a morning weigh-in hours + * off, and on the previous or next day for anyone far from UTC. + */ +function bodyLogInstant(entry: FitbitBodyLogEntry, tz?: string): Date { const date = typeof entry.date === "string" ? entry.date : undefined; const time = typeof entry.time === "string" ? entry.time : "12:00:00"; if (date) { - const d = new Date(`${date}T${time}Z`); - if (!Number.isNaN(d.getTime())) return d; - const civil = parseCivilDate(date); - if (civil) return civil; + const d = parseLocalInstant(`${date}T${time}`, tz ?? "UTC"); + if (d) return d; + const civil = parseCivilKey(date); + if (civil) return canonicalDailyTimestamp(civil, tz); } return new Date(); } -/** Stable anchor for a body-log externalId — the logId, else the instant. */ +/** + * Stable anchor for a body-log externalId — the logId, else the logged wall + * clock. The wall clock is spelled the way earlier versions spelled it (as + * if it were UTC), so an entry without a logId keeps its externalId. + */ function bodyLogAnchor(entry: FitbitBodyLogEntry): string { if (typeof entry.logId === "number" && Number.isFinite(entry.logId)) { return String(entry.logId); } - return bodyLogInstant(entry).toISOString(); + const date = typeof entry.date === "string" ? entry.date : undefined; + const time = typeof entry.time === "string" ? entry.time : "12:00:00"; + const asWritten = date ? new Date(`${date}T${time}Z`) : null; + return asWritten && !Number.isNaN(asWritten.getTime()) + ? asWritten.toISOString() + : bodyLogInstant(entry).toISOString(); } -export function mapWeight(body: unknown): FitbitMappedMeasurement[] { +export function mapWeight( + body: unknown, + tz?: string, +): FitbitMappedMeasurement[] { const arr = readArray(body, "weight"); const out: FitbitMappedMeasurement[] = []; for (const raw of arr) { @@ -615,14 +634,17 @@ export function mapWeight(body: unknown): FitbitMappedMeasurement[] { type: "WEIGHT", value: round2(e.weight), unit: "kg", - measuredAt: bodyLogInstant(e), + measuredAt: bodyLogInstant(e, tz), fieldTag: `${bodyLogAnchor(e)}:weight`, }); } return out; } -export function mapBodyFat(body: unknown): FitbitMappedMeasurement[] { +export function mapBodyFat( + body: unknown, + tz?: string, +): FitbitMappedMeasurement[] { const arr = readArray(body, "fat"); const out: FitbitMappedMeasurement[] = []; for (const raw of arr) { @@ -632,7 +654,7 @@ export function mapBodyFat(body: unknown): FitbitMappedMeasurement[] { type: "BODY_FAT", value: round2(e.fat), unit: "%", - measuredAt: bodyLogInstant(e), + measuredAt: bodyLogInstant(e, tz), fieldTag: `${bodyLogAnchor(e)}:body_fat`, }); } @@ -665,10 +687,11 @@ function mapDailySummary( leaves: string[]; factor?: number; }, + tz: string | undefined, ): FitbitMappedMeasurement[] { const out: FitbitMappedMeasurement[] = []; for (const row of rows) { - const day = parseCivilDate(row.dateTime); + const day = parseCivilKey(row.dateTime); if (!day) continue; const value = firstPositiveLeaf(row.value, spec.leaves); if (value === null) continue; @@ -677,8 +700,8 @@ function mapDailySummary( type: spec.type, value: round2(scaled), unit: spec.unit, - measuredAt: day, - fieldTag: `${fitbitDate(day)}:${spec.fieldTag}`, + measuredAt: canonicalDailyTimestamp(day, tz), + fieldTag: `${day}:${spec.fieldTag}`, }); } return out; @@ -694,41 +717,60 @@ function firstPositiveLeaf(obj: unknown, leaves: string[]): number | null { return null; } -export function mapOxygenSaturation(body: unknown): FitbitMappedMeasurement[] { +export function mapOxygenSaturation( + body: unknown, + tz?: string, +): FitbitMappedMeasurement[] { // The SpO2 summary endpoint returns a BARE ARRAY of daily rows (not wrapped). const rows = Array.isArray(body) ? (body as FitbitDailyRow[]) : []; - return mapDailySummary(rows, { - type: "OXYGEN_SATURATION", - unit: "%", - fieldTag: "spo2", - leaves: ["avg"], - }); + return mapDailySummary( + rows, + { + type: "OXYGEN_SATURATION", + unit: "%", + fieldTag: "spo2", + leaves: ["avg"], + }, + tz, + ); } export function mapHeartRateVariability( body: unknown, + tz?: string, ): FitbitMappedMeasurement[] { // Fitbit's HRV summary is an RMSSD estimator (`value.dailyRmssd`). It lands in // the canonical `HEART_RATE_VARIABILITY` slot that FITBIT occupies in the // source-priority `hrv` ladder (alongside Apple / Oura), keeping cross-source // comparison on one axis. (`HRV_RMSSD` is reserved for the WHOOP-native slot.) const rows = readArray(body, "hrv") as FitbitDailyRow[]; - return mapDailySummary(rows, { - type: "HEART_RATE_VARIABILITY", - unit: "ms", - fieldTag: "hrv", - leaves: ["dailyRmssd"], - }); + return mapDailySummary( + rows, + { + type: "HEART_RATE_VARIABILITY", + unit: "ms", + fieldTag: "hrv", + leaves: ["dailyRmssd"], + }, + tz, + ); } -export function mapRespiratoryRate(body: unknown): FitbitMappedMeasurement[] { +export function mapRespiratoryRate( + body: unknown, + tz?: string, +): FitbitMappedMeasurement[] { const rows = readArray(body, "br") as FitbitDailyRow[]; - return mapDailySummary(rows, { - type: "RESPIRATORY_RATE", - unit: "breaths/min", - fieldTag: "resp_rate", - leaves: ["breathingRate"], - }); + return mapDailySummary( + rows, + { + type: "RESPIRATORY_RATE", + unit: "breaths/min", + fieldTag: "resp_rate", + leaves: ["breathingRate"], + }, + tz, + ); } /** @@ -736,14 +778,21 @@ export function mapRespiratoryRate(body: unknown): FitbitMappedMeasurement[] { * GET /1/user/-/activities/heart/date/{start}/{end}.json * → { "activities-heart": [{ dateTime, value: { restingHeartRate } }] } */ -export function mapRestingHeartRate(body: unknown): FitbitMappedMeasurement[] { +export function mapRestingHeartRate( + body: unknown, + tz?: string, +): FitbitMappedMeasurement[] { const rows = readArray(body, "activities-heart") as FitbitDailyRow[]; - return mapDailySummary(rows, { - type: "RESTING_HEART_RATE", - unit: "bpm", - fieldTag: "rhr", - leaves: ["restingHeartRate"], - }); + return mapDailySummary( + rows, + { + type: "RESTING_HEART_RATE", + unit: "bpm", + fieldTag: "rhr", + leaves: ["restingHeartRate"], + }, + tz, + ); } /** @@ -753,11 +802,14 @@ export function mapRestingHeartRate(body: unknown): FitbitMappedMeasurement[] { * The value is a STRING that may be a single number or a range; a range resolves * to its midpoint. Daily latest-wins, day-keyed externalId. */ -export function mapVo2Max(body: unknown): FitbitMappedMeasurement[] { +export function mapVo2Max( + body: unknown, + tz?: string, +): FitbitMappedMeasurement[] { const rows = readArray(body, "cardioScore") as FitbitDailyRow[]; const out: FitbitMappedMeasurement[] = []; for (const row of rows) { - const day = parseCivilDate(row.dateTime); + const day = parseCivilKey(row.dateTime); if (!day) continue; const v = parseVo2Max( (row.value as Record | undefined)?.vo2Max, @@ -767,8 +819,8 @@ export function mapVo2Max(body: unknown): FitbitMappedMeasurement[] { type: "VO2_MAX", value: round2(v), unit: "mL/(kg·min)", - measuredAt: day, - fieldTag: `vo2_max:${fitbitDate(day)}`, + measuredAt: canonicalDailyTimestamp(day, tz), + fieldTag: `vo2_max:${day}`, cumulativeDaily: true, }); } @@ -811,11 +863,12 @@ function mapActivitySeries( fieldTag: string; factor?: number; }, + tz: string | undefined, ): FitbitMappedMeasurement[] { const rows = readArray(body, spec.arrayKey) as FitbitDailyRow[]; const out: FitbitMappedMeasurement[] = []; for (const row of rows) { - const day = parseCivilDate(row.dateTime); + const day = parseCivilKey(row.dateTime); if (!day) continue; const n = toFiniteNumber(row.value); if (n === null || !nonNegative(n)) continue; @@ -824,52 +877,80 @@ function mapActivitySeries( type: spec.type, value: round2(scaled), unit: spec.unit, - measuredAt: day, - fieldTag: `${spec.fieldTag}:${fitbitDate(day)}`, + measuredAt: canonicalDailyTimestamp(day, tz), + fieldTag: `${spec.fieldTag}:${day}`, cumulativeDaily: true, }); } return out; } -export function mapSteps(body: unknown): FitbitMappedMeasurement[] { - return mapActivitySeries(body, { - arrayKey: "activities-steps", - type: "ACTIVITY_STEPS", - unit: "steps", - fieldTag: "steps", - }); +export function mapSteps( + body: unknown, + tz?: string, +): FitbitMappedMeasurement[] { + return mapActivitySeries( + body, + { + arrayKey: "activities-steps", + type: "ACTIVITY_STEPS", + unit: "steps", + fieldTag: "steps", + }, + tz, + ); } -export function mapDistance(body: unknown): FitbitMappedMeasurement[] { +export function mapDistance( + body: unknown, + tz?: string, +): FitbitMappedMeasurement[] { // The metric-locale distance series reports kilometres → metres. - return mapActivitySeries(body, { - arrayKey: "activities-distance", - type: "WALKING_RUNNING_DISTANCE", - unit: "m", - fieldTag: "distance", - factor: 1000, - }); + return mapActivitySeries( + body, + { + arrayKey: "activities-distance", + type: "WALKING_RUNNING_DISTANCE", + unit: "m", + fieldTag: "distance", + factor: 1000, + }, + tz, + ); } -export function mapActiveCalories(body: unknown): FitbitMappedMeasurement[] { +export function mapActiveCalories( + body: unknown, + tz?: string, +): FitbitMappedMeasurement[] { // `activityCalories` is the ACTIVE portion (excludes BMR), unlike the total // `calories` resource. - return mapActivitySeries(body, { - arrayKey: "activities-activityCalories", - type: "ACTIVE_ENERGY_BURNED", - unit: "kcal", - fieldTag: "active_calories", - }); + return mapActivitySeries( + body, + { + arrayKey: "activities-activityCalories", + type: "ACTIVE_ENERGY_BURNED", + unit: "kcal", + fieldTag: "active_calories", + }, + tz, + ); } -export function mapFloors(body: unknown): FitbitMappedMeasurement[] { - return mapActivitySeries(body, { - arrayKey: "activities-floors", - type: "FLIGHTS_CLIMBED", - unit: "flights", - fieldTag: "floors", - }); +export function mapFloors( + body: unknown, + tz?: string, +): FitbitMappedMeasurement[] { + return mapActivitySeries( + body, + { + arrayKey: "activities-floors", + type: "FLIGHTS_CLIMBED", + unit: "flights", + fieldTag: "floors", + }, + tz, + ); } /** diff --git a/src/lib/fitbit/sync-activity.ts b/src/lib/fitbit/sync-activity.ts index a0451fd5d..10e1140ac 100644 --- a/src/lib/fitbit/sync-activity.ts +++ b/src/lib/fitbit/sync-activity.ts @@ -30,6 +30,8 @@ import { mapVo2Max, } from "./client"; import type { FitbitMappedMeasurement } from "./client"; +import { resolveUserTimezone } from "@/lib/tz/resolver"; +import { localTodayAsDate } from "./sync-core"; import { chunkDateRanges, getValidToken, @@ -45,7 +47,7 @@ import { annotate } from "@/lib/logging/context"; /** One mappable activity metric: a range fetcher + a body mapper + a verb. */ interface ActivityResource { fetch: (accessToken: string, start: Date, end: Date) => Promise; - map: (body: unknown) => FitbitMappedMeasurement[]; + map: (body: unknown, tz: string) => FitbitMappedMeasurement[]; verb: string; } @@ -90,8 +92,12 @@ export async function syncUserActivity( const tokenInfo = await getValidToken(userId); if (!tokenInfo) return 0; + // Daily rows are anchored at local noon of their date in the user's zone, + // and the window runs through the user's own today (a UTC "now" is still + // yesterday for most of a morning in a zone ahead of UTC). + const tz = await resolveUserTimezone(userId); const start = opts.start ?? new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); - const end = opts.end ?? new Date(); + const end = opts.end ?? localTodayAsDate(tz); const windows = chunkDateRanges(start, end, FITBIT_RANGE_DAYS); let imported = 0; @@ -112,7 +118,7 @@ export async function syncUserActivity( break; } try { - for (const m of resource.map(body)) { + for (const m of resource.map(body, tz)) { readings.push({ type: m.type, value: m.value, diff --git a/src/lib/fitbit/sync-core.ts b/src/lib/fitbit/sync-core.ts index f67f4422b..133c7fdf2 100644 --- a/src/lib/fitbit/sync-core.ts +++ b/src/lib/fitbit/sync-core.ts @@ -24,6 +24,7 @@ import { refreshAccessToken } from "./client"; import { getUserFitbitCredentials } from "./credentials"; import { FitbitApiError, classifyFitbitError } from "./response-classifier"; import type { FitbitClassification } from "./response-classifier"; +import { userDayKey } from "@/lib/tz/format"; /** Refresh the access token this many ms before `tokenExpiresAt`. */ const TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1000; @@ -383,6 +384,14 @@ export function incrementalStart( return new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); } +/** + * The user's local today as a Date whose UTC calendar date is that day, for + * the date-keyed Fitbit range endpoints (`fitbitDate` reads the UTC date). + */ +export function localTodayAsDate(tz: string, now: Date = new Date()): Date { + return new Date(`${userDayKey(now, tz)}T12:00:00.000Z`); +} + /** * Slice `[start, end]` into consecutive inclusive windows no wider than * `maxDays` calendar days, the per-endpoint range cap the classic Web API @@ -626,6 +635,8 @@ export async function upsertFitbitMeasurements( r: FitbitMeasurementUpsert; /** Set when a key-format migration re-keys the row in place (see below). */ reKeyTo?: string; + /** The row's instant before this write, when the write moves it. */ + movedFrom?: Date; }> = []; const touched: Array<{ type: MeasurementType; measuredAt: Date }> = []; @@ -648,7 +659,16 @@ export async function upsertFitbitMeasurements( existingRow.unit === r.unit && existingRow.measuredAt.getTime() === r.measuredAt.getTime() && (existingRow.sleepStage ?? null) === (r.sleepStage ?? null); - if (!unchanged) toUpdate.push({ id: existingRow.id, r }); + if (!unchanged) { + toUpdate.push({ + id: existingRow.id, + r, + movedFrom: + existingRow.measuredAt.getTime() !== r.measuredAt.getTime() + ? existingRow.measuredAt + : undefined, + }); + } } else if (!plannedCreateKeys.has(key)) { plannedCreateKeys.add(key); toCreate.push({ @@ -813,7 +833,7 @@ export async function upsertFitbitMeasurements( // `syncVersion`. `deletedAt: null` rides along unconditionally — a no-op on // a live row, a deliberate RESURRECTION on a tombstoned one (Fitbit is the // source of truth for its own rows; see TOMBSTONES RESURRECT above). - for (const { id, r, reKeyTo } of toUpdate) { + for (const { id, r, reKeyTo, movedFrom } of toUpdate) { try { await prisma.measurement.update({ where: { id }, @@ -833,6 +853,14 @@ export async function upsertFitbitMeasurements( type: r.type as MeasurementType, measuredAt: r.measuredAt, }); + // A row that moved (a daily anchor re-cut on the user's local noon) + // leaves its old day too; that day's rollup needs the refold. + if (movedFrom) { + touched.push({ + type: r.type as MeasurementType, + measuredAt: movedFrom, + }); + } imported++; } catch (err) { getEvent()?.addWarning(`Fitbit: failed to update measurement: ${err}`); diff --git a/src/lib/fitbit/sync-metrics.ts b/src/lib/fitbit/sync-metrics.ts index 17a6365c5..066bd4a6d 100644 --- a/src/lib/fitbit/sync-metrics.ts +++ b/src/lib/fitbit/sync-metrics.ts @@ -38,6 +38,8 @@ import { mapWeight, } from "./client"; import type { FitbitMappedMeasurement } from "./client"; +import { resolveUserTimezone } from "@/lib/tz/resolver"; +import { localTodayAsDate } from "./sync-core"; import { chunkDateRanges, getValidToken, @@ -52,7 +54,7 @@ import type { /** One mappable metric: a range fetcher + a body mapper + a verb. */ interface MetricResource { fetch: (accessToken: string, start: Date, end: Date) => Promise; - map: (body: unknown) => FitbitMappedMeasurement[]; + map: (body: unknown, tz: string) => FitbitMappedMeasurement[]; verb: string; } @@ -80,8 +82,12 @@ export async function syncUserMetrics( const tokenInfo = await getValidToken(userId); if (!tokenInfo) return 0; + // Daily rows are anchored at local noon of their date in the user's zone, + // and the window runs through the user's own today (a UTC "now" is still + // yesterday for most of a morning in a zone ahead of UTC). + const tz = await resolveUserTimezone(userId); const start = opts.start ?? new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); - const end = opts.end ?? new Date(); + const end = opts.end ?? localTodayAsDate(tz); const windows = chunkDateRanges(start, end, FITBIT_RANGE_DAYS); let imported = 0; @@ -106,7 +112,7 @@ export async function syncUserMetrics( break; } try { - for (const m of resource.map(body)) { + for (const m of resource.map(body, tz)) { readings.push({ type: m.type, value: m.value, diff --git a/src/lib/google-health/__tests__/mappers.test.ts b/src/lib/google-health/__tests__/mappers.test.ts index c32d36c88..49f3fa8ea 100644 --- a/src/lib/google-health/__tests__/mappers.test.ts +++ b/src/lib/google-health/__tests__/mappers.test.ts @@ -762,3 +762,37 @@ describe("chunkCivilRange — dailyRollUp 90-day slicing", () => { ).toEqual([]); }); }); + +describe("daily anchors in the user's zone", () => { + it.each([ + ["Pacific/Tongatapu", "2026-05-31T23:00:00.000Z"], + ["Pacific/Auckland", "2026-06-01T00:00:00.000Z"], + ["America/New_York", "2026-06-01T16:00:00.000Z"], + ["Europe/Berlin", "2026-06-01T10:00:00.000Z"], + ])( + "anchors a daily total and a daily summary at local noon in %s", + (tz, iso) => { + const [steps] = mapSteps( + { + civilStartTime: { date: { year: 2026, month: 6, day: 1 } }, + steps: { countSum: "8500" }, + }, + tz, + ); + expect(steps!.measuredAt.toISOString()).toBe(iso); + // The externalId stays on the civil date whatever the zone. + expect(steps!.fieldTag).toBe("steps:2026-06-01"); + const [spo2] = mapOxygenSaturation( + { + dailyOxygenSaturation: { + averagePercentage: 97.4, + date: { year: 2026, month: 6, day: 1 }, + }, + }, + tz, + ); + expect(spo2!.measuredAt.toISOString()).toBe(iso); + expect(spo2!.fieldTag).toBe("2026-06-01:spo2"); + }, + ); +}); diff --git a/src/lib/google-health/__tests__/sync-upsert-resurrect.test.ts b/src/lib/google-health/__tests__/sync-upsert-resurrect.test.ts index bd207e2b2..0704e346c 100644 --- a/src/lib/google-health/__tests__/sync-upsert-resurrect.test.ts +++ b/src/lib/google-health/__tests__/sync-upsert-resurrect.test.ts @@ -88,6 +88,7 @@ describe("upsertGoogleHealthMeasurements — tombstones resurrect", () => { id: "row-1", type: "ACTIVITY_STEPS", externalId: "stats:steps:2026-07-08", + measuredAt: STEPS_READING.measuredAt, }, ]); diff --git a/src/lib/google-health/mappers.ts b/src/lib/google-health/mappers.ts index a0684fbaa..a94d7abed 100644 --- a/src/lib/google-health/mappers.ts +++ b/src/lib/google-health/mappers.ts @@ -14,6 +14,7 @@ * this file performs I/O — everything is synchronous and deterministic given a * payload (+ optional user timezone). */ +import { canonicalDailyTimestamp } from "@/lib/measurements/consolidation-tz"; import { zonedWallClockToUtc } from "@/lib/tz/wall-clock"; /** @@ -290,8 +291,19 @@ function readPath(obj: unknown, path: string): unknown { return cur; } -/** Parse a `{year,month,day}` civil-date object into a UTC-midday Date, or null. */ -function parseCivilDateObject(val: unknown): Date | null { +/** + * Read a Google civil date — a `{year,month,day}` object, or a civil string + * (`YYYY-MM-DD`, optionally with a time suffix like `2026-06-02T00:00:00`) — + * as a `YYYY-MM-DD` day key, or null. The key names the row's externalId; the + * row's instant is the key's local noon in the user's zone + * (`canonicalDailyTimestamp`), which reads the same calendar day there. Noon + * UTC, the previous anchor, is already the next day from UTC+13 on. + */ +function parseCivilKey(val: unknown): string | null { + if (typeof val === "string") { + const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(val.trim()); + return m ? `${m[1]}-${m[2]}-${m[3]}` : null; + } if (!val || typeof val !== "object") return null; const o = val as Record; if ( @@ -299,30 +311,12 @@ function parseCivilDateObject(val: unknown): Date | null { typeof o.month === "number" && typeof o.day === "number" ) { - // Google civil dates are 1-based months; anchor at UTC midday so a timezone - // shift can't roll the civil day across a boundary. - return new Date(Date.UTC(o.year, o.month - 1, o.day, 12)); + const pad = (n: number, w: number) => String(n).padStart(w, "0"); + return `${pad(o.year, 4)}-${pad(o.month, 2)}-${pad(o.day, 2)}`; } return null; } -/** - * Parse a Google civil START anchor — the calendar day a cumulative daily total - * belongs to — into a UTC-midday Date, or null. Accepts a `{year,month,day}` - * object OR a civil string (`YYYY-MM-DD`, optionally carrying a time suffix like - * `2026-06-02T00:00:00`); only the Y-M-D is kept, anchored at UTC midday so a - * timezone shift can't roll the civil day. Mirrors the Fitbit `parseCivilDate` - * UTC-midday convention. - */ -function parseCivilStart(val: unknown): Date | null { - if (typeof val === "string") { - const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(val.trim()); - if (!m) return null; - return new Date(Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]), 12)); - } - return parseCivilDateObject(val); -} - /** * Matches an offset-less local ISO wall-clock string (`2026-06-02T03:02:30` / * `...T03:02:30.000`). No trailing `Z`, no `±hh:mm` — those denote an absolute @@ -369,7 +363,7 @@ function parseLocalInstant(iso: string, tz?: string): Date | null { * - `sample` → `{key}.sampleTime.physicalTime` (a spot instant; offset-less * strings resolve against `tz`). * - `date` → `{key}.date` (a `{year,month,day}` object, or a civil string) — - * anchored at UTC-midday so a tz shift can't roll the civil day. + * anchored at local noon of that day in the user's zone. * Falls back to `fallback` only when nothing parses, so a row is never dropped * for a missing anchor. (Sleep / exercise sessions and the rollup types anchor * through their own helpers, never here.) @@ -387,9 +381,8 @@ function resolveMeasuredAt( if (d) return d; } } else if (dataType.timeField === "date") { - const dateVal = readPath(point, `${dataType.key}.date`); - const civil = parseCivilStart(dateVal); - if (civil) return civil; + const day = parseCivilKey(readPath(point, `${dataType.key}.date`)); + if (day) return canonicalDailyTimestamp(day, tz); } return fallback; } @@ -404,14 +397,14 @@ function externalAnchor( dataType: GoogleHealthDataType, tz?: string, ): string { - const at = resolveMeasuredAt(point, dataType, new Date(0), tz); // Daily summaries share the civil-day externalId grain so a re-fetched day // overwrites in place. (Sleep/exercise sessions and the rollup daily totals // mint their own anchors and never reach this helper.) if (dataType.timeField === "date") { - return at.toISOString().slice(0, 10); + const day = parseCivilKey(readPath(point, `${dataType.key}.date`)); + if (day) return day; } - return at.toISOString(); + return resolveMeasuredAt(point, dataType, new Date(0), tz).toISOString(); } /** @@ -429,6 +422,7 @@ function mapSimple( valuePaths: string[]; factor?: number; }, + tz: string | undefined, ): GoogleHealthMappedMeasurement[] { let value = firstNumber(point, spec.valuePaths); if (value === null) return []; @@ -438,7 +432,7 @@ function mapSimple( type: spec.type, value: round2(value), unit: spec.unit, - measuredAt: resolveMeasuredAt(point, dataType, new Date()), + measuredAt: resolveMeasuredAt(point, dataType, new Date(), tz), fieldTag: `${externalAnchor(point, dataType)}:${spec.fieldTag}`, }, ]; @@ -446,44 +440,63 @@ function mapSimple( export function mapWeight( point: GoogleHealthDataPoint, + tz?: string, ): GoogleHealthMappedMeasurement[] { const dt = GOOGLE_HEALTH_DATA_TYPES.weight; // Documented field is `weightGrams` (grams) → kg. - return mapSimple(point, dt, { - type: "WEIGHT", - unit: "kg", - fieldTag: "weight", - valuePaths: [`${dt.key}.weightGrams`], - factor: 0.001, - }); + return mapSimple( + point, + dt, + { + type: "WEIGHT", + unit: "kg", + fieldTag: "weight", + valuePaths: [`${dt.key}.weightGrams`], + factor: 0.001, + }, + tz, + ); } export function mapBodyFat( point: GoogleHealthDataPoint, + tz?: string, ): GoogleHealthMappedMeasurement[] { const dt = GOOGLE_HEALTH_DATA_TYPES.bodyFat; - return mapSimple(point, dt, { - type: "BODY_FAT", - unit: "%", - fieldTag: "body_fat", - valuePaths: [`${dt.key}.percentage`], - }); + return mapSimple( + point, + dt, + { + type: "BODY_FAT", + unit: "%", + fieldTag: "body_fat", + valuePaths: [`${dt.key}.percentage`], + }, + tz, + ); } export function mapOxygenSaturation( point: GoogleHealthDataPoint, + tz?: string, ): GoogleHealthMappedMeasurement[] { const dt = GOOGLE_HEALTH_DATA_TYPES.oxygenSaturation; - return mapSimple(point, dt, { - type: "OXYGEN_SATURATION", - unit: "%", - fieldTag: "spo2", - valuePaths: [`${dt.key}.averagePercentage`], - }); + return mapSimple( + point, + dt, + { + type: "OXYGEN_SATURATION", + unit: "%", + fieldTag: "spo2", + valuePaths: [`${dt.key}.averagePercentage`], + }, + tz, + ); } export function mapHeartRateVariability( point: GoogleHealthDataPoint, + tz?: string, ): GoogleHealthMappedMeasurement[] { const dt = GOOGLE_HEALTH_DATA_TYPES.heartRateVariability; // The daily field is an unlabelled "average HRV ms". Per the design decision @@ -491,72 +504,102 @@ export function mapHeartRateVariability( // (Apple-comparable), NOT WHOOP's `HRV_RMSSD` (reserved for the WHOOP-native // estimator). The per-sample type carries explicit RMSSD + SDNN fields — // re-confirm the estimator against live data and revisit if warranted. - return mapSimple(point, dt, { - type: "HEART_RATE_VARIABILITY", - unit: "ms", - fieldTag: "hrv", - valuePaths: [`${dt.key}.averageHeartRateVariabilityMilliseconds`], - }); + return mapSimple( + point, + dt, + { + type: "HEART_RATE_VARIABILITY", + unit: "ms", + fieldTag: "hrv", + valuePaths: [`${dt.key}.averageHeartRateVariabilityMilliseconds`], + }, + tz, + ); } export function mapRestingHeartRate( point: GoogleHealthDataPoint, + tz?: string, ): GoogleHealthMappedMeasurement[] { const dt = GOOGLE_HEALTH_DATA_TYPES.restingHeartRate; // `beatsPerMinute` is an int64 JSON string — coerced by the extractor. - return mapSimple(point, dt, { - type: "RESTING_HEART_RATE", - unit: "bpm", - fieldTag: "rhr", - valuePaths: [`${dt.key}.beatsPerMinute`], - }); + return mapSimple( + point, + dt, + { + type: "RESTING_HEART_RATE", + unit: "bpm", + fieldTag: "rhr", + valuePaths: [`${dt.key}.beatsPerMinute`], + }, + tz, + ); } export function mapRespiratoryRate( point: GoogleHealthDataPoint, + tz?: string, ): GoogleHealthMappedMeasurement[] { const dt = GOOGLE_HEALTH_DATA_TYPES.respiratoryRate; // Documented schema: `{ date, breathsPerMinute }` — `breathsPerMinute` is a // plain number ("The average number of breaths taken per minute"). The // earlier `dailyRespiratoryRateBpm` leaf does not exist and never parsed. - return mapSimple(point, dt, { - type: "RESPIRATORY_RATE", - unit: "breaths/min", - fieldTag: "resp_rate", - valuePaths: [`${dt.key}.breathsPerMinute`], - }); + return mapSimple( + point, + dt, + { + type: "RESPIRATORY_RATE", + unit: "breaths/min", + fieldTag: "resp_rate", + valuePaths: [`${dt.key}.breathsPerMinute`], + }, + tz, + ); } export function mapBloodGlucose( point: GoogleHealthDataPoint, + tz?: string, ): GoogleHealthMappedMeasurement[] { const dt = GOOGLE_HEALTH_DATA_TYPES.bloodGlucose; // Documented field `bloodGlucoseMilligramsPerDeciliter` (number) — already in // HealthLog's canonical mg/dL storage unit, no conversion. - return mapSimple(point, dt, { - type: "BLOOD_GLUCOSE", - unit: "mg/dL", - fieldTag: "glucose", - valuePaths: [`${dt.key}.bloodGlucoseMilligramsPerDeciliter`], - }); + return mapSimple( + point, + dt, + { + type: "BLOOD_GLUCOSE", + unit: "mg/dL", + fieldTag: "glucose", + valuePaths: [`${dt.key}.bloodGlucoseMilligramsPerDeciliter`], + }, + tz, + ); } export function mapCoreBodyTemperature( point: GoogleHealthDataPoint, + tz?: string, ): GoogleHealthMappedMeasurement[] { const dt = GOOGLE_HEALTH_DATA_TYPES.coreBodyTemperature; // Documented field `temperatureCelsius` (number) → the core BODY_TEMPERATURE // slot (distinct from SKIN_TEMPERATURE / WRIST_TEMPERATURE surface readings). - return mapSimple(point, dt, { - type: "BODY_TEMPERATURE", - unit: "celsius", - fieldTag: "core_temp", - valuePaths: [`${dt.key}.temperatureCelsius`], - }); + return mapSimple( + point, + dt, + { + type: "BODY_TEMPERATURE", + unit: "celsius", + fieldTag: "core_temp", + valuePaths: [`${dt.key}.temperatureCelsius`], + }, + tz, + ); } export function mapWristTemperature( point: GoogleHealthDataPoint, + tz?: string, ): GoogleHealthMappedMeasurement[] { const dt = GOOGLE_HEALTH_DATA_TYPES.sleepTemperature; // `nightlyTemperatureCelsius` is the ABSOLUTE nightly skin temperature ("the @@ -565,25 +608,36 @@ export function mapWristTemperature( // absolute-reading convention. The sibling `baselineTemperatureCelsius` / // `relativeNightlyStddev30dCelsius` derivations are not stored — the user's // own series carries the baseline. - return mapSimple(point, dt, { - type: "WRIST_TEMPERATURE", - unit: "celsius", - fieldTag: "wrist_temp", - valuePaths: [`${dt.key}.nightlyTemperatureCelsius`], - }); + return mapSimple( + point, + dt, + { + type: "WRIST_TEMPERATURE", + unit: "celsius", + fieldTag: "wrist_temp", + valuePaths: [`${dt.key}.nightlyTemperatureCelsius`], + }, + tz, + ); } export function mapHeartRate( point: GoogleHealthDataPoint, + tz?: string, ): GoogleHealthMappedMeasurement[] { const dt = GOOGLE_HEALTH_DATA_TYPES.heartRate; // `beatsPerMinute` is an int64 JSON string. - return mapSimple(point, dt, { - type: "PULSE", - unit: "bpm", - fieldTag: "hr", - valuePaths: [`${dt.key}.beatsPerMinute`], - }); + return mapSimple( + point, + dt, + { + type: "PULSE", + unit: "bpm", + fieldTag: "hr", + valuePaths: [`${dt.key}.beatsPerMinute`], + }, + tz, + ); } /** One extracted `height` sample: centimetres + the sample instant (if any). */ @@ -635,8 +689,8 @@ function firstNonNegativeNumber( /** * Map one `dailyRollUp` aggregate window (`windowSizeDays: 1`) into a single * daily-total Measurement reading. The day key comes from the window's - * `civilStartTime.date` (a `{year,month,day}` object), anchored at UTC-midday - * per the shared civil-day convention; a window with no parseable civil day is + * `civilStartTime.date` (a `{year,month,day}` object), anchored at local noon + * of that day in the user's zone; a window with no parseable civil day is * dropped (it cannot be keyed). The externalId is the `stats:`-prefixed * daily-total shape so a re-fetched day overwrites in place — the same * overwrite contract the Apple-Health `stats::` daily totals @@ -651,21 +705,21 @@ function mapDailyRollup( valuePaths: string[]; factor?: number; }, + tz: string | undefined, ): GoogleHealthMappedMeasurement[] { - const day = - parseCivilDateObject(readPath(point, "civilStartTime.date")) ?? - parseCivilStart(readPath(point, "civilStartTime")); - if (!day) return []; + const dayKey = + parseCivilKey(readPath(point, "civilStartTime.date")) ?? + parseCivilKey(readPath(point, "civilStartTime")); + if (!dayKey) return []; let value = firstNonNegativeNumber(point, spec.valuePaths); if (value === null) return []; if (spec.factor) value = value * spec.factor; - const dayKey = day.toISOString().slice(0, 10); return [ { type: spec.type, value: round2(value), unit: spec.unit, - measuredAt: day, + measuredAt: canonicalDailyTimestamp(dayKey, tz), // `stats::` — the sync layer reads `cumulativeDaily` // to assemble the externalId, matching the Apple-Health daily-total shape. fieldTag: `${spec.fieldTag}:${dayKey}`, @@ -676,55 +730,75 @@ function mapDailyRollup( export function mapSteps( point: GoogleHealthRollupPoint, + tz?: string, ): GoogleHealthMappedMeasurement[] { const dt = GOOGLE_HEALTH_DATA_TYPES.steps; // `countSum` is an int64 JSON string. - return mapDailyRollup(point, { - type: "ACTIVITY_STEPS", - unit: "steps", - fieldTag: "steps", - valuePaths: [`${dt.key}.countSum`], - }); + return mapDailyRollup( + point, + { + type: "ACTIVITY_STEPS", + unit: "steps", + fieldTag: "steps", + valuePaths: [`${dt.key}.countSum`], + }, + tz, + ); } export function mapDistance( point: GoogleHealthRollupPoint, + tz?: string, ): GoogleHealthMappedMeasurement[] { const dt = GOOGLE_HEALTH_DATA_TYPES.distance; // `millimetersSum` is an int64 JSON string → metres. - return mapDailyRollup(point, { - type: "WALKING_RUNNING_DISTANCE", - unit: "m", - fieldTag: "distance", - valuePaths: [`${dt.key}.millimetersSum`], - factor: 0.001, - }); + return mapDailyRollup( + point, + { + type: "WALKING_RUNNING_DISTANCE", + unit: "m", + fieldTag: "distance", + valuePaths: [`${dt.key}.millimetersSum`], + factor: 0.001, + }, + tz, + ); } export function mapActiveEnergy( point: GoogleHealthRollupPoint, + tz?: string, ): GoogleHealthMappedMeasurement[] { const dt = GOOGLE_HEALTH_DATA_TYPES.activeEnergy; // ACTIVE energy only — NOT total-calories (which folds in BMR). - return mapDailyRollup(point, { - type: "ACTIVE_ENERGY_BURNED", - unit: "kcal", - fieldTag: "active_energy", - valuePaths: [`${dt.key}.kcalSum`], - }); + return mapDailyRollup( + point, + { + type: "ACTIVE_ENERGY_BURNED", + unit: "kcal", + fieldTag: "active_energy", + valuePaths: [`${dt.key}.kcalSum`], + }, + tz, + ); } export function mapFloors( point: GoogleHealthRollupPoint, + tz?: string, ): GoogleHealthMappedMeasurement[] { const dt = GOOGLE_HEALTH_DATA_TYPES.floors; // `countSum` is an int64 JSON string. - return mapDailyRollup(point, { - type: "FLIGHTS_CLIMBED", - unit: "flights", - fieldTag: "floors", - valuePaths: [`${dt.key}.countSum`], - }); + return mapDailyRollup( + point, + { + type: "FLIGHTS_CLIMBED", + unit: "flights", + fieldTag: "floors", + valuePaths: [`${dt.key}.countSum`], + }, + tz, + ); } /** @@ -735,11 +809,12 @@ export function mapFloors( */ export function mapVo2Max( point: GoogleHealthDataPoint, + tz?: string, ): GoogleHealthMappedMeasurement[] { const dt = GOOGLE_HEALTH_DATA_TYPES.vo2Max; const value = firstNumber(point, [`${dt.key}.vo2Max`]); if (value === null) return []; - const measuredAt = resolveMeasuredAt(point, dt, new Date()); + const measuredAt = resolveMeasuredAt(point, dt, new Date(), tz); return [ { type: "VO2_MAX", diff --git a/src/lib/google-health/sync-activity.ts b/src/lib/google-health/sync-activity.ts index 8f40c9ca8..80cd1be80 100644 --- a/src/lib/google-health/sync-activity.ts +++ b/src/lib/google-health/sync-activity.ts @@ -49,7 +49,10 @@ import { resolveUserTimezone } from "@/lib/tz/resolver"; /** One mappable activity metric: its data-type encoding + the mapper + a verb. */ interface ActivityResource { dataType: GoogleHealthDataType; - map: (point: Record) => GoogleHealthMappedMeasurement[]; + map: ( + point: Record, + tz: string, + ) => GoogleHealthMappedMeasurement[]; verb: string; } @@ -96,8 +99,8 @@ export async function syncUserActivity( // The dailyRollUp request range is civil and user-local; resolve the user's // stored zone so the range bounds land on the correct civil days rather than - // the process zone's. (The response day-key comes from each window's own - // `civilStartTime.date`, tz-independent.) + // the process zone's. The response day-key comes from each window's own + // `civilStartTime.date`; the row is anchored at local noon of that day. const tz = await resolveUserTimezone(userId); // Cycle-wide watermark snapshotted once by `syncUserGoogleHealth`; undefined @@ -136,7 +139,7 @@ export async function syncUserActivity( const readings: GoogleHealthMeasurementUpsert[] = []; try { for (const point of points) { - for (const m of resource.map(point)) { + for (const m of resource.map(point, tz)) { readings.push({ type: m.type, value: m.value, @@ -180,7 +183,7 @@ export async function syncUserActivity( ); const readings: GoogleHealthMeasurementUpsert[] = []; for (const point of points) { - for (const m of mapVo2Max(point)) { + for (const m of mapVo2Max(point, tz)) { readings.push({ type: m.type, value: m.value, diff --git a/src/lib/google-health/sync-core.ts b/src/lib/google-health/sync-core.ts index d0545ee0d..bef34d971 100644 --- a/src/lib/google-health/sync-core.ts +++ b/src/lib/google-health/sync-core.ts @@ -585,6 +585,8 @@ export async function upsertGoogleHealthMeasurements( r: GoogleHealthMeasurementUpsert; /** Set when a key-format migration re-keys the row in place (see below). */ reKeyTo?: string; + /** The row's instant before this write, when the write moves it. */ + movedFrom?: Date; }> = []; const touched: Array<{ type: MeasurementType; measuredAt: Date }> = []; @@ -607,7 +609,16 @@ export async function upsertGoogleHealthMeasurements( existingRow.unit === r.unit && existingRow.measuredAt.getTime() === r.measuredAt.getTime() && (existingRow.sleepStage ?? null) === (r.sleepStage ?? null); - if (!unchanged) toUpdate.push({ id: existingRow.id, r }); + if (!unchanged) { + toUpdate.push({ + id: existingRow.id, + r, + movedFrom: + existingRow.measuredAt.getTime() !== r.measuredAt.getTime() + ? existingRow.measuredAt + : undefined, + }); + } } else if (!plannedCreateKeys.has(key)) { plannedCreateKeys.add(key); toCreate.push({ @@ -776,7 +787,7 @@ export async function upsertGoogleHealthMeasurements( // `syncVersion`. `deletedAt: null` rides along unconditionally — a no-op on // a live row, a deliberate RESURRECTION on a tombstoned one (Google is the // source of truth for its own rows; see TOMBSTONES RESURRECT above). - for (const { id, r, reKeyTo } of toUpdate) { + for (const { id, r, reKeyTo, movedFrom } of toUpdate) { try { await prisma.measurement.update({ where: { id }, @@ -796,6 +807,14 @@ export async function upsertGoogleHealthMeasurements( type: r.type as MeasurementType, measuredAt: r.measuredAt, }); + // A row that moved (a daily anchor re-cut on the user's local noon) + // leaves its old day too; that day's rollup needs the refold. + if (movedFrom) { + touched.push({ + type: r.type as MeasurementType, + measuredAt: movedFrom, + }); + } imported++; } catch (err) { getEvent()?.addWarning( diff --git a/src/lib/google-health/sync-metrics.ts b/src/lib/google-health/sync-metrics.ts index 85ad5951d..6c204c7ab 100644 --- a/src/lib/google-health/sync-metrics.ts +++ b/src/lib/google-health/sync-metrics.ts @@ -51,11 +51,15 @@ import { } from "./sync-core"; import { getEvent } from "@/lib/logging/context"; import { prisma } from "@/lib/db"; +import { resolveUserTimezone } from "@/lib/tz/resolver"; /** One mappable metric: its data-type encoding + the per-point mapper + a verb. */ interface MetricResource { dataType: GoogleHealthDataType; - map: (point: Record) => GoogleHealthMappedMeasurement[]; + map: ( + point: Record, + tz: string, + ) => GoogleHealthMappedMeasurement[]; verb: string; } @@ -127,6 +131,9 @@ export async function syncUserMetrics( // `syncUserGoogleHealth` — never re-read here, so a sibling resource's stamp // can't shrink this one's window. Undefined on a full/backfill run. const start = opts.start; + // Daily summaries are anchored at local noon of their date, and offset-less + // sample times are read on the user's wall clock. + const tz = await resolveUserTimezone(userId); let imported = 0; @@ -155,7 +162,7 @@ export async function syncUserMetrics( const readings: GoogleHealthMeasurementUpsert[] = []; try { for (const point of points) { - for (const m of resource.map(point)) { + for (const m of resource.map(point, tz)) { readings.push({ type: m.type, value: m.value, diff --git a/src/lib/measurements/consolidation-tz.ts b/src/lib/measurements/consolidation-tz.ts index de34efc92..133cf4f16 100644 --- a/src/lib/measurements/consolidation-tz.ts +++ b/src/lib/measurements/consolidation-tz.ts @@ -70,9 +70,9 @@ export function dayKeyForUserTz(date: Date, tz: string): string { /** * Compute the canonical timestamp for a calendar-day key. With a `tz`, * returns the JS-Date instant at the user's local 12:00 noon; without - * one, returns 12:00 UTC of the day. Either anchor sits a full 12 h - * inside its calendar day, so it round-trips back to the same day - * through `userDayKey()` for every zone within ±12 h — which is the + * one, returns 12:00 UTC of the day. The local-noon anchor sits a full + * 12 h inside its calendar day, so it round-trips back to the same day + * through `userDayKey()` in that zone — which is the * whole point of anchoring date-only daily records at noon rather than * midnight (a UTC-midnight anchor double-shifts the day for west-of-UTC * users on read). Matches the Withings activity sync convention (one @@ -80,10 +80,11 @@ export function dayKeyForUserTz(date: Date, tz: string): string { * between same-day spot samples). The string returned by * `toISOString()` is UTC. * - * The optional-`tz` shape is what the wearable daily mappers (Oura, - * Polar, Fitbit) anchor on — they do not have the user's timezone in - * scope at map time, so they take the noon-UTC fallback, which is - * correct for every zone within ±12 h. + * Every daily row a sync writes (Withings, Fitbit, Google Health, Polar, + * Oura) passes the user's zone. The noon-UTC fallback is NOT day-stable + * everywhere: from UTC+12 on (New Zealand all year, Fiji, Tonga, Samoa) noon + * UTC is already the next local day, which is how those users saw every + * synced daily total a day late up to v1.39.2. */ export function canonicalDailyTimestamp(dateKey: string, tz?: string): Date { // No timezone in scope: 12:00 UTC is a safe day-stable anchor for diff --git a/src/lib/oura/__tests__/client.test.ts b/src/lib/oura/__tests__/client.test.ts index ae2897107..1fead0330 100644 --- a/src/lib/oura/__tests__/client.test.ts +++ b/src/lib/oura/__tests__/client.test.ts @@ -642,3 +642,23 @@ describe("derivePeriodDaysFromCyclePhases", () => { expect(days).toEqual(["2026-06-10"]); }); }); + +describe("date-only records in the user's zone", () => { + it.each([ + ["Pacific/Tongatapu", "2026-05-09T23:00:00.000Z"], + ["Pacific/Auckland", "2026-05-10T00:00:00.000Z"], + ["America/New_York", "2026-05-10T16:00:00.000Z"], + ["Europe/Berlin", "2026-05-10T10:00:00.000Z"], + ])("anchors a date-only record at local noon in %s", (tz, iso) => { + const [spo2] = mapDailySpo2( + { day: "2026-05-10", spo2_percentage: { average: 97 } } as never, + tz, + ); + expect(spo2!.measuredAt.toISOString()).toBe(iso); + const [resilience] = mapResilience( + { day: "2026-05-10", level: "solid" } as never, + tz, + ); + expect(resilience!.measuredAt.toISOString()).toBe(iso); + }); +}); diff --git a/src/lib/oura/client.ts b/src/lib/oura/client.ts index 6758b2901..ede36d3e6 100644 --- a/src/lib/oura/client.ts +++ b/src/lib/oura/client.ts @@ -512,17 +512,20 @@ export interface MappedMeasurement { externalId?: string; } -function dayAnchor(day: string, bedtimeEnd?: string): Date | null { +function dayAnchor( + day: string, + tz: string | undefined, + bedtimeEnd?: string, +): Date | null { // Prefer the precise wake instant when present; else anchor the date-only - // day at noon (canonicalDailyTimestamp), NOT UTC midnight — a midnight - // anchor double-shifts the calendar day for west-of-UTC users when the - // recovery resolver re-buckets via userDayKey(). Noon sits a full 12 h - // inside the day, so it round-trips to the same day for every zone ±12 h. + // day at local noon in the user's zone (canonicalDailyTimestamp), which + // reads the same calendar day there. Noon UTC, the previous anchor, is + // already the next day from UTC+13 on. if (bedtimeEnd) { const d = new Date(bedtimeEnd); return Number.isNaN(d.getTime()) ? null : d; } - const d = canonicalDailyTimestamp(day); + const d = canonicalDailyTimestamp(day, tz); return Number.isNaN(d.getTime()) ? null : d; } @@ -539,8 +542,11 @@ function dayAnchor(day: string, bedtimeEnd?: string): Date | null { * `temperature_trend_deviation` is the long-term trend twin; we keep only the * nightly deviation as the actionable signal. */ -export function mapReadiness(r: OuraReadiness): MappedMeasurement[] { - const measuredAt = dayAnchor(r.day, r.timestamp); +export function mapReadiness( + r: OuraReadiness, + tz?: string, +): MappedMeasurement[] { + const measuredAt = dayAnchor(r.day, tz, r.timestamp); if (!measuredAt) return []; const out: MappedMeasurement[] = []; if (typeof r.score === "number") { @@ -648,8 +654,8 @@ function mapSleepTimeline(s: OuraSleep): MappedMeasurement[] | null { * sleep END. Either way we add efficiency, HRV, RHR (`lowest_heart_rate`), and * respiratory rate. */ -export function mapSleep(s: OuraSleep): MappedMeasurement[] { - const measuredAt = dayAnchor(s.day, s.bedtime_end); +export function mapSleep(s: OuraSleep, tz?: string): MappedMeasurement[] { + const measuredAt = dayAnchor(s.day, tz, s.bedtime_end); if (!measuredAt) return []; const out: MappedMeasurement[] = []; @@ -729,9 +735,12 @@ export function mapSleep(s: OuraSleep): MappedMeasurement[] { } /** Map one Oura daily-sleep record: the headline Sleep Score → `SLEEP_SCORE`. */ -export function mapDailySleep(d: OuraDailySleep): MappedMeasurement[] { +export function mapDailySleep( + d: OuraDailySleep, + tz?: string, +): MappedMeasurement[] { if (typeof d.score !== "number") return []; - const measuredAt = dayAnchor(d.day, d.timestamp); + const measuredAt = dayAnchor(d.day, tz, d.timestamp); if (!measuredAt) return []; return [ { @@ -745,12 +754,15 @@ export function mapDailySleep(d: OuraDailySleep): MappedMeasurement[] { } /** Map one Oura daily-spo2 record: average overnight SpO2 → `OXYGEN_SATURATION`. */ -export function mapDailySpo2(s: OuraDailySpo2): MappedMeasurement[] { +export function mapDailySpo2( + s: OuraDailySpo2, + tz?: string, +): MappedMeasurement[] { const avg = s.spo2_percentage?.average; if (typeof avg !== "number" || avg <= 0) return []; - // Anchor SpO2 at the day's UTC midnight (no per-record instant in the + // Anchor SpO2 at the day's local noon (no per-record instant in the // daily_spo2 collection); the recovery / rollup readers key off the local day. - const measuredAt = dayAnchor(s.day); + const measuredAt = dayAnchor(s.day, tz); if (!measuredAt) return []; return [ { @@ -765,8 +777,11 @@ export function mapDailySpo2(s: OuraDailySpo2): MappedMeasurement[] { /** Map one Oura daily-activity record: steps, active energy, and the * equivalent walking distance (B5 — the OURA distance ladder slot was dead). */ -export function mapDailyActivity(a: OuraDailyActivity): MappedMeasurement[] { - const measuredAt = dayAnchor(a.day, a.timestamp); +export function mapDailyActivity( + a: OuraDailyActivity, + tz?: string, +): MappedMeasurement[] { + const measuredAt = dayAnchor(a.day, tz, a.timestamp); if (!measuredAt) return []; const out: MappedMeasurement[] = []; if (typeof a.steps === "number" && a.steps >= 0) { @@ -809,9 +824,9 @@ export function mapDailyActivity(a: OuraDailyActivity): MappedMeasurement[] { * `VO2_MAX` (mL/(kg·min), the canonical DB unit — no conversion). Skips a * record with no positive value. */ -export function mapVo2Max(v: OuraVo2Max): MappedMeasurement[] { +export function mapVo2Max(v: OuraVo2Max, tz?: string): MappedMeasurement[] { if (typeof v.vo2_max !== "number" || v.vo2_max <= 0) return []; - const measuredAt = dayAnchor(v.day, v.timestamp); + const measuredAt = dayAnchor(v.day, tz, v.timestamp); if (!measuredAt) return []; return [ { @@ -832,9 +847,10 @@ export function mapVo2Max(v: OuraVo2Max): MappedMeasurement[] { */ export function mapCardiovascularAge( c: OuraCardiovascularAge, + tz?: string, ): MappedMeasurement[] { if (typeof c.vascular_age !== "number" || c.vascular_age <= 0) return []; - const measuredAt = dayAnchor(c.day); + const measuredAt = dayAnchor(c.day, tz); if (!measuredAt) return []; return [ { @@ -870,14 +886,17 @@ export const RESILIENCE_UNIT = "level" as const; * (limited=1 … exceptional=5) into the numeric `value`. An unknown / missing * level string mints NO row (skipped, never coerced to 0) so a future Oura band * we do not recognise never lands as a misleading reading. Anchored at the - * day's UTC midnight (the collection carries no per-record instant). + * day's local noon (the collection carries no per-record instant). */ -export function mapResilience(r: OuraResilience): MappedMeasurement[] { +export function mapResilience( + r: OuraResilience, + tz?: string, +): MappedMeasurement[] { const level = typeof r.level === "string" ? r.level.toLowerCase() : null; if (!level) return []; const ordinal = RESILIENCE_LEVELS[level]; if (typeof ordinal !== "number") return []; - const measuredAt = dayAnchor(r.day); + const measuredAt = dayAnchor(r.day, tz); if (!measuredAt) return []; return [ { diff --git a/src/lib/oura/sync.ts b/src/lib/oura/sync.ts index 8fb9bae01..b545206b0 100644 --- a/src/lib/oura/sync.ts +++ b/src/lib/oura/sync.ts @@ -90,6 +90,8 @@ import { } from "./credentials"; import { OuraApiError, classifyOuraError } from "./response-classifier"; import { syncUserOuraCyclePhases } from "./cycle-sync"; +import { shiftDateKey, userDayKey } from "@/lib/tz/format"; +import { resolveUserTimezone } from "@/lib/tz/resolver"; /** Floor of the lookback window (days) for an incremental sync. Oura finalises * a night's scores hours after wake; 7 days re-fetches a handful of records @@ -194,6 +196,7 @@ export interface OuraMeasurementUpsert { function toUpsert( mapped: MappedMeasurement[], resourcePrefix: string, + dateOnlyDay?: string, ): OuraMeasurementUpsert[] { return mapped.map((m) => ({ type: m.type, @@ -202,9 +205,13 @@ function toUpsert( measuredAt: m.measuredAt, // A mapper that needs a record-scoped key (sleep rows — per-segment timeline // + nightly scalars) carries its own externalId; everything else falls back - // to the day-keyed `::` shape. + // to the day-keyed `::` shape. A record with no + // instant of its own (`dateOnlyDay`) keys on its date: its row sits at + // local noon in the user's zone, which east of UTC+12 is the previous UTC + // date. A timed record keys on its instant's UTC date, as it always has. externalId: - m.externalId ?? `${resourcePrefix}:${ymd(m.measuredAt)}:${m.fieldTag}`, + m.externalId ?? + `${resourcePrefix}:${dateOnlyDay ?? ymd(m.measuredAt)}:${m.fieldTag}`, sleepStage: m.sleepStage ?? null, })); } @@ -260,9 +267,15 @@ async function fetchAll( refreshTokenCiphertext: string, lookbackDays: number, ): Promise { - const now = new Date(); - const start = new Date(now.getTime() - lookbackDays * 24 * 60 * 60 * 1000); - const query = { startDate: ymd(start), endDate: ymd(now) }; + // Oura keys its daily collections by the user's calendar date, so the + // window is cut on the user's own days, and date-only records are anchored + // at local noon of their date in this zone. + const tz = await resolveUserTimezone(userId); + const today = userDayKey(new Date(), tz); + const query = { + startDate: shiftDateKey(today, -lookbackDays), + endDate: today, + }; // Filled by the sleep collection's closure as it maps records; carried on // the result so the caller can run the record-scoped sweep before the @@ -275,7 +288,11 @@ async function fetchAll( name: "readiness", collect: async (t) => (await fetchReadiness(t, query)).flatMap((r) => - toUpsert(mapReadiness(r), "readiness"), + toUpsert( + mapReadiness(r, tz), + "readiness", + r.timestamp ? undefined : r.day, + ), ), }, { @@ -284,7 +301,11 @@ async function fetchAll( const records = await fetchSleep(t, query); const ups: OuraMeasurementUpsert[] = []; for (const s of records) { - const rows = toUpsert(mapSleep(s), "sleep"); + const rows = toUpsert( + mapSleep(s, tz), + "sleep", + s.bedtime_end ? undefined : s.day, + ); ups.push(...rows); // Record-scoped sweep entry: every SLEEP_DURATION row of this // record keys under `sleep::` (hypnogram runs AND the @@ -306,42 +327,50 @@ async function fetchAll( name: "activity", collect: async (t) => (await fetchDailyActivity(t, query)).flatMap((a) => - toUpsert(mapDailyActivity(a), "activity"), + toUpsert( + mapDailyActivity(a, tz), + "activity", + a.timestamp ? undefined : a.day, + ), ), }, { name: "daily_sleep", collect: async (t) => (await fetchDailySleep(t, query)).flatMap((d) => - toUpsert(mapDailySleep(d), "daily_sleep"), + toUpsert( + mapDailySleep(d, tz), + "daily_sleep", + d.timestamp ? undefined : d.day, + ), ), }, { name: "spo2", collect: async (t) => (await fetchDailySpo2(t, query)).flatMap((s) => - toUpsert(mapDailySpo2(s), "spo2"), + toUpsert(mapDailySpo2(s, tz), "spo2", s.day), ), }, { name: "vo2max", collect: async (t) => (await fetchVo2Max(t, query)).flatMap((v) => - toUpsert(mapVo2Max(v), "vo2max"), + toUpsert(mapVo2Max(v, tz), "vo2max", v.timestamp ? undefined : v.day), ), }, { name: "cardio_age", collect: async (t) => (await fetchCardiovascularAge(t, query)).flatMap((c) => - toUpsert(mapCardiovascularAge(c), "cardio_age"), + toUpsert(mapCardiovascularAge(c, tz), "cardio_age", c.day), ), }, { name: "resilience", collect: async (t) => (await fetchResilience(t, query)).flatMap((r) => - toUpsert(mapResilience(r), "resilience"), + toUpsert(mapResilience(r, tz), "resilience", r.day), ), }, ]; diff --git a/src/lib/polar/__tests__/client.test.ts b/src/lib/polar/__tests__/client.test.ts index a92276f8f..97f3c0702 100644 --- a/src/lib/polar/__tests__/client.test.ts +++ b/src/lib/polar/__tests__/client.test.ts @@ -609,3 +609,28 @@ describe("POLAR_OAUTH_SCOPE", () => { expect(POLAR_OAUTH_SCOPE).toBe("accesslink.read_all"); }); }); + +describe("date-keyed rows in the user's zone", () => { + it.each([ + ["Pacific/Tongatapu", "2026-05-09T23:00:00.000Z"], + ["Pacific/Auckland", "2026-05-10T00:00:00.000Z"], + ["America/New_York", "2026-05-10T16:00:00.000Z"], + ["Europe/Berlin", "2026-05-10T10:00:00.000Z"], + ])( + "anchors a day's activity at local noon in %s and names the day", + (tz, iso) => { + const rows = mapActivity( + { start_time: "2026-05-10T00:00:00", steps: 9000 } as never, + tz, + ); + expect(rows[0]!.measuredAt.toISOString()).toBe(iso); + expect(rows[0]!.day).toBe("2026-05-10"); + const [load] = mapCardioLoad( + { date: "2026-05-10", cardio_load: 12 } as never, + tz, + ); + expect(load!.measuredAt.toISOString()).toBe(iso); + expect(load!.day).toBe("2026-05-10"); + }, + ); +}); diff --git a/src/lib/polar/client.ts b/src/lib/polar/client.ts index 4a6092a06..9739329ce 100644 --- a/src/lib/polar/client.ts +++ b/src/lib/polar/client.ts @@ -422,6 +422,13 @@ export interface MappedMeasurement { * sync layer uses it verbatim. */ externalId?: string; + /** + * The calendar date a date-keyed row belongs to (`YYYY-MM-DD`). The sync + * layer keys the externalId on it rather than on `measuredAt`, which sits at + * the date's local noon in the user's zone and so can fall on a different + * UTC date. + */ + day?: string; /** * `true` on per-segment sleep rows whose ORDER is synthesised. When Polar * gives only per-stage duration totals (no per-stage onset timestamps), the @@ -444,13 +451,14 @@ function rechargeStatusToScore(status: number): number { * Map one Polar Nightly Recharge record. The recovery band → `RECOVERY_SCORE` * (source POLAR, distinct from the COMPUTED proxy); HRV → `HRV_RMSSD`; the * nightly average HR → `RESTING_HEART_RATE`; breathing rate → `RESPIRATORY_RATE`. - * `measuredAt` is the record's date anchored at noon (the wake - * morning the night belongs to). + * `measuredAt` is the record's date anchored at local noon in the user's + * zone (the wake morning the night belongs to). */ export function mapNightlyRecharge( r: PolarNightlyRecharge, + tz?: string, ): MappedMeasurement[] { - const measuredAt = canonicalDailyTimestamp(r.date); + const measuredAt = canonicalDailyTimestamp(r.date, tz); if (Number.isNaN(measuredAt.getTime())) return []; const out: MappedMeasurement[] = []; @@ -507,7 +515,7 @@ export function mapNightlyRecharge( fieldTag: "ans_charge", }); } - return out; + return out.map((m) => ({ ...m, day: r.date })); } /** @@ -534,7 +542,7 @@ export function mapNightlyRecharge( * fall back to a midnight-UTC anchor and emit untimed stage rows — degraded but * never wrong-day for the common UTC-positive case, matching the pre-fix shape. */ -export function mapSleep(s: PolarSleep): MappedMeasurement[] { +export function mapSleep(s: PolarSleep, tz?: string): MappedMeasurement[] { const out: MappedMeasurement[] = []; const startMs = s.sleep_start_time ? Date.parse(s.sleep_start_time) : NaN; @@ -599,10 +607,10 @@ export function mapSleep(s: PolarSleep): MappedMeasurement[] { return out; } - // Fallback: no usable window. Anchor at noon of the wake date and emit - // untimed stage rows (the pre-v1.17.1 shape) so a record missing the window - // fields still contributes a night total. - const measuredAt = canonicalDailyTimestamp(s.date); + // Fallback: no usable window. Anchor at local noon of the wake date and + // emit untimed stage rows (the pre-v1.17.1 shape) so a record missing the + // window fields still contributes a night total. + const measuredAt = canonicalDailyTimestamp(s.date, tz); if (Number.isNaN(measuredAt.getTime())) return []; for (const [sec, stage, fieldTag] of stages) { if (typeof sec === "number" && sec >= 0) { @@ -625,15 +633,19 @@ export function mapSleep(s: PolarSleep): MappedMeasurement[] { fieldTag: "sleep_score", }); } - return out; + return out.map((m) => ({ ...m, day: s.date })); } /** Map one current AccessLink daily-activity record: `steps` + * `active_calories` where Polar reports them. The active energy portion is * used instead of total `calories`, which includes BMR. */ -export function mapActivity(a: PolarActivity): MappedMeasurement[] { +export function mapActivity( + a: PolarActivity, + tz?: string, +): MappedMeasurement[] { if (typeof a.start_time !== "string") return []; - const measuredAt = canonicalDailyTimestamp(a.start_time.slice(0, 10)); + const day = a.start_time.slice(0, 10); + const measuredAt = canonicalDailyTimestamp(day, tz); if (Number.isNaN(measuredAt.getTime())) return []; const out: MappedMeasurement[] = []; const steps = a.steps; @@ -669,14 +681,17 @@ export function mapActivity(a: PolarActivity): MappedMeasurement[] { fieldTag: "distance", }); } - return out; + return out.map((m) => ({ ...m, day })); } /** Map one Polar Training Load Pro record: `cardio_load` → `CARDIO_LOAD` * (Polar's device-native cardiovascular-strain figure). `0` is a valid load, so * guard on `number` only. */ -export function mapCardioLoad(c: PolarCardioLoad): MappedMeasurement[] { - const measuredAt = canonicalDailyTimestamp(c.date); +export function mapCardioLoad( + c: PolarCardioLoad, + tz?: string, +): MappedMeasurement[] { + const measuredAt = canonicalDailyTimestamp(c.date, tz); if (Number.isNaN(measuredAt.getTime())) return []; if (typeof c.cardio_load !== "number" || c.cardio_load < 0) return []; return [ @@ -686,6 +701,7 @@ export function mapCardioLoad(c: PolarCardioLoad): MappedMeasurement[] { unit: "score", measuredAt, fieldTag: "cardio_load", + day: c.date, }, ]; } diff --git a/src/lib/polar/sync.ts b/src/lib/polar/sync.ts index f249f3288..db71ff320 100644 --- a/src/lib/polar/sync.ts +++ b/src/lib/polar/sync.ts @@ -81,6 +81,7 @@ import { } from "@/lib/sleep/sweep-stale-segments"; import { getPolarConnection } from "./credentials"; import { PolarApiError, classifyPolarError } from "./response-classifier"; +import { resolveUserTimezone } from "@/lib/tz/resolver"; /** Map a Polar error onto the shared integration-ledger failure kind. */ export function classifyPolarFailure(err: unknown): FailureKind { @@ -134,12 +135,12 @@ function toUpsert( // Reconstructed sleep segments supply their own indexed externalId so the // several rows of one night stay distinct; everything else keys on // `::` — stable across re-syncs of the same day. - // The date is read off `measuredAt` for untimed rows (midnight-UTC - // anchored), so a mapper-supplied externalId is honoured verbatim to avoid - // the timed sleep instants drifting the date slice. + // A date-keyed row names its date (`day`): its instant is local noon in + // the user's zone, which east of UTC+12 falls on the previous UTC date. + // A timed row keys on its instant's UTC date, as it always has. externalId: m.externalId ?? - `${resourcePrefix}:${m.measuredAt.toISOString().slice(0, 10)}:${m.fieldTag}`, + `${resourcePrefix}:${m.day ?? m.measuredAt.toISOString().slice(0, 10)}:${m.fieldTag}`, sleepStage: m.sleepStage ?? null, })); } @@ -178,6 +179,8 @@ export interface PolarCollectionFailure { export async function syncUserPolar(userId: string): Promise { const conn = await getPolarConnection(userId); if (!conn) return { imported: 0, failed: false }; + // Date-keyed rows are anchored at local noon of their date in this zone. + const tz = await resolveUserTimezone(userId); // Filled by the sleep collection's closure as it maps records; carried so the // caller can run the record-scoped sweep before the upsert. A collection that @@ -190,7 +193,7 @@ export async function syncUserPolar(userId: string): Promise { name: "recharge", collect: async (t) => (await fetchNightlyRecharges(t)).flatMap((r) => - toUpsert(mapNightlyRecharge(r), "recharge"), + toUpsert(mapNightlyRecharge(r, tz), "recharge"), ), }, { @@ -199,7 +202,7 @@ export async function syncUserPolar(userId: string): Promise { const records = await fetchSleeps(t); const rowsOut: PolarMeasurementUpsert[] = []; for (const s of records) { - const rows = toUpsert(mapSleep(s), "sleep"); + const rows = toUpsert(mapSleep(s, tz), "sleep"); rowsOut.push(...rows); // Night-scoped sweep entry: the reconstructed segments of this date // all key under `sleep::seg:` (mapper-supplied). Any live row @@ -223,14 +226,14 @@ export async function syncUserPolar(userId: string): Promise { name: "activity", collect: async (t) => (await fetchActivities(t)).flatMap((a) => - toUpsert(mapActivity(a), "activity"), + toUpsert(mapActivity(a, tz), "activity"), ), }, { name: "cardioload", collect: async (t) => (await fetchCardioLoads(t)).flatMap((c) => - toUpsert(mapCardioLoad(c), "cardioload"), + toUpsert(mapCardioLoad(c, tz), "cardioload"), ), }, { diff --git a/src/lib/withings/__tests__/sync-activity.test.ts b/src/lib/withings/__tests__/sync-activity.test.ts index 8c1ba17fe..bd8c9db39 100644 --- a/src/lib/withings/__tests__/sync-activity.test.ts +++ b/src/lib/withings/__tests__/sync-activity.test.ts @@ -32,6 +32,10 @@ vi.mock("@/lib/db", () => { }; }); +vi.mock("@/lib/tz/resolver", () => ({ + resolveUserTimezone: vi.fn(), +})); + vi.mock("@/lib/integrations/status", () => ({ isReauthRequired: vi.fn().mockResolvedValue(false), parkIntegrationAtReauth: vi.fn(), @@ -78,6 +82,7 @@ import { recordSyncSuccess, } from "@/lib/integrations/status"; import { recomputeBucketsForMeasurement } from "@/lib/rollups/measurement-rollups"; +import { resolveUserTimezone } from "@/lib/tz/resolver"; import { fetchWithingsActivity, syncUserActivity } from "../sync-activity"; @@ -114,6 +119,7 @@ beforeEach(() => { vi.mocked(prisma.withingsConnection.findUnique).mockResolvedValue({ scope: "user.metrics,user.activity", } as never); + vi.mocked(resolveUserTimezone).mockResolvedValue("UTC"); }); afterEach(() => { @@ -253,6 +259,7 @@ describe("syncUserActivity — field mapping + idempotency", () => { id: "row-steps", type: "ACTIVITY_STEPS", measuredAt, + externalId: "withings:activity:user-1:2026-05-12:steps", value: 8420, deletedAt: null, }, @@ -296,38 +303,89 @@ describe("syncUserActivity — field mapping + idempotency", () => { expect(arg.data.deletedAt).toBeNull(); }); - it("anchors measuredAt at noon UTC so the instant lands inside the local day for every supported tz", async () => { - installFetchMock([{ date: "2026-05-12", steps: 1000 }]); - vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never); - vi.mocked(prisma.measurement.createMany).mockResolvedValue({ - count: 1, - } as never); + it.each([ + ["UTC", "2026-05-12T12:00:00.000Z"], + ["America/New_York", "2026-05-12T16:00:00.000Z"], + ["Europe/Berlin", "2026-05-12T10:00:00.000Z"], + ["Pacific/Auckland", "2026-05-12T00:00:00.000Z"], + ["Pacific/Tongatapu", "2026-05-11T23:00:00.000Z"], + ])( + "anchors a %s day at local noon, which reads the same calendar day there", + async (tz, expected) => { + vi.mocked(resolveUserTimezone).mockResolvedValue(tz); + installFetchMock([{ date: "2026-05-12", steps: 1000 }]); + vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.measurement.createMany).mockResolvedValue({ + count: 1, + } as never); + + await syncUserActivity("user-1"); + + const measuredAt = createManyRows()[0].measuredAt as Date; + expect(measuredAt.toISOString()).toBe(expected); + expect( + new Intl.DateTimeFormat("en-CA", { + timeZone: tz, + year: "numeric", + month: "2-digit", + day: "2-digit", + }).format(measuredAt), + ).toBe("2026-05-12"); + }, + ); + + it("moves a row stored at the old noon-UTC anchor to local noon instead of adding a second one", async () => { + // Noon UTC is already 01:00 the next day in Tonga, so the old anchor + // counted 12 May's steps on 13 May there. + vi.mocked(resolveUserTimezone).mockResolvedValue("Pacific/Tongatapu"); + installFetchMock([{ date: "2026-05-12", steps: 8420 }]); + const legacy = new Date("2026-05-12T12:00:00.000Z"); + vi.mocked(prisma.measurement.findMany).mockResolvedValue([ + { + id: "row-steps", + type: "ACTIVITY_STEPS", + measuredAt: legacy, + externalId: "withings:activity:user-1:2026-05-12:steps", + value: 8420, + deletedAt: null, + }, + ] as never); + vi.mocked(prisma.measurement.update).mockResolvedValue({} as never); await syncUserActivity("user-1"); - const measuredAt = createManyRows()[0].measuredAt as Date; - expect(measuredAt.toISOString()).toBe("2026-05-12T12:00:00.000Z"); + expect(prisma.measurement.createMany).not.toHaveBeenCalled(); + expect(prisma.measurement.update).toHaveBeenCalledTimes(1); + const arg = vi.mocked(prisma.measurement.update).mock.calls[0][0] as { + data: { measuredAt: Date }; + }; + expect(arg.data.measuredAt.toISOString()).toBe("2026-05-11T23:00:00.000Z"); + // Both the day the row left and the day it joined are refolded. + const refolded = vi + .mocked(recomputeBucketsForMeasurement) + .mock.calls.map((c) => (c[2] as Date).toISOString().slice(0, 10)); + expect(refolded).toEqual( + expect.arrayContaining(["2026-05-11", "2026-05-12"]), + ); + }); - // Regression: anchoring at noon UTC keeps the row inside the - // calendar day every user reads it in. Bucketing the same instant - // via `Intl.DateTimeFormat` across the canonical Withings user - // span — Honolulu (UTC-10), Los Angeles (UTC-7/-8), Berlin - // (UTC+1/+2) and Tokyo (UTC+9) — must all return "2026-05-12". - // Anchoring at end-of-day UTC (the v1.4.25 W17b shape) shifted - // Tokyo by +1 day; anchoring at midnight UTC would shift LA by -1. - // Noon UTC is the only choice that holds across the practical - // [-11, +12) range. - const bucket = (tz: string) => - new Intl.DateTimeFormat("en-CA", { - timeZone: tz, - year: "numeric", - month: "2-digit", - day: "2-digit", - }).format(measuredAt); - expect(bucket("Pacific/Honolulu")).toBe("2026-05-12"); - expect(bucket("America/Los_Angeles")).toBe("2026-05-12"); - expect(bucket("Europe/Berlin")).toBe("2026-05-12"); - expect(bucket("Asia/Tokyo")).toBe("2026-05-12"); + it("fetches through today's date in the user's zone", async () => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date("2026-05-12T20:00:00.000Z")); + try { + vi.mocked(resolveUserTimezone).mockResolvedValue("Pacific/Auckland"); + const fetchMock = installFetchMock([]); + await syncUserActivity("user-1"); + const [, init] = fetchMock.mock.calls[0] as unknown as [ + string, + { body: unknown }, + ]; + const body = String(init.body); + // 08:00 on 13 May in Auckland: the window ends on the 13th. + expect(body).toContain("enddateymd=2026-05-13"); + } finally { + vi.useRealTimers(); + } }); it("skips missing fields without writing zero rows or throwing", async () => { @@ -440,10 +498,18 @@ describe("syncUserActivity — field mapping + idempotency", () => { // The single read is keyed by the full distinct (type, measuredAt) // sets the batch will write — `skipDuplicates` guards the insert. const readArg = vi.mocked(prisma.measurement.findMany).mock.calls[0][0] as { - where: { type: { in: unknown[] }; measuredAt: { in: unknown[] } }; + where: { + type: { in: unknown[] }; + OR: [ + { externalId: { in: unknown[] } }, + { measuredAt: { in: unknown[] } }, + ]; + }; }; expect(readArg.where.type.in).toHaveLength(3); - expect(readArg.where.measuredAt.in).toHaveLength(5); + expect(readArg.where.OR[0].externalId.in).toHaveLength(15); + // UTC: the local-noon and the old noon-UTC anchor coincide. + expect(readArg.where.OR[1].measuredAt.in).toHaveLength(5); const createArg = vi.mocked(prisma.measurement.createMany).mock .calls[0][0] as { skipDuplicates: boolean }; expect(createArg.skipDuplicates).toBe(true); diff --git a/src/lib/withings/sync-activity.ts b/src/lib/withings/sync-activity.ts index a4a0098b2..18ca9c4b5 100644 --- a/src/lib/withings/sync-activity.ts +++ b/src/lib/withings/sync-activity.ts @@ -36,6 +36,9 @@ import type { MeasurementType } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; +import { canonicalDailyTimestamp } from "@/lib/measurements/consolidation-tz"; +import { shiftDateKey, userDayKey } from "@/lib/tz/format"; +import { resolveUserTimezone } from "@/lib/tz/resolver"; import { getEvent } from "@/lib/logging/context"; import { safeFetch } from "@/lib/safe-fetch"; import { getUnitForType } from "@/lib/validations/measurement"; @@ -112,16 +115,11 @@ const ACTIVITY_FIELD_MAP: ReadonlyArray<{ ]; /** - * Anchor a per-day activity row at noon UTC. Chosen so the instant - * always lands inside the local day for users in the [-11, +12] zone - * range — anchoring at end-of-day UTC (the v1.4.25 W17b shape) sent - * Tokyo readings into the *following* local day, mis-bucketing every - * positive-offset user's "today" tile. Noon UTC is the standard - * "calendar-day with no clock" representation per RFC 3339 §5.6 and - * the JS `Date` analytics layer day-keys it cleanly in every - * supported user timezone. + * Where the per-day activity rows sat up to v1.39.2: noon UTC of the date. + * Still read, so a row written under that anchor is found and moved to the + * local-noon anchor instead of being duplicated. */ -function activityMeasuredAt(yyyymmdd: string): Date { +function legacyActivityMeasuredAt(yyyymmdd: string): Date { return new Date(`${yyyymmdd}T12:00:00.000Z`); } @@ -268,18 +266,18 @@ export async function syncUserActivity( // The webhook-driven path narrows naturally to today/yesterday; // the cron fallback walks the full window. void opts.fullSync; // reserved for future expansion - const now = new Date(); - const start = new Date( - now.getTime() - ACTIVITY_BACKFILL_DAYS * 24 * 60 * 60 * 1000, - ); - const ymd = (d: Date) => d.toISOString().slice(0, 10); + // Withings activity is keyed by the user's calendar date, so the window + // is cut on the user's own days: a UTC cut left "today" out of the fetch + // until UTC caught up with a zone ahead of it. + const timeZone = await resolveUserTimezone(userId); + const today = userDayKey(new Date(), timeZone); let entries: WithingsActivityEntry[]; try { entries = await fetchWithingsActivity( tokenInfo.accessToken, - ymd(start), - ymd(now), + shiftDateKey(today, -ACTIVITY_BACKFILL_DAYS), + today, ); } catch (err) { // v1.4.43 W7-B3 — typed-classification path. The thrown @@ -340,13 +338,19 @@ export async function syncUserActivity( interface PlannedRow { type: MeasurementType; measuredAt: Date; + /** The pre-v1.39.3 noon-UTC anchor of the same date. */ + legacyMeasuredAt: Date; value: number; externalId: string; } const collected: PlannedRow[] = []; for (const entry of entries) { if (!entry.date) continue; - const measuredAt = activityMeasuredAt(entry.date); + // Local noon of the date in the user's zone: the instant reads the same + // calendar day there. Noon UTC read as the next day from UTC+13 on + // (Auckland in summer, Tonga, Samoa), so every daily total landed a day + // late for those users. + const measuredAt = canonicalDailyTimestamp(entry.date, timeZone); for (const { field, type } of ACTIVITY_FIELD_MAP) { const raw = entry[field]; // Withings returns 0 for "active but no movement" — that's @@ -356,6 +360,7 @@ export async function syncUserActivity( collected.push({ type, measuredAt, + legacyMeasuredAt: legacyActivityMeasuredAt(entry.date), value: raw, externalId: `withings:activity:${userId}:${entry.date}:${field}`, }); @@ -377,13 +382,11 @@ export async function syncUserActivity( try { // One read for the whole batch: every existing WITHINGS / non-sleep - // row that occupies a slot we intend to write. Keyed by the same - // four columns the per-row `findFirst` matched on, so the - // overwrite-vs-insert decision is identical — just resolved in - // memory instead of N round-trips. - const distinctMeasuredAt = [ - ...new Map(planned.map((p) => [p.measuredAt.getTime(), p.measuredAt])), - ].map(([, d]) => d); + // row for a planned day, found by its stable `externalId` or, for a + // row written before it carried one, by the day's old noon-UTC slot. + // A match is rewritten in place, including its `measuredAt`, so a row + // written under the old anchor moves to local noon on the next sync + // rather than being duplicated beside it. const distinctTypes = [...new Set(planned.map((p) => p.type))]; const existingRows = await prisma.measurement.findMany({ where: { @@ -391,40 +394,65 @@ export async function syncUserActivity( source: "WITHINGS", sleepStage: null, type: { in: distinctTypes }, - measuredAt: { in: distinctMeasuredAt }, + OR: [ + { externalId: { in: planned.map((p) => p.externalId) } }, + { + measuredAt: { + in: [ + ...new Map( + planned.flatMap((p) => [ + [p.measuredAt.getTime(), p.measuredAt], + [p.legacyMeasuredAt.getTime(), p.legacyMeasuredAt], + ]), + ).values(), + ], + }, + }, + ], }, select: { id: true, type: true, measuredAt: true, + externalId: true, value: true, deletedAt: true, }, }); - const existingBySlot = new Map< - string, - { id: string; value: number; deletedAt: Date | null } - >(); + type Existing = (typeof existingRows)[number]; + const byExternalId = new Map(); + const bySlot = new Map(); for (const row of existingRows) { - existingBySlot.set(slotKey(row.type, row.measuredAt), { - id: row.id, - value: row.value, - deletedAt: row.deletedAt, - }); + if (row.externalId) byExternalId.set(row.externalId, row); + bySlot.set(slotKey(row.type, row.measuredAt), row); } const toCreate: PlannedRow[] = []; - const toUpdate: Array<{ id: string; value: number }> = []; + const toUpdate: Array<{ id: string; p: PlannedRow }> = []; for (const p of planned) { - const existing = existingBySlot.get(slotKey(p.type, p.measuredAt)); + const existing = + byExternalId.get(p.externalId) ?? + bySlot.get(slotKey(p.type, p.measuredAt)) ?? + bySlot.get(slotKey(p.type, p.legacyMeasuredAt)); if (existing) { - // Only write when the value actually moved — a re-sync of an - // unchanged LIVE day is then a pure no-op on the write path. A - // TOMBSTONED row always writes: the update is what resurrects it - // (`deletedAt: null`) — Withings is the source of truth for its - // own rows (mirrors Google / Fitbit). - if (existing.value !== p.value || existing.deletedAt !== null) { - toUpdate.push({ id: existing.id, value: p.value }); + // Only write when something moved — a re-sync of an unchanged LIVE + // day is then a pure no-op on the write path. A TOMBSTONED row + // always writes: the update is what resurrects it (`deletedAt: + // null`) — Withings is the source of truth for its own rows + // (mirrors Google / Fitbit). + const moved = + existing.measuredAt.getTime() !== p.measuredAt.getTime(); + if ( + existing.value !== p.value || + existing.deletedAt !== null || + moved || + existing.externalId !== p.externalId + ) { + toUpdate.push({ id: existing.id, p }); + } + // The day the row leaves needs its rollup refolded too. + if (moved) { + touched.push({ type: p.type, measuredAt: existing.measuredAt }); } } else { toCreate.push(p); @@ -461,7 +489,12 @@ export async function syncUserActivity( where: { id: u.id }, // `deletedAt: null` — no-op on a live row, deliberate // RESURRECTION on a tombstoned one (see the skip above). - data: { value: u.value, deletedAt: null }, + data: { + value: u.p.value, + measuredAt: u.p.measuredAt, + externalId: u.p.externalId, + deletedAt: null, + }, }), ), ); From b2bb81cac3c8a1f60b93112d0f3a1f8562c5de33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 26 Sep 2026 15:59:07 +0200 Subject: [PATCH 09/15] fix(apple-health): keep one source per device across an OS update in the export import The export importer keeps the largest source subtotal per day for step-like totals, and it told sources apart by name, version and device. On the day a phone or watch updates, its records carry two versions, so the one device became two sources and the day kept only the larger half. The identity is now the source and its device without any version. --- .../import-apple-health-export.test.ts | 26 +++++++++++++++++++ .../import-apple-health-export.ts | 19 +++++++++++--- 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/src/lib/measurements/__tests__/import-apple-health-export.test.ts b/src/lib/measurements/__tests__/import-apple-health-export.test.ts index 17aa4b3c4..e5f346f5b 100644 --- a/src/lib/measurements/__tests__/import-apple-health-export.test.ts +++ b/src/lib/measurements/__tests__/import-apple-health-export.test.ts @@ -722,6 +722,32 @@ describe("streamParseExportXml — cumulative source-day estimates", () => { }); }, ); + it("counts a device's whole day across an OS update", async () => { + // The phone updated mid-morning: its records carry two sourceVersion + // values and two software fields. They are still one source, so the day + // is 6,000 + 3,000, not the larger half. + const { prisma } = await importCumulativeFixture(` + + + + +`); + + expect(prisma._measurements).toHaveLength(1); + expect(prisma._measurements[0]).toMatchObject({ value: 9_000 }); + }); + it("folds unattributed cumulative records into one bounded source bucket", async () => { const { prisma } = await importCumulativeFixture(` diff --git a/src/lib/measurements/import-apple-health-export.ts b/src/lib/measurements/import-apple-health-export.ts index d76027feb..37b5b0c35 100644 --- a/src/lib/measurements/import-apple-health-export.ts +++ b/src/lib/measurements/import-apple-health-export.ts @@ -233,8 +233,17 @@ const UNATTRIBUTED_CUMULATIVE_SOURCE_HASH = createHash("sha256") */ const HK_DEVICE_RUNTIME_ADDRESS = /0x[0-9a-f]+/gi; +/** + * The device's `software:` field is its OS version, which changes when the + * device updates. Like `sourceVersion`, it names the same device twice on an + * update day, so it is dropped from the identity too. + */ +const HK_DEVICE_SOFTWARE_VERSION = /software:[^,>]*/gi; + function stableDeviceIdentity(device: string | undefined): string | undefined { - return device?.replace(HK_DEVICE_RUNTIME_ADDRESS, "0x0"); + return device + ?.replace(HK_DEVICE_RUNTIME_ADDRESS, "0x0") + .replace(HK_DEVICE_SOFTWARE_VERSION, "software:"); } /** @@ -242,15 +251,18 @@ function stableDeviceIdentity(device: string | undefined): string | undefined { * separate. Records without source metadata share one stable bucket so parser * state remains bounded by actual source cardinality rather than record count. * Raw source/device labels never leave the parser. + * + * The identity is the source and its device, never a version. On the day a + * phone or watch updates, its records carry two `sourceVersion` values; keyed + * on the version, the one device became two sources, and the per-day pick + * (the largest source subtotal) kept only the larger half of that day. */ export function hashCumulativeSourceIdentity( sourceName: string | undefined, - sourceVersion: string | undefined, device: string | undefined, ): string { const sourceTuple = [ sourceName?.trim() ?? "", - sourceVersion?.trim() ?? "", stableDeviceIdentity(device)?.trim() ?? "", ]; if (!sourceTuple.some(Boolean)) { @@ -899,7 +911,6 @@ export async function streamParseExportXml( const dayKey = dayKeyForUserTz(mapped.takenAt, userTimezone); const sourceHash = hashCumulativeSourceIdentity( attrs.sourceName, - attrs.sourceVersion, attrs.device, ); let byDay = cumulativeBucket.get(mapped.type); From 4e7beb797cc01bdfbe27d0a83bda2dfcd9be14c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 26 Sep 2026 16:07:45 +0200 Subject: [PATCH 10/15] fix(analytics): all-time extremes and range deltas count one source per day The all-time weight, blood-pressure and pulse figures the insight features carry averaged every raw row, so a device that samples densely outweighed the readings the source ladder prefers on the same days. They now keep one source per day, like the dashboard's all-time figures. The period-over-period range delta averaged readings. For step-like totals the current window still holds the last day and a half as many small samples, so the average collapsed; it now compares average daily totals. For sleep it averaged stage segments and in-bed envelopes; it now compares average nights of time asleep. The API descriptions say which. --- docs/api/openapi.yaml | 17 ++- .../__tests__/range-delta-per-period.test.ts | 93 ++++++++++++ src/lib/analytics/range-delta.ts | 137 ++++++++++++++++++ src/lib/insights/__tests__/features.test.ts | 8 +- src/lib/insights/feature-blocks.ts | 72 +++++++-- src/lib/openapi/routes/insights/schemas.ts | 15 +- .../rollup-per-day-canonical.test.ts | 83 +++++++++++ 7 files changed, 396 insertions(+), 29 deletions(-) create mode 100644 src/lib/analytics/__tests__/range-delta-per-period.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 89ecc1799..5f77cff92 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -54940,8 +54940,8 @@ components: description: Trailing-window length in days for the chosen range. granularity: type: string - description: Rollup granularity the read resolved against (`DAY` / `WEEK` / `MONTH` / `YEAR`, or `none` on a coverage - miss). + description: Rollup granularity the read resolved against (`DAY` / `WEEK` / `MONTH` / `YEAR`), `live` for SLEEP_DURATION + (nights are reconstructed from the stage rows), or `none` when there is no data. current: description: Aggregate over the current window `[now-N, now)`. $ref: "#/components/schemas/AnalyticsWindowAggregate" @@ -54968,9 +54968,10 @@ components: - delta - deltaPct additionalProperties: false - description: Single-metric period-over-period aggregate. Reads the current and previous comparable windows from the WMY - rollup tier and composes a count-weighted-mean delta. `count/min/max/mean/sum` are linearly composable across - buckets; SD/slope/r² are intentionally excluded (not composable). + description: Single-metric period-over-period aggregate. Reads the current and previous comparable windows from the + rollup tier and composes a count-weighted-mean delta, one source per day. Step-like totals compare average daily + totals and SLEEP_DURATION compares average nights, not per-reading means. `count/min/max/mean/sum` are linearly + composable across buckets; SD/slope/r² are intentionally excluded (not composable). AnalyticsWindowAggregate: type: object properties: @@ -54978,7 +54979,8 @@ components: type: integer minimum: -9007199254740991 maximum: 9007199254740991 - description: Reading count composed across buckets. + description: Reading count composed across buckets. For step-like totals it is the number of days with a total, for + SLEEP_DURATION the number of nights. min: anyOf: - type: number @@ -54993,7 +54995,8 @@ components: anyOf: - type: number - type: "null" - description: Count-weighted mean across buckets; null when empty. + description: Count-weighted mean across buckets; null when empty. For step-like totals (steps, energy, distance, + flights) it is the average daily total, for SLEEP_DURATION the average time asleep per night. sum: anyOf: - type: number diff --git a/src/lib/analytics/__tests__/range-delta-per-period.test.ts b/src/lib/analytics/__tests__/range-delta-per-period.test.ts new file mode 100644 index 000000000..445aba5bc --- /dev/null +++ b/src/lib/analytics/__tests__/range-delta-per-period.test.ts @@ -0,0 +1,93 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + readRollupBuckets: vi.fn(), + findMany: vi.fn(), +})); + +vi.mock("@/lib/db", () => ({ + prisma: { measurement: { findMany: mocks.findMany } }, +})); +vi.mock("@/lib/rollups/measurement-rollups", () => ({ + readRollupBuckets: mocks.readRollupBuckets, +})); +vi.mock("@/lib/rollups/measurement-read", () => ({ + loadUserSourcePriority: vi.fn(async () => null), +})); +vi.mock("@/lib/rollups/measurement-read-wmy", () => ({ + aggregateWmyBuckets: vi.fn(), + readBestGranularityRollups: vi.fn(async () => null), +})); +vi.mock("@/lib/tz/resolver", () => ({ + resolveUserTimezone: vi.fn(async () => "UTC"), +})); + +import { computeRangeDelta } from "@/lib/analytics/range-delta"; + +const DAY_MS = 86_400_000; +const NOW = Date.UTC(2026, 5, 30, 12); + +function dayRow(daysAgo: number, total: number, samples: number) { + const bucketStart = new Date(Date.UTC(2026, 5, 30) - daysAgo * DAY_MS); + return { + bucketStart, + count: samples, + mean: total / samples, + minValue: 0, + maxValue: total, + sd: null, + slope: null, + r2: null, + computedAt: bucketStart, + }; +} + +beforeEach(() => { + mocks.readRollupBuckets.mockReset(); + mocks.findMany.mockReset(); +}); + +describe("computeRangeDelta — per-day and per-night metrics", () => { + it("compares average daily step totals, not the mean of the readings", async () => { + // Six complete days of 8,000 in the previous week, and in the current + // week five drained days plus today still arriving as 200 samples. + mocks.readRollupBuckets.mockResolvedValue([ + ...[8, 9, 10, 11, 12, 13].map((d) => dayRow(d, 8000, 1)), + ...[1, 2, 3, 4, 5].map((d) => dayRow(d, 8000, 1)), + dayRow(0, 4000, 200), + ]); + + const result = await computeRangeDelta("u", "ACTIVITY_STEPS", "7d", NOW); + + expect(result.granularity).toBe("DAY"); + expect(result.previous).toMatchObject({ count: 6, mean: 8000 }); + expect(result.current.count).toBe(6); + expect(result.current.mean).toBeCloseTo((5 * 8000 + 4000) / 6, 6); + expect(result.current.sum).toBeCloseTo(44_000, 6); + }); + + it("compares average nights of sleep, not the mean of stage rows", async () => { + const row = (iso: string, minutes: number, sleepStage: string) => ({ + value: minutes, + measuredAt: new Date(iso), + sleepStage, + source: "APPLE_HEALTH", + deviceType: null, + }); + // Two nights: 7 h asleep (split into three stage rows plus an in-bed + // envelope) and 6 h asleep. + mocks.findMany.mockResolvedValue([ + row("2026-06-28T01:00:00.000Z", 480, "IN_BED"), + row("2026-06-28T02:00:00.000Z", 180, "CORE"), + row("2026-06-28T04:00:00.000Z", 120, "DEEP"), + row("2026-06-28T06:00:00.000Z", 120, "REM"), + row("2026-06-29T06:00:00.000Z", 360, "ASLEEP"), + ]); + + const result = await computeRangeDelta("u", "SLEEP_DURATION", "7d", NOW); + + expect(result.granularity).toBe("live"); + expect(result.current.count).toBe(2); + expect(result.current.mean).toBeCloseTo(390, 6); + }); +}); diff --git a/src/lib/analytics/range-delta.ts b/src/lib/analytics/range-delta.ts index 388321fab..d2673064c 100644 --- a/src/lib/analytics/range-delta.ts +++ b/src/lib/analytics/range-delta.ts @@ -25,14 +25,34 @@ * a window aggregate built from WEEK / MONTH buckets equals the per-row * aggregate over the same span. SD / slope / r² are intentionally NOT part of * the delta — they do not compose, matching the rest of the WMY reader tier. + * + * Two kinds of metric are not averaged per reading: + * - step-like totals (steps, energy, distance, flights, …) compare the + * average DAILY total: a day's readings are pieces of one total, and the + * last day and a half still arrives as many small samples before the + * drain folds them, so a per-reading mean collapsed the current window; + * - sleep compares the average NIGHT (time asleep, reconstructed from the + * stage rows): a per-row mean averaged stage segments and in-bed + * envelopes, which is no amount of sleep at all. + * For both, `count` is the number of days or nights. */ import type { MeasurementType } from "@/generated/prisma/client"; +import { prisma } from "@/lib/db"; +import { CUMULATIVE_HK_TYPES } from "@/lib/measurements/apple-health-mapping"; +import { + reconstructSleepNights, + type SleepStageRow, +} from "@/lib/analytics/sleep-night"; +import { readRollupBuckets } from "@/lib/rollups/measurement-rollups"; +import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; import { aggregateWmyBuckets, readBestGranularityRollups, type RollupBucketRow, } from "@/lib/rollups/measurement-read-wmy"; +import { startOfUtcDay } from "@/lib/tz/start-of-utc-day"; +import { resolveUserTimezone } from "@/lib/tz/resolver"; import { ANALYTICS_RANGES, rangeWindowDays, @@ -86,6 +106,99 @@ export function sliceWindowDelta( }; } +/** + * Aggregate one value per day or night (a daily total, a night's time + * asleep): `count` is the number of days or nights, `mean` their average. + */ +export function aggregatePerPeriod(values: readonly number[]): WindowAggregate { + if (values.length === 0) { + return { count: 0, min: null, max: null, mean: null, sum: null }; + } + let sum = 0; + let min = Infinity; + let max = -Infinity; + for (const v of values) { + sum += v; + if (v < min) min = v; + if (v > max) max = v; + } + return { count: values.length, min, max, mean: sum / values.length, sum }; +} + +/** Split `(at, value)` points into the current and previous halves. */ +function splitPerPeriod( + points: ReadonlyArray<{ at: Date; value: number }>, + windowDays: number, + now: number, +): { current: WindowAggregate; previous: WindowAggregate } { + const currentStart = now - windowDays * DAY_MS; + const previousStart = now - 2 * windowDays * DAY_MS; + const current: number[] = []; + const previous: number[] = []; + for (const p of points) { + const t = p.at.getTime(); + if (t >= currentStart && t < now) current.push(p.value); + else if (t >= previousStart && t < currentStart) previous.push(p.value); + } + return { + current: aggregatePerPeriod(current), + previous: aggregatePerPeriod(previous), + }; +} + +/** The canonical daily totals of a step-like metric, one point per day. */ +async function readDailyTotals( + userId: string, + type: MeasurementType, + windowDays: number, + now: number, +): Promise> { + const rows = await readRollupBuckets( + userId, + type, + "DAY", + startOfUtcDay(new Date(now - 2 * windowDays * DAY_MS)), + new Date(now), + ); + return rows.map((r) => ({ at: r.bucketStart, value: r.mean * r.count })); +} + +/** Time asleep per reconstructed night, one point per night. */ +async function readNightlySleep( + userId: string, + windowDays: number, + now: number, +): Promise> { + // A night's rows start the evening before its wake day: read one day more. + const rows = (await prisma.measurement.findMany({ + where: { + userId, + type: "SLEEP_DURATION", + deletedAt: null, + measuredAt: { + gte: new Date(now - (2 * windowDays + 1) * DAY_MS), + lt: new Date(now), + }, + }, + orderBy: { measuredAt: "asc" }, + select: { + value: true, + measuredAt: true, + sleepStage: true, + source: true, + deviceType: true, + }, + })) as SleepStageRow[]; + if (rows.length === 0) return []; + const [tz, priorityJson] = await Promise.all([ + resolveUserTimezone(userId), + loadUserSourcePriority(userId), + ]); + return reconstructSleepNights(rows, tz, priorityJson) + .filter((n) => n.asleepMinutes > 0) + .map((n) => ({ at: n.measuredAt, value: n.asleepMinutes })); +} + /** * Compose the period-over-period delta from the two window aggregates. * Pure — pinned by unit test. Guards both the missing-data case (either @@ -119,6 +232,30 @@ export async function computeRangeDelta( now: number = Date.now(), ): Promise { const windowDays = rangeWindowDays(range); + + if (type === "SLEEP_DURATION" || CUMULATIVE_HK_TYPES.has(type)) { + const points = + type === "SLEEP_DURATION" + ? await readNightlySleep(userId, windowDays, now) + : await readDailyTotals(userId, type, windowDays, now); + const { current, previous } = splitPerPeriod(points, windowDays, now); + const { delta, deltaPct } = composeDelta(current, previous); + return { + range, + windowDays, + granularity: + points.length === 0 + ? "none" + : type === "SLEEP_DURATION" + ? "live" + : "DAY", + current, + previous, + delta, + deltaPct, + }; + } + // Read the full 2N span in one go so the current and previous halves share // a granularity; the router picks the coarsest tier that resolves 2N. const resolved = await readBestGranularityRollups( diff --git a/src/lib/insights/__tests__/features.test.ts b/src/lib/insights/__tests__/features.test.ts index 2fa796a92..07b10173f 100644 --- a/src/lib/insights/__tests__/features.test.ts +++ b/src/lib/insights/__tests__/features.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; vi.mock("@/lib/db", () => ({ prisma: { user: { findUnique: vi.fn() }, - measurement: { findMany: vi.fn(), groupBy: vi.fn() }, + measurement: { findMany: vi.fn() }, measurementRollup: { findMany: vi.fn() }, moodEntry: { findMany: vi.fn(), findFirst: vi.fn() }, moodEntryRollup: { findMany: vi.fn(), findFirst: vi.fn() }, @@ -15,6 +15,8 @@ vi.mock("@/lib/db", () => ({ workout: { findMany: vi.fn() }, ecgRecording: { findMany: vi.fn() }, $queryRawUnsafe: vi.fn(), + // The all-time extremes read (one grouped aggregate). + $queryRaw: vi.fn(), }, })); @@ -49,7 +51,6 @@ const prismaMock = prisma as unknown as { user: { findUnique: ReturnType }; measurement: { findMany: ReturnType; - groupBy: ReturnType; }; measurementRollup: { findMany: ReturnType }; moodEntry: { @@ -66,6 +67,7 @@ const prismaMock = prisma as unknown as { measurementReminder: { findMany: ReturnType }; workout: { findMany: ReturnType }; ecgRecording: { findMany: ReturnType }; + $queryRaw: ReturnType; }; const dayMs = 24 * 60 * 60 * 1000; @@ -123,7 +125,7 @@ beforeEach(() => { gender: "MALE", }); prismaMock.measurement.findMany.mockResolvedValue([]); - prismaMock.measurement.groupBy.mockResolvedValue([]); + prismaMock.$queryRaw.mockResolvedValue([]); prismaMock.measurementRollup.findMany.mockResolvedValue([]); prismaMock.moodEntry.findMany.mockResolvedValue([]); prismaMock.moodEntry.findFirst.mockResolvedValue(null); diff --git a/src/lib/insights/feature-blocks.ts b/src/lib/insights/feature-blocks.ts index 062d5b5ef..fcdf7ec74 100644 --- a/src/lib/insights/feature-blocks.ts +++ b/src/lib/insights/feature-blocks.ts @@ -10,6 +10,9 @@ * point (`extractFeatures`) stays in the hub. */ import { prisma } from "@/lib/db"; +import { Prisma } from "@/generated/prisma/client"; +import { buildSourceRankCase } from "@/lib/analytics/source-rank-sql"; +import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; import { classifyAgainstEffectiveRange } from "@/lib/labs/reference-range"; import { calendarDaysUntil } from "@/lib/measurement-reminders/due-day"; import { resolveLabFields } from "@/lib/labs/serialise"; @@ -33,10 +36,16 @@ interface AllTimeExtremes { /** * v1.18.11 P1 — full-history min / max / mean per measurement type via ONE - * grouped SQL aggregation, with NO row materialisation in JS. Used to fill the - * `allTime*` feature fields honestly when the bulk feature read is bounded to a - * recent window: the windowed `summarize()` covers trends + recent windows, and - * this covers the long-horizon extremes the prompt labels "allTime". + * grouped SQL aggregation, with NO row materialisation in JS. Fills the + * `allTime*` feature fields; the windowed `summarize()` covers trends + recent + * windows, and this covers the long-horizon figures the prompt labels + * "allTime". + * + * One source per (type, day): each source is folded per day first, each day + * keeps its ladder-canonical source, then the days fold into the figures — + * the same pick the dashboard's all-time figures make. Averaging every raw + * row let a device that samples densely (a watch's pulse) outweigh the + * readings the source ladder prefers on the same days. * * Only the four types that expose `allTime*` fields are aggregated (weight, * systolic, diastolic, pulse). Returns a map keyed by `MeasurementType`; a type @@ -46,19 +55,52 @@ export async function readAllTimeExtremes( userId: string, types: readonly MeasurementType[], ): Promise> { - const rows = await prisma.measurement.groupBy({ - by: ["type"], - where: { userId, deletedAt: null, type: { in: [...types] } }, - _avg: { value: true }, - _min: { value: true }, - _max: { value: true }, - }); const out = new Map(); + if (types.length === 0) return out; + const priorityJson = await loadUserSourcePriority(userId); + const rank = Prisma.raw( + buildSourceRankCase(priorityJson, 'p."type"', 'p."source"'), + ); + const typeList = Prisma.join( + types.map((t) => Prisma.sql`${t}::measurement_type`), + ); + const rows = await prisma.$queryRaw< + Array<{ type: string; mean: number; min: number; max: number }> + >` + WITH per_source AS ( + SELECT + m."type", + m."source", + date_trunc('day', m."measured_at") AS day, + COUNT(*)::int AS cnt, + SUM(m."value")::double precision AS total, + MIN(m."value")::double precision AS min_value, + MAX(m."value")::double precision AS max_value + FROM measurements m + WHERE m."user_id" = ${userId} + AND m."deleted_at" IS NULL + AND m."type" IN (${typeList}) + GROUP BY m."type", m."source", 3 + ), + canon AS ( + SELECT DISTINCT ON (p."type", p.day) + p."type", p.cnt, p.total, p.min_value, p.max_value + FROM per_source p + ORDER BY p."type", p.day, (${rank}), p."source"::text + ) + SELECT + c."type"::text AS type, + (SUM(c.total) / SUM(c.cnt))::double precision AS mean, + MIN(c.min_value)::double precision AS min, + MAX(c.max_value)::double precision AS max + FROM canon c + GROUP BY c."type" + `; for (const r of rows) { - out.set(r.type, { - mean: r._avg.value ?? null, - min: r._min.value ?? null, - max: r._max.value ?? null, + out.set(r.type as MeasurementType, { + mean: r.mean === null ? null : Number(r.mean), + min: r.min === null ? null : Number(r.min), + max: r.max === null ? null : Number(r.max), }); } return out; diff --git a/src/lib/openapi/routes/insights/schemas.ts b/src/lib/openapi/routes/insights/schemas.ts index a629ac1e2..2f7b934e6 100644 --- a/src/lib/openapi/routes/insights/schemas.ts +++ b/src/lib/openapi/routes/insights/schemas.ts @@ -913,13 +913,20 @@ export const analyticsRangeQuery = z export const analyticsWindowAggregate = z .object({ - count: z.number().int().describe("Reading count composed across buckets."), + count: z + .number() + .int() + .describe( + "Reading count composed across buckets. For step-like totals it is the number of days with a total, for SLEEP_DURATION the number of nights.", + ), min: z.number().nullable().describe("Window minimum; null when empty."), max: z.number().nullable().describe("Window maximum; null when empty."), mean: z .number() .nullable() - .describe("Count-weighted mean across buckets; null when empty."), + .describe( + "Count-weighted mean across buckets; null when empty. For step-like totals (steps, energy, distance, flights) it is the average daily total, for SLEEP_DURATION the average time asleep per night.", + ), sum: z .number() .nullable() @@ -941,7 +948,7 @@ export const analyticsRangeResponse = z granularity: z .string() .describe( - "Rollup granularity the read resolved against (`DAY` / `WEEK` / `MONTH` / `YEAR`, or `none` on a coverage miss).", + "Rollup granularity the read resolved against (`DAY` / `WEEK` / `MONTH` / `YEAR`), `live` for SLEEP_DURATION (nights are reconstructed from the stage rows), or `none` when there is no data.", ), current: analyticsWindowAggregate.describe( "Aggregate over the current window `[now-N, now)`.", @@ -965,7 +972,7 @@ export const analyticsRangeResponse = z .meta({ id: "AnalyticsRangeResponse", description: - "Single-metric period-over-period aggregate. Reads the current and previous comparable windows from the WMY rollup tier and composes a count-weighted-mean delta. `count/min/max/mean/sum` are linearly composable across buckets; SD/slope/r² are intentionally excluded (not composable).", + "Single-metric period-over-period aggregate. Reads the current and previous comparable windows from the rollup tier and composes a count-weighted-mean delta, one source per day. Step-like totals compare average daily totals and SLEEP_DURATION compares average nights, not per-reading means. `count/min/max/mean/sum` are linearly composable across buckets; SD/slope/r² are intentionally excluded (not composable).", }); export const insightsPregenerateRequest = z.object({}).meta({ diff --git a/tests/integration/rollup-per-day-canonical.test.ts b/tests/integration/rollup-per-day-canonical.test.ts index 8cc597e26..a720de64b 100644 --- a/tests/integration/rollup-per-day-canonical.test.ts +++ b/tests/integration/rollup-per-day-canonical.test.ts @@ -20,6 +20,8 @@ import { recomputeUserRollups, } from "@/lib/rollups/measurement-rollups"; import { readLiveBuckets } from "@/lib/measurements/daily-series-read"; +import { readAllTimeExtremes } from "@/lib/insights/feature-blocks"; +import { computeSummariesSlice } from "@/lib/analytics/summaries-slice"; import type { MeasurementType } from "@/generated/prisma/client"; vi.mock("@/lib/db-compat", () => ({ @@ -283,4 +285,85 @@ describe("coarse buckets — canonical source per day", () => { expect(rollupDay.mean).toBe(110); expect(liveMonth.value).toBe(110); }); + + it("all-time weight figures keep one source per day", async () => { + const user = await seedUser(); + const prisma = getPrismaClient(); + await prisma.measurement.createMany({ + data: [ + // Day A: the scale (WITHINGS leads the ladder) and a typed reading. + { + userId: user.id, + type: "WEIGHT", + value: 80, + unit: "kg", + source: "WITHINGS", + measuredAt: day(3, 7), + }, + { + userId: user.id, + type: "WEIGHT", + value: 90, + unit: "kg", + source: "MANUAL", + measuredAt: day(3, 9), + }, + // Day B: a typed reading only. + { + userId: user.id, + type: "WEIGHT", + value: 70, + unit: "kg", + source: "MANUAL", + measuredAt: day(4, 9), + }, + ], + }); + + const extremes = await readAllTimeExtremes(user.id, ["WEIGHT"]); + expect(extremes.get("WEIGHT")).toEqual({ mean: 75, min: 70, max: 80 }); + }); + + it("rows older than the rollup window count one source per day in the all-time figures", async () => { + const user = await seedUser(); + const prisma = getPrismaClient(); + const now = Date.now(); + const recent = new Date(now - 2 * DAY_MS); + const old = new Date(now - 6 * 365 * DAY_MS); + await prisma.measurement.createMany({ + data: [ + { + userId: user.id, + type: "WEIGHT", + value: 82, + unit: "kg", + source: "MANUAL", + measuredAt: recent, + }, + // Six years ago, one day seen by two sources. + { + userId: user.id, + type: "WEIGHT", + value: 90, + unit: "kg", + source: "WITHINGS", + measuredAt: old, + }, + { + userId: user.id, + type: "WEIGHT", + value: 100, + unit: "kg", + source: "MANUAL", + measuredAt: new Date(old.getTime() + 60_000), + }, + ], + }); + await recomputeUserRollups(user.id, { granularities: ["DAY"] }); + + const slice = await computeSummariesSlice(user.id); + expect(slice.summaries.WEIGHT.count).toBe(2); + expect(slice.summaries.WEIGHT.max).toBe(90); + expect(slice.summaries.WEIGHT.mean).toBe(86); + }); }); From c88754a0b50f7aa3a7d4a6a7f1b4c2e9921003bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 26 Sep 2026 16:07:51 +0200 Subject: [PATCH 11/15] fix(measurements): re-warm status assessments from every measurement write The MCP writes, the Telegram reply, the CSV and backup importers and the Apple export import refreshed the rollups by hand and never re-warmed the cached status assessments, so a reading logged there kept the day's old assessment until the nightly pass. They now run the shared post-write tail, and the export import re-warms every type it wrote. The WHOOP body sync removed its legacy weight row without refolding that day; it does now. A structural test lists every file that writes measurements and how it keeps both readers current, so a new write site fails until it says. --- .../measurement-write-tail-guard.test.ts | 171 ++++++++++++++++++ src/app/api/import/csv/route.ts | 27 +-- src/app/api/import/route.ts | 31 +--- src/lib/jobs/apple-health-import-worker.ts | 19 +- src/lib/mcp/__tests__/writes.test.ts | 38 +++- src/lib/mcp/writes.ts | 49 ++--- .../__tests__/create-from-telegram.test.ts | 12 +- src/lib/measurements/create-from-telegram.ts | 19 +- src/lib/whoop/__tests__/sync-body.test.ts | 31 +++- src/lib/whoop/sync-body.ts | 23 ++- 10 files changed, 318 insertions(+), 102 deletions(-) create mode 100644 src/__tests__/measurement-write-tail-guard.test.ts diff --git a/src/__tests__/measurement-write-tail-guard.test.ts b/src/__tests__/measurement-write-tail-guard.test.ts new file mode 100644 index 000000000..dc56b62f8 --- /dev/null +++ b/src/__tests__/measurement-write-tail-guard.test.ts @@ -0,0 +1,171 @@ +/** + * Every place that writes `Measurement` rows either runs the shared + * post-write tail or says here why it does not. + * + * Two readers hang off every measurement write: the rollup buckets the write + * dirtied, and the cached status assessments of the types it touched. The + * shared tail (`afterMeasurementMutation`) runs both. Several write paths + * refreshed the rollups by hand and never re-warmed the assessments (the MCP + * writes, the Telegram reply, both importers), so a reading logged there kept + * the day's old assessment until the nightly pass. This guard freezes the set + * of write sites: a new one fails until it is listed with its tail. + * + * A tripwire, not a proof: it cannot tell whether a listed reason is still + * true, only that the set did not change without someone editing this file. + */ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { walkSourceFiles } from "./helpers/source-files"; + +const SRC = join(process.cwd(), "src"); + +/** + * `prisma.measurement.create(`, `tx.measurement.deleteMany(`, … Whitespace + * and line breaks between the parts are tolerated so a formatter reflow does + * not hide a site. + */ +const WRITE_RE = + /\.\s*measurement\s*\.\s*(?:create|createMany|createManyAndReturn|upsert|update|updateMany|delete|deleteMany)\s*\(/g; + +/** Runs `afterMeasurementMutation` on the rows it wrote. */ +const TAIL = "runs afterMeasurementMutation"; + +/** + * Every file with a measurement write, and how it keeps the two readers + * current. A file listed here must still contain a write (a stale entry + * fails too). + */ +const WRITE_SITES: Record = { + "app/api/import/csv/route.ts": TAIL, + "app/api/import/route.ts": TAIL, + "app/api/measurements/[id]/route.ts": TAIL, + "app/api/measurements/bulk-delete/route.ts": TAIL, + "app/api/measurements/by-external-ids/route.ts": TAIL, + "app/api/measurements/restore/route.ts": TAIL, + "app/api/measurements/route.ts": TAIL, + "app/api/mental-health/assessments/route.ts": TAIL, + "lib/insights/score-row.ts": TAIL, + "lib/mcp/writes.ts": TAIL, + "lib/measurements/create-from-telegram.ts": TAIL, + "lib/whoop/sync-body.ts": TAIL, + "lib/whoop/webhook-handler.ts": TAIL, + // Integration syncs: each collects the (type, day) pairs it touched and + // runs both legs itself at the end of the sync. + "lib/fitbit/sync-core.ts": + "refolds the touched days and re-warms the touched types at the end of the upsert", + "lib/google-health/sync-core.ts": + "refolds the touched days and re-warms the touched types at the end of the upsert", + "lib/nightscout/sync.ts": + "refolds the touched days and re-warms the touched types at the end of the sync", + "lib/withings/sync.ts": + "refolds the touched days and re-warms the touched types at the end of the sync", + "lib/withings/sync-activity.ts": + "refolds the touched days (and the day a moved row left) and re-warms the touched types", + "lib/withings/sync-ecg.ts": + "refolds the touched days and re-warms the touched types at the end of the sync", + // The reconcile primitive writes one row inside the caller's transaction; + // its callers (Polar, Oura, WHOOP, the Apple export import) collect the + // verdicts' dirty identities and run the tail once per batch. + "lib/measurements/reconcile-external-measurement.ts": + "a primitive; every caller runs the tail over the verdicts' dirty identities", + "lib/measurements/import-apple-health-export.ts": + "the import worker refolds the import's span and re-warms every imported type", + "lib/export/restore-backup.ts": + "a restore replaces the whole record: it refolds the whole window and drops every per-user cache", + // Value-preserving reshapes: the same readings folded into daily or hourly + // rows. The rollups are refolded; the figures an assessment reads do not + // change, so re-warming would only re-bill the same text. + "lib/measurements/consolidate-daily-mean.ts": + "value-preserving consolidation; refolds the touched days", + "lib/measurements/consolidate-legacy-steps.ts": + "value-preserving consolidation; refolds the touched days", + "lib/measurements/drain-per-sample-cumulative.ts": + "value-preserving consolidation; refolds the touched days", + "lib/measurements/dense-intraday-hourly-rebuild.ts": + "value-preserving hourly fold; refolds the touched spans", + "lib/measurements/dense-intraday-retention.ts": + "value-preserving retention fold; refolds the touched days", + "lib/jobs/sleep-timeline-backfill.ts": + "one-shot re-stamp of stored sleep rows onto their timeline; refolds the touched days", + "lib/jobs/step-consolidation-repair.ts": + "one-shot restore of step rows an old consolidation removed; refolds the touched days", + "lib/sleep/sweep-stale-segments.ts": + "tombstones re-scored sleep segments inside a sync; the calling sync refolds the night", + // Writes that do not change a reading. + "lib/jobs/note-encryption-backfill.ts": + "encrypts the note column in place; no value, instant or type changes", + "lib/jobs/measurement-tombstone-cleanup.ts": + "hard-deletes rows that were already soft-deleted and read nowhere", +}; + +function sourceFiles(): string[] { + return walkSourceFiles(SRC, { floor: 3000 }) + .filter((p) => !p.startsWith("generated/")) + .filter((p) => !p.includes("__tests__")) + .filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx")) + .sort(); +} + +function writeCounts(): Map { + const out = new Map(); + for (const rel of sourceFiles()) { + const matches = readFileSync(join(SRC, rel), "utf8").match(WRITE_RE); + if (matches && matches.length > 0) out.set(rel, matches.length); + } + return out; +} + +describe("measurement write tail", () => { + const counts = writeCounts(); + + it("finds the write sites at all", () => { + // An empty match set would agree with any allowlist. + expect(counts.size).toBeGreaterThanOrEqual(30); + }); + + it("lists every file that writes measurements", () => { + const unlisted = [...counts.keys()].filter((f) => !(f in WRITE_SITES)); + expect( + unlisted, + "a new measurement write site: run afterMeasurementMutation on the rows it writes, or list it here with the reason it does not", + ).toEqual([]); + }); + + it("has no stale entries", () => { + const stale = Object.keys(WRITE_SITES).filter((f) => !counts.has(f)); + expect(stale).toEqual([]); + }); + + it("the files that claim the shared tail call it", () => { + const missing = Object.entries(WRITE_SITES) + .filter(([, reason]) => reason === TAIL) + .map(([file]) => file) + .filter( + (file) => + !/afterMeasurementMutation\s*\(/.test( + readFileSync(join(SRC, file), "utf8"), + ), + ); + expect(missing).toEqual([]); + }); + + it("the syncs that run both legs by hand call both", () => { + const handRolled = Object.entries(WRITE_SITES) + .filter(([, reason]) => reason.includes("re-warms")) + .map(([file]) => file) + .filter( + (file) => file !== "lib/measurements/import-apple-health-export.ts", + ); + expect(handRolled.length).toBeGreaterThan(0); + const missing = handRolled.filter((file) => { + const src = readFileSync(join(SRC, file), "utf8"); + return !( + /recomputeBucketsForMeasurement|afterMeasurementMutation/.test(src) && + /invalidateStatusInsightsForTypes|afterMeasurementMutation/.test(src) + ); + }); + expect(missing).toEqual([]); + }); +}); diff --git a/src/app/api/import/csv/route.ts b/src/app/api/import/csv/route.ts index bbd46810c..e5535b9a9 100644 --- a/src/app/api/import/csv/route.ts +++ b/src/app/api/import/csv/route.ts @@ -11,10 +11,7 @@ import { parseCsvMeasurements, type CsvRowResult, } from "@/lib/import/csv-measurements"; -import { - collapseToTypeDayKeys, - recomputeBucketsForMeasurement, -} from "@/lib/rollups/measurement-rollups"; +import { afterMeasurementMutation } from "@/lib/rollups/after-measurement-mutation"; import type { MeasurementType, GlucoseContext, @@ -317,24 +314,10 @@ export const POST = apiHandler(async (request: NextRequest) => { } // One bounded rollup re-fold per touched (type, day) — a 10 000-row CSV - // pays at most ~N (type, day) recomputes, not 10 000 per-row hooks. - // Best-effort: a populator hiccup never fails the importer. - if (touchedMeasurements.length > 0) { - try { - const keys = collapseToTypeDayKeys(touchedMeasurements); - for (const k of keys) { - await recomputeBucketsForMeasurement(userId, k.type, k.measuredAt); - } - } catch (err) { - annotate({ - meta: { - measurement_rollup_csv_import_failed: true, - measurement_rollup_csv_import_error: - err instanceof Error ? err.message : String(err), - }, - }); - } - } + // pays at most ~N (type, day) recomputes, not 10 000 per-row hooks — and + // the status re-warm for the touched types. Best-effort: a populator + // hiccup never fails the importer. + await afterMeasurementMutation(userId, touchedMeasurements, "import.csv"); await auditLog("import.csv.upload", { userId, diff --git a/src/app/api/import/route.ts b/src/app/api/import/route.ts index 02bad9389..88ea23e34 100644 --- a/src/app/api/import/route.ts +++ b/src/app/api/import/route.ts @@ -32,10 +32,7 @@ import { } from "@/lib/validations/external-id"; import { encryptNote } from "@/lib/crypto/note-cipher"; import { recomputeUserMoodRollups } from "@/lib/rollups/mood-rollups"; -import { - collapseToTypeDayKeys, - recomputeBucketsForMeasurement, -} from "@/lib/rollups/measurement-rollups"; +import { afterMeasurementMutation } from "@/lib/rollups/after-measurement-mutation"; import type { MeasurementType } from "@/generated/prisma/client"; import { emitInsertedMeasurementArrivals } from "@/lib/arrivals/measurement-emit"; import { maybeEnqueueMorningRefresh } from "@/lib/daily/morning-refresh-trigger"; @@ -481,27 +478,11 @@ export const POST = apiHandler(async (request: NextRequest) => { } } - // v1.4.39.1 — refresh the persistent measurement rollup table for - // every distinct (type, day) the import touched. Collapsed so a - // 10 000-row CSV restore pays at most ~N (type, day) recomputes - // rather than 10 000 per-row hooks. Best-effort: a populator hiccup - // never fails the importer. - if (touchedMeasurements.length > 0) { - try { - const keys = collapseToTypeDayKeys(touchedMeasurements); - for (const k of keys) { - await recomputeBucketsForMeasurement(userId, k.type, k.measuredAt); - } - } catch (err) { - annotate({ - meta: { - measurement_rollup_import_failed: true, - measurement_rollup_import_error: - err instanceof Error ? err.message : String(err), - }, - }); - } - } + // Refresh the persistent measurement rollup table for every distinct + // (type, day) the import touched (collapsed, so a 10 000-row restore pays + // at most ~N recomputes) and re-warm the touched types' status + // assessments. Best-effort: a populator hiccup never fails the importer. + await afterMeasurementMutation(userId, touchedMeasurements, "import"); annotate({ meta: { diff --git a/src/lib/jobs/apple-health-import-worker.ts b/src/lib/jobs/apple-health-import-worker.ts index 257649e72..2875cddf2 100644 --- a/src/lib/jobs/apple-health-import-worker.ts +++ b/src/lib/jobs/apple-health-import-worker.ts @@ -26,7 +26,7 @@ */ import { unlinkSync } from "node:fs"; import { getHeapStatistics } from "node:v8"; -import type { PrismaClient } from "@/generated/prisma/client"; +import type { MeasurementType, PrismaClient } from "@/generated/prisma/client"; import { prisma, toJson } from "@/lib/db"; import type { Job } from "pg-boss"; @@ -35,6 +35,7 @@ import { parseAppleHealthEcgCsv } from "@/lib/apple-health/ecg-csv"; import { importAppleHealthEcg } from "@/lib/apple-health/ecg-import"; import { extractExportXml } from "@/lib/import/unzip-export-xml"; import { getGlobalBoss } from "@/lib/jobs/boss-instance"; +import { invalidateStatusInsightsForTypes } from "@/lib/insights/status-invalidation"; import { jobDone, jobFailed, type JobOutcome } from "@/lib/jobs/job-outcome"; import { streamParseExportXml, @@ -533,6 +534,22 @@ export async function handleAppleHealthImport( ); } + // The imported types' cached status assessments describe data that just + // changed; re-warm them, the second leg every other measurement write + // runs (`afterMeasurementMutation`). The rollups above are refolded over + // the import's span rather than per day, so the leg is called directly. + const importedTypes = Object.entries(result.perType) + .filter(([, stat]) => stat.inserted + stat.updated > 0) + .map(([type]) => type as MeasurementType); + if (importedTypes.length > 0) { + invalidateStatusInsightsForTypes(userId, importedTypes).catch((err) => { + console.warn( + `[apple-health-import] status-insight invalidate failed for user ${userId}`, + err, + ); + }); + } + // Best-effort cleanup. A failed unlink is not fatal — `/tmp` is // periodically swept on the host. safeUnlink(unzip.xmlPath); diff --git a/src/lib/mcp/__tests__/writes.test.ts b/src/lib/mcp/__tests__/writes.test.ts index 6772d4f77..a762f1f4c 100644 --- a/src/lib/mcp/__tests__/writes.test.ts +++ b/src/lib/mcp/__tests__/writes.test.ts @@ -21,8 +21,8 @@ vi.mock("@/lib/cache/invalidate", () => ({ invalidateUserMood: vi.fn(), })); -vi.mock("@/lib/rollups/measurement-rollups", () => ({ - recomputeBucketsForMeasurement: vi.fn().mockResolvedValue(undefined), +vi.mock("@/lib/rollups/after-measurement-mutation", () => ({ + afterMeasurementMutation: vi.fn().mockResolvedValue(undefined), })); vi.mock("@/lib/rollups/mood-rollups", () => ({ @@ -41,6 +41,7 @@ vi.mock("@/lib/logging/context", () => ({ import { logMcpMeasurement, logMcpMood, logMcpBloodPressure } from "../writes"; import { invalidateUserMeasurements } from "@/lib/cache/invalidate"; +import { afterMeasurementMutation } from "@/lib/rollups/after-measurement-mutation"; beforeEach(() => { vi.clearAllMocks(); @@ -465,3 +466,36 @@ describe("MCP writes — idempotency-key stability floor", () => { expect(measurement.create).toHaveBeenCalledTimes(1); }); }); + +describe("MCP writes — post-write tail", () => { + it("refolds and re-warms through the shared helper for a single reading", async () => { + await logMcpMeasurement({ + userId: "u-1", + type: "WEIGHT", + value: 80, + idempotencyKey: "tail-1", + }); + expect(afterMeasurementMutation).toHaveBeenCalledWith( + "u-1", + [expect.objectContaining({ type: "WEIGHT" })], + "mcp", + ); + }); + + it("refolds and re-warms both blood-pressure series", async () => { + await logMcpBloodPressure({ + userId: "u-1", + systolic: 120, + diastolic: 80, + idempotencyKey: "tail-2", + }); + expect(afterMeasurementMutation).toHaveBeenCalledWith( + "u-1", + [ + expect.objectContaining({ type: "BLOOD_PRESSURE_SYS" }), + expect.objectContaining({ type: "BLOOD_PRESSURE_DIA" }), + ], + "mcp", + ); + }); +}); diff --git a/src/lib/mcp/writes.ts b/src/lib/mcp/writes.ts index a25903f19..aa0047673 100644 --- a/src/lib/mcp/writes.ts +++ b/src/lib/mcp/writes.ts @@ -36,7 +36,7 @@ import { invalidateUserMeasurements, invalidateUserMood, } from "@/lib/cache/invalidate"; -import { recomputeBucketsForMeasurement } from "@/lib/rollups/measurement-rollups"; +import { afterMeasurementMutation } from "@/lib/rollups/after-measurement-mutation"; import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups"; import { MOOD_ENUM_BY_SCORE } from "@/lib/mood/labels"; import { getScoreForMood } from "@/lib/validations/mood"; @@ -349,15 +349,13 @@ export async function logMcpMeasurement(input: { invalidateUserMeasurements(input.userId, { evict: true }); - // Best-effort rollup refresh — a cache tier, never a write-path invariant. - try { - await recomputeBucketsForMeasurement(input.userId, input.type, measuredAt); - } catch (rollupErr) { - getEvent()?.addMeta( - "mcp_measurement_rollup_failed", - rollupErr instanceof Error ? rollupErr.message : String(rollupErr), - ); - } + // Rollup refresh plus the status re-warm, both best-effort — a cache + // tier, never a write-path invariant. + await afterMeasurementMutation( + input.userId, + [{ type: input.type, measuredAt }], + "mcp", + ); await auditLog("mcp.write.measurement", { userId: input.userId, @@ -522,27 +520,16 @@ export async function logMcpBloodPressure(input: { invalidateUserMeasurements(input.userId, { evict: true }); - // Best-effort rollup refresh for both series — a cache tier, never a - // write-path invariant. - try { - await Promise.all([ - recomputeBucketsForMeasurement( - input.userId, - "BLOOD_PRESSURE_SYS", - measuredAt, - ), - recomputeBucketsForMeasurement( - input.userId, - "BLOOD_PRESSURE_DIA", - measuredAt, - ), - ]); - } catch (rollupErr) { - getEvent()?.addMeta( - "mcp_blood_pressure_rollup_failed", - rollupErr instanceof Error ? rollupErr.message : String(rollupErr), - ); - } + // Rollup refresh plus the status re-warm for both series, best-effort — + // a cache tier, never a write-path invariant. + await afterMeasurementMutation( + input.userId, + [ + { type: "BLOOD_PRESSURE_SYS", measuredAt }, + { type: "BLOOD_PRESSURE_DIA", measuredAt }, + ], + "mcp", + ); await auditLog("mcp.write.blood_pressure", { userId: input.userId, diff --git a/src/lib/measurements/__tests__/create-from-telegram.test.ts b/src/lib/measurements/__tests__/create-from-telegram.test.ts index 7dfdf3141..54fcbd717 100644 --- a/src/lib/measurements/__tests__/create-from-telegram.test.ts +++ b/src/lib/measurements/__tests__/create-from-telegram.test.ts @@ -15,8 +15,8 @@ vi.mock("@/lib/cache/invalidate", () => ({ invalidateUserMeasurements: vi.fn(), })); -vi.mock("@/lib/rollups/measurement-rollups", () => ({ - recomputeBucketsForMeasurement: vi.fn().mockResolvedValue(undefined), +vi.mock("@/lib/rollups/after-measurement-mutation", () => ({ + afterMeasurementMutation: vi.fn().mockResolvedValue(undefined), })); vi.mock("@/lib/logging/context", () => ({ @@ -29,6 +29,7 @@ import { parseTelegramNumber, } from "@/lib/measurements/create-from-telegram"; import { invalidateUserMeasurements } from "@/lib/cache/invalidate"; +import { afterMeasurementMutation } from "@/lib/rollups/after-measurement-mutation"; beforeEach(() => { vi.clearAllMocks(); @@ -90,6 +91,13 @@ describe("logTelegramMeasurement", () => { expect(invalidateUserMeasurements).toHaveBeenCalledWith("user-1", { evict: true, }); + // Rollups refold and the status assessments re-warm through the one + // shared tail. + expect(afterMeasurementMutation).toHaveBeenCalledWith( + "user-1", + [expect.objectContaining({ type: "WEIGHT" })], + "telegram-measurement", + ); }); it("rejects a non-numeric reply without writing", async () => { diff --git a/src/lib/measurements/create-from-telegram.ts b/src/lib/measurements/create-from-telegram.ts index 713a931f9..9bbe84543 100644 --- a/src/lib/measurements/create-from-telegram.ts +++ b/src/lib/measurements/create-from-telegram.ts @@ -24,8 +24,7 @@ import { validateMeasurementRange, } from "@/lib/validations/measurement"; import { invalidateUserMeasurements } from "@/lib/cache/invalidate"; -import { recomputeBucketsForMeasurement } from "@/lib/rollups/measurement-rollups"; -import { getEvent } from "@/lib/logging/context"; +import { afterMeasurementMutation } from "@/lib/rollups/after-measurement-mutation"; import { emitInsertedMeasurementArrivals } from "@/lib/arrivals/measurement-emit"; /** @@ -158,15 +157,13 @@ export async function logTelegramMeasurement(input: { invalidateUserMeasurements(input.userId, { evict: true }); - // Best-effort rollup refresh — a cache tier, never a write-path invariant. - try { - await recomputeBucketsForMeasurement(input.userId, input.type, measuredAt); - } catch (rollupErr) { - getEvent()?.addMeta( - "telegram_measurement_rollup_failed", - rollupErr instanceof Error ? rollupErr.message : String(rollupErr), - ); - } + // Rollup refresh plus the status re-warm, both best-effort — a cache + // tier, never a write-path invariant. + await afterMeasurementMutation( + input.userId, + [{ type: input.type, measuredAt }], + "telegram-measurement", + ); return { status: "ok" }; } diff --git a/src/lib/whoop/__tests__/sync-body.test.ts b/src/lib/whoop/__tests__/sync-body.test.ts index 80db3fd5f..2002fd38b 100644 --- a/src/lib/whoop/__tests__/sync-body.test.ts +++ b/src/lib/whoop/__tests__/sync-body.test.ts @@ -23,6 +23,7 @@ const { update: vi.fn<(...a: unknown[]) => Promise>(async () => ({})), }, measurement: { + findMany: vi.fn<(...a: unknown[]) => Promise>(async () => []), deleteMany: vi.fn<(...a: unknown[]) => Promise>(async () => ({ count: 0, })), @@ -45,6 +46,10 @@ const { vi.mock("@/lib/db", () => ({ prisma: prismaMock })); +vi.mock("@/lib/rollups/after-measurement-mutation", () => ({ + afterMeasurementMutation: vi.fn(async () => {}), +})); + vi.mock("@/lib/logging/context", () => ({ getEvent: () => null, annotate: () => {}, @@ -73,6 +78,7 @@ vi.mock("../sync-core", async (orig) => { import { mapBody } from "../client"; import { syncUserBody, WHOOP_BODY_WEIGHT_EXTERNAL_ID } from "../sync-body"; +import { afterMeasurementMutation } from "@/lib/rollups/after-measurement-mutation"; const TOKEN = { accessToken: "acc", @@ -129,12 +135,22 @@ describe("syncUserBody — profile weight is not ingested (v1.16.11)", () => { expect(upsertWhoopMeasurementsMock).not.toHaveBeenCalled(); }); - it("clears the legacy overwrite row on every pass (idempotent no-op after the first)", async () => { + it("clears the legacy overwrite row and refolds the day it sat on", async () => { fetchBodyMeasurementMock.mockResolvedValue({ weight_kilogram: 80 }); prismaMock.user.findUnique.mockResolvedValue({ heightCm: 170 }); + const legacyRow = { + type: "WEIGHT", + measuredAt: new Date("2026-01-02T08:00:00.000Z"), + }; + prismaMock.measurement.findMany.mockResolvedValueOnce([legacyRow]); await syncUserBody("user1"); + expect(afterMeasurementMutation).toHaveBeenCalledWith( + "user1", + [legacyRow], + "whoop-body", + ); expect(prismaMock.measurement.deleteMany).toHaveBeenCalledWith({ where: { userId: "user1", @@ -249,3 +265,16 @@ describe("syncUserBody — tier degradation", () => { expect(fetchBodyMeasurementMock).not.toHaveBeenCalled(); }); }); + +describe("syncUserBody — legacy row already gone", () => { + it("is a no-op once the legacy row has been removed", async () => { + fetchBodyMeasurementMock.mockResolvedValue({ weight_kilogram: 80 }); + prismaMock.user.findUnique.mockResolvedValue({ heightCm: 170 }); + prismaMock.measurement.findMany.mockResolvedValueOnce([]); + prismaMock.measurement.deleteMany.mockClear(); + + await syncUserBody("user1"); + + expect(prismaMock.measurement.deleteMany).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/whoop/sync-body.ts b/src/lib/whoop/sync-body.ts index 0739fbe5a..2600b2687 100644 --- a/src/lib/whoop/sync-body.ts +++ b/src/lib/whoop/sync-body.ts @@ -28,6 +28,7 @@ import { } from "./sync-core"; import { prisma } from "@/lib/db"; import { getEvent } from "@/lib/logging/context"; +import { afterMeasurementMutation } from "@/lib/rollups/after-measurement-mutation"; /** * Stable externalId the retired profile-weight ingestion wrote under — @@ -63,14 +64,22 @@ export async function syncUserBody( // legacy overwrite row once; deleteMany is a no-op after the first // pass, keeping the sync idempotent. try { - await prisma.measurement.deleteMany({ - where: { - userId, - type: "WEIGHT", - source: "WHOOP", - externalId: WHOOP_BODY_WEIGHT_EXTERNAL_ID, - }, + const legacyWhere = { + userId, + type: "WEIGHT" as const, + source: "WHOOP" as const, + externalId: WHOOP_BODY_WEIGHT_EXTERNAL_ID, + }; + // Read the day it sat on before removing it, so that day's rollup is + // refolded without it. + const legacy = await prisma.measurement.findMany({ + where: legacyWhere, + select: { type: true, measuredAt: true }, }); + if (legacy.length > 0) { + await prisma.measurement.deleteMany({ where: legacyWhere }); + await afterMeasurementMutation(userId, legacy, "whoop-body"); + } } catch (err) { getEvent()?.addWarning( `whoop: failed to clear the legacy profile-weight row for ${userId}: ${err}`, From c366d4946c24f3956d333ad3116c95cbfa4ceaed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 26 Sep 2026 16:08:57 +0200 Subject: [PATCH 12/15] fix(integrations): treat an empty redirect URI variable as unset The compose file passes every whitelisted variable through, so an unset redirect URI reaches the process as an empty string. Withings then sent redirect_uri= to the provider, and Fitbit and Google Health refused to start the connection as not configured although NEXT_PUBLIC_APP_URL was set. Every integration now treats an empty or blank value as unset and derives the callback from the app URL. --- src/lib/fitbit/client.ts | 7 +- src/lib/google-health/client.ts | 7 +- .../__tests__/redirect-uri-empty-env.test.ts | 66 +++++++++++++++++++ src/lib/oura/client.ts | 6 +- src/lib/polar/client.ts | 9 +-- src/lib/strava/client.ts | 9 +-- src/lib/whoop/client.ts | 9 +-- src/lib/withings/client.ts | 7 +- 8 files changed, 100 insertions(+), 20 deletions(-) create mode 100644 src/lib/integrations/__tests__/redirect-uri-empty-env.test.ts diff --git a/src/lib/fitbit/client.ts b/src/lib/fitbit/client.ts index 6521cd8f0..6a01c454f 100644 --- a/src/lib/fitbit/client.ts +++ b/src/lib/fitbit/client.ts @@ -77,8 +77,11 @@ export interface FitbitCredentials { * - when derived from `NEXT_PUBLIC_APP_URL`, must stay same-origin with it. */ export function getFitbitRedirectUri(): string { - const explicit = process.env.FITBIT_REDIRECT_URI; - const appUrl = process.env.NEXT_PUBLIC_APP_URL; + // An empty or blank value counts as unset: the compose whitelist + // materialises an unset var as an empty string, which must fall through to + // the derived URI rather than read as "not configured". + const explicit = process.env.FITBIT_REDIRECT_URI?.trim() || undefined; + const appUrl = process.env.NEXT_PUBLIC_APP_URL?.trim() || undefined; const raw = explicit ?? (appUrl ? `${appUrl}/api/fitbit/callback` : undefined); diff --git a/src/lib/google-health/client.ts b/src/lib/google-health/client.ts index cb5a33b94..6a675631b 100644 --- a/src/lib/google-health/client.ts +++ b/src/lib/google-health/client.ts @@ -147,8 +147,11 @@ export interface GoogleHealthCredentials { * - when derived from `NEXT_PUBLIC_APP_URL`, must stay same-origin with it. */ export function getGoogleHealthRedirectUri(): string { - const explicit = process.env.GOOGLE_HEALTH_REDIRECT_URI; - const appUrl = process.env.NEXT_PUBLIC_APP_URL; + // An empty or blank value counts as unset: the compose whitelist + // materialises an unset var as an empty string, which must fall through to + // the derived URI rather than read as "not configured". + const explicit = process.env.GOOGLE_HEALTH_REDIRECT_URI?.trim() || undefined; + const appUrl = process.env.NEXT_PUBLIC_APP_URL?.trim() || undefined; const raw = explicit ?? (appUrl ? `${appUrl}/api/google-health/callback` : undefined); diff --git a/src/lib/integrations/__tests__/redirect-uri-empty-env.test.ts b/src/lib/integrations/__tests__/redirect-uri-empty-env.test.ts new file mode 100644 index 000000000..25924210c --- /dev/null +++ b/src/lib/integrations/__tests__/redirect-uri-empty-env.test.ts @@ -0,0 +1,66 @@ +/** + * The compose file passes every whitelisted variable through, so an unset + * redirect URI reaches the process as an empty string. Every integration + * treats an empty or blank value as unset and derives the callback from + * `NEXT_PUBLIC_APP_URL`: Withings used to send `redirect_uri=` and Fitbit and + * Google Health refused to start the handshake as "not configured". + */ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { getWithingsRedirectUri } from "@/lib/withings/client"; +import { getFitbitRedirectUri } from "@/lib/fitbit/client"; +import { getGoogleHealthRedirectUri } from "@/lib/google-health/client"; +import { getPolarRedirectUri } from "@/lib/polar/client"; +import { getWhoopRedirectUri } from "@/lib/whoop/client"; +import { getStravaRedirectUri } from "@/lib/strava/client"; +import { getOuraRedirectUri } from "@/lib/oura/client"; + +const APP = "https://health.example.test"; + +const CASES: Array<[string, string, () => string, string]> = [ + ["Withings", "WITHINGS_REDIRECT_URI", getWithingsRedirectUri, "withings"], + ["Fitbit", "FITBIT_REDIRECT_URI", getFitbitRedirectUri, "fitbit"], + [ + "Google Health", + "GOOGLE_HEALTH_REDIRECT_URI", + getGoogleHealthRedirectUri, + "google-health", + ], + ["Polar", "POLAR_REDIRECT_URI", getPolarRedirectUri, "polar"], + ["WHOOP", "WHOOP_REDIRECT_URI", getWhoopRedirectUri, "whoop"], + ["Strava", "STRAVA_REDIRECT_URI", getStravaRedirectUri, "strava"], + ["Oura", "OURA_REDIRECT_URI", getOuraRedirectUri, "oura"], +]; + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe.each(CASES)("%s redirect URI", (_name, variable, resolve, path) => { + it.each(["", " "])( + "derives the callback when the variable is %j", + (value) => { + vi.stubEnv(variable, value); + vi.stubEnv("NEXT_PUBLIC_APP_URL", APP); + expect(resolve()).toBe(`${APP}/api/${path}/callback`); + }, + ); + + it("uses an explicit value, trimmed", () => { + const explicit = `${APP}/api/${path}/callback`; + vi.stubEnv(variable, ` ${explicit} `); + vi.stubEnv("NEXT_PUBLIC_APP_URL", APP); + expect(resolve()).toBe(explicit); + }); +}); + +describe("with neither variable set", () => { + it.each([ + ["Fitbit", "FITBIT_REDIRECT_URI", getFitbitRedirectUri], + ["Google Health", "GOOGLE_HEALTH_REDIRECT_URI", getGoogleHealthRedirectUri], + ] as const)("%s still says it is not configured", (_n, variable, resolve) => { + vi.stubEnv(variable, ""); + vi.stubEnv("NEXT_PUBLIC_APP_URL", " "); + expect(() => resolve()).toThrow(/not configured/); + }); +}); diff --git a/src/lib/oura/client.ts b/src/lib/oura/client.ts index ede36d3e6..577087167 100644 --- a/src/lib/oura/client.ts +++ b/src/lib/oura/client.ts @@ -42,9 +42,11 @@ export function getOuraCredentials(): OuraCredentials | null { } export function getOuraRedirectUri(): string { + // An empty or blank value counts as unset (the compose whitelist + // materialises an unset var as an empty string). return ( - process.env.OURA_REDIRECT_URI || - `${process.env.NEXT_PUBLIC_APP_URL}/api/oura/callback` + process.env.OURA_REDIRECT_URI?.trim() || + `${process.env.NEXT_PUBLIC_APP_URL?.trim()}/api/oura/callback` ); } diff --git a/src/lib/polar/client.ts b/src/lib/polar/client.ts index 9739329ce..b3560d73f 100644 --- a/src/lib/polar/client.ts +++ b/src/lib/polar/client.ts @@ -54,11 +54,12 @@ export function getPolarCredentials(): PolarCredentials | null { } export function getPolarRedirectUri(): string { - // `||`, not `??`: the compose whitelist materialises an unset var as an empty - // string, which must still fall through to the derived URI. + // An empty or blank value counts as unset: the compose whitelist + // materialises an unset var as an empty string, which must still fall + // through to the derived URI. return ( - process.env.POLAR_REDIRECT_URI || - `${process.env.NEXT_PUBLIC_APP_URL}/api/polar/callback` + process.env.POLAR_REDIRECT_URI?.trim() || + `${process.env.NEXT_PUBLIC_APP_URL?.trim()}/api/polar/callback` ); } diff --git a/src/lib/strava/client.ts b/src/lib/strava/client.ts index 09b95fc66..b3e68db25 100644 --- a/src/lib/strava/client.ts +++ b/src/lib/strava/client.ts @@ -52,11 +52,12 @@ export function getStravaCredentials(): StravaCredentials | null { } export function getStravaRedirectUri(): string { - // `||`, not `??`: the compose whitelist materialises an unset var as an empty - // string, which must still fall through to the derived URI. + // An empty or blank value counts as unset: the compose whitelist + // materialises an unset var as an empty string, which must still fall + // through to the derived URI. return ( - process.env.STRAVA_REDIRECT_URI || - `${process.env.NEXT_PUBLIC_APP_URL}/api/strava/callback` + process.env.STRAVA_REDIRECT_URI?.trim() || + `${process.env.NEXT_PUBLIC_APP_URL?.trim()}/api/strava/callback` ); } diff --git a/src/lib/whoop/client.ts b/src/lib/whoop/client.ts index 6419e0170..bc91beb31 100644 --- a/src/lib/whoop/client.ts +++ b/src/lib/whoop/client.ts @@ -33,11 +33,12 @@ export interface WhoopCredentials { } export function getWhoopRedirectUri(): string { - // `||`, not `??`: the compose whitelist materialises the var as an empty - // string when unset, which must still fall through to the derived URI. + // An empty or blank value counts as unset: the compose whitelist + // materialises the var as an empty string when unset, which must still fall + // through to the derived URI. return ( - process.env.WHOOP_REDIRECT_URI || - `${process.env.NEXT_PUBLIC_APP_URL}/api/whoop/callback` + process.env.WHOOP_REDIRECT_URI?.trim() || + `${process.env.NEXT_PUBLIC_APP_URL?.trim()}/api/whoop/callback` ); } diff --git a/src/lib/withings/client.ts b/src/lib/withings/client.ts index db6a41775..6bfe27f4e 100644 --- a/src/lib/withings/client.ts +++ b/src/lib/withings/client.ts @@ -21,9 +21,12 @@ export interface WithingsCredentials { } export function getWithingsRedirectUri(): string { + // An empty or blank value counts as unset: the compose whitelist + // materialises an unset var as an empty string, which must fall through to + // the derived URI rather than go out as `redirect_uri=`. return ( - process.env.WITHINGS_REDIRECT_URI ?? - `${process.env.NEXT_PUBLIC_APP_URL}/api/withings/callback` + process.env.WITHINGS_REDIRECT_URI?.trim() || + `${process.env.NEXT_PUBLIC_APP_URL?.trim()}/api/withings/callback` ); } From 9d2fd1db14968dbcc488c98761c6f6996f6acc01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 26 Sep 2026 16:22:57 +0200 Subject: [PATCH 13/15] fix(tz): one default zone, and the user's own calendar where Berlin was hard-coded The status assessments folded their recent days, weeks, months and years on Berlin days whatever the user's zone, the mood and cycle crosstabs keyed their metric days in Berlin, the resting-pulse proxy defaulted to Berlin days, the briefing's opener rotated at Berlin midnight, and every assessment prompt labelled today's date as Berlin's. They now use the user's zone. Rollup months and years are labelled by their UTC start, which is how they are cut; read in a zone west of UTC the label named the month before. "Europe/Berlin" was written out in some eighty places. DEFAULT_TIMEZONE in lib/tz/format is now the one definition: the second copy in mood/date-key, DISPLAY_TIMEZONE, the Berlin-only day key helpers and the per-route copies are gone. An in-repo lint rule rejects the literal outside lib/tz and tests; four areas another change owns are listed as pending. --- .../no-default-zone-literal.test.mjs | 75 +++++++++++++ eslint-plugins/healthlog/index.js | 4 + .../healthlog/no-default-zone-literal.js | 102 ++++++++++++++++++ eslint.config.mjs | 4 + ...ate-order-explicit-formatter-guard.test.ts | 12 --- .../notifications/reminder-check/route.ts | 3 +- .../api/coach/reminder-suggestions/route.ts | 3 +- src/app/api/coach/suggested-actions/route.ts | 2 +- src/app/api/cycle/calendar/route.ts | 3 +- src/app/api/cycle/day-logs/[id]/route.ts | 2 +- src/app/api/cycle/day-logs/bulk/route.ts | 2 +- src/app/api/cycle/day-logs/route.ts | 2 +- src/app/api/cycle/insights/route.ts | 5 +- src/app/api/cycle/period/route.ts | 2 +- .../episodes/[id]/correlation/route.ts | 2 +- .../illness/episodes/[id]/day-logs/route.ts | 2 +- src/app/api/import/route.ts | 3 +- src/app/api/insights/cards/route.ts | 3 +- src/app/api/insights/comprehensive/route.ts | 6 +- src/app/api/insights/correlations/route.ts | 3 +- src/app/api/insights/generate/route.ts | 1 + src/app/api/measurements/route.ts | 5 +- .../api/medications/[id]/compliance/route.ts | 3 +- .../medications/[id]/dose-history/route.ts | 3 +- .../api/medications/[id]/efficacy/route.ts | 3 +- src/app/api/medications/[id]/route.ts | 5 +- src/app/api/medications/compliance/route.ts | 3 +- src/app/api/mood-entries/[id]/route.ts | 3 +- src/app/api/mood-entries/bulk/route.ts | 3 +- src/app/api/mood-entries/route.ts | 3 +- src/app/api/mood/linked-context/route.ts | 3 +- src/app/api/sync/state/route.ts | 3 +- src/app/medications/page-client.tsx | 3 +- .../charts/medication-compliance-chart.tsx | 3 +- .../dashboard/medication-intake-quick-add.tsx | 3 +- .../insights/intraday-pulse-chart.tsx | 4 +- .../__tests__/vorsorge-section.test.tsx | 6 +- .../card-parts/displayed-slot-instant.ts | 3 +- .../medications/dose-history-ledger.tsx | 3 +- .../medications/glp1-medication-card.tsx | 3 +- .../medications/log-intake-dialog.tsx | 3 +- .../medications/medication-card.tsx | 3 +- .../medications/medication-table.tsx | 3 +- src/components/medications/take-all-due.ts | 3 +- .../settings/account-section/index.tsx | 6 +- .../managed-record-settings-section.tsx | 3 +- src/components/settings/timezone-picker.tsx | 4 +- src/lib/ai/coach/cycle-snapshot.ts | 9 +- src/lib/ai/coach/illness-snapshot.ts | 2 +- src/lib/ai/coach/tools/correlations-read.ts | 3 +- src/lib/ai/coach/workout-evidence-builder.ts | 3 +- .../briefing-personalisation.test.ts | 5 + src/lib/ai/prompts/blood-pressure.ts | 4 +- src/lib/ai/prompts/bmi.ts | 4 +- src/lib/ai/prompts/general-status.ts | 4 +- src/lib/ai/prompts/insight-generator.ts | 15 +-- src/lib/ai/prompts/medication-compliance.ts | 4 +- src/lib/ai/prompts/metric-archetypes.ts | 4 +- src/lib/ai/prompts/mood.ts | 4 +- src/lib/ai/prompts/pulse.ts | 4 +- src/lib/ai/prompts/weight.ts | 4 +- .../__tests__/intraday-pulse-io.test.ts | 5 +- .../analytics/__tests__/resting-pulse.test.ts | 31 ++++-- src/lib/analytics/mood-prognosis/load.ts | 2 +- src/lib/analytics/mood-prognosis/read.ts | 3 +- src/lib/analytics/resting-pulse.ts | 16 ++- .../cycle/__tests__/phase-crosstab.test.ts | 39 +++++-- src/lib/cycle/phase-crosstab.ts | 18 +++- src/lib/cycle/prediction-refresh.ts | 3 +- src/lib/daily/daily-briefing-push.ts | 4 +- src/lib/doctor-report-pdf-core.ts | 4 +- src/lib/doctor-report/collect.ts | 4 +- src/lib/encounters/service.ts | 3 +- src/lib/format-locale.ts | 16 +-- .../__tests__/achievements.test.ts | 11 -- .../__tests__/expansion-metrics.test.ts | 4 +- src/lib/gamification/achievements.ts | 26 ----- src/lib/i18n/context.tsx | 6 +- .../__tests__/biomarker-status.test.ts | 4 +- .../insights/__tests__/bucket-series.test.ts | 44 ++++---- .../__tests__/graded-series-rollups.test.ts | 12 ++- .../insights/__tests__/graded-series.test.ts | 38 +++++-- .../__tests__/mood-aggregates.test.ts | 82 ++++++++++++-- src/lib/insights/blood-pressure-status.ts | 26 +++-- src/lib/insights/bmi-status.ts | 1 + src/lib/insights/bucket-series.ts | 2 +- src/lib/insights/comprehensive-generate.ts | 1 + src/lib/insights/general-status.ts | 10 +- src/lib/insights/graded-series.ts | 66 +++++------- .../insights/medication-compliance-status.ts | 2 +- .../insights/metric-correlation-context.ts | 3 +- src/lib/insights/metric-status.ts | 3 +- src/lib/insights/mood-aggregates.ts | 10 +- src/lib/insights/mood-crosstab.ts | 38 ++++--- src/lib/insights/mood-narratives.ts | 4 +- src/lib/insights/mood-patterns.ts | 4 +- src/lib/insights/mood-status.ts | 3 +- .../narrative/period-narrative-generate.ts | 3 +- .../insights/narrative/period-narrative.ts | 3 +- src/lib/insights/pulse-status.ts | 9 +- src/lib/insights/weight-status.ts | 23 ++-- src/lib/jobs/apple-health-import-worker.ts | 19 ++-- src/lib/jobs/cycle-reminder.ts | 5 +- src/lib/jobs/daily-briefing.ts | 3 +- src/lib/jobs/mood-reminder.ts | 3 +- src/lib/jobs/period-narrative-warm.ts | 3 +- .../reminder/medication-reminder-check.ts | 3 +- src/lib/jobs/reminder/registrar-shared.ts | 3 +- src/lib/mcp/tools.ts | 2 +- src/lib/mcp/writes.ts | 3 +- src/lib/measurements/consolidation-base.ts | 2 +- .../measurements/dense-intraday-retention.ts | 3 +- src/lib/mood/__tests__/date-key.test.ts | 3 +- src/lib/mood/create-from-telegram.ts | 3 +- src/lib/mood/date-key.ts | 4 +- src/lib/mood/linked-context.ts | 3 +- src/lib/openapi/routes/mood.ts | 5 +- .../personal-records/pr-detection-worker.ts | 3 +- src/lib/timezone-mirror.ts | 4 +- src/lib/tz/format.ts | 2 +- src/lib/tz/resolver.ts | 50 +-------- src/lib/vaccinations/service.ts | 2 +- .../cycle-crosstab-day-aggregates.test.ts | 4 +- tests/integration/dense-read-bounded.test.ts | 2 +- 124 files changed, 705 insertions(+), 415 deletions(-) create mode 100644 eslint-plugins/healthlog/__tests__/no-default-zone-literal.test.mjs create mode 100644 eslint-plugins/healthlog/no-default-zone-literal.js diff --git a/eslint-plugins/healthlog/__tests__/no-default-zone-literal.test.mjs b/eslint-plugins/healthlog/__tests__/no-default-zone-literal.test.mjs new file mode 100644 index 000000000..8213cc9b4 --- /dev/null +++ b/eslint-plugins/healthlog/__tests__/no-default-zone-literal.test.mjs @@ -0,0 +1,75 @@ +/** + * Unit tests for the `healthlog/no-default-zone-literal` rule: the literal is + * banned in application source, allowed in `src/lib/tz/` and in tests, and + * the pending files are exempt until their owner switches them. + */ +import { describe, it } from "vitest"; +import { RuleTester } from "eslint"; +import rule from "../no-default-zone-literal.js"; + +RuleTester.describe = describe; +RuleTester.it = it; +RuleTester.itOnly = it.only; + +const ruleTester = new RuleTester({ + languageOptions: { + ecmaVersion: 2023, + sourceType: "module", + parserOptions: { ecmaFeatures: { jsx: true } }, + }, +}); + +ruleTester.run("no-default-zone-literal", rule, { + valid: [ + { + code: 'import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; const tz = user.timezone || DEFAULT_TIMEZONE;', + filename: "/repo/src/app/api/insights/cards/route.ts", + }, + { + // The one definition. + code: 'export const DEFAULT_TIMEZONE = "Europe/Berlin";', + filename: "/repo/src/lib/tz/format.ts", + }, + { + // Fixtures pick a concrete zone on purpose. + code: 'const TZ = "Europe/Berlin";', + filename: "/repo/src/lib/insights/__tests__/graded-series.test.ts", + }, + { + // Comments are not checked. + code: "// read on Europe/Berlin days before v1.39.3\nconst x = 1;", + filename: "/repo/src/lib/insights/graded-series.ts", + }, + { + // Pending: owned by another change, switched at merge. + code: 'const DEFAULT_TZ = "Europe/Berlin";', + filename: "/repo/src/lib/medications/window-status.ts", + }, + { + code: 'const other = "America/New_York";', + filename: "/repo/src/lib/insights/graded-series.ts", + }, + ], + invalid: [ + { + code: 'const tz = user.timezone || "Europe/Berlin";', + filename: "/repo/src/app/api/insights/cards/route.ts", + errors: [{ messageId: "zoneLiteral" }], + }, + { + code: 'readDayAggregates({ timeZone: "Europe/Berlin" });', + filename: "/repo/src/lib/insights/graded-series.ts", + errors: [{ messageId: "zoneLiteral" }], + }, + { + code: "const label = `Date: ${todayKey} (Europe/Berlin)`;", + filename: "/repo/src/lib/ai/prompts/pulse.ts", + errors: [{ messageId: "zoneLiteral" }], + }, + { + code: '', + filename: "/repo/src/components/settings/timezone-picker.tsx", + errors: [{ messageId: "zoneLiteral" }], + }, + ], +}); diff --git a/eslint-plugins/healthlog/index.js b/eslint-plugins/healthlog/index.js index 8147b1540..961372c8b 100644 --- a/eslint-plugins/healthlog/index.js +++ b/eslint-plugins/healthlog/index.js @@ -10,6 +10,8 @@ * and no off-scale `5` step on a bg-card shell. * - `job-handler-outcome` — pg-boss bindings route through createAndWork, * whose handler returns a JobOutcome. + * - `no-default-zone-literal` — the default zone is DEFAULT_TIMEZONE from + * src/lib/tz/format, never written out. */ "use strict"; @@ -20,6 +22,7 @@ const apiFetchRequired = require("./api-fetch-required.js"); const noRawPaletteColor = require("./no-raw-palette-color.js"); const spacingScale = require("./spacing-scale.js"); const jobHandlerOutcome = require("./job-handler-outcome.js"); +const noDefaultZoneLiteral = require("./no-default-zone-literal.js"); module.exports = { rules: { @@ -34,5 +37,6 @@ module.exports = { // the semantic sweep). See the rule header. "no-dracula-utility": noRawPaletteColor, "job-handler-outcome": jobHandlerOutcome, + "no-default-zone-literal": noDefaultZoneLiteral, }, }; diff --git a/eslint-plugins/healthlog/no-default-zone-literal.js b/eslint-plugins/healthlog/no-default-zone-literal.js new file mode 100644 index 000000000..f3a26edc3 --- /dev/null +++ b/eslint-plugins/healthlog/no-default-zone-literal.js @@ -0,0 +1,102 @@ +/** + * @fileoverview ESLint rule — the default zone is named in one place. + * + * `"Europe/Berlin"` was written out in some eighty places: fallbacks for a + * user with no zone, day keys cut in Berlin whatever the user's zone, a + * graded series folded on Berlin days, a prompt that labelled every date as + * Berlin's. Each copy was a place where a user outside that zone got + * someone else's calendar. `DEFAULT_TIMEZONE` in `src/lib/tz/format.ts` is + * the one definition; everything else imports it, and a surface that means + * the user's own zone asks for it (`resolveUserTimezone`). + * + * The rule flags a string literal or template element containing + * `Europe/Berlin` in `src/`, outside `src/lib/tz/` and outside test files + * (fixtures pick concrete zones on purpose). Comments are not checked. + * + * WHAT THIS RULE DOES NOT CATCH + * + * A zone assembled from parts (`"Europe/" + city`), a different hard-coded + * zone, or a Berlin day computed through an `Intl.DateTimeFormat` built from + * a variable. It removes the literal, not every way to hard-code a calendar. + * + * `PENDING` lists files another change owns at the moment; they switch to the + * constant when that change lands, and the list shrinks to nothing. + */ + +"use strict"; + +const ZONE = "Europe/Berlin"; + +const EXEMPT_ROOTS = ["src/lib/tz/"]; + +// Owned by the medication-scheduling change in flight; switched at merge. +const PENDING = [ + "src/lib/medications/", + "src/lib/jobs/measurement-reminder.ts", + "src/lib/measurement-reminders/", + "src/app/api/measurement-reminders/", +]; + +function toPosix(filename) { + return filename.replace(/\\/g, "/"); +} + +function isTestFile(posix) { + return ( + /\.test\.[cm]?[jt]sx?$/.test(posix) || + /\.spec\.[cm]?[jt]sx?$/.test(posix) || + posix.includes("/__tests__/") || + posix.includes("/__mocks__/") + ); +} + +function isEnforced(filename) { + const posix = toPosix(filename); + if (!posix.includes("/src/") && !posix.startsWith("src/")) return false; + if (EXEMPT_ROOTS.some((root) => posix.includes(root))) return false; + if (PENDING.some((p) => posix.includes(p))) return false; + if (isTestFile(posix)) return false; + return true; +} + +/** @type {import("eslint").Rule.RuleModule} */ +const noDefaultZoneLiteralRule = { + meta: { + type: "problem", + docs: { + description: + "Import DEFAULT_TIMEZONE from @/lib/tz/format instead of writing the zone out.", + }, + schema: [], + messages: { + zoneLiteral: + 'Do not write "Europe/Berlin" out. Import DEFAULT_TIMEZONE from "@/lib/tz/format" for a fallback, or use the user\'s own zone (resolveUserTimezone / the profile timezone) for a day boundary.', + }, + }, + create(context) { + const filename = context.filename ?? context.getFilename?.(); + if (!filename || !isEnforced(filename)) { + return {}; + } + return { + Literal(node) { + if (typeof node.value === "string" && node.value.includes(ZONE)) { + context.report({ node, messageId: "zoneLiteral" }); + } + }, + TemplateElement(node) { + const raw = node.value && (node.value.cooked ?? node.value.raw); + if (typeof raw === "string" && raw.includes(ZONE)) { + context.report({ node, messageId: "zoneLiteral" }); + } + }, + JSXText(node) { + if (typeof node.value === "string" && node.value.includes(ZONE)) { + context.report({ node, messageId: "zoneLiteral" }); + } + }, + }; + }, +}; + +module.exports = noDefaultZoneLiteralRule; diff --git a/eslint.config.mjs b/eslint.config.mjs index 36fb7684e..1399d6eee 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -101,6 +101,10 @@ const eslintConfig = defineConfig([ // catches the omission only — a handler that returns `{ ok: true }` // after swallowing still passes, and its header says so. "healthlog/job-handler-outcome": "error", + // The default zone is named once, in src/lib/tz/format.ts. A copy of the + // literal anywhere else is where a user outside that zone got someone + // else's calendar. src/lib/tz/ and test files are exempt; see the rule. + "healthlog/no-default-zone-literal": "error", }, }, // v1.28.17 — every recharts-rendering component funnels through the diff --git a/src/__tests__/date-order-explicit-formatter-guard.test.ts b/src/__tests__/date-order-explicit-formatter-guard.test.ts index faec9324c..cf89630c0 100644 --- a/src/__tests__/date-order-explicit-formatter-guard.test.ts +++ b/src/__tests__/date-order-explicit-formatter-guard.test.ts @@ -259,18 +259,10 @@ const RAW_ALLOWLIST: ReadonlyArray<{ file: string; why: string }> = [ file: "src/components/measurement-reminders/vorsorge-section.tsx", why: "The ISO day the postpone request writes to the API. A wire value, and the wire format is ISO whatever the reader prefers.", }, - { - file: "src/lib/gamification/achievements.ts", - why: "The day key streak arithmetic counts on.", - }, { file: "src/lib/charts/bucket-time-series.ts", why: "Bucket boundary arithmetic. The bucket LABELS are a separate step and go through `makeBucketLabelFormatters`.", }, - { - file: "src/lib/ai/prompts/insight-generator.ts", - why: "An ISO day inside a model prompt. The model reads ISO; the user never sees this string.", - }, { file: "src/lib/jobs/measurement-reminder.ts", why: "The local day in the notification dedup key. Changing it with a display preference would re-send a claimed reminder.", @@ -283,10 +275,6 @@ const RAW_ALLOWLIST: ReadonlyArray<{ file: string; why: string }> = [ file: "src/lib/i18n/relative-time.ts", why: "The en-CA day keys that decide today / yesterday. A comparison between two keys, never printed.", }, - { - file: "src/lib/tz/resolver.ts", - why: "The Berlin day formatter the zone arithmetic itself is built on.", - }, ]; /** diff --git a/src/app/api/admin/notifications/reminder-check/route.ts b/src/app/api/admin/notifications/reminder-check/route.ts index 6cb32ae31..78b6fc9c8 100644 --- a/src/app/api/admin/notifications/reminder-check/route.ts +++ b/src/app/api/admin/notifications/reminder-check/route.ts @@ -9,6 +9,7 @@ import { parseScheduleRecurrence } from "@/lib/medication-schedule"; import { dispatchLocalisedNotification } from "@/lib/notifications/dispatch-localised"; import { getUserTodayBounds, getDayOfWeekInTz } from "@/lib/tz/local-day"; import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; export const dynamic = "force-dynamic"; @@ -116,7 +117,7 @@ export const POST = apiHandler(async (request: NextRequest) => { let notificationsSent = 0; for (const med of medications) { - const userTz = med.user.timezone || "Europe/Berlin"; + const userTz = med.user.timezone || DEFAULT_TIMEZONE; const { start: todayStart, end: todayEnd } = getUserTodayBounds( now, userTz, diff --git a/src/app/api/coach/reminder-suggestions/route.ts b/src/app/api/coach/reminder-suggestions/route.ts index de7a7f1ed..eaafd4844 100644 --- a/src/app/api/coach/reminder-suggestions/route.ts +++ b/src/app/api/coach/reminder-suggestions/route.ts @@ -44,8 +44,7 @@ import { type ReminderScheduleInput, } from "@/lib/measurement-reminders/scheduling"; import { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto"; - -const DEFAULT_TIMEZONE = "Europe/Berlin"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; async function postAction(request: NextRequest): Promise { const { user } = await requireAuth(); diff --git a/src/app/api/coach/suggested-actions/route.ts b/src/app/api/coach/suggested-actions/route.ts index 8302bc33c..aa8e73959 100644 --- a/src/app/api/coach/suggested-actions/route.ts +++ b/src/app/api/coach/suggested-actions/route.ts @@ -47,8 +47,8 @@ import { type ReminderScheduleInput, } from "@/lib/measurement-reminders/scheduling"; import { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; -const DEFAULT_TIMEZONE = "Europe/Berlin"; const CHECKUP_NOTIFY_HOUR = 9; async function postAction(request: NextRequest): Promise { diff --git a/src/app/api/cycle/calendar/route.ts b/src/app/api/cycle/calendar/route.ts index 6e68e71f1..7d3b07dda 100644 --- a/src/app/api/cycle/calendar/route.ts +++ b/src/app/api/cycle/calendar/route.ts @@ -39,7 +39,8 @@ import { import { resolveCycleVerdict } from "@/lib/cycle/verdict"; import { addDays, dayDiff } from "@/lib/cycle/day-math"; import { BBT_WINDOW } from "@/lib/cycle/types"; -import { DEFAULT_TIMEZONE, moodDateKey } from "@/lib/mood/date-key"; +import { moodDateKey } from "@/lib/mood/date-key"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; import { getServerTranslator } from "@/lib/i18n/server-translator"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; diff --git a/src/app/api/cycle/day-logs/[id]/route.ts b/src/app/api/cycle/day-logs/[id]/route.ts index 211e07eea..4c92fe9a2 100644 --- a/src/app/api/cycle/day-logs/[id]/route.ts +++ b/src/app/api/cycle/day-logs/[id]/route.ts @@ -28,7 +28,7 @@ import { ensureCycleForBleedingDay, removeCycleStartedOn, } from "@/lib/cycle/cycle-boundaries"; -import { DEFAULT_TIMEZONE } from "@/lib/mood/date-key"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; import { encrypt, decrypt } from "@/lib/crypto"; import { getOrCreateCycleProfile } from "@/lib/cycle/profile"; import { replaceSymptomLinks } from "@/lib/cycle/day-log-write"; diff --git a/src/app/api/cycle/day-logs/bulk/route.ts b/src/app/api/cycle/day-logs/bulk/route.ts index 93c56116b..66abea65a 100644 --- a/src/app/api/cycle/day-logs/bulk/route.ts +++ b/src/app/api/cycle/day-logs/bulk/route.ts @@ -40,7 +40,7 @@ import { type UnstableExternalIdShape, } from "@/lib/validations/external-id"; import { upsertCycleDayLog } from "@/lib/cycle/day-log-write"; -import { DEFAULT_TIMEZONE } from "@/lib/mood/date-key"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; const BATCH_RATE_LIMIT_MAX = 60; const BATCH_RATE_LIMIT_WINDOW_MS = 60 * 1000; diff --git a/src/app/api/cycle/day-logs/route.ts b/src/app/api/cycle/day-logs/route.ts index cacf927fd..158d85c84 100644 --- a/src/app/api/cycle/day-logs/route.ts +++ b/src/app/api/cycle/day-logs/route.ts @@ -40,7 +40,7 @@ import { import { upsertCycleDayLog } from "@/lib/cycle/day-log-write"; import { findOwningCycleId } from "@/lib/cycle/cycle-attribution"; import { toCycleDayLogDTO, dayLogSymptomInclude } from "@/lib/cycle/dto"; -import { DEFAULT_TIMEZONE } from "@/lib/mood/date-key"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; export const POST = apiHandler(withIdempotency<[NextRequest]>(postDayLog)); diff --git a/src/app/api/cycle/insights/route.ts b/src/app/api/cycle/insights/route.ts index 287406817..4fc89c5b3 100644 --- a/src/app/api/cycle/insights/route.ts +++ b/src/app/api/cycle/insights/route.ts @@ -43,7 +43,8 @@ import { type SymptomDay, } from "@/lib/cycle/symptom-phase"; import { addDays } from "@/lib/cycle/day-math"; -import { DEFAULT_TIMEZONE, moodDateKey } from "@/lib/mood/date-key"; +import { moodDateKey } from "@/lib/mood/date-key"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; /** Trailing window the phase-contrast walks (days). */ const WINDOW_DAYS = 365; @@ -228,6 +229,7 @@ export const GET = apiHandler(async () => { phaseByDay, measurements, userPriorityJson, + timeZone: tz, }); const headline = selectHeadlinePhaseRow(rows); // The lagged rows are served with their narrated `interpretation` intact, so @@ -239,6 +241,7 @@ export const GET = apiHandler(async () => { phaseByDay, measurements, userPriorityJson, + timeZone: tz, locale, }); diff --git a/src/app/api/cycle/period/route.ts b/src/app/api/cycle/period/route.ts index 4fc37c6a8..80b276b3f 100644 --- a/src/app/api/cycle/period/route.ts +++ b/src/app/api/cycle/period/route.ts @@ -37,7 +37,7 @@ import { toMenstrualCycleDTO, dayLogSymptomInclude, } from "@/lib/cycle/dto"; -import { DEFAULT_TIMEZONE } from "@/lib/mood/date-key"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; import { openCycleAt, type MovedAnchors } from "@/lib/cycle/cycle-boundaries"; export const POST = apiHandler(withIdempotency<[NextRequest]>(postPeriod)); diff --git a/src/app/api/illness/episodes/[id]/correlation/route.ts b/src/app/api/illness/episodes/[id]/correlation/route.ts index c0f647364..abc456568 100644 --- a/src/app/api/illness/episodes/[id]/correlation/route.ts +++ b/src/app/api/illness/episodes/[id]/correlation/route.ts @@ -22,7 +22,7 @@ import { apiSuccess, apiError } from "@/lib/api-response"; import { requireIllnessEnabled } from "@/lib/illness/gate"; import { computeEpisodeCorrelation } from "@/lib/illness/correlation-read"; import { notifyIllnessRedFlag } from "@/lib/illness/red-flag-notify"; -import { DEFAULT_TIMEZONE } from "@/lib/mood/date-key"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; type RouteParams = { params: Promise<{ id: string }> }; diff --git a/src/app/api/illness/episodes/[id]/day-logs/route.ts b/src/app/api/illness/episodes/[id]/day-logs/route.ts index 2202f338f..56b18f31d 100644 --- a/src/app/api/illness/episodes/[id]/day-logs/route.ts +++ b/src/app/api/illness/episodes/[id]/day-logs/route.ts @@ -34,7 +34,7 @@ import { } from "@/lib/validations/illness"; import { upsertIllnessDayLog } from "@/lib/illness/day-log-write"; import { toIllnessDayLogDTO, dayLogSymptomInclude } from "@/lib/illness/dto"; -import { DEFAULT_TIMEZONE } from "@/lib/mood/date-key"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; type RouteParams = { params: Promise<{ id: string }> }; diff --git a/src/app/api/import/route.ts b/src/app/api/import/route.ts index 88ea23e34..e827cc674 100644 --- a/src/app/api/import/route.ts +++ b/src/app/api/import/route.ts @@ -37,7 +37,8 @@ import type { MeasurementType } from "@/generated/prisma/client"; import { emitInsertedMeasurementArrivals } from "@/lib/arrivals/measurement-emit"; import { maybeEnqueueMorningRefresh } from "@/lib/daily/morning-refresh-trigger"; import { isP2002, isP2025 } from "@/lib/prisma-errors"; -import { DEFAULT_TIMEZONE, moodDateKey } from "@/lib/mood/date-key"; +import { moodDateKey } from "@/lib/mood/date-key"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; import { deriveA1 } from "@/lib/mood/level-a"; import { zonedWallClockToUtc } from "@/lib/tz/wall-clock"; diff --git a/src/app/api/insights/cards/route.ts b/src/app/api/insights/cards/route.ts index b37ae9652..499171ebe 100644 --- a/src/app/api/insights/cards/route.ts +++ b/src/app/api/insights/cards/route.ts @@ -34,6 +34,7 @@ import { } from "@/lib/analytics/compliance"; import type { MeasurementType } from "@/generated/prisma/client"; import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; type Severity = "alert" | "caution" | "info" | "good"; @@ -216,7 +217,7 @@ export const GET = apiHandler(async () => { arr.push(e); eventsByMed.set(e.medicationId, arr); } - const tz = user.timezone || "Europe/Berlin"; + const tz = user.timezone || DEFAULT_TIMEZONE; for (const med of activeMeds) { const events = eventsByMed.get(med.id) ?? []; const mapped = events.map((e) => ({ diff --git a/src/app/api/insights/comprehensive/route.ts b/src/app/api/insights/comprehensive/route.ts index 46d6b9be7..cc55cd882 100644 --- a/src/app/api/insights/comprehensive/route.ts +++ b/src/app/api/insights/comprehensive/route.ts @@ -41,7 +41,7 @@ import { ensureUserMoodRollupsFresh, readMoodDayRollups, } from "@/lib/rollups/mood-rollups"; -import { userDayKey } from "@/lib/tz/format"; +import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/format"; import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking"; export const dynamic = "force-dynamic"; @@ -125,7 +125,7 @@ export async function buildComprehensiveResponse(user: AuthedUser) { // pass in this route (resting-pulse proxy, medication-continuity, BP // pairing). Was previously re-derived ad hoc (or hardcoded UTC) at each // call site. - const userTz = user.timezone || "Europe/Berlin"; + const userTz = user.timezone || DEFAULT_TIMEZONE; // Fetch user profile (height + DOB drive BMI + BP targets). const dbUser = await prisma.user.findUnique({ @@ -445,7 +445,7 @@ export async function buildComprehensiveResponse(user: AuthedUser) { const medicationContext = buildComplianceMedicationContext( med, lastNonSkippedTakenAt(mapped), - user.timezone || "Europe/Berlin", + user.timezone || DEFAULT_TIMEZONE, ); const c7 = calculateCompliance(mapped, med.schedules, 7, med.createdAt, { medicationContext, diff --git a/src/app/api/insights/correlations/route.ts b/src/app/api/insights/correlations/route.ts index eb4e4358e..f93c5262f 100644 --- a/src/app/api/insights/correlations/route.ts +++ b/src/app/api/insights/correlations/route.ts @@ -41,6 +41,7 @@ import { } from "@/lib/insights/correlation-patterns"; import { assembleDiscoveryMatrix } from "@/lib/insights/discovery-matrix"; import { fetchLabDraws } from "@/lib/insights/correlation-channel-series"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; export const dynamic = "force-dynamic"; @@ -115,7 +116,7 @@ async function buildCorrelationsResponse( where: { id: userId }, select: { timezone: true }, }); - const tz = profile?.timezone ?? "Europe/Berlin"; + const tz = profile?.timezone ?? DEFAULT_TIMEZONE; const now = new Date(); const since = new Date(now.getTime() - WINDOW_DAYS * MS_PER_DAY); diff --git a/src/app/api/insights/generate/route.ts b/src/app/api/insights/generate/route.ts index b5706dd8b..266d77959 100644 --- a/src/app/api/insights/generate/route.ts +++ b/src/app/api/insights/generate/route.ts @@ -692,6 +692,7 @@ export const POST = apiHandler((request: NextRequest) => userId, dbUser?.displayName ?? null, locale, + await resolveUserTimezone(userId), ); // v1.12.7 (B5) — inject the curated SOURCES block for the metric sections diff --git a/src/app/api/measurements/route.ts b/src/app/api/measurements/route.ts index ccc8742d6..ffcaafece 100644 --- a/src/app/api/measurements/route.ts +++ b/src/app/api/measurements/route.ts @@ -76,6 +76,7 @@ import type { MeasurementSource, GlucoseContext, } from "@/generated/prisma/client"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; export const GET = apiHandler(async (request: NextRequest) => { const { user } = await requireRecordAuth("read", "measurements"); @@ -192,7 +193,7 @@ export const GET = apiHandler(async (request: NextRequest) => { const tz = user.timezone && user.timezone.length > 0 ? user.timezone - : "Europe/Berlin"; + : DEFAULT_TIMEZONE; // Resolve `[dayStart, dayEnd)` for the requested calendar day in // the user's local zone. v1.4.37 W10 — `localDayWindow` honours // DST: the previous shape (`canonicalDailyTimestamp ± 12 h`) @@ -242,7 +243,7 @@ export const GET = apiHandler(async (request: NextRequest) => { const tz = user.timezone && user.timezone.length > 0 ? user.timezone - : "Europe/Berlin"; + : DEFAULT_TIMEZONE; // v1.29.6 — DESC + take so a long history returns the MOST RECENT // window of raw samples for bucketing. The previous `orderBy: asc` // + `take: limit` sliced the OLDEST page of rows before the diff --git a/src/app/api/medications/[id]/compliance/route.ts b/src/app/api/medications/[id]/compliance/route.ts index c53eeba65..4831388da 100644 --- a/src/app/api/medications/[id]/compliance/route.ts +++ b/src/app/api/medications/[id]/compliance/route.ts @@ -9,6 +9,7 @@ import { } from "@/lib/medications/compliance-payload"; import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache"; import { checkRateLimit } from "@/lib/rate-limit"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; type RouteParams = { params: Promise<{ id: string }> }; @@ -43,7 +44,7 @@ export const GET = apiHandler( return apiError("Too many compliance requests. Please retry later.", 429); } - const userTz = user.timezone || "Europe/Berlin"; + const userTz = user.timezone || DEFAULT_TIMEZONE; // Read-through the per-user compliance cache (15 min fresh TTL + // SWR window). The ownership guard and the medication read live diff --git a/src/app/api/medications/[id]/dose-history/route.ts b/src/app/api/medications/[id]/dose-history/route.ts index f494ed66e..0159323a1 100644 --- a/src/app/api/medications/[id]/dose-history/route.ts +++ b/src/app/api/medications/[id]/dose-history/route.ts @@ -41,6 +41,7 @@ import { } from "@/lib/medications/scheduling/worker-helpers"; import { assertMedicationOwnership } from "@/lib/medications/route-guards"; import { dueSchedules } from "@/lib/medications/intake-tracking"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; type RouteParams = { params: Promise<{ id: string }> }; @@ -120,7 +121,7 @@ export const GET = apiHandler( } const now = new Date(); - const userTz = user.timezone || "Europe/Berlin"; + const userTz = user.timezone || DEFAULT_TIMEZONE; const medication = await prisma.medication.findUnique({ where: { id }, diff --git a/src/app/api/medications/[id]/efficacy/route.ts b/src/app/api/medications/[id]/efficacy/route.ts index 5bc75939d..53c54fedd 100644 --- a/src/app/api/medications/[id]/efficacy/route.ts +++ b/src/app/api/medications/[id]/efficacy/route.ts @@ -2,6 +2,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiSuccess, apiError } from "@/lib/api-response"; import { checkRateLimit } from "@/lib/rate-limit"; import { buildMedicationEfficacy } from "@/lib/medications/efficacy/build-efficacy"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; type RouteParams = { params: Promise<{ id: string }> }; @@ -31,7 +32,7 @@ export const GET = apiHandler( return apiError("Too many efficacy requests. Please retry later.", 429); } - const userTz = user.timezone || "Europe/Berlin"; + const userTz = user.timezone || DEFAULT_TIMEZONE; const dto = await buildMedicationEfficacy(user.id, id, userTz); if (!dto) { return apiError("Medication not found", 404); diff --git a/src/app/api/medications/[id]/route.ts b/src/app/api/medications/[id]/route.ts index 2d281fbff..1afbbbb26 100644 --- a/src/app/api/medications/[id]/route.ts +++ b/src/app/api/medications/[id]/route.ts @@ -46,6 +46,7 @@ import { isRecordOnly, scheduleWireFields, } from "@/lib/medications/intake-tracking"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; type RouteParams = { params: Promise<{ id: string }> }; @@ -175,7 +176,7 @@ export const GET = apiHandler( }, schedules: liveSchedules, now, - userTz: user.timezone || "Europe/Berlin", + userTz: user.timezone || DEFAULT_TIMEZONE, lastIntakeAt: lastIntake?.takenAt ?? null, resolvedSlots: resolvedRows.map(toResolvedSlotMark), eraStart: latestRevision?.validUntil ?? null, @@ -603,7 +604,7 @@ export const PUT = apiHandler( // `syncVersion` so delta-sync clients drop them. The projector and // the reminder worker re-mint the anchors for the new times on their // next pass — the route does not pre-create them. - const userTz = user.timezone || "Europe/Berlin"; + const userTz = user.timezone || DEFAULT_TIMEZONE; const { start: todayStart } = getUserTodayBounds(new Date(), userTz); const tombstoneWhere = { userId: user.id, diff --git a/src/app/api/medications/compliance/route.ts b/src/app/api/medications/compliance/route.ts index 06836c731..eee5e5f97 100644 --- a/src/app/api/medications/compliance/route.ts +++ b/src/app/api/medications/compliance/route.ts @@ -12,6 +12,7 @@ import { import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache"; import { checkRateLimit } from "@/lib/rate-limit"; import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; /** * Batched card-compliance read: one round trip for every medication the @@ -43,7 +44,7 @@ export const GET = apiHandler(async () => { return apiError("Too many compliance requests. Please retry later.", 429); } - const userTz = user.timezone || "Europe/Berlin"; + const userTz = user.timezone || DEFAULT_TIMEZONE; // Same ordering as the medications list so the page's card order and // this payload walk the same sequence. diff --git a/src/app/api/mood-entries/[id]/route.ts b/src/app/api/mood-entries/[id]/route.ts index f6535df55..94cb5f59e 100644 --- a/src/app/api/mood-entries/[id]/route.ts +++ b/src/app/api/mood-entries/[id]/route.ts @@ -14,7 +14,8 @@ import { NextRequest } from "next/server"; import { Prisma } from "@/generated/prisma/client"; import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; -import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key"; +import { moodDateKey } from "@/lib/mood/date-key"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; import { deriveA1, shapeLevelA } from "@/lib/mood/level-a"; import { contextForWire, persistMoodContext } from "@/lib/mood/context"; import { encryptNote, shapeMoodNote } from "@/lib/crypto/note-cipher"; diff --git a/src/app/api/mood-entries/bulk/route.ts b/src/app/api/mood-entries/bulk/route.ts index 29bb815d7..3a504fa71 100644 --- a/src/app/api/mood-entries/bulk/route.ts +++ b/src/app/api/mood-entries/bulk/route.ts @@ -51,7 +51,8 @@ import { UNSTABLE_EXTERNAL_ID_REASON, type UnstableExternalIdShape, } from "@/lib/validations/external-id"; -import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key"; +import { moodDateKey } from "@/lib/mood/date-key"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; import { levelAForWrite } from "@/lib/mood/level-a"; import { persistMoodContext } from "@/lib/mood/context"; import { encryptNote } from "@/lib/crypto/note-cipher"; diff --git a/src/app/api/mood-entries/route.ts b/src/app/api/mood-entries/route.ts index 96fb20d0a..86b14901a 100644 --- a/src/app/api/mood-entries/route.ts +++ b/src/app/api/mood-entries/route.ts @@ -24,7 +24,8 @@ import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { withIdempotency } from "@/lib/idempotency"; import { encryptNote, shapeMoodNote } from "@/lib/crypto/note-cipher"; -import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key"; +import { moodDateKey } from "@/lib/mood/date-key"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; import { levelAForWrite, shapeLevelA } from "@/lib/mood/level-a"; import { contextForWire, persistMoodContext } from "@/lib/mood/context"; import { listMoodEntriesPage } from "@/lib/mood/list-read"; diff --git a/src/app/api/mood/linked-context/route.ts b/src/app/api/mood/linked-context/route.ts index c66e0030c..8756d58d0 100644 --- a/src/app/api/mood/linked-context/route.ts +++ b/src/app/api/mood/linked-context/route.ts @@ -38,8 +38,7 @@ import { apiSuccess, returnAllZodIssues } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { requireModuleEnabled } from "@/lib/modules/gate"; import { resolveLinkedDayContext } from "@/lib/mood/linked-context"; -import { DEFAULT_TIMEZONE } from "@/lib/mood/date-key"; -import { isValidTimezone } from "@/lib/tz/format"; +import { DEFAULT_TIMEZONE, isValidTimezone } from "@/lib/tz/format"; export const dynamic = "force-dynamic"; diff --git a/src/app/api/sync/state/route.ts b/src/app/api/sync/state/route.ts index b45e269a1..4d4083640 100644 --- a/src/app/api/sync/state/route.ts +++ b/src/app/api/sync/state/route.ts @@ -42,6 +42,7 @@ import { NATIVE_REFRESH_TOKEN_DAYS, TOMBSTONE_RETENTION_DAYS, } from "@/lib/auth/native-client"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; export const GET = apiHandler(async (_request: NextRequest) => { const { user } = await requireAuth(); @@ -119,7 +120,7 @@ export const GET = apiHandler(async (_request: NextRequest) => { return apiSuccess({ userId: user.id, - timezone: currentUser?.timezone ?? "Europe/Berlin", + timezone: currentUser?.timezone ?? DEFAULT_TIMEZONE, lastSyncedAt: lastSyncedAt?.toISOString() ?? null, serverNow: serverNow.toISOString(), measurements: { diff --git a/src/app/medications/page-client.tsx b/src/app/medications/page-client.tsx index 54e39bf6d..e37e45adf 100644 --- a/src/app/medications/page-client.tsx +++ b/src/app/medications/page-client.tsx @@ -48,6 +48,7 @@ import { apiGet } from "@/lib/api/api-fetch"; import { useMedicationComplianceSummaryAll } from "@/lib/queries/use-medication-compliance-summary"; import { useMedicationListLayout } from "@/lib/queries/use-medication-list-layout"; import { applyMedicationOrder } from "@/lib/medications/medication-order"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; interface Schedule { id: string; @@ -397,7 +398,7 @@ export default function MedicationsPageClient() { // medications past their band tail stay takeable from the batch. Page // order is preserved so the confirm dialog lists medications as rendered. const dueMeds = deriveDueMedications(activeMeds, { - tz: user?.timezone || "Europe/Berlin", + tz: user?.timezone || DEFAULT_TIMEZONE, thresholds: thresholds ?? undefined, doseStatusById: new Map( (complianceRows ?? []).map((row) => [ diff --git a/src/components/charts/medication-compliance-chart.tsx b/src/components/charts/medication-compliance-chart.tsx index 866759999..fa5aefa0e 100644 --- a/src/components/charts/medication-compliance-chart.tsx +++ b/src/components/charts/medication-compliance-chart.tsx @@ -69,6 +69,7 @@ import { useViewportWidth } from "@/hooks/use-viewport-width"; import { chooseTickInterval } from "@/lib/charts/x-axis-density"; import { apiGet } from "@/lib/api/api-fetch"; import { shouldFireDataReady } from "@/lib/charts/data-ready-latch"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; interface DailyCompliancePoint { /** Berlin calendar day, "YYYY-MM-DD". */ @@ -208,7 +209,7 @@ interface MedicationComplianceChartProps { export function MedicationComplianceChart({ title, - userTimezone = "Europe/Berlin", + userTimezone = DEFAULT_TIMEZONE, compareBaseline, onDataReady, }: MedicationComplianceChartProps) { diff --git a/src/components/dashboard/medication-intake-quick-add.tsx b/src/components/dashboard/medication-intake-quick-add.tsx index 1476bfb78..52788fd3a 100644 --- a/src/components/dashboard/medication-intake-quick-add.tsx +++ b/src/components/dashboard/medication-intake-quick-add.tsx @@ -31,6 +31,7 @@ import { intakeToastOptions, runUndoIntake, } from "@/components/medications/use-medication-intake"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; /** * v1.4.37 W7b — dashboard "Hinzufügen" → "Medikamenteneinnahme" quick-add. @@ -115,7 +116,7 @@ export function MedicationIntakeQuickAdd({ // source the cards use); Berlin stays the last-resort fallback so // logged-out mounts behave unchanged. const { user } = useAuth(); - const userTz = user?.timezone || "Europe/Berlin"; + const userTz = user?.timezone || DEFAULT_TIMEZONE; // The record this dose lands in, or null in the caller's own. const recordName = useActiveRecordName(); diff --git a/src/components/insights/intraday-pulse-chart.tsx b/src/components/insights/intraday-pulse-chart.tsx index 965014e94..09e1ae79a 100644 --- a/src/components/insights/intraday-pulse-chart.tsx +++ b/src/components/insights/intraday-pulse-chart.tsx @@ -19,7 +19,7 @@ import { useAuth } from "@/hooks/use-auth"; import { apiGet } from "@/lib/api/api-fetch"; import { queryKeys } from "@/lib/query-keys"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; -import { userDayKey, shiftDateKey } from "@/lib/tz/format"; +import { userDayKey, shiftDateKey, DEFAULT_TIMEZONE } from "@/lib/tz/format"; import { TileHeader } from "@/components/insights/tile-header"; import { Button } from "@/components/ui/button"; import { QueryErrorCard } from "@/components/ui/query-error-card"; @@ -94,7 +94,7 @@ export function IntradayPulseChart({ const { isAuthenticated } = useAuth(); const { t } = useTranslations(); const fmt = useFormatters(); - const tz = userTimezone ?? "Europe/Berlin"; + const tz = userTimezone ?? DEFAULT_TIMEZONE; const todayKey = userDayKey(new Date(), tz); // `null` tracks "today" live (so a session left open across midnight keeps // following the current day); a non-null value pins the viewed day once diff --git a/src/components/measurement-reminders/__tests__/vorsorge-section.test.tsx b/src/components/measurement-reminders/__tests__/vorsorge-section.test.tsx index 7bba97edb..da56bb22f 100644 --- a/src/components/measurement-reminders/__tests__/vorsorge-section.test.tsx +++ b/src/components/measurement-reminders/__tests__/vorsorge-section.test.tsx @@ -34,7 +34,7 @@ vi.mock("@/hooks/use-measurement-reminders", () => ({ })); import { VorsorgeSection, postponeOffsetTargets } from "../vorsorge-section"; -import { DISPLAY_TIMEZONE } from "@/lib/format-locale"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; /** * Noon on the day `offsetDays` from today, counted on the DISPLAY zone's @@ -53,7 +53,7 @@ import { DISPLAY_TIMEZONE } from "@/lib/format-locale"; function noonOnDisplayDay(offsetDays: number): Date { // `en-CA` formats as YYYY-MM-DD, which is the shape we want to add days to. const today = new Intl.DateTimeFormat("en-CA", { - timeZone: DISPLAY_TIMEZONE, + timeZone: DEFAULT_TIMEZONE, year: "numeric", month: "2-digit", day: "2-digit", @@ -219,7 +219,7 @@ describe(" loading + empty", () => { // the wall-clock gap to now exceeds the 24h a rolling delta would key on. // Anchor the due instant to noon on the DISPLAY zone's today, not the // process clock's. `vitest.config.mts` pins the suite to TZ=UTC while the - // component floors in the profile zone (`DISPLAY_TIMEZONE`, Europe/Berlin), + // component floors in the profile zone (`DEFAULT_TIMEZONE`, Europe/Berlin), // and those two disagree about the date for the two hours before UTC // midnight. Anchoring on `setHours` made this case fail every night in // that window, which is the same confusion of clocks the assertion exists diff --git a/src/components/medications/card-parts/displayed-slot-instant.ts b/src/components/medications/card-parts/displayed-slot-instant.ts index 62eedae0d..a8d633239 100644 --- a/src/components/medications/card-parts/displayed-slot-instant.ts +++ b/src/components/medications/card-parts/displayed-slot-instant.ts @@ -1,3 +1,4 @@ +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; /** * v1.12.3 — resolve the canonical slot instant of the dose a medication * card is currently surfacing, so the card's "Genommen" / "Skip" buttons @@ -138,7 +139,7 @@ export function resolveDisplayedSlotInstant( currentWindowStatus, nextDueAt, now, - timeZone = "Europe/Berlin", + timeZone = DEFAULT_TIMEZONE, } = input; // The card surfaces the current/overdue dose band — record that band's diff --git a/src/components/medications/dose-history-ledger.tsx b/src/components/medications/dose-history-ledger.tsx index e80592a7f..642e4bb7a 100644 --- a/src/components/medications/dose-history-ledger.tsx +++ b/src/components/medications/dose-history-ledger.tsx @@ -111,6 +111,7 @@ import { type LedgerRow, } from "@/components/medications/dose-history-ledger-compute"; import { apiDelete, apiGet, apiPost, apiPut } from "@/lib/api/api-fetch"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; const DAY_MS = 24 * 60 * 60 * 1000; @@ -182,7 +183,7 @@ export function DoseHistoryLedger({ // marked — the most-encountered of the set. const recordName = useActiveRecordName(); const queryClient = useQueryClient(); - const timeZone = user?.timezone || "Europe/Berlin"; + const timeZone = user?.timezone || DEFAULT_TIMEZONE; const [addOpen, setAddOpen] = useState(false); const [editingEvent, setEditingEvent] = useState<{ diff --git a/src/components/medications/glp1-medication-card.tsx b/src/components/medications/glp1-medication-card.tsx index b0f22972e..1895daa0a 100644 --- a/src/components/medications/glp1-medication-card.tsx +++ b/src/components/medications/glp1-medication-card.tsx @@ -37,6 +37,7 @@ import { } from "@/components/medications/detail/supply-runway"; import { useAuth } from "@/hooks/use-auth"; import { useMedicationComplianceSummary } from "@/lib/queries/use-medication-compliance-summary"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; /** * v1.4.25 W4d — GLP-1 medication card variant. @@ -202,7 +203,7 @@ export function Glp1MedicationCard({ // v1.16.9 — the card reasons in the PROFILE timezone; Berlin stays the // last-resort fallback for logged-out mounts and legacy fixtures. const { user } = useAuth(); - const userTz = user?.timezone || "Europe/Berlin"; + const userTz = user?.timezone || DEFAULT_TIMEZONE; const fmt = useFormatters(); // Issue #490 — the zone `fmt` renders dates in (mirror → Berlin). The // weekday name paired with `fmt.dateShort` must be derived in this SAME diff --git a/src/components/medications/log-intake-dialog.tsx b/src/components/medications/log-intake-dialog.tsx index 111282526..411ef022f 100644 --- a/src/components/medications/log-intake-dialog.tsx +++ b/src/components/medications/log-intake-dialog.tsx @@ -44,6 +44,7 @@ import { runLogIntake, runUndoIntake, } from "@/components/medications/use-medication-intake"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; interface LogIntakeSchedule extends ScheduleWindowInput { label: string | null; @@ -120,7 +121,7 @@ export function LogIntakeDialog({ const recordName = useActiveRecordName(); const queryClient = useQueryClient(); const { user } = useAuth(); - const userTz = user?.timezone || "Europe/Berlin"; + const userTz = user?.timezone || DEFAULT_TIMEZONE; const formId = useId(); const [medicationOverride, setMedicationOverride] = useState( diff --git a/src/components/medications/medication-card.tsx b/src/components/medications/medication-card.tsx index b59b2ac0b..a4d1623c3 100644 --- a/src/components/medications/medication-card.tsx +++ b/src/components/medications/medication-card.tsx @@ -34,6 +34,7 @@ import { LogInjectionSiteDialog } from "@/components/medications/log-injection-s import { useGlobalExcludedInjectionSites } from "@/lib/medications/use-injection-site-prefs"; import { useAuth } from "@/hooks/use-auth"; import type { InjectionSiteKey } from "@/lib/medications/injection-sites"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; interface Schedule { id: string; @@ -149,7 +150,7 @@ export function MedicationCard({ // session carries); Berlin stays the last-resort fallback so logged-out // mounts and legacy fixtures behave unchanged. const { user } = useAuth(); - const userTz = user?.timezone || "Europe/Berlin"; + const userTz = user?.timezone || DEFAULT_TIMEZONE; const fmt = useFormatters(); const weekdayLabel = useWeekdayLabel(); // v1.8.5 — post-dose injection-site prompt state. Holds the intake diff --git a/src/components/medications/medication-table.tsx b/src/components/medications/medication-table.tsx index d5089f611..e52c9c92a 100644 --- a/src/components/medications/medication-table.tsx +++ b/src/components/medications/medication-table.tsx @@ -43,6 +43,7 @@ import { } from "@/lib/queries/use-medication-compliance-summary"; import { useAuth } from "@/hooks/use-auth"; import { cn } from "@/lib/utils"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; /** * v1.16.10 — the compact table view for /medications (issue #316 item 3). @@ -194,7 +195,7 @@ export function MedicationTable({ }: MedicationTableProps) { const { t, locale } = useTranslations(); const { user } = useAuth(); - const userTz = user?.timezone || "Europe/Berlin"; + const userTz = user?.timezone || DEFAULT_TIMEZONE; const [sort, setSort] = useState(initialSort); const [, forceUpdate] = useReducer((x: number) => x + 1, 0); diff --git a/src/components/medications/take-all-due.ts b/src/components/medications/take-all-due.ts index aaab08810..d2ff9ac7a 100644 --- a/src/components/medications/take-all-due.ts +++ b/src/components/medications/take-all-due.ts @@ -57,6 +57,7 @@ import { } from "@/lib/medications/window-status"; import { resolveDisplayedSlotInstant } from "@/components/medications/card-parts/displayed-slot-instant"; import type { DoseStatus } from "@/lib/analytics/compliance"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; type Translator = ( key: string, @@ -142,7 +143,7 @@ export function deriveDueMedications( } = {}, ): DueMedication[] { const now = options.now ?? new Date(); - const tz = options.tz ?? "Europe/Berlin"; + const tz = options.tz ?? DEFAULT_TIMEZONE; const { lateMinutes, missedMinutes } = options.thresholds ?? DEFAULT_THRESHOLDS; diff --git a/src/components/settings/account-section/index.tsx b/src/components/settings/account-section/index.tsx index e07b9c7fa..41eb35e93 100644 --- a/src/components/settings/account-section/index.tsx +++ b/src/components/settings/account-section/index.tsx @@ -33,7 +33,7 @@ import { TimeFormatSelect } from "@/components/settings/time-format-select"; import { DateFormatSelect } from "@/components/settings/date-format-select"; import { UnitPreferenceSelect } from "@/components/settings/unit-preference-select"; import { GlucoseUnitSelect } from "@/components/settings/glucose-unit-select"; -import { detectBrowserTimezone } from "@/lib/tz/format"; +import { detectBrowserTimezone, DEFAULT_TIMEZONE } from "@/lib/tz/format"; import { apiFetchRaw } from "@/lib/api/api-fetch"; import { describeRejectedProfileField, @@ -74,7 +74,7 @@ export function AccountSection() { const [height, setHeight] = useState(EMPTY_HEIGHT_DRAFT); const [dateOfBirth, setDateOfBirth] = useState(""); const [gender, setGender] = useState(""); - const [timezone, setTimezone] = useState("Europe/Berlin"); + const [timezone, setTimezone] = useState(DEFAULT_TIMEZONE); // v1.7.0 — optional patient-identity fields for the health-record export. const [fullName, setFullName] = useState(""); const [insurerName, setInsurerName] = useState(""); @@ -84,7 +84,7 @@ export function AccountSection() { height: EMPTY_HEIGHT_DRAFT, dateOfBirth: "", gender: "", - timezone: "Europe/Berlin", + timezone: DEFAULT_TIMEZONE, fullName: "", insurerName: "", insuranceNumber: "", diff --git a/src/components/settings/managed-record-settings-section.tsx b/src/components/settings/managed-record-settings-section.tsx index 72639967c..fe98c4f5a 100644 --- a/src/components/settings/managed-record-settings-section.tsx +++ b/src/components/settings/managed-record-settings-section.tsx @@ -39,6 +39,7 @@ import { Switch } from "@/components/ui/switch"; import { SettingsCardActions } from "./_card-actions"; import { SettingsCardHeader } from "./_card-header"; import { SettingsCard } from "./settings-card"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; interface ManagedRecordSettingsResponse { recordId: string; @@ -414,7 +415,7 @@ function ProfileSettingsForm({