diff --git a/src/lib/analytics/__tests__/range-delta-per-period.test.ts b/src/lib/analytics/__tests__/range-delta-per-period.test.ts index 445aba5bc..b16f32343 100644 --- a/src/lib/analytics/__tests__/range-delta-per-period.test.ts +++ b/src/lib/analytics/__tests__/range-delta-per-period.test.ts @@ -22,7 +22,11 @@ vi.mock("@/lib/tz/resolver", () => ({ resolveUserTimezone: vi.fn(async () => "UTC"), })); -import { computeRangeDelta } from "@/lib/analytics/range-delta"; +import { + computeRangeDelta, + dailyTotalsEndKey, + splitDailyTotals, +} from "@/lib/analytics/range-delta"; const DAY_MS = 86_400_000; const NOW = Date.UTC(2026, 5, 30, 12); @@ -49,8 +53,9 @@ beforeEach(() => { 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. + // Six complete days of 8,000 in the previous week and five in the + // current one. Today is still arriving as 200 small samples and must not + // count: its partial total made every morning's delta read "down". 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)), @@ -61,9 +66,38 @@ describe("computeRangeDelta — per-day and per-night metrics", () => { 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); + expect(result.current).toMatchObject({ count: 5, mean: 8000, sum: 40_000 }); + expect(result.delta).toBe(0); + const [, , , from, to] = mocks.readRollupBuckets.mock.calls[0]!; + expect((from as Date).toISOString()).toBe("2026-06-16T00:00:00.000Z"); + expect((to as Date).toISOString()).toBe("2026-06-30T00:00:00.000Z"); + }); + + it.each([ + // West of UTC in the evening: the UTC day already rolled over, the + // user's calendar did not. + ["America/New_York", "2026-07-01T01:00:00.000Z", "2026-06-29"], + // East of UTC in the early morning: the bucket keyed yesterday is still + // filling until UTC midnight. + ["Asia/Tokyo", "2026-06-29T20:00:00.000Z", "2026-06-28"], + ["Europe/Berlin", "2026-06-30T06:00:00.000Z", "2026-06-29"], + ["UTC", "2026-06-30T12:00:00.000Z", "2026-06-29"], + ])("ends the daily-total window on a completed day in %s", (tz, iso, end) => { + expect(dailyTotalsEndKey(Date.parse(iso), tz)).toBe(end); + }); + + it("splits daily totals on day keys ending at the end key", () => { + const points = [ + { day: "2026-06-30", value: 100 }, + { day: "2026-06-29", value: 9000 }, + { day: "2026-06-23", value: 9000 }, + { day: "2026-06-22", value: 7000 }, + { day: "2026-06-16", value: 7000 }, + { day: "2026-06-15", value: 1 }, + ]; + const { current, previous } = splitDailyTotals(points, 7, "2026-06-29"); + expect(current).toMatchObject({ count: 2, mean: 9000 }); + expect(previous).toMatchObject({ count: 2, mean: 7000 }); }); it("compares average nights of sleep, not the mean of stage rows", async () => { diff --git a/src/lib/analytics/range-delta.ts b/src/lib/analytics/range-delta.ts index d2673064c..eba1c4abe 100644 --- a/src/lib/analytics/range-delta.ts +++ b/src/lib/analytics/range-delta.ts @@ -51,7 +51,7 @@ import { readBestGranularityRollups, type RollupBucketRow, } from "@/lib/rollups/measurement-read-wmy"; -import { startOfUtcDay } from "@/lib/tz/start-of-utc-day"; +import { shiftDateKey, userDayKey } from "@/lib/tz/format"; import { resolveUserTimezone } from "@/lib/tz/resolver"; import { ANALYTICS_RANGES, @@ -146,21 +146,62 @@ function splitPerPeriod( }; } +/** + * The last day a daily total may count for: yesterday on the user's + * calendar, and never a UTC day bucket that has not ended yet. The DAY + * rollups are UTC buckets; east of UTC the bucket keyed "yesterday" is still + * filling until UTC midnight, so the earlier of the two keys wins. Counting + * today's still-growing total made every delta read "down" in the morning. + */ +export function dailyTotalsEndKey(now: number, timezone: string): string { + const localYesterday = shiftDateKey(userDayKey(new Date(now), timezone), -1); + const utcYesterday = shiftDateKey( + new Date(now).toISOString().slice(0, 10), + -1, + ); + return localYesterday < utcYesterday ? localYesterday : utcYesterday; +} + +/** + * Split one-per-day totals into the `windowDays` completed days ending + * `endKey` (current) and the `windowDays` days before them (previous). + */ +export function splitDailyTotals( + points: ReadonlyArray<{ day: string; value: number }>, + windowDays: number, + endKey: string, +): { current: WindowAggregate; previous: WindowAggregate } { + const currentStart = shiftDateKey(endKey, -(windowDays - 1)); + const previousStart = shiftDateKey(endKey, -(2 * windowDays - 1)); + const current: number[] = []; + const previous: number[] = []; + for (const p of points) { + if (p.day > endKey) continue; + if (p.day >= currentStart) current.push(p.value); + else if (p.day >= previousStart) 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), + endKey: string, +): Promise> { + const from = new Date( + `${shiftDateKey(endKey, -(2 * windowDays - 1))}T00:00:00.000Z`, ); - return rows.map((r) => ({ at: r.bucketStart, value: r.mean * r.count })); + const to = new Date(`${shiftDateKey(endKey, 1)}T00:00:00.000Z`); + const rows = await readRollupBuckets(userId, type, "DAY", from, to); + return rows.map((r) => ({ + day: r.bucketStart.toISOString().slice(0, 10), + value: r.mean * r.count, + })); } /** Time asleep per reconstructed night, one point per night. */ @@ -233,22 +274,30 @@ export async function computeRangeDelta( ): 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); + if (type === "SLEEP_DURATION") { + const points = await readNightlySleep(userId, 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", + granularity: points.length === 0 ? "none" : "live", + current, + previous, + delta, + deltaPct, + }; + } + + if (CUMULATIVE_HK_TYPES.has(type)) { + const endKey = dailyTotalsEndKey(now, await resolveUserTimezone(userId)); + const points = await readDailyTotals(userId, type, windowDays, endKey); + const { current, previous } = splitDailyTotals(points, windowDays, endKey); + const { delta, deltaPct } = composeDelta(current, previous); + return { + range, + windowDays, + granularity: points.length === 0 ? "none" : "DAY", current, previous, delta,