Skip to content

Commit 224bd30

Browse files
schwaaampclaude
andcommitted
Fix sleep duration underreporting: aggregate per day instead of per session
The starter-pack engine averaged sleep duration across individual sessions (38 sessions / 30 days) instead of grouping by wake date and summing per day. Naps and short sleep segments (1-3h) dragged the average from 8.27h to 6.97h (~19% underreported). Also fixed date attribution from start_time to wake date. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent f62f30f commit 224bd30

File tree

2 files changed

+104
-53
lines changed

2 files changed

+104
-53
lines changed

supabase/functions/ai-engine/engines/starter-pack.test.ts

Lines changed: 74 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -41,27 +41,49 @@ vi.mock('../../_shared/supabaseClient.ts', () => ({
4141
}));
4242

4343
vi.mock('../../_shared/metric-extraction.ts', () => ({
44-
extractDeepSleepPct: (row: Record<string, unknown>) => {
45-
const vm = row.vendor_metadata as Record<string, unknown> | null;
46-
if (!vm) return null;
47-
const deep = vm.deep_minutes;
48-
const total = row.total_duration_minutes as number;
49-
return deep != null && total > 0 ? ((deep as number) / total) * 100 : null;
50-
},
51-
extractRemSleepPct: (row: Record<string, unknown>) => {
52-
const vm = row.vendor_metadata as Record<string, unknown> | null;
53-
if (!vm) return null;
54-
const rem = vm.rem_minutes;
55-
const total = row.total_duration_minutes as number;
56-
return rem != null && total > 0 ? ((rem as number) / total) * 100 : null;
57-
},
5844
extractHrvFromDailySummary: (row: Record<string, unknown>) => {
5945
const vm = row.vendor_metadata as Record<string, unknown> | null;
6046
return vm?.hrv_ms != null ? vm.hrv_ms as number : null;
6147
},
62-
extractHrvFromSleepSession: (row: Record<string, unknown>) => {
63-
const vm = row.vendor_metadata as Record<string, unknown> | null;
64-
return vm?.hrv_ms != null ? vm.hrv_ms as number : null;
48+
}));
49+
50+
// Mock daily-aggregation to avoid pulling in the full metric-extraction surface.
51+
// Provides a simplified aggregateSleepByDay that groups by wake date and computes
52+
// the metrics the starter-pack actually reads.
53+
vi.mock('../../_shared/daily-aggregation.ts', () => ({
54+
aggregateSleepByDay: (sessions: Array<Record<string, unknown>>) => {
55+
const groups = new Map<string, Array<Record<string, unknown>>>();
56+
for (const s of sessions) {
57+
const start = s.start_time as string;
58+
const dur = s.total_duration_minutes as number;
59+
if (!start || !dur || dur <= 0) continue;
60+
const wakeMs = new Date(start).getTime() + dur * 60 * 1000;
61+
const wakeDate = new Date(wakeMs).toISOString().split('T')[0];
62+
if (!groups.has(wakeDate)) groups.set(wakeDate, []);
63+
groups.get(wakeDate)!.push(s);
64+
}
65+
const result = new Map<string, Map<string, number | null>>();
66+
for (const [date, daySessions] of groups) {
67+
let totalMin = 0;
68+
let longest = daySessions[0];
69+
let longestDur = 0;
70+
for (const s of daySessions) {
71+
const d = (s.total_duration_minutes as number) ?? 0;
72+
totalMin += d;
73+
if (d > longestDur) { longestDur = d; longest = s; }
74+
}
75+
const vm = longest.vendor_metadata as Record<string, unknown> | null;
76+
const deep = vm?.deep_minutes as number | undefined;
77+
const rem = vm?.rem_minutes as number | undefined;
78+
const hrv = vm?.hrv_ms as number | undefined;
79+
const metrics = new Map<string, number | null>();
80+
metrics.set('sleep_duration', totalMin > 0 ? totalMin / 60 : null);
81+
metrics.set('sleep_deep_pct', deep != null && longestDur > 0 ? (deep / longestDur) * 100 : null);
82+
metrics.set('sleep_rem_pct', rem != null && longestDur > 0 ? (rem / longestDur) * 100 : null);
83+
metrics.set('hrv_sleep', hrv ?? null);
84+
result.set(date, metrics);
85+
}
86+
return result;
6587
},
6688
}));
6789

@@ -137,7 +159,9 @@ describe('StarterPack — R2: daily_history and days_of_data', () => {
137159
makeDailySummary('2026-03-03', 61, 7500, 42),
138160
];
139161
mockSleepSessions = [
162+
// start 03-01T22:00Z + 480min → wake 03-02T06:00Z → wake date: 03-02
140163
makeSleepSession('2026-03-01', 480, 90, 100),
164+
// start 03-02T22:00Z + 460min → wake 03-03T05:40Z → wake date: 03-03
141165
makeSleepSession('2026-03-02', 460, 85, 95),
142166
];
143167

@@ -147,7 +171,8 @@ describe('StarterPack — R2: daily_history and days_of_data', () => {
147171
mockProvider,
148172
);
149173

150-
// 3 unique dates from daily_summary + 2 from sleep (but 2026-03-01 and 2026-03-02 overlap)
174+
// daily_summary dates: 03-01, 03-02, 03-03
175+
// sleep wake dates: 03-02, 03-03 (overlap with daily_summary)
151176
// Unique dates: 2026-03-01, 2026-03-02, 2026-03-03
152177
expect(result.days_of_data).toBe(3);
153178
});
@@ -158,6 +183,7 @@ describe('StarterPack — R2: daily_history and days_of_data', () => {
158183
makeDailySummary('2026-03-02', 60, 9000, 48),
159184
];
160185
mockSleepSessions = [
186+
// start 03-01T22:00Z + 480min → wake 03-02T06:00Z → wake date: 03-02
161187
makeSleepSession('2026-03-01', 480, 90, 100, 50),
162188
];
163189

@@ -178,9 +204,9 @@ describe('StarterPack — R2: daily_history and days_of_data', () => {
178204
// steps should have 2 entries
179205
expect(dh.steps).toHaveLength(2);
180206

181-
// sleep_duration should have 1 entry (480 min = 8 hrs)
207+
// sleep_duration should have 1 entry (480 min = 8 hrs), attributed to wake date 03-02
182208
expect(dh.sleep_duration).toHaveLength(1);
183-
expect(dh.sleep_duration[0].value).toBe(8);
209+
expect(dh.sleep_duration[0]).toEqual({ date: '2026-03-02', value: 8 });
184210

185211
// sleep_deep_pct should have 1 entry (90/480 * 100 = 18.75)
186212
expect(dh.sleep_deep_pct).toHaveLength(1);
@@ -232,8 +258,34 @@ describe('StarterPack — R2: daily_history and days_of_data', () => {
232258
}
233259
});
234260

235-
it('sleep_sessions use date derived from start_time for daily_history', async () => {
261+
it('sums nap + main sleep per day instead of averaging per session', async () => {
262+
mockSleepSessions = [
263+
// Main sleep: start 03-01T22:00Z + 480min → wake 03-02T06:00Z (8h)
264+
makeSleepSession('2026-03-01', 480, 90, 100),
265+
// Nap same wake day: start 03-02T13:00 + 90min → wake 03-02T14:30 (1.5h)
266+
{ start_time: '2026-03-02T13:00:00Z', total_duration_minutes: 90, vendor_metadata: {} },
267+
];
268+
269+
const result = await getStarterPack(
270+
{ connected_providers: ['whoop'] },
271+
'test-user',
272+
mockProvider,
273+
);
274+
275+
const dh = result.daily_history as Record<string, { date: string; value: number }[]>;
276+
// Both sessions wake on 03-02, so they should be summed: 8 + 1.5 = 9.5h
277+
expect(dh.sleep_duration).toHaveLength(1);
278+
expect(dh.sleep_duration[0]).toEqual({ date: '2026-03-02', value: 9.5 });
279+
280+
// The average should also be 9.5 (one day), not (8 + 1.5) / 2 = 4.75
281+
const scorecard = result.metric_scorecard as Array<{ metric_key: string; current_value: number }>;
282+
const sleepMetric = scorecard.find((m) => m.metric_key === 'sleep_duration');
283+
expect(sleepMetric?.current_value).toBe(9.5);
284+
});
285+
286+
it('sleep_sessions are attributed to wake date, not start date', async () => {
236287
mockSleepSessions = [
288+
// start 03-05T22:00Z + 480min → wake 03-06T06:00Z → wake date: 03-06
237289
makeSleepSession('2026-03-05', 480, 90, 100),
238290
];
239291

@@ -244,8 +296,7 @@ describe('StarterPack — R2: daily_history and days_of_data', () => {
244296
);
245297

246298
const dh = result.daily_history as Record<string, { date: string; value: number }[]>;
247-
// Sleep entries should have dates derived from start_time
248299
expect(dh.sleep_duration).toHaveLength(1);
249-
expect(dh.sleep_duration[0].date).toBe('2026-03-05');
300+
expect(dh.sleep_duration[0].date).toBe('2026-03-06');
250301
});
251302
});

supabase/functions/ai-engine/engines/starter-pack.ts

Lines changed: 30 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,9 @@
88

99
import { createClient } from '../../_shared/supabaseClient.ts';
1010
import {
11-
extractDeepSleepPct,
12-
extractRemSleepPct,
1311
extractHrvFromDailySummary,
14-
extractHrvFromSleepSession,
1512
} from '../../_shared/metric-extraction.ts';
13+
import { aggregateSleepByDay } from '../../_shared/daily-aggregation.ts';
1614
import { autoCorrectCompliance } from '../../_shared/compliance/output-validator.ts';
1715
import type { AIProvider } from '../providers/types.ts';
1816
import {
@@ -218,33 +216,35 @@ export async function getStarterPack(
218216
}
219217
}
220218

221-
for (const ss of sleepSessions ?? []) {
222-
const total = ss.total_duration_minutes as number;
223-
// Derive date from start_time (YYYY-MM-DD portion)
224-
const startTime = ss.start_time as string | undefined;
225-
const sleepDate = startTime ? startTime.split('T')[0] : undefined;
226-
if (sleepDate) uniqueDates.add(sleepDate);
227-
228-
if (total > 0) {
229-
const durationHrs = total / 60;
230-
metricValues.sleep_duration.push(durationHrs);
231-
if (sleepDate) dailyHistory.sleep_duration.push({ date: sleepDate, value: durationHrs });
232-
233-
const deepPct = extractDeepSleepPct(ss as Record<string, unknown>);
234-
if (deepPct !== null) {
235-
metricValues.sleep_deep_pct.push(deepPct);
236-
if (sleepDate) dailyHistory.sleep_deep_pct.push({ date: sleepDate, value: deepPct });
237-
}
238-
const remPct = extractRemSleepPct(ss as Record<string, unknown>);
239-
if (remPct !== null) {
240-
metricValues.sleep_rem_pct.push(remPct);
241-
if (sleepDate) dailyHistory.sleep_rem_pct.push({ date: sleepDate, value: remPct });
242-
}
243-
const hrvFromSleep = extractHrvFromSleepSession(ss as Record<string, unknown>);
244-
if (hrvFromSleep !== null) {
245-
metricValues.hrv.push(hrvFromSleep);
246-
if (sleepDate) dailyHistory.hrv.push({ date: sleepDate, value: hrvFromSleep });
247-
}
219+
// Group sleep sessions by wake date and aggregate per day.
220+
// This correctly sums naps + main sleep per day (instead of averaging
221+
// per-session, which under-reports when naps/short sessions exist).
222+
const sleepByDay = aggregateSleepByDay(
223+
(sleepSessions ?? []) as Array<Record<string, unknown>>,
224+
);
225+
226+
for (const [wakeDate, metrics] of sleepByDay) {
227+
uniqueDates.add(wakeDate);
228+
229+
const duration = metrics.get('sleep_duration');
230+
if (duration != null) {
231+
metricValues.sleep_duration.push(duration);
232+
dailyHistory.sleep_duration.push({ date: wakeDate, value: duration });
233+
}
234+
const deepPct = metrics.get('sleep_deep_pct');
235+
if (deepPct != null) {
236+
metricValues.sleep_deep_pct.push(deepPct);
237+
dailyHistory.sleep_deep_pct.push({ date: wakeDate, value: deepPct });
238+
}
239+
const remPct = metrics.get('sleep_rem_pct');
240+
if (remPct != null) {
241+
metricValues.sleep_rem_pct.push(remPct);
242+
dailyHistory.sleep_rem_pct.push({ date: wakeDate, value: remPct });
243+
}
244+
const hrvSleep = metrics.get('hrv_sleep');
245+
if (hrvSleep != null) {
246+
metricValues.hrv.push(hrvSleep);
247+
dailyHistory.hrv.push({ date: wakeDate, value: hrvSleep });
248248
}
249249
}
250250

0 commit comments

Comments
 (0)