mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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.
This commit is contained in:
1 parent
a66976874e
commit
5caa15f4e1
4 files changed
+106
-3
No files matched your search
@@ -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({
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<string>();
|
||||
|
||||
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],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<string>();
|
||||
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;
|
||||
}
|
||||
Reference in new issue
Block a user