Files
HealthLog/src/lib/export.ts
T
Marc-André Bombeck 766b395900 fix(sleep): dedup the bare ASLEEP aggregate across the stage breakdown and hypnogram
Apple Health writes an unspecified ASLEEP aggregate alongside the granular
CORE/DEEP/REM partition for the same period. The asleep total already preferred
the granular partition, but the per-night/per-session `stages` map and the
hypnogram `segments` list recorded every stage row unconditionally, so a night
ended up carrying the granular set PLUS its ~equal bare twin — a double-height
green ASLEEP block on the stacked bar and the hypnogram, and a doubled total in
the analytics stage breakdown. Route the `stages` map and the `segments` list
through the same granular-over-bare gate as the asleep total: when a session
carries a granular stage, drop the redundant bare ASLEEP (and stage-less) rows
everywhere. WHOOP never emits a bare ASLEEP, so WHOOP nights are unaffected.

Apply the gate per session, not over the merged night pool, so a granular
overnight no longer strips a same-day bare-only nap's minutes from the night
total. Render the hypnogram per-segment in the stage palette (one ReferenceArea
per segment) instead of a single flat cyan line, and show the breakdown plus
totals footer whenever any per-stage minutes exist — reserving the unavailable
state for a truly empty session — so a legacy stage-summary night still surfaces
its breakdown. Thread the user's source priority into the CSV export's sleep
dedup, and honour offset with a true pre-slice night count on the sleep list.
2026-06-04 19:39:37 +02:00

298 lines
9.3 KiB
TypeScript

/**
* Export utilities — CSV, JSON, and ZIP generation.
*
* v1.4.25 W7 — per-user timezone. Timestamps were emitted with
* `.toISOString()` (UTC `Z` suffix), which Excel / LibreOffice
* silently render in the viewer's local zone — issue #167. The
* helpers now accept a `userTz` argument and write
* `2026-05-11T11:05:00+02:00` instead, so the offset survives the
* round-trip and the on-screen value is unambiguous regardless of
* the spreadsheet's locale defaults.
*
* Backward compatibility: `userTz` is optional; when omitted the
* helpers fall back to `.toISOString()` so callers that don't yet
* thread a user context (admin tools, the backup-on-disk shape) keep
* 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;
}
/**
* Convert records to CSV string.
*
* The first record's keys define the column order (callers should pass
* a stable shape — see `buildAuditLogCsvRecords`). When `headerLabels`
* is provided, those strings replace the object-key header row so the
* exported CSV can carry translated column titles without changing
* the record-key contract that drives the columns.
*
* Escapes RFC 4180 special characters: commas, double quotes, line
* breaks (`\n` and `\r`). Values containing any of these are wrapped
* in double quotes with embedded quotes doubled (`"` → `""`).
*/
export function toCSV(
records: ExportableRecord[],
headerLabels?: Record<string, string>,
): string {
if (records.length === 0) return "";
const headers = Object.keys(records[0]);
const headerLine = headerLabels
? headers.map((h) => escapeCsvCell(headerLabels[h] ?? h)).join(",")
: headers.map(escapeCsvCell).join(",");
const lines = [headerLine];
for (const record of records) {
const values = headers.map((h) => {
const val = record[h];
if (val === null || val === undefined) return "";
const str = val instanceof Date ? val.toISOString() : String(val);
return escapeCsvCell(str);
});
lines.push(values.join(","));
}
return lines.join("\n");
}
/**
* RFC 4180 cell escaping. `\r` is treated the same as `\n` so a
* Windows-newline value (`\r\n`) inside a single field doesn't break
* row alignment when Excel re-imports the CSV.
*/
function escapeCsvCell(str: string): string {
if (
str.includes(",") ||
str.includes('"') ||
str.includes("\n") ||
str.includes("\r")
) {
return `"${str.replace(/"/g, '""')}"`;
}
return str;
}
function formatTimestamp(date: Date, userTz?: string): string {
if (userTz) return formatInUserTz(date, userTz, "iso-with-offset");
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.
*
* `userTz` (optional) — when provided, `measuredAt` is emitted as
* 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: ExportMeasurement[],
userTz?: string,
opts: {
granularity?: "night" | "raw";
sleepTz?: string;
/**
* The user's persisted `sourcePriorityJson` (or null for the defaults).
* Threaded into `reconstructSleepSessions` so the CSV's per-night sleep
* dedup picks the SAME canonical source the UI shows on a multi-source
* night — without it the export silently fell back to the default ladder.
*/
sourcePriorityJson?: unknown;
} = {},
): ExportableRecord[] {
const granularity = opts.granularity ?? "night";
const toRecord = (m: ExportMeasurement): ExportableRecord => ({
type: m.type,
value: m.value,
unit: m.unit,
measuredAt: formatTimestamp(m.measuredAt, userTz),
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,
opts.sourcePriorityJson ?? null,
);
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);
}
/**
* Format medications for export.
*/
export function formatMedicationsForExport(
medications: Array<{
name: string;
dose: string;
active: boolean;
schedules: Array<{
windowStart: string;
windowEnd: string;
label: string | null;
dose: string | null;
}>;
}>,
): ExportableRecord[] {
return medications.map((m) => ({
name: m.name,
dose: m.dose,
active: m.active,
schedules: m.schedules
.map(
(s) =>
`${s.label ? s.label + ": " : ""}${s.windowStart}-${s.windowEnd}${s.dose ? " (" + s.dose + ")" : ""}`,
)
.join("; "),
}));
}
/**
* Format intake events for export.
*
* `userTz` (optional) — see `formatMeasurementsForExport`.
*/
export function formatIntakeEventsForExport(
events: Array<{
medication: { name: string };
scheduledFor: Date;
takenAt: Date | null;
skipped: boolean;
source: string;
}>,
userTz?: string,
): ExportableRecord[] {
return events.map((e) => ({
medication: e.medication.name,
scheduledFor: formatTimestamp(e.scheduledFor, userTz),
takenAt: e.takenAt ? formatTimestamp(e.takenAt, userTz) : "",
skipped: e.skipped,
source: e.source,
}));
}
/**
* Format mood entries for export.
*
* `userTz` (optional) — see `formatMeasurementsForExport`. The
* `date` column is a Berlin-anchored `YYYY-MM-DD` string in the
* stored data (`MoodEntry.date`); the migration risk discussed in
* §6.3 of the timezone proposal applies to that column specifically.
* We do NOT rewrite the `date` column here — the read-side
* interpretation lives where the field is consumed, not in the
* export. Only `loggedAt` (the timestamptz) carries the user-tz
* offset.
*/
export function formatMoodEntriesForExport(
entries: Array<{
date: string;
mood: string;
score: number;
tags: string | null;
source: string;
moodLoggedAt: Date;
}>,
userTz?: string,
): ExportableRecord[] {
return entries.map((e) => ({
date: e.date,
mood: e.mood,
score: e.score,
tags: e.tags ?? "",
source: e.source,
loggedAt: formatTimestamp(e.moodLoggedAt, userTz),
}));
}