fix(insights): compare completed days in the step-total range delta

The current half of a cumulative metric's range delta counted today's
still-growing total, so the comparison read lower every morning. Both
halves now end on the last completed day: yesterday on the user's
calendar, and never a UTC day bucket that has not ended yet.
This commit is contained in:
Marc-André Bombeck committed 2026-09-26 22:05:37 +02:00
1 parent 66b1aef40c
commit 96b98cb719
2 files changed
+110 -27

No files matched your search

@@ -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 () => {
+70 -21
View File
@@ -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<Array<{ at: Date; value: number }>> {
const rows = await readRollupBuckets(
userId,
type,
"DAY",
startOfUtcDay(new Date(now - 2 * windowDays * DAY_MS)),
new Date(now),
endKey: string,
): Promise<Array<{ day: string; value: number }>> {
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<RangeDeltaResult> {
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,