mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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:
1 parent
5c863a0bcc
commit
bb4f762968
4 files changed
+217
-14
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user