mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user