diff --git a/src/app/api/export/measurements/route.ts b/src/app/api/export/measurements/route.ts index cd3fa45c9..acf4e1dc2 100644 --- a/src/app/api/export/measurements/route.ts +++ b/src/app/api/export/measurements/route.ts @@ -37,6 +37,12 @@ export const GET = apiHandler(async (request: NextRequest) => { const { since, until } = parseRange(request.url); const where = buildWhere(user.id, { since, until }); + // v1.11.5 — sleep export defaults to one row per night; `granularity=raw` + // keeps the per-stage rows for power users. + const granularity = + new URL(request.url).searchParams.get("granularity") === "raw" + ? "raw" + : "night"; const [measurements, userTz] = await Promise.all([ prisma.measurement.findMany({ @@ -46,7 +52,12 @@ export const GET = apiHandler(async (request: NextRequest) => { resolveUserTimezone(user.id), ]); - const csv = toCSV(formatMeasurementsForExport(measurements, userTz)); + const csv = toCSV( + formatMeasurementsForExport(measurements, userTz, { + granularity, + sleepTz: userTz, + }), + ); await auditLog("user.export.measurements", { userId: user.id, diff --git a/src/app/api/export/route.ts b/src/app/api/export/route.ts index 6b9fc30fe..87b46ca82 100644 --- a/src/app/api/export/route.ts +++ b/src/app/api/export/route.ts @@ -61,10 +61,15 @@ export const GET = apiHandler(async (request: NextRequest) => { where: { userId, deletedAt: null }, orderBy: { measuredAt: "desc" }, }); + // v1.11.5 — sleep collapses to one row per night by default (the + // formatter's `night` granularity), matching the per-type CSV route. + const measurementRecords = formatMeasurementsForExport( + measurements, + userTz, + { sleepTz: userTz }, + ); data.measurements = - format === "csv" - ? toCSV(formatMeasurementsForExport(measurements, userTz)) - : formatMeasurementsForExport(measurements, userTz); + format === "csv" ? toCSV(measurementRecords) : measurementRecords; } if (type === "medications" || type === "all") { diff --git a/src/lib/__tests__/export.test.ts b/src/lib/__tests__/export.test.ts index cc006fcfc..944a38e69 100644 --- a/src/lib/__tests__/export.test.ts +++ b/src/lib/__tests__/export.test.ts @@ -143,6 +143,108 @@ describe("formatMeasurementsForExport", () => { const result = formatMeasurementsForExport(measurements); expect(result[0].measuredAt).toBe("2026-05-15T09:00:00.000Z"); }); + + // v1.11.5 — sleep collapses to one row per night by default. + it("collapses SLEEP_DURATION stage rows to one row per night", () => { + const measurements = [ + { + type: "SLEEP_DURATION", + value: 240, + unit: "minutes", + measuredAt: new Date("2026-06-04T03:00:00Z"), + source: "APPLE_HEALTH", + notes: null, + sleepStage: "CORE" as const, + }, + { + type: "SLEEP_DURATION", + value: 120, + unit: "minutes", + measuredAt: new Date("2026-06-04T05:00:00Z"), + source: "APPLE_HEALTH", + notes: null, + sleepStage: "DEEP" as const, + }, + { + type: "SLEEP_DURATION", + value: 120, + unit: "minutes", + measuredAt: new Date("2026-06-04T07:00:00Z"), + source: "APPLE_HEALTH", + notes: null, + sleepStage: "REM" as const, + }, + ]; + const result = formatMeasurementsForExport(measurements, "UTC", { + sleepTz: "UTC", + }); + // One night row, not three stage rows. + expect(result).toHaveLength(1); + expect(result[0].type).toBe("SLEEP_DURATION"); + // TIME ASLEEP = 240 + 120 + 120 = 480 min. + expect(result[0].value).toBe(480); + // Stage breakdown carried in notes. + expect(String(result[0].notes)).toContain("CORE=240m"); + expect(String(result[0].notes)).toContain("DEEP=120m"); + expect(String(result[0].notes)).toContain("REM=120m"); + }); + + it("keeps per-stage sleep rows when granularity is raw", () => { + const measurements = [ + { + type: "SLEEP_DURATION", + value: 240, + unit: "minutes", + measuredAt: new Date("2026-06-04T03:00:00Z"), + source: "APPLE_HEALTH", + notes: null, + sleepStage: "CORE" as const, + }, + { + type: "SLEEP_DURATION", + value: 120, + unit: "minutes", + measuredAt: new Date("2026-06-04T05:00:00Z"), + source: "APPLE_HEALTH", + notes: null, + sleepStage: "DEEP" as const, + }, + ]; + const result = formatMeasurementsForExport(measurements, "UTC", { + granularity: "raw", + }); + expect(result).toHaveLength(2); + }); + + it("does not collapse non-sleep rows alongside a sleep night", () => { + const measurements = [ + { + type: "WEIGHT", + value: 80, + unit: "kg", + measuredAt: new Date("2026-06-04T08:00:00Z"), + source: "MANUAL", + notes: null, + }, + { + type: "SLEEP_DURATION", + value: 480, + unit: "minutes", + measuredAt: new Date("2026-06-04T06:00:00Z"), + source: "APPLE_HEALTH", + notes: null, + sleepStage: "ASLEEP" as const, + }, + ]; + const result = formatMeasurementsForExport(measurements, "UTC", { + sleepTz: "UTC", + }); + expect(result).toHaveLength(2); + // Sorted descending by time → WEIGHT (08:00) first, then the sleep night. + expect(result[0].type).toBe("WEIGHT"); + expect(result[1].type).toBe("SLEEP_DURATION"); + expect(result[1].value).toBe(480); + }); }); describe("formatIntakeEventsForExport with userTz", () => { diff --git a/src/lib/export.ts b/src/lib/export.ts index 35e8f0aaf..15ab25432 100644 --- a/src/lib/export.ts +++ b/src/lib/export.ts @@ -15,6 +15,12 @@ * the old contract. Production routes always pass `userTz`. */ import { formatInUserTz } from "./tz/format"; +import { + reconstructSleepSessions, + pickMainNightAndNaps, + type SleepStageRow, +} from "./analytics/sleep-night"; +import type { SleepStage } from "@/generated/prisma/client"; export interface ExportableRecord { [key: string]: unknown; @@ -80,6 +86,17 @@ function formatTimestamp(date: Date, userTz?: string): string { return date.toISOString(); } +interface ExportMeasurement { + type: string; + value: number; + unit: string; + measuredAt: Date; + source: string; + notes: string | null; + glucoseContext?: string | null; + sleepStage?: SleepStage | null; +} + /** * Format measurements for export. * @@ -87,20 +104,22 @@ function formatTimestamp(date: Date, userTz?: string): string { * ISO-8601 with the user's UTC offset (e.g. `+02:00` in Berlin in * May) instead of the bare `Z` suffix. See module-level docstring * for the rationale (issue #167). + * + * v1.11.5 — SLEEP_DURATION is stored one row per STAGE per night. A flat + * export emitted a wall of per-stage minutes with no night attribution. + * By default sleep rows are now COLLAPSED to one record per night (the + * main session's TIME ASLEEP per the nap convention) carrying the stage + * breakdown in `notes` so the single uniform CSV reads as one night, not + * many stages. Pass `granularity: "raw"` to keep the per-stage rows for + * power users. Storage is untouched either way. */ export function formatMeasurementsForExport( - measurements: Array<{ - type: string; - value: number; - unit: string; - measuredAt: Date; - source: string; - notes: string | null; - glucoseContext?: string | null; - }>, + measurements: ExportMeasurement[], userTz?: string, + opts: { granularity?: "night" | "raw"; sleepTz?: string } = {}, ): ExportableRecord[] { - return measurements.map((m) => ({ + const granularity = opts.granularity ?? "night"; + const toRecord = (m: ExportMeasurement): ExportableRecord => ({ type: m.type, value: m.value, unit: m.unit, @@ -108,7 +127,73 @@ export function formatMeasurementsForExport( source: m.source, notes: m.notes ?? "", glucoseContext: m.glucoseContext ?? "", + }); + + if (granularity === "raw") { + return measurements.map(toRecord); + } + + // Split sleep stage rows out, collapse them per night, and merge back + // in chronological order with the non-sleep rows. + const sleepRows = measurements.filter((m) => m.type === "SLEEP_DURATION"); + if (sleepRows.length === 0) { + return measurements.map(toRecord); + } + const other = measurements.filter((m) => m.type !== "SLEEP_DURATION"); + // `reconstructSleepSessions` clusters by wake-day using the user's tz; + // fall back to UTC when no tz is threaded (admin / on-disk shape). + const tz = opts.sleepTz ?? userTz ?? "UTC"; + const stageRows: SleepStageRow[] = sleepRows.map((m) => ({ + value: m.value, + measuredAt: m.measuredAt, + sleepStage: m.sleepStage ?? null, + source: m.source as SleepStageRow["source"], })); + const sessions = reconstructSleepSessions(stageRows, tz); + const byDay = new Map(); + for (const s of sessions) { + const list = byDay.get(s.night) ?? []; + list.push(s); + byDay.set(s.night, list); + } + const nightRecords: Array<{ at: Date; rec: ExportableRecord }> = []; + for (const daySessions of byDay.values()) { + const { main, naps } = pickMainNightAndNaps(daySessions); + if (!main) continue; + const stageParts = (Object.entries(main.stages) as Array<[string, number]>) + .filter(([, mins]) => mins > 0) + .map(([stage, mins]) => `${stage}=${Math.round(mins)}m`) + .join(" "); + const napNote = + naps.length > 0 + ? ` naps=${naps.length}(${Math.round( + naps.reduce((sum, n) => sum + n.asleepMinutes, 0), + )}m)` + : ""; + const awakeningNote = + main.awakenings > 0 ? ` awakenings=${main.awakenings}` : ""; + nightRecords.push({ + at: main.end, + rec: { + type: "SLEEP_DURATION", + // Headline value = the main night's TIME ASLEEP in minutes. + value: Math.round(main.asleepMinutes), + unit: "minutes", + measuredAt: formatTimestamp(main.end, userTz), + source: main.source ?? "", + notes: `${stageParts}${napNote}${awakeningNote}`.trim(), + glucoseContext: "", + }, + }); + } + + // Merge: keep the same descending-by-time order the route reads in. + const merged: Array<{ at: Date; rec: ExportableRecord }> = [ + ...other.map((m) => ({ at: m.measuredAt, rec: toRecord(m) })), + ...nightRecords, + ]; + merged.sort((a, b) => b.at.getTime() - a.at.getTime()); + return merged.map((x) => x.rec); } /**