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.
This commit is contained in:
Marc-André Bombeck committed 2026-09-26 15:37:25 +02:00
1 parent 148d6c035f
commit e1e664be46
3 files changed
+103 -22

No files matched your search

@@ -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,
+20 -7
View File
@@ -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<PillarValue> {
@@ -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({
+10 -10
View File
@@ -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,