From 055cbcff0eae1e4113432d3dc099cc7694ffdc8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 8 Oct 2026 21:53:56 +0200 Subject: [PATCH] fix(insights): count device strain by day, not by cycle The band writes one strain row per physiological cycle, stamped at its start, and a bedtime either side of midnight puts two starts on one calendar day. The strain page counted those rows as days and drew an extra sparkline point the recovery chart does not have. Collapse the rows to one per local day at the day's mean, the chart's own value. --- .../derived/__tests__/wellness-scores.test.ts | 36 +++++++++++++ src/lib/insights/derived/wellness-scores.ts | 51 ++++++++++++++++++- 2 files changed, 86 insertions(+), 1 deletion(-) diff --git a/src/lib/insights/derived/__tests__/wellness-scores.test.ts b/src/lib/insights/derived/__tests__/wellness-scores.test.ts index 6340f98b0..619e0232b 100644 --- a/src/lib/insights/derived/__tests__/wellness-scores.test.ts +++ b/src/lib/insights/derived/__tests__/wellness-scores.test.ts @@ -352,6 +352,42 @@ describe("computeWellnessScore — strain reads the device's day strain", () => expect(r.provenance.inputs).toEqual(["DAY_STRAIN"]); }); + it("counts days, not cycles, when two cycles start on one local day", async () => { + // Bed at 00:10 and again at 23:50 the same Berlin day: two cycle rows, + // one calendar day, which the recovery chart shows as one point (the + // day's mean). + byType({ + STRAIN_SCORE: [], + DAY_STRAIN: [ + { + value: 4, + measuredAt: new Date("2026-06-01T21:50:00Z"), + source: "WHOOP", + }, + { + value: 12, + measuredAt: new Date("2026-05-31T22:10:00Z"), + source: "WHOOP", + }, + { + value: 9, + measuredAt: new Date("2026-05-30T21:30:00Z"), + source: "WHOOP", + }, + ], + }); + const r = await computeWellnessScore("STRAIN_SCORE", "u1", PROFILE, { + now: NOW, + }); + expect(r.status).toBe("ok"); + if (r.status !== "ok") return; + const v = r.value as WellnessScoreValue; + expect(v.daysInWindow).toBe(2); + expect(v.series).toHaveLength(2); + expect(v.device).toEqual({ value: 8, scaleMax: 21 }); + expect(r.coverage.historyDays).toBe(2); + }); + it("keeps the computed proxy when one exists", async () => { byType({ STRAIN_SCORE: [ diff --git a/src/lib/insights/derived/wellness-scores.ts b/src/lib/insights/derived/wellness-scores.ts index e41f66f0e..992d0f4b1 100644 --- a/src/lib/insights/derived/wellness-scores.ts +++ b/src/lib/insights/derived/wellness-scores.ts @@ -24,6 +24,8 @@ import { computeReadiness, type ReadinessComponent } from "./readiness"; import { resolveCanonicalRecovery } from "./recovery-resolve"; import { SPARKLINE_MAX_POINTS, type Derived } from "./types"; import { dateOnlyKey } from "@/lib/tz/date-only"; +import { dayKeyForUserTz } from "@/lib/measurements/consolidation-tz"; +import { resolveUserTimezone } from "@/lib/measurements/consolidation-base"; /** A 0–100 wellness score band. Higher is better for recovery; for stress a * higher score is worse, so the band direction flips (see `WELLNESS_DIR`). */ @@ -208,7 +210,18 @@ export async function computeWellnessScore( orderBy: { measuredAt: "desc" }, }); if (deviceRows.length > 0) { - return buildDeviceStrain(deviceRows, windowDays, computedAt); + if (timezone === null) { + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { timezone: true }, + }); + timezone = user?.timezone ?? null; + } + return buildDeviceStrain( + deviceStrainDays(deviceRows, timezone), + windowDays, + computedAt, + ); } } @@ -314,6 +327,42 @@ function deviceStrainShare(value: number): number { return Math.round((clamped / DEVICE_STRAIN_SCALE_MAX) * 100); } +/** + * One row per local calendar day from the band's DAY_STRAIN rows. The band + * writes one row per physiological cycle, stamped at the cycle's start, and + * a bedtime either side of midnight puts two cycle starts on one calendar + * day. Counted as rows, those days were counted twice and the sparkline + * carried an extra point the recovery page's daily chart does not have. The + * day's value is the mean of its rows, which is the chart's own daily value; + * the day stands at its latest row. Newest day first. + */ +export function deviceStrainDays( + rows: readonly { value: number; measuredAt: Date }[], + timezone: string | null, +): { value: number; measuredAt: Date }[] { + const tz = resolveUserTimezone(timezone); + const byDay = new Map< + string, + { sum: number; count: number; measuredAt: Date } + >(); + for (const row of rows) { + const key = dayKeyForUserTz(row.measuredAt, tz); + const day = byDay.get(key); + if (!day) { + byDay.set(key, { sum: row.value, count: 1, measuredAt: row.measuredAt }); + continue; + } + day.sum += row.value; + day.count += 1; + if (row.measuredAt.getTime() > day.measuredAt.getTime()) { + day.measuredAt = row.measuredAt; + } + } + return [...byDay.values()] + .map((day) => ({ value: day.sum / day.count, measuredAt: day.measuredAt })) + .sort((a, b) => b.measuredAt.getTime() - a.measuredAt.getTime()); +} + function buildDeviceStrain( rows: { value: number; measuredAt: Date }[], windowDays: number,