Files
HealthLog/src/lib/export.ts
T
Marc-André Bombeck 17b8d8d2c2 chore(release): v1.4.25
Largest feature delta in the v1.4.x line. Insights expands into seven dedicated metric sub-pages; GLP-1 medication tracking lands end-to-end (drug-level chart with Research Mode acknowledgment, EMA titration ladder, cadence + compliance, side-effect taxonomy, pen/vial inventory); cross-source priority becomes a two-axis resolver; per-user timezone threads through ten analytics surfaces; Withings activity + sleep v2 sync; onboarding rebuilt as a nested-route wizard; Personal Records ship end-to-end; Health Score provenance accordion; Coach runs native first-party prompts across six locales with a 1800-assertion refusal-probe matrix; OpenAPI drift gate hard-fails; multi-arch GHCR image. Migrations 0043 through 0060 are additive and forward-only. PROMPT_VERSION 4.24.0 → 4.25.0 with GROUND RULE 9 (refuses dose recommendations) and GROUND RULE 15 (refuses drug-level estimates with MDR + MDCG 2021-24 cites).

See CHANGELOG.md for the full per-section delta.
2026-05-14 23:49:32 +02:00

199 lines
5.6 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";
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();
}
/**
* 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).
*/
export function formatMeasurementsForExport(
measurements: Array<{
type: string;
value: number;
unit: string;
measuredAt: Date;
source: string;
notes: string | null;
glucoseContext?: string | null;
}>,
userTz?: string,
): ExportableRecord[] {
return measurements.map((m) => ({
type: m.type,
value: m.value,
unit: m.unit,
measuredAt: formatTimestamp(m.measuredAt, userTz),
source: m.source,
notes: m.notes ?? "",
glucoseContext: m.glucoseContext ?? "",
}));
}
/**
* 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),
}));
}