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] 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,