Files
HealthLog/src/lib/export.ts
T
Marc-André Bombeck 25944ed57a feat(glucose): read glucose in one unit across series, CSV, and detail page
Glucose is stored canonical mg/dL but the series DTO, CSV export, and detail
page emitted raw mg/dL while FHIR already converted to the user preference.

Convert at serialization through the single convertGlucose helper:
- series route sets unit from the user's glucoseUnit and converts each point
- CSV export converts BLOOD_GLUCOSE rows and emits the unit in the unit column
- the detail page + stat strip honour the preference via the chart value scale
- declare the trailing-90-day window in the glucose median label across locales

mmol/L-preference users now see the blood-glucose page, stat strip, iOS series,
and CSV in mmol/L; mg/dL users are unchanged.
2026-06-14 17:16:48 +02:00

352 lines
12 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 { convertGlucose, type GlucoseUnit } from "./glucose";
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 (`"` → `""`).
*
* Spreadsheet formula injection (CSV injection): free-TEXT cells whose
* value starts with `=`, `+`, `-`, `@`, TAB, or CR get a leading `'`
* (the OWASP single-quote neutralisation — Excel / LibreOffice /
* Google Sheets treat the cell as text and hide the quote). The guard
* is scoped to STRING-typed record values only: numeric columns
* (measurement values, mood scores) stay typed `number` all the way to
* this function, so a negative reading exports as `-5.2`, never
* `'-5.2`. The text columns are where third-party content can land —
* mood `note` originates from the moodLog webhook, measurement `notes`
* from arbitrary clients — and a crafted `=HYPERLINK(...)` /
* `=cmd|...` there must not execute when an operator opens the export.
*/
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(neutraliseFormulaPrefix(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 "";
if (val instanceof Date) return escapeCsvCell(val.toISOString());
// Only genuine TEXT cells get the formula-prefix guard; numbers /
// booleans stringify verbatim so numeric columns are never mangled.
const str = String(val);
return escapeCsvCell(
typeof val === "string" ? neutraliseFormulaPrefix(str) : str,
);
});
lines.push(values.join(","));
}
return lines.join("\n");
}
/**
* OWASP CSV-injection neutralisation: prefix a text cell that starts
* with a formula trigger (`=`, `+`, `-`, `@`) or a control character
* Excel interprets as a field continuation (TAB, CR) with a single
* quote so the spreadsheet renders it as literal text. Applied to
* string cells only — see `toCSV`.
*/
function neutraliseFormulaPrefix(str: string): string {
return /^[=+\-@\t\r]/.test(str) ? `'${str}` : str;
}
/**
* 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;
/** Writer tag for the per-night sleep collapse (watch vs phone). */
deviceType?: string | 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;
/**
* v1.16.16 — the user's resolved glucose display unit. BLOOD_GLUCOSE is
* stored canonical mg/dL; when this is `"mmol/L"` the export converts the
* value (1 decimal) and emits `mmol/L` in the `unit` column, matching the
* FHIR export (`src/lib/fhir/resources.ts`) so the CSV reads in ONE unit.
* Defaults to `"mg/dL"` (the canonical-stored unit) → byte-identical for
* mg/dL-preference users.
*/
glucoseUnit?: GlucoseUnit;
} = {},
): ExportableRecord[] {
const granularity = opts.granularity ?? "night";
const glucoseUnit: GlucoseUnit = opts.glucoseUnit ?? "mg/dL";
const toRecord = (m: ExportMeasurement): ExportableRecord => {
// BLOOD_GLUCOSE is stored mg/dL; convert to the user's display unit at
// serialization so the value + unit column agree and match FHIR.
const isGlucose = m.type === "BLOOD_GLUCOSE";
return {
type: m.type,
value: isGlucose ? convertGlucose(m.value, glucoseUnit) : m.value,
unit: isGlucose ? glucoseUnit : 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"],
deviceType: m.deviceType ?? null,
}));
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;
/** Free-text note — exported in full so the texts are readable offline. */
note?: 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 ?? "",
note: e.note ?? "",
source: e.source,
loggedAt: formatTimestamp(e.moodLoggedAt, userTz),
}));
}