mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
fix(insights): unify sleep reconstruction and make WHOOP recovery canonical
Route the derived Sleep Score through the canonical night reconstruction instead of its own UTC-day grouping, so a night logged by more than one source is deduplicated to a single writer rather than summed. The score and the readiness that folds it now read true per-night totals; multi-source nights drop to their real figure. Emitted minutes and the night stage map round to integers. Resolve recovery to one canonical series — WHOOP-native when present, the computed proxy as fallback — in the wellness reader, the doctor report, and the coach snapshot, and stop presenting the proxy and the native score as two numbers. A WHOOP-connected account's recovery now reads the native value on the tile, the chart, and the printed report alike.
This commit is contained in:
commit
e27bd8b9bf
16 files changed
+529
-106
No files matched your search
@@ -64,7 +64,14 @@ function serializeSession(s: SleepSession) {
|
||||
inBedMinutes: s.inBedMinutes === null ? null : Math.round(s.inBedMinutes),
|
||||
awakeMinutes: s.awakeMinutes === null ? null : Math.round(s.awakeMinutes),
|
||||
awakenings: s.awakenings,
|
||||
stages: s.stages,
|
||||
// iOS #18 — round the per-stage map too; the underlying totals sum
|
||||
// second-precision segments and otherwise serialise as e.g. 88.4999.
|
||||
stages: Object.fromEntries(
|
||||
Object.entries(s.stages).map(([stage, minutes]) => [
|
||||
stage,
|
||||
Math.round(minutes),
|
||||
]),
|
||||
),
|
||||
segments: s.segments.map((seg) => ({
|
||||
stage: seg.stage,
|
||||
start: seg.start.toISOString(),
|
||||
|
||||
@@ -319,6 +319,15 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
});
|
||||
} else {
|
||||
// Every row carries its `source`. RECOVERY_SCORE is written by BOTH the
|
||||
// WHOOP-native sync (`source = WHOOP`) and the COMPUTED proxy job
|
||||
// (`source = COMPUTED`) for the same day; the client must resolve to ONE
|
||||
// canonical recovery series — WHOOP wins when present, COMPUTED is the
|
||||
// fallback — and never chart the proxy and the native value as two
|
||||
// competing series. The feed faithfully mirrors both rows (a delta-sync
|
||||
// mirror cannot drop a row mid-keyset without a tombstone); the
|
||||
// canonical pick is the same WHOOP-over-COMPUTED rule the server's
|
||||
// wellness tile + doctor PDF apply (`resolveCanonicalRecovery`).
|
||||
measurementUpserts.push({
|
||||
id: row.id,
|
||||
externalId: row.externalId,
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { summariseCanonicalRecovery } from "@/lib/doctor-report-data";
|
||||
|
||||
function m(type: string, value: number, iso: string, source: string) {
|
||||
return { type, value, measuredAt: new Date(iso), source: source as never };
|
||||
}
|
||||
|
||||
describe("summariseCanonicalRecovery", () => {
|
||||
it("returns null when no recovery rows exist", () => {
|
||||
expect(
|
||||
summariseCanonicalRecovery([
|
||||
m("STRESS_SCORE", 40, "2026-06-01T12:00:00Z", "COMPUTED"),
|
||||
]),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("prefers the WHOOP-native row over the COMPUTED proxy per day", () => {
|
||||
// The same day has a low proxy AND a high native row. The PDF must read the
|
||||
// native value, not blend both into one average.
|
||||
const summary = summariseCanonicalRecovery([
|
||||
m("RECOVERY_SCORE", 50, "2026-06-01T12:00:00Z", "COMPUTED"),
|
||||
m("RECOVERY_SCORE", 80, "2026-06-01T06:00:00Z", "WHOOP"),
|
||||
]);
|
||||
expect(summary).not.toBeNull();
|
||||
// Only the canonical (WHOOP) row counts — avg/min/max/latest all 80, not 65.
|
||||
expect(summary!.latest).toBe(80);
|
||||
expect(summary!.avg).toBe(80);
|
||||
expect(summary!.min).toBe(80);
|
||||
expect(summary!.max).toBe(80);
|
||||
expect(summary!.count).toBe(1);
|
||||
});
|
||||
|
||||
it("summarises across days using one canonical row each", () => {
|
||||
const summary = summariseCanonicalRecovery([
|
||||
m("RECOVERY_SCORE", 60, "2026-06-02T12:00:00Z", "COMPUTED"),
|
||||
m("RECOVERY_SCORE", 80, "2026-06-01T06:00:00Z", "WHOOP"),
|
||||
m("RECOVERY_SCORE", 40, "2026-06-01T12:00:00Z", "COMPUTED"),
|
||||
]);
|
||||
expect(summary!.count).toBe(2);
|
||||
// Canonical set: 06-02 COMPUTED 60, 06-01 WHOOP 80 (proxy 40 dropped).
|
||||
expect(summary!.latest).toBe(60);
|
||||
expect(summary!.min).toBe(60);
|
||||
expect(summary!.max).toBe(80);
|
||||
expect(summary!.avg).toBe(70);
|
||||
});
|
||||
});
|
||||
@@ -61,6 +61,32 @@ describe("buildDerivedSnapshotBlock", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops RECOVERY_SCORE when it mirrors READINESS (COMPUTED proxy, no native)", async () => {
|
||||
// With no WHOOP-native recovery, the COMPUTED proxy IS the readiness blend
|
||||
// verbatim — feeding both labels hands the model the same number twice.
|
||||
compute.mockImplementation(async (args?: { metric?: string }) => {
|
||||
if (args?.metric === "READINESS") return ok({ score: 64, band: "yellow" });
|
||||
if (args?.metric === "RECOVERY_SCORE") return ok({ score: 64, band: "yellow" });
|
||||
return insufficient;
|
||||
});
|
||||
const block = await buildDerivedSnapshotBlock("u1", PROFILE, NOW);
|
||||
expect(block).not.toBeNull();
|
||||
expect(block!.READINESS).toBeDefined();
|
||||
// The redundant proxy is removed — readiness once, not twice.
|
||||
expect(block!.RECOVERY_SCORE).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps RECOVERY_SCORE when the WHOOP-native value differs from READINESS", async () => {
|
||||
compute.mockImplementation(async (args?: { metric?: string }) => {
|
||||
if (args?.metric === "READINESS") return ok({ score: 64, band: "yellow" });
|
||||
if (args?.metric === "RECOVERY_SCORE") return ok({ score: 80, band: "green" });
|
||||
return insufficient;
|
||||
});
|
||||
const block = await buildDerivedSnapshotBlock("u1", PROFILE, NOW);
|
||||
expect(block!.READINESS?.value).toBe(64);
|
||||
expect(block!.RECOVERY_SCORE?.value).toBe(80);
|
||||
});
|
||||
|
||||
it("isolates a per-metric compute failure", async () => {
|
||||
compute.mockImplementation(async (args?: { metric?: string }) => {
|
||||
if (args?.metric === "READINESS") throw new Error("boom");
|
||||
|
||||
@@ -124,5 +124,18 @@ export async function buildDerivedSnapshotBlock(
|
||||
};
|
||||
}
|
||||
|
||||
// Recovery dedup (one number, one engine). The COMPUTED recovery proxy IS the
|
||||
// readiness blend verbatim, so when no WHOOP-native row exists the resolved
|
||||
// recovery equals readiness — feeding both labels would hand the model the
|
||||
// identical score under two names. Drop the redundant recovery line in that
|
||||
// case; a WHOOP-native recovery resolves to a DIFFERENT number and both stay.
|
||||
if (
|
||||
block.READINESS !== undefined &&
|
||||
block.RECOVERY_SCORE !== undefined &&
|
||||
block.RECOVERY_SCORE.value === block.READINESS.value
|
||||
) {
|
||||
delete block.RECOVERY_SCORE;
|
||||
}
|
||||
|
||||
return Object.keys(block).length > 0 ? block : null;
|
||||
}
|
||||
@@ -18,7 +18,11 @@ import {
|
||||
thresholdMetricForContext,
|
||||
type GlucoseUnit,
|
||||
} from "@/lib/glucose";
|
||||
import type { GlucoseContext } from "@/generated/prisma/client";
|
||||
import type {
|
||||
GlucoseContext,
|
||||
MeasurementSource,
|
||||
} from "@/generated/prisma/client";
|
||||
import { resolveCanonicalRecovery } from "@/lib/insights/derived/recovery-resolve";
|
||||
import {
|
||||
DEFAULT_DOCTOR_REPORT_PREFS,
|
||||
type DoctorReportPrefs,
|
||||
@@ -221,6 +225,59 @@ export const WELLNESS_SCORE_REPORT_TYPES = [
|
||||
"STRAIN_SCORE",
|
||||
] as const;
|
||||
|
||||
/** One wellness-summary row (latest + range over the report window). */
|
||||
interface WellnessScoreSummary {
|
||||
type: string;
|
||||
latest: number;
|
||||
avg: number;
|
||||
min: number;
|
||||
max: number;
|
||||
count: number;
|
||||
latestAt: string;
|
||||
}
|
||||
|
||||
/** A measurement row as the doctor-report aggregator reads it (subset). */
|
||||
interface RecoveryMeasurementRow {
|
||||
type: string;
|
||||
value: number;
|
||||
measuredAt: Date;
|
||||
source: MeasurementSource;
|
||||
}
|
||||
|
||||
/**
|
||||
* Summarise RECOVERY_SCORE over the window from the CANONICAL row per day:
|
||||
* a WHOOP-native row wins over the COMPUTED proxy for the same day, so the
|
||||
* doctor PDF never blends the proxy and the native value into one min/avg/max.
|
||||
* Returns null when no recovery row exists. Exported for unit tests.
|
||||
*/
|
||||
export function summariseCanonicalRecovery(
|
||||
measurements: readonly RecoveryMeasurementRow[],
|
||||
): WellnessScoreSummary | null {
|
||||
const recovery = measurements.filter((m) => m.type === "RECOVERY_SCORE");
|
||||
if (recovery.length === 0) return null;
|
||||
const canonical = resolveCanonicalRecovery(
|
||||
recovery.map((m) => ({
|
||||
value: m.value,
|
||||
measuredAt: m.measuredAt,
|
||||
source: m.source,
|
||||
})),
|
||||
);
|
||||
if (canonical.length === 0) return null;
|
||||
// `resolveCanonicalRecovery` returns rows newest-first; the report wants the
|
||||
// latest value + the window range over the canonical set.
|
||||
const values = canonical.map((r) => r.value);
|
||||
const latestRow = canonical[0];
|
||||
return {
|
||||
type: "RECOVERY_SCORE",
|
||||
latest: Math.round(latestRow.value),
|
||||
avg: Math.round(values.reduce((a, b) => a + b, 0) / values.length),
|
||||
min: Math.round(Math.min(...values)),
|
||||
max: Math.round(Math.max(...values)),
|
||||
count: values.length,
|
||||
latestAt: latestRow.measuredAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
const GLUCOSE_CONTEXTS: GlucoseContext[] = [
|
||||
"FASTING",
|
||||
"POSTPRANDIAL",
|
||||
@@ -927,12 +984,21 @@ export async function collectDoctorReportData(
|
||||
};
|
||||
}
|
||||
|
||||
// v1.10.0 — wellness-score summary. The persisted COMPUTED `*_SCORE`
|
||||
// rows are already in `byType`/`stats` (they're only filtered out of the
|
||||
// clinical vitals table). Summarise each present score type for the
|
||||
// separate "Wellness summary" section. Empty array → null so the renderer
|
||||
// + FHIR builder skip the section entirely.
|
||||
// v1.10.0 — wellness-score summary. The persisted `*_SCORE` rows are already
|
||||
// in `byType`/`stats` (they're only filtered out of the clinical vitals
|
||||
// table). Summarise each present score type for the separate "Wellness
|
||||
// summary" section. Empty array → null so the renderer + FHIR builder skip
|
||||
// the section entirely.
|
||||
//
|
||||
// RECOVERY_SCORE carries BOTH the WHOOP-native row and the COMPUTED proxy for
|
||||
// the same day; mixing them into one min/avg/max would blend two distinct
|
||||
// series. Resolve to the canonical row per day (WHOOP wins) so the PDF reads
|
||||
// the SAME value the tile + iOS feed show — one number, one engine.
|
||||
const wellnessScoreSummaries = WELLNESS_SCORE_REPORT_TYPES.flatMap((type) => {
|
||||
if (type === "RECOVERY_SCORE") {
|
||||
const summary = summariseCanonicalRecovery(measurements);
|
||||
return summary ? [summary] : [];
|
||||
}
|
||||
const s = stats[type];
|
||||
const rows = byType[type];
|
||||
if (!s || !rows || rows.length === 0) return [];
|
||||
|
||||
@@ -4,6 +4,9 @@ vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
measurement: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
moodEntry: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
// The Sleep Score reads the user's source priority for the canonical
|
||||
// writer-dedup ladder; default to no override.
|
||||
user: { findUnique: vi.fn().mockResolvedValue(null) },
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/rollups/measurement-coverage", () => ({
|
||||
|
||||
@@ -4,6 +4,9 @@ vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
measurement: { findMany: vi.fn() },
|
||||
moodEntry: { findMany: vi.fn() },
|
||||
// The folded Sleep Score now reads the user's source priority for the
|
||||
// canonical writer-dedup ladder; default to no override.
|
||||
user: { findUnique: vi.fn().mockResolvedValue(null) },
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/rollups/measurement-coverage", () => ({
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
resolveCanonicalRecovery,
|
||||
type RecoveryRow,
|
||||
} from "../recovery-resolve";
|
||||
|
||||
function row(iso: string, value: number, source: string): RecoveryRow {
|
||||
return { value, measuredAt: new Date(iso), source: source as never };
|
||||
}
|
||||
|
||||
describe("resolveCanonicalRecovery", () => {
|
||||
it("prefers the WHOOP-native row over the COMPUTED proxy for the same day", () => {
|
||||
const rows = [
|
||||
row("2026-06-01T12:00:00Z", 50, "COMPUTED"),
|
||||
row("2026-06-01T06:00:00Z", 80, "WHOOP"),
|
||||
];
|
||||
const resolved = resolveCanonicalRecovery(rows);
|
||||
expect(resolved).toHaveLength(1);
|
||||
expect(resolved[0].source).toBe("WHOOP");
|
||||
expect(resolved[0].value).toBe(80);
|
||||
});
|
||||
|
||||
it("falls back to COMPUTED when no WHOOP row exists for the day", () => {
|
||||
const rows = [row("2026-06-01T12:00:00Z", 55, "COMPUTED")];
|
||||
const resolved = resolveCanonicalRecovery(rows);
|
||||
expect(resolved).toHaveLength(1);
|
||||
expect(resolved[0].source).toBe("COMPUTED");
|
||||
expect(resolved[0].value).toBe(55);
|
||||
});
|
||||
|
||||
it("resolves per day — WHOOP one day, COMPUTED another", () => {
|
||||
const rows = [
|
||||
row("2026-06-02T12:00:00Z", 51, "COMPUTED"),
|
||||
row("2026-06-01T12:00:00Z", 49, "COMPUTED"),
|
||||
row("2026-06-01T06:00:00Z", 70, "WHOOP"),
|
||||
];
|
||||
const resolved = resolveCanonicalRecovery(rows);
|
||||
// Sorted desc by measuredAt: 06-02 COMPUTED first, then 06-01 WHOOP.
|
||||
expect(resolved).toHaveLength(2);
|
||||
expect(resolved[0].source).toBe("COMPUTED");
|
||||
expect(resolved[0].value).toBe(51);
|
||||
expect(resolved[1].source).toBe("WHOOP");
|
||||
expect(resolved[1].value).toBe(70);
|
||||
});
|
||||
|
||||
it("keeps the latest same-source row within a day", () => {
|
||||
const rows = [
|
||||
row("2026-06-01T06:00:00Z", 60, "WHOOP"),
|
||||
row("2026-06-01T18:00:00Z", 75, "WHOOP"),
|
||||
];
|
||||
const resolved = resolveCanonicalRecovery(rows);
|
||||
expect(resolved).toHaveLength(1);
|
||||
expect(resolved[0].value).toBe(75);
|
||||
});
|
||||
|
||||
it("returns an empty list for no rows", () => {
|
||||
expect(resolveCanonicalRecovery([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,12 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: { measurement: { findMany: vi.fn() } },
|
||||
prisma: {
|
||||
measurement: { findMany: vi.fn() },
|
||||
// `computeSleepScore` reads the user's source priority for the canonical
|
||||
// writer-dedup ladder; default to no override (null → default ladder).
|
||||
user: { findUnique: vi.fn().mockResolvedValue(null) },
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/tz/resolver", () => ({
|
||||
resolveUserTimezone: vi.fn().mockResolvedValue("UTC"),
|
||||
@@ -39,6 +44,22 @@ function night(day: string, stages: Array<[string | null, number, string]>) {
|
||||
}));
|
||||
}
|
||||
|
||||
/** Build per-stage rows tagged with an ingest source + device-type. */
|
||||
function srcNight(
|
||||
day: string,
|
||||
source: string,
|
||||
stages: Array<[string | null, number, string]>,
|
||||
deviceType?: string,
|
||||
) {
|
||||
return stages.map(([sleepStage, value, hhmm]) => ({
|
||||
value,
|
||||
measuredAt: new Date(`${day}T${hhmm}:00Z`),
|
||||
sleepStage: sleepStage as never,
|
||||
source: source as never,
|
||||
deviceType: deviceType ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
describe("pure sleep scorers", () => {
|
||||
it("sleepNeedMinutes is age-banded with an adult default", () => {
|
||||
expect(sleepNeedMinutes(40)).toBe(7 * 60);
|
||||
@@ -154,8 +175,9 @@ describe("reconstructNights", () => {
|
||||
expect(nights[0].deepMinutes).toBe(60);
|
||||
});
|
||||
|
||||
it("expresses the midpoint in UTC by default", () => {
|
||||
// Earliest 01:00Z, latest 05:00Z → midpoint 03:00Z = 180 min-of-day.
|
||||
it("expresses the midpoint in the user's wall clock (asleep span centred on wake)", () => {
|
||||
// Asleep span ends 05:00Z, 240 asleep minutes → midpoint at wake −
|
||||
// 120 min = 03:00Z = 180 min-of-day in UTC.
|
||||
const rows = night("2026-06-02", [
|
||||
["CORE", 120, "01:00"],
|
||||
["CORE", 120, "05:00"],
|
||||
@@ -174,6 +196,39 @@ describe("reconstructNights", () => {
|
||||
const nights = reconstructNights(rows, "Asia/Kolkata");
|
||||
expect(nights[0].midpoint).toBe(8 * 60 + 30);
|
||||
});
|
||||
|
||||
it("deduplicates a multi-source night to ONE canonical total (no double-count)", () => {
|
||||
// The SAME physical night written by both Apple Health AND WHOOP. The
|
||||
// divergent UTC-day reconstructor used to SUM both writers (~doubling the
|
||||
// night). The canonical engine collapses to one writer (WHOOP wins the
|
||||
// sleep ladder) before summing.
|
||||
const apple = srcNight(
|
||||
"2026-06-02",
|
||||
"APPLE_HEALTH",
|
||||
[
|
||||
["CORE", 240, "03:00"],
|
||||
["DEEP", 60, "04:00"],
|
||||
["REM", 90, "05:00"],
|
||||
],
|
||||
"watch",
|
||||
);
|
||||
const whoop = srcNight(
|
||||
"2026-06-02",
|
||||
"WHOOP",
|
||||
[
|
||||
["CORE", 250, "03:00"],
|
||||
["DEEP", 65, "04:00"],
|
||||
["REM", 95, "05:00"],
|
||||
],
|
||||
"strap",
|
||||
);
|
||||
const nights = reconstructNights([...apple, ...whoop]);
|
||||
expect(nights).toHaveLength(1);
|
||||
// ONE writer's total, NOT apple + whoop summed (~780).
|
||||
expect(nights[0].asleepMinutes).toBeLessThan(500);
|
||||
// WHOOP wins the default sleep ladder → its 250+65+95 = 410 total.
|
||||
expect(nights[0].asleepMinutes).toBe(410);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeSleepScore", () => {
|
||||
|
||||
@@ -122,7 +122,7 @@ describe("computeWellnessScore", () => {
|
||||
|
||||
it("RECOVERY does not read the strain cache and carries a null anchor", async () => {
|
||||
findMany.mockResolvedValue([
|
||||
{ value: 72, measuredAt: new Date("2026-06-01T12:00:00Z") },
|
||||
{ value: 72, measuredAt: new Date("2026-06-01T12:00:00Z"), source: "COMPUTED" },
|
||||
]);
|
||||
const r = await computeWellnessScore("RECOVERY_SCORE", "u1", PROFILE, {
|
||||
now: NOW,
|
||||
@@ -133,4 +133,33 @@ describe("computeWellnessScore", () => {
|
||||
expect((r.value as WellnessScoreValue).anchor).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("RECOVERY reads BOTH sources and resolves the WHOOP-native row when present", async () => {
|
||||
// Same day written by both the COMPUTED proxy AND the WHOOP-native sync.
|
||||
// The native row is canonical → the tile shows 80, not the proxy's 50.
|
||||
findMany.mockResolvedValue([
|
||||
{ value: 50, measuredAt: new Date("2026-06-02T12:00:00Z"), source: "COMPUTED" },
|
||||
{ value: 80, measuredAt: new Date("2026-06-02T06:00:00Z"), source: "WHOOP" },
|
||||
]);
|
||||
const r = await computeWellnessScore("RECOVERY_SCORE", "u1", PROFILE, {
|
||||
now: NOW,
|
||||
});
|
||||
// The read must NOT hard-filter to COMPUTED — both sources reach the resolver.
|
||||
const where = findMany.mock.calls[0][0].where as { source?: unknown };
|
||||
expect(where.source).toBeUndefined();
|
||||
expect(r.status).toBe("ok");
|
||||
if (r.status === "ok") {
|
||||
expect((r.value as WellnessScoreValue).score).toBe(80);
|
||||
expect((r.value as WellnessScoreValue).band).toBe("green");
|
||||
}
|
||||
});
|
||||
|
||||
it("STRESS still hard-filters to the COMPUTED source", async () => {
|
||||
findMany.mockResolvedValue([
|
||||
{ value: 30, measuredAt: new Date("2026-06-02T12:00:00Z"), source: "COMPUTED" },
|
||||
]);
|
||||
await computeWellnessScore("STRESS_SCORE", "u1", PROFILE, { now: NOW });
|
||||
const where = findMany.mock.calls[0][0].where as { source?: unknown };
|
||||
expect(where.source).toBe("COMPUTED");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* v1.16.16 — canonical recovery resolution (one number, one engine).
|
||||
*
|
||||
* `RECOVERY_SCORE` is written by TWO sources that mean different things for the
|
||||
* same day:
|
||||
* - `WHOOP` — the device's own native recovery percentage (ground truth
|
||||
* when the user wears a WHOOP strap), ingested by the sync.
|
||||
* - `COMPUTED` — the server-derived proxy (the READINESS blend persisted by
|
||||
* the nightly `recovery-score` job) — the fallback for a user
|
||||
* with no native recovery signal.
|
||||
*
|
||||
* The DECISION (locked): the WHOOP-native row is canonical WHEN PRESENT; the
|
||||
* COMPUTED proxy is the fallback. Every read surface — the wellness tile, the
|
||||
* doctor PDF, the iOS feed — must resolve to the SAME canonical row per day so
|
||||
* none of them shows the proxy and the native value as two competing series.
|
||||
*
|
||||
* Pure resolution: group rows by their calendar day and, per day, keep the
|
||||
* WHOOP row when one exists, else the COMPUTED one. No DB access here — the
|
||||
* caller does the bounded read and passes rows in.
|
||||
*/
|
||||
import type { MeasurementSource } from "@/generated/prisma/client";
|
||||
|
||||
/** A `RECOVERY_SCORE` row as read for resolution. */
|
||||
export interface RecoveryRow {
|
||||
value: number;
|
||||
measuredAt: Date;
|
||||
source: MeasurementSource;
|
||||
}
|
||||
|
||||
/**
|
||||
* The per-source preference for `RECOVERY_SCORE`: a native WHOOP row outranks
|
||||
* the COMPUTED proxy for the same day. Lower number = higher authority. A
|
||||
* source not listed (there is none today) falls back below both.
|
||||
*/
|
||||
const RECOVERY_SOURCE_RANK: Partial<Record<MeasurementSource, number>> = {
|
||||
WHOOP: 0,
|
||||
COMPUTED: 1,
|
||||
};
|
||||
|
||||
function rankOf(source: MeasurementSource): number {
|
||||
return RECOVERY_SOURCE_RANK[source] ?? Number.MAX_SAFE_INTEGER;
|
||||
}
|
||||
|
||||
/** The UTC calendar-day key a recovery row is filed under. */
|
||||
function dayKeyOf(d: Date): string {
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse a mixed-source `RECOVERY_SCORE` set to ONE canonical row per day:
|
||||
* the WHOOP-native row wins when present, else the COMPUTED proxy. Within a
|
||||
* source the latest `measuredAt` wins (a same-day re-score). Returns the
|
||||
* canonical rows in the same shape, sorted by `measuredAt` DESCENDING so the
|
||||
* first element is the most recent canonical day — matching the order the
|
||||
* wellness reader already expects.
|
||||
*/
|
||||
export function resolveCanonicalRecovery(rows: RecoveryRow[]): RecoveryRow[] {
|
||||
const byDay = new Map<string, RecoveryRow>();
|
||||
for (const row of rows) {
|
||||
const key = dayKeyOf(row.measuredAt);
|
||||
const incumbent = byDay.get(key);
|
||||
if (incumbent === undefined) {
|
||||
byDay.set(key, row);
|
||||
continue;
|
||||
}
|
||||
const rowRank = rankOf(row.source);
|
||||
const incumbentRank = rankOf(incumbent.source);
|
||||
const better =
|
||||
rowRank < incumbentRank ||
|
||||
(rowRank === incumbentRank &&
|
||||
row.measuredAt.getTime() > incumbent.measuredAt.getTime());
|
||||
if (better) byDay.set(key, row);
|
||||
}
|
||||
return [...byDay.values()].sort(
|
||||
(a, b) => b.measuredAt.getTime() - a.measuredAt.getTime(),
|
||||
);
|
||||
}
|
||||
@@ -32,10 +32,19 @@
|
||||
* are not rolled up by stage, so the night reconstruction reads raw, but
|
||||
* bounded to the window). The pure scorers are exported for unit tests.
|
||||
*/
|
||||
import type { MeasurementType, SleepStage } from "@/generated/prisma/client";
|
||||
import type {
|
||||
MeasurementSource,
|
||||
MeasurementType,
|
||||
SleepStage,
|
||||
} from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { wallClockInTz } from "@/lib/tz/wall-clock";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
import {
|
||||
reconstructSleepNights,
|
||||
type SleepStageRow,
|
||||
} from "@/lib/analytics/sleep-night";
|
||||
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
|
||||
import {
|
||||
buildInsufficient,
|
||||
buildOk,
|
||||
@@ -48,8 +57,6 @@ import type { Derived } from "./types";
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
/** Default trailing window for the consistency/timing baseline (days). */
|
||||
const DEFAULT_WINDOW_DAYS = 30;
|
||||
/** A night needs ≥ this many stage rows to score at all. */
|
||||
const MIN_STAGE_ROWS_PER_NIGHT = 1;
|
||||
|
||||
/** Transparent sub-score weights (shown to the user, sum to 1 over present). */
|
||||
export const SLEEP_SUBSCORE_WEIGHTS = {
|
||||
@@ -88,13 +95,6 @@ export interface SleepScoreValue {
|
||||
windowNights: number;
|
||||
}
|
||||
|
||||
const ASLEEP_STAGES: ReadonlySet<SleepStage> = new Set<SleepStage>([
|
||||
"ASLEEP",
|
||||
"REM",
|
||||
"CORE",
|
||||
"DEEP",
|
||||
]);
|
||||
|
||||
// ── pure scorers (exported for tests) ─────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -300,6 +300,14 @@ interface SleepRow {
|
||||
value: number;
|
||||
measuredAt: Date;
|
||||
sleepStage: SleepStage | null;
|
||||
/**
|
||||
* Ingest source + device-type — fed to the canonical `reconstructSleepNights`
|
||||
* writer-dedup so a multi-source night (WHOOP + Apple Health) collapses to
|
||||
* ONE writer before summing instead of double-counting. Optional: a legacy
|
||||
* fixture or single-source caller that omits them dedups as before.
|
||||
*/
|
||||
source?: MeasurementSource | null;
|
||||
deviceType?: string | null;
|
||||
}
|
||||
|
||||
export interface NightSummary {
|
||||
@@ -316,83 +324,67 @@ export interface NightSummary {
|
||||
}
|
||||
|
||||
/**
|
||||
* Group raw stage rows into per-night summaries. A "night" is keyed by the
|
||||
* latest stage row's calendar day (the wake day). Pure — the caller does
|
||||
* the bounded DB read and passes rows in.
|
||||
* Adapt the canonical per-night reconstruction (`reconstructSleepNights`) into
|
||||
* the `NightSummary` shape the Sleep Score scorers consume. ONE ENGINE: the
|
||||
* same session-clustering, local-wake-day keying, and multi-source writer
|
||||
* dedup the dashboard / hypnogram / doctor surfaces use now also backs the
|
||||
* Sleep Score, so a multi-source night (WHOOP + Apple Health) is counted ONCE,
|
||||
* not summed across writers.
|
||||
*
|
||||
* This is an ADAPTER, not a drop-in: `reconstructSleepNights` returns the
|
||||
* canonical asleep total + per-stage map + in-bed/awake totals, and this
|
||||
* function derives the score-only fields the canonical engine does not carry —
|
||||
* `remMinutes` / `deepMinutes` from the stage map, `hasStageBreakdown` from the
|
||||
* presence of any granular stage, and `midpoint` from the asleep span (the
|
||||
* canonical night's wake instant minus half the asleep minutes), expressed in
|
||||
* the user's wall clock.
|
||||
*
|
||||
* `tz` is the IANA zone the midpoint is expressed against: a sleeper's
|
||||
* 03:00-local midpoint must read as minutes-of-day in THEIR wall clock, not
|
||||
* UTC, so a non-UTC user's consistency / timing sub-scores don't drift with
|
||||
* the offset. Defaults to UTC for back-compatible pure use.
|
||||
* the offset. Defaults to UTC for back-compatible pure use. `priorityJson` is
|
||||
* the user's persisted source priority (or null for the defaults) so the
|
||||
* writer-dedup ladder matches every other sleep surface.
|
||||
*/
|
||||
export function reconstructNights(
|
||||
rows: SleepRow[],
|
||||
tz: string = "UTC",
|
||||
priorityJson: unknown = null,
|
||||
): NightSummary[] {
|
||||
// Bucket rows by the wake-day key (UTC day of the row's measuredAt).
|
||||
const byNight = new Map<string, SleepRow[]>();
|
||||
for (const row of rows) {
|
||||
const key = row.measuredAt.toISOString().slice(0, 10);
|
||||
const list = byNight.get(key) ?? [];
|
||||
list.push(row);
|
||||
byNight.set(key, list);
|
||||
}
|
||||
const nights: NightSummary[] = [];
|
||||
for (const [night, nightRows] of byNight) {
|
||||
let asleep = 0;
|
||||
let awake = 0;
|
||||
let rem = 0;
|
||||
let deep = 0;
|
||||
let inBed = 0;
|
||||
let sawInBed = false;
|
||||
let sawStageBreakdown = false;
|
||||
let earliest = Infinity;
|
||||
let latest = -Infinity;
|
||||
for (const r of nightRows) {
|
||||
const stage = r.sleepStage;
|
||||
const minutes = Number.isFinite(r.value) ? r.value : 0;
|
||||
const t = r.measuredAt.getTime();
|
||||
if (t < earliest) earliest = t;
|
||||
if (t > latest) latest = t;
|
||||
if (stage === "IN_BED") {
|
||||
inBed += minutes;
|
||||
sawInBed = true;
|
||||
continue;
|
||||
}
|
||||
if (stage === "AWAKE") {
|
||||
awake += minutes;
|
||||
continue;
|
||||
}
|
||||
if (stage && ASLEEP_STAGES.has(stage)) {
|
||||
asleep += minutes;
|
||||
if (stage === "REM") rem += minutes;
|
||||
if (stage === "DEEP") deep += minutes;
|
||||
if (stage === "REM" || stage === "CORE" || stage === "DEEP") {
|
||||
sawStageBreakdown = true;
|
||||
}
|
||||
} else if (stage == null) {
|
||||
// A bare SLEEP_DURATION row (no stage) is the night's total asleep.
|
||||
asleep += minutes;
|
||||
}
|
||||
}
|
||||
// In-bed = explicit IN_BED rows when present, else asleep + awake.
|
||||
const inBedMinutes = sawInBed ? inBed : awake > 0 ? asleep + awake : null;
|
||||
const stageRows: SleepStageRow[] = rows.map((r) => ({
|
||||
value: r.value,
|
||||
measuredAt: r.measuredAt,
|
||||
sleepStage: r.sleepStage,
|
||||
source: r.source ?? null,
|
||||
deviceType: r.deviceType ?? null,
|
||||
}));
|
||||
const nights = reconstructSleepNights(stageRows, tz, priorityJson);
|
||||
return nights.map((n) => {
|
||||
const rem = n.stages.REM ?? 0;
|
||||
const deep = n.stages.DEEP ?? 0;
|
||||
const core = n.stages.CORE ?? 0;
|
||||
const hasStageBreakdown = rem > 0 || deep > 0 || core > 0;
|
||||
// Midpoint = centre of the asleep span. The canonical night's `measuredAt`
|
||||
// is the wake instant (latest stage END); the asleep span is
|
||||
// `asleepMinutes` long ending there, so its centre is wake − asleep/2.
|
||||
const midpoint =
|
||||
Number.isFinite(earliest) && Number.isFinite(latest) && latest > earliest
|
||||
? minutesOfDay(new Date((earliest + latest) / 2), tz)
|
||||
n.asleepMinutes > 0
|
||||
? minutesOfDay(
|
||||
new Date(n.measuredAt.getTime() - (n.asleepMinutes / 2) * 60_000),
|
||||
tz,
|
||||
)
|
||||
: null;
|
||||
nights.push({
|
||||
night,
|
||||
asleepMinutes: asleep,
|
||||
awakeMinutes: awake,
|
||||
return {
|
||||
night: n.night,
|
||||
asleepMinutes: n.asleepMinutes,
|
||||
awakeMinutes: n.awakeMinutes ?? 0,
|
||||
remMinutes: rem,
|
||||
deepMinutes: deep,
|
||||
inBedMinutes,
|
||||
hasStageBreakdown: sawStageBreakdown,
|
||||
inBedMinutes: n.inBedMinutes,
|
||||
hasStageBreakdown,
|
||||
midpoint,
|
||||
});
|
||||
}
|
||||
return nights.sort((a, b) => (a.night < b.night ? -1 : 1));
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function minutesOfDay(d: Date, tz: string): number {
|
||||
@@ -430,6 +422,10 @@ export async function computeSleepScore(
|
||||
// non-UTC sleeper's consistency / timing sub-scores don't drift with the
|
||||
// offset. Resolve the stored zone unless a caller pins one.
|
||||
const tz = opts.tz ?? (await resolveUserTimezone(userId));
|
||||
// The canonical writer-dedup ladder needs the user's source priority — read
|
||||
// it alongside so a multi-source night collapses to the SAME writer the
|
||||
// dashboard / hypnogram pick.
|
||||
const priorityJson = await loadUserSourcePriority(userId);
|
||||
const inputs = ["SLEEP_DURATION"];
|
||||
const required = 1;
|
||||
const since = new Date(now.getTime() - windowDays * MS_PER_DAY);
|
||||
@@ -442,7 +438,15 @@ export async function computeSleepScore(
|
||||
measuredAt: { gte: since },
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: { value: true, measuredAt: true, sleepStage: true },
|
||||
// `source` + `deviceType` feed the canonical writer-dedup so a multi-source
|
||||
// night is counted ONCE, not summed across writers.
|
||||
select: {
|
||||
value: true,
|
||||
measuredAt: true,
|
||||
sleepStage: true,
|
||||
source: true,
|
||||
deviceType: true,
|
||||
},
|
||||
})) as SleepRow[];
|
||||
|
||||
if (rows.length === 0) {
|
||||
@@ -460,12 +464,14 @@ export async function computeSleepScore(
|
||||
});
|
||||
}
|
||||
|
||||
const nights = reconstructNights(rows, tz).filter(
|
||||
// Reconstruct via the canonical engine (session-clustering, local-wake-day
|
||||
// keying, multi-source writer dedup) so the Sleep Score reads the SAME night
|
||||
// totals the dashboard / hypnogram / doctor surfaces do. Every reconstructed
|
||||
// night derives from ≥ 1 contributing row by construction, so a scorable
|
||||
// night is just one that carries asleep minutes.
|
||||
const scorableNights = reconstructNights(rows, tz, priorityJson).filter(
|
||||
(n) => n.asleepMinutes > 0,
|
||||
);
|
||||
const scorableNights = nights.filter(
|
||||
(n) => countNightRows(rows, n.night) >= MIN_STAGE_ROWS_PER_NIGHT,
|
||||
);
|
||||
|
||||
if (scorableNights.length === 0) {
|
||||
const { coverage } = deriveCoverage({
|
||||
@@ -527,8 +533,12 @@ export async function computeSleepScore(
|
||||
score,
|
||||
band: bandForScore(score),
|
||||
night: latest.night,
|
||||
asleepMinutes: latest.asleepMinutes,
|
||||
inBedMinutes: latest.inBedMinutes,
|
||||
// Emit whole minutes (iOS #18) — the canonical totals sum second-
|
||||
// precision segments and can otherwise serialise as e.g. 433.4999.
|
||||
// Null-preserving on the in-bed signal.
|
||||
asleepMinutes: Math.round(latest.asleepMinutes),
|
||||
inBedMinutes:
|
||||
latest.inBedMinutes === null ? null : Math.round(latest.inBedMinutes),
|
||||
subScores,
|
||||
windowNights: scorableNights.length,
|
||||
},
|
||||
@@ -542,11 +552,3 @@ export async function computeSleepScore(
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function countNightRows(rows: SleepRow[], night: string): number {
|
||||
let c = 0;
|
||||
for (const r of rows) {
|
||||
if (r.measuredAt.toISOString().slice(0, 10) === night) c += 1;
|
||||
}
|
||||
return c;
|
||||
}
|
||||
@@ -13,10 +13,14 @@
|
||||
* Server-only — reads `@/lib/db`.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import type {
|
||||
MeasurementSource,
|
||||
MeasurementType,
|
||||
} from "@/generated/prisma/client";
|
||||
import { buildInsufficient, buildOk, nowProvenanceTimestamp } from "./coverage";
|
||||
import type { BaselineProfile } from "./baseline";
|
||||
import type { StrainAnchor } from "@/lib/insights/strain-score";
|
||||
import { resolveCanonicalRecovery } from "./recovery-resolve";
|
||||
import { SPARKLINE_MAX_POINTS, type Derived } from "./types";
|
||||
|
||||
/** A 0–100 wellness score band. Higher is better for recovery; for stress a
|
||||
@@ -106,18 +110,38 @@ export async function computeWellnessScore(
|
||||
const computedAt = nowProvenanceTimestamp(now);
|
||||
const measurementType = WELLNESS_SCORE_TYPES[type] as MeasurementType;
|
||||
|
||||
const rows = await prisma.measurement.findMany({
|
||||
// RECOVERY_SCORE is written by TWO sources for the same day — the WHOOP
|
||||
// native percentage and the COMPUTED proxy. The native row is canonical when
|
||||
// present, so the read must NOT hard-filter to COMPUTED (that silently drops
|
||||
// every ingested native row); read both sources and resolve per day below.
|
||||
// STRESS / STRAIN are COMPUTED-only, so they keep the source filter.
|
||||
const isRecovery = type === "RECOVERY_SCORE";
|
||||
const rawRows = await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
type: measurementType,
|
||||
source: "COMPUTED",
|
||||
...(isRecovery ? {} : { source: "COMPUTED" as MeasurementSource }),
|
||||
deletedAt: null,
|
||||
measuredAt: { gte: cutoff, lte: now },
|
||||
},
|
||||
select: { value: true, measuredAt: true },
|
||||
select: { value: true, measuredAt: true, source: true },
|
||||
orderBy: { measuredAt: "desc" },
|
||||
});
|
||||
|
||||
// Collapse a mixed-source recovery set to ONE canonical row per day (WHOOP
|
||||
// wins over COMPUTED). Non-recovery sets are already single-source, so the
|
||||
// resolver is recovery-only — the tile, doctor PDF, and iOS feed all read the
|
||||
// SAME canonical value.
|
||||
const rows = isRecovery
|
||||
? resolveCanonicalRecovery(
|
||||
rawRows.map((r) => ({
|
||||
value: r.value,
|
||||
measuredAt: r.measuredAt,
|
||||
source: r.source,
|
||||
})),
|
||||
)
|
||||
: rawRows;
|
||||
|
||||
if (rows.length === 0) {
|
||||
return buildInsufficient<WellnessScoreValue>({
|
||||
coverage: {
|
||||
|
||||
@@ -200,7 +200,7 @@ const sleepSessionResource = z.object({
|
||||
inBedMinutes: z.number().int().nonnegative().nullable(),
|
||||
awakeMinutes: z.number().int().nonnegative().nullable(),
|
||||
awakenings: z.number().int().nonnegative(),
|
||||
stages: z.record(sleepStageEnum, z.number()),
|
||||
stages: z.record(sleepStageEnum, z.number().int().nonnegative()),
|
||||
segments: z.array(sleepSegmentResource),
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user