mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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.
This commit is contained in:
1 parent
facfcad107
commit
055cbcff0e
2 files changed
+86
-1
No files matched your search
@@ -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: [
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user