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,