feat(sleep): export one row per night, not per stage

The CSV + JSON export emitted a wall of per-stage sleep minutes with no
night attribution. Collapse SLEEP_DURATION to one record per night by
default — the main session's TIME ASLEEP per the nap convention — with
the stage breakdown, nap count, and awakenings in the notes column so
the single uniform export reads as one night. `granularity=raw` keeps
the per-stage rows for power users. Storage is untouched.
This commit is contained in:
Marc-André Bombeck committed 2026-06-04 18:56:20 +02:00
1 parent 5c863a0bcc
commit bb4f762968
4 files changed
+217 -14

No files matched your search

+12 -1
View File
@@ -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,
+8 -3
View File
@@ -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") {
+102
View File
@@ -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", () => {
+95 -10
View File
@@ -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<string, typeof sessions>();
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);
}
/**