fix(insights): use the reconstructed sleep night in status and null-out unestablished readiness contributors

The generic metric-status generator read SLEEP_DURATION row-per-stage and
fed the snapshot a single sleep STAGE (~16 min) as the current value, so the
sleep prose reported a fraction of an hour for a full night. Route the
SLEEP_DURATION status/snapshot through reconstructSleepNights — the same
per-night, source-deduped, midnight-aware time-asleep total the dashboard,
series, and targets surfaces use — so the snapshot's series, summary, and
latest value are all the night total in minutes (the registry unit and normal
range are already minutes). Gated to SLEEP_DURATION; no other metric's status
path changes.

The readiness wellness index could surface a contributor value of 0 for an
account that recorded resting heart rate / HRV / respiratory rate but had no
scorable baseline: a non-finite baseline center or today value fell through
clamp100 to a misleading 0 that reads as a real worst-case sub-score. Return
null instead so the client drops the contributor, and document the null-vs-0
contract: null means no usable signal (no baseline, no recent reading,
non-finite input); a finite value — including a genuine 0 — means the baseline
is established and the latest reading really deviates that far.

(cherry picked from commit 0422ac012765f30069a41f662c2b025197ed9a88)
This commit is contained in:
Marc-André Bombeck committed 2026-06-05 11:34:59 +02:00
1 parent f9daa94963
commit ea30317723
4 files changed
+198 -15

No files matched your search

@@ -22,7 +22,20 @@ vi.mock("@/lib/insights/metric-correlation-context", () => ({
getRelevantCorrelationsForMetric: vi.fn().mockResolvedValue([]),
}));
vi.mock("@/lib/tz/resolver", async () => {
const actual = await vi.importActual<typeof import("@/lib/tz/resolver")>(
"@/lib/tz/resolver",
);
return { ...actual, resolveUserTimezone: vi.fn() };
});
vi.mock("@/lib/rollups/measurement-read", () => ({
loadUserSourcePriority: vi.fn(),
}));
import { prisma } from "@/lib/db";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
import { runStatusCompletion } from "@/lib/insights/status-provider";
import { getRelevantCorrelationsForMetric } from "@/lib/insights/metric-correlation-context";
import { formatPreviousContextForPrompt } from "@/lib/insights/memory";
@@ -68,6 +81,60 @@ beforeEach(() => {
// are no-ops unless a test overrides them.
vi.mocked(getRelevantCorrelationsForMetric).mockResolvedValue([]);
vi.mocked(formatPreviousContextForPrompt).mockReturnValue("");
vi.mocked(resolveUserTimezone).mockResolvedValue("Europe/Berlin");
vi.mocked(loadUserSourcePriority).mockResolvedValue(null as never);
});
describe("generateMetricStatus — SLEEP_DURATION night reconstruction (iOS E2)", () => {
it("feeds the snapshot the per-night time-asleep total, not a single stage", async () => {
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dateOfBirth: null,
gender: null,
} as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: new Date("2026-06-04T07:00:00.000Z"),
} as never);
// One overnight session of granular stages, each row ONE stage (minutes).
// Total time asleep = CORE + DEEP + REM = 300 + 90 + 75 = 465 min (7.75 h).
// A single stage (DEEP, 90 min) is the largest, and an old per-row read
// would have surfaced the LATEST stage (~75 min REM) as "current sleep".
const wake = new Date("2026-06-04T06:30:00.000Z");
const m = 60 * 1000;
const stageRows = [
{ value: 300, measuredAt: new Date(wake.getTime() - 90 * m), sleepStage: "CORE", source: "APPLE_HEALTH" },
{ value: 90, measuredAt: new Date(wake.getTime() - 30 * m), sleepStage: "DEEP", source: "APPLE_HEALTH" },
{ value: 75, measuredAt: wake, sleepStage: "REM", source: "APPLE_HEALTH" },
];
vi.mocked(prisma.measurement.count).mockResolvedValue(
stageRows.length as never,
);
vi.mocked(prisma.measurement.findMany).mockResolvedValue(
stageRows as never,
);
const capture = { systemPrompt: null, userPrompt: null } as {
systemPrompt: string | null;
userPrompt: string | null;
};
stubCompletion("Summary: solide Nacht.", capture);
const res = await generateMetricStatus({
metric: "SLEEP_DURATION",
userId: "u1",
locale: "de",
force: true,
});
expect(res.text).toBeTruthy();
expect(capture.userPrompt).toBeTruthy();
const prompt = capture.userPrompt as string;
// The snapshot must carry the NIGHT total (465 min), never a lone stage.
expect(prompt).toContain("465");
expect(prompt).not.toContain('"value": 75');
expect(prompt).not.toContain('"value": 90');
});
});
describe("metric-status registry", () => {
@@ -168,6 +168,52 @@ describe("computeReadiness gating", () => {
}
});
it("emits null (not 0) for a vital with readings below the baseline floor (iOS F2)", async () => {
// The user HAS recorded RHR + HRV + respiratory, but only on 2 distinct
// days each — below the baseline-band history floor. The contributor must
// be NULL (dropped → iOS hides it), never 0 (which would read as a real
// worst-case sub-score). Mood + sleep carry the headline so the blend
// still reaches the minimum-components floor.
const DAY_MS = 24 * 60 * 60 * 1000;
const sparse = (type: string, base: number) =>
Array.from({ length: 2 }, (_, i) => ({
value: base,
measuredAt: new Date(NOW.getTime() - (i + 1) * DAY_MS),
type,
}));
moodFindMany.mockResolvedValue(
Array.from({ length: 8 }, (_, i) => ({
score: 4,
moodLoggedAt: new Date(`2026-05-${String(20 + i).padStart(2, "0")}T20:00:00Z`),
})),
);
measurementFindMany.mockImplementation(
async (args: { where: { type: string } }) => {
const type = args.where.type;
if (type === "RESTING_HEART_RATE") return sparse(type, 58);
if (type === "HEART_RATE_VARIABILITY") return sparse(type, 65);
if (type === "RESPIRATORY_RATE") return sparse(type, 14);
if (type === "SLEEP_DURATION") {
return [
{ value: 420, measuredAt: new Date("2026-05-31T06:00:00Z"), sleepStage: "ASLEEP" },
{ value: 430, measuredAt: new Date("2026-06-01T06:10:00Z"), sleepStage: "ASLEEP" },
{ value: 410, measuredAt: new Date("2026-06-02T06:05:00Z"), sleepStage: "ASLEEP" },
];
}
return [];
},
);
const result = await computeReadiness("u1", PROFILE, { now: NOW });
expect(result.status).toBe("ok");
if (result.status === "ok") {
for (const key of ["rhr", "hrv", "respiratory"] as const) {
const c = result.value.components.find((x) => x.key === key)!;
expect(c.value).toBeNull();
expect(c.value).not.toBe(0);
}
}
});
it("provenance source is 'live' for a blend backed only by sleep + mood", async () => {
// No vital readings (RHR/HRV/resp all gate). Mood entries present + a
// scorable sleep night → the two present components are both live reads,
+22 -3
View File
@@ -194,8 +194,19 @@ async function readLatestDayMean(
/**
* Score one baseline-deviation vital component (RHR / HRV / respiratory).
* Returns null when the baseline is insufficient or there is no recent
* reading — the component then drops from the blend.
*
* null-vs-0 contract (iOS v0.14.3 F2)
* -----------------------------------
* A contributor is `null` ONLY when there is no usable signal: the baseline
* is not established (insufficient history / band failed), there is no recent
* reading, or the inputs are non-finite. A `null` contributor drops from the
* blend and the client hides it. A FINITE number — including a genuine `0` —
* means the baseline IS established and the latest reading really does deviate
* that far (≥ 2 spreads in the unfavourable direction maps to 0; see
* `scoreDeviation`). We deliberately never emit `0` as a "missing data"
* sentinel: the previous behaviour of letting a non-finite `today`/`center`
* fall through `clamp100` could surface a misleading 0 for an account that had
* the metric but no scorable baseline — that case is now `null`.
*/
async function scoreVitalDeviation(
userId: string,
@@ -225,7 +236,15 @@ async function scoreVitalDeviation(
};
}
const today = await readLatestDayMean(userId, type, windowDays, now);
if (today == null) {
// No recent reading, or a non-finite baseline center / today value: there
// is no scorable deviation. Return null (hidden) rather than letting a NaN
// fall through `scoreDeviation` → `clamp100`, which would surface a
// misleading 0 for an account that actually has the metric (iOS F2).
if (
today == null ||
!Number.isFinite(today) ||
!Number.isFinite(baseline.value.center)
) {
return {
value: null,
source: baseline.provenance.source,
+63 -12
View File
@@ -59,6 +59,12 @@ import {
summarizeSeries,
} from "@/lib/insights/status-shared";
import { runStatusCompletion } from "@/lib/insights/status-provider";
import {
reconstructSleepNights,
type SleepStageRow,
} from "@/lib/analytics/sleep-night";
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import {
readFreshStatusText,
resolveReadOnlyStatusMiss,
@@ -206,19 +212,64 @@ export async function generateMetricStatus(args: {
// Bounded raw read for the derived recent stats; the monthly/yearly
// tail comes from the rollup tier (full-history fallback on a miss),
// exactly as the specialised cards do.
const measurements = await prisma.measurement
.findMany({
where: { userId: args.userId, type: meta.measurementType, deletedAt: null },
orderBy: { measuredAt: "desc" },
take: 365,
select: { value: true, measuredAt: true },
})
.then((rows) => rows.reverse());
//
// SLEEP_DURATION is the one type stored ONE ROW PER STAGE per night
// (IN_BED / AWAKE / CORE / DEEP / REM, minutes each), so a raw per-row
// read would feed the status prose a single ~16 min stage as the
// "current sleep" instead of the night total. Route it through
// `reconstructSleepNights` (the same helper the dashboard / series /
// targets use) so every derived value here — `series`, `summary`,
// `latest` — is a per-night TIME-ASLEEP total in minutes (the registry
// unit + normalRange are already minutes). The night reconstruction
// clusters stages into sessions, dedups multi-source nights via the
// user's `sleep` priority ladder, and handles the midnight split. Gated
// to SLEEP_DURATION only so no other metric's status path changes.
const isSleep = meta.measurementType === "SLEEP_DURATION";
const points = measurements.map((m) => ({
measuredAt: m.measuredAt,
value: m.value,
}));
const points: Array<{ measuredAt: Date; value: number }> = [];
let measurements: Array<{ measuredAt: Date }> = [];
if (isSleep) {
const [tz, priorityJson, sleepRows] = await Promise.all([
resolveUserTimezone(args.userId),
loadUserSourcePriority(args.userId),
prisma.measurement.findMany({
where: {
userId: args.userId,
type: "SLEEP_DURATION",
deletedAt: null,
},
orderBy: { measuredAt: "desc" },
take: 365 * 6, // ≈ 5 stage rows/night × source-count for a year
select: { value: true, measuredAt: true, sleepStage: true, source: true },
}),
]);
const nights = reconstructSleepNights(
sleepRows as SleepStageRow[],
tz,
priorityJson,
).filter((n) => n.asleepMinutes > 0);
for (const n of nights) {
points.push({ measuredAt: n.measuredAt, value: n.asleepMinutes });
}
measurements = nights.map((n) => ({ measuredAt: n.measuredAt }));
} else {
const rows = await prisma.measurement
.findMany({
where: {
userId: args.userId,
type: meta.measurementType,
deletedAt: null,
},
orderBy: { measuredAt: "desc" },
take: 365,
select: { value: true, measuredAt: true },
})
.then((r) => r.reverse());
for (const m of rows) {
points.push({ measuredAt: m.measuredAt, value: m.value });
}
measurements = rows.map((m) => ({ measuredAt: m.measuredAt }));
}
const series = applyPayloadBudget(points, { now });
const graded = await buildGradedSeriesWithRollups(
args.userId,