From 5caa15f4e1ecf7aa82918928c97dc7325a504011 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 10:05:35 +0200 Subject: [PATCH] fix(google-health): count sleep segments with a stage label the mapper does not know A segment whose stage label was unknown (SLEEP_STAGE_TYPE_UNSPECIFIED, or a label added upstream later) was skipped without a trace, so the night total read short and nothing said why. The mapper now counts those segments and keeps their labels, and the sleep sync puts both on its wide event. --- .../google-health/__tests__/mappers.test.ts | 32 +++++++++++++++++++ .../sync-sleep-morning-refresh.test.ts | 30 +++++++++++++++++ src/lib/google-health/mappers.ts | 31 ++++++++++++++++-- src/lib/google-health/sync-sleep.ts | 16 ++++++++++ 4 files changed, 106 insertions(+), 3 deletions(-) diff --git a/src/lib/google-health/__tests__/mappers.test.ts b/src/lib/google-health/__tests__/mappers.test.ts index 49f3fa8ea..c20559389 100644 --- a/src/lib/google-health/__tests__/mappers.test.ts +++ b/src/lib/google-health/__tests__/mappers.test.ts @@ -34,6 +34,7 @@ import { mapRespiratoryRate, mapRestingHeartRate, mapSleepSession, + mapSleepSessionDetailed, mapSteps, mapVo2Max, mapWeight, @@ -376,6 +377,37 @@ describe("mapVo2Max — daily-vo2-max summary", () => { }); }); +describe("mapSleepSessionDetailed — unknown stage labels are counted", () => { + it("reports how many segments it skipped and which labels they carried", () => { + const session = mapSleepSessionDetailed({ + sleep: { + stages: [ + { + type: "DEEP", + startTime: "2026-06-02T02:00:00.000Z", + endTime: "2026-06-02T02:45:00.000Z", + }, + { + type: "SLEEP_STAGE_TYPE_UNSPECIFIED", + startTime: "2026-06-02T04:00:00.000Z", + endTime: "2026-06-02T04:10:00.000Z", + }, + { + type: "SLEEP_STAGE_TYPE_UNSPECIFIED", + startTime: "2026-06-02T04:10:00.000Z", + endTime: "2026-06-02T04:20:00.000Z", + }, + ], + }, + }); + expect(session.rows).toHaveLength(1); + expect(session.unknownStageSegments).toBe(2); + expect(session.unknownStageLabels).toEqual([ + "SLEEP_STAGE_TYPE_UNSPECIFIED", + ]); + }); +}); + describe("mapSleepSession — per-stage segments", () => { it("emits one SLEEP_DURATION row per known stage, skipping unknown labels", () => { const rows = mapSleepSession({ diff --git a/src/lib/google-health/__tests__/sync-sleep-morning-refresh.test.ts b/src/lib/google-health/__tests__/sync-sleep-morning-refresh.test.ts index 4be90b284..4a9d99813 100644 --- a/src/lib/google-health/__tests__/sync-sleep-morning-refresh.test.ts +++ b/src/lib/google-health/__tests__/sync-sleep-morning-refresh.test.ts @@ -54,6 +54,7 @@ vi.mock("@/lib/daily/morning-refresh-trigger", () => ({ maybeEnqueueMorningRefresh: maybeEnqueueMorningRefreshMock, })); +import { annotate } from "@/lib/logging/context"; import { syncUserSleep } from "../sync-sleep"; beforeEach(() => { @@ -72,6 +73,8 @@ beforeEach(() => { ], windowStart: new Date("2026-06-01T22:00:00.000Z"), windowEnd: sleepMeasuredAt, + unknownStageSegments: 0, + unknownStageLabels: [], }); upsertMock.mockImplementation( async ( @@ -125,6 +128,8 @@ describe("google-health syncUserSleep — S4 morning-refresh trigger", () => { ], windowStart: new Date("2026-06-01T22:00:00.000Z"), windowEnd: sleepMeasuredAt, + unknownStageSegments: 0, + unknownStageLabels: [], }); await syncUserSleep("user-1"); @@ -135,3 +140,28 @@ describe("google-health syncUserSleep — S4 morning-refresh trigger", () => { expect(maybeEnqueueMorningRefreshMock).toHaveBeenCalledWith("user-1", []); }); }); + +describe("google-health syncUserSleep — segments with an unknown stage", () => { + it("counts them on the wide event with their labels instead of dropping them silently", async () => { + fetchDataPointsMock.mockResolvedValue([{ raw: "a" }, { raw: "b" }]); + mapSleepSessionDetailedMock.mockReturnValue({ + rows: [], + windowStart: null, + windowEnd: null, + unknownStageSegments: 2, + unknownStageLabels: ["SLEEP_STAGE_TYPE_UNSPECIFIED"], + }); + + await syncUserSleep("user-1"); + + expect(annotate).toHaveBeenCalledWith( + expect.objectContaining({ + action: expect.objectContaining({ name: "googleHealth.sleep.sync" }), + meta: { + unknown_stage_segments: 4, + unknown_stage_labels: "SLEEP_STAGE_TYPE_UNSPECIFIED", + }, + }), + ); + }); +}); diff --git a/src/lib/google-health/mappers.ts b/src/lib/google-health/mappers.ts index a94d7abed..d73666dc6 100644 --- a/src/lib/google-health/mappers.ts +++ b/src/lib/google-health/mappers.ts @@ -955,6 +955,15 @@ export interface GoogleHealthSleepSession { /** Latest segment end across the session (UTC), or null if none map. */ windowEnd: Date | null; rows: GoogleHealthMappedMeasurement[]; + /** + * Segments skipped because their stage label is not one this mapper knows + * (`SLEEP_STAGE_TYPE_UNSPECIFIED`, or a label Google adds later). Counted + * rather than dropped in silence: the night total reads short by exactly + * these segments, and the sync's wide event is where that shows. + */ + unknownStageSegments: number; + /** The distinct unknown labels, for the wide event (at most five). */ + unknownStageLabels: string[]; } /** @@ -972,7 +981,8 @@ export interface GoogleHealthSleepSession { * duplicate rows the night-total then double-counted. A segment's start instant * is stable per block, and dropping the stage from the key means a mere * re-classification (LIGHT→DEEP on the same block) UPDATES the row in place - * rather than orphaning it. Unknown stage labels are skipped; a session with no + * rather than orphaning it. Unknown stage labels are skipped and counted + * (`unknownStageSegments`); a session with no * parseable segment yields an empty `rows` (and a null window). * * Segment timestamps can arrive OFFSET-LESS (local wall clock); `tz` (the user's @@ -989,10 +999,18 @@ export function mapSleepSessionDetailed( const rows: GoogleHealthMappedMeasurement[] = []; let windowStart: Date | null = null; let windowEnd: Date | null = null; + let unknownStageSegments = 0; + const unknownStageLabels = new Set(); for (const seg of segments) { const stage = mapGoogleHealthSleepStage(seg.stage); - if (!stage) continue; + if (!stage) { + unknownStageSegments += 1; + if (unknownStageLabels.size < 5) { + unknownStageLabels.add(seg.stage.slice(0, 40)); + } + continue; + } const mins = minutesBetween(seg.startTime, seg.endTime); if (mins === null || !(mins > 0)) continue; const start = parseLocalInstant(seg.startTime as string, tz); @@ -1010,7 +1028,14 @@ export function mapSleepSessionDetailed( }); } - return { anchor, windowStart, windowEnd, rows }; + return { + anchor, + windowStart, + windowEnd, + rows, + unknownStageSegments, + unknownStageLabels: [...unknownStageLabels], + }; } /** diff --git a/src/lib/google-health/sync-sleep.ts b/src/lib/google-health/sync-sleep.ts index 7c16469f7..7bd1bf4be 100644 --- a/src/lib/google-health/sync-sleep.ts +++ b/src/lib/google-health/sync-sleep.ts @@ -70,6 +70,12 @@ export async function syncUserSleep( const readings: GoogleHealthMeasurementUpsert[] = []; const replaceWindows: GoogleHealthSleepReplaceWindow[] = []; + // Segments whose stage label the mapper does not know are skipped, so the + // night total reads short by them. Counted across the page for the wide + // event, with the labels, so a new upstream stage shows up instead of + // quietly shortening nights. + let unknownStageSegments = 0; + const unknownStageLabels = new Set(); for (const point of points) { // Per-session throw-guard (mapper parity with sync-metrics): a single // malformed session must not abort the sibling sessions in the same page. @@ -85,6 +91,10 @@ export async function syncUserSleep( noteHardFailure("mapSleepSession"); continue; } + unknownStageSegments += session.unknownStageSegments; + for (const label of session.unknownStageLabels) { + if (unknownStageLabels.size < 5) unknownStageLabels.add(label); + } if (session.rows.length === 0) continue; for (const m of session.rows) { readings.push({ @@ -130,6 +140,12 @@ export async function syncUserSleep( annotate({ action: { name: "googleHealth.sleep.sync", details: { imported } }, + meta: { + unknown_stage_segments: unknownStageSegments, + ...(unknownStageSegments > 0 + ? { unknown_stage_labels: [...unknownStageLabels].join(",") } + : {}), + }, }); return imported; }