From 96076dbc3318017a27a5827dd6fc0e35ac2429b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 14 May 2026 11:55:06 +0200 Subject: [PATCH] feat(export): emit ISO-8601 with user-tz offset instead of Z MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v1.4.25 W7 phase 3.1 — closes the export half of issue #167. CSV/JSON exports previously emitted timestamps via .toISOString(), producing 2026-05-15T09:00:00.000Z. Excel and LibreOffice drop the trailing Z silently and render the bare datetime in the viewer's locale, so a Warsaw user reads UTC as local time — 11:00 entries display as 09:00 in their spreadsheet. Export helpers now accept an optional userTz argument and emit ISO-8601 with the user's UTC offset (2026-05-15T11:00:00+02:00). The offset survives the round-trip and the on-screen value is unambiguous regardless of the spreadsheet's defaults. Routes resolve the user's stored timezone via the v1.4.25 W7 phase 2 resolver and thread it through formatMeasurements / formatIntakeEvents / formatMoodEntries. Backward-compatible: userTz is optional. When omitted (admin tools, canonical-backup-on-disk shape) the helpers fall back to .toISOString(), keeping the legacy contract for the JSON full-backup route that the upload-restore validator depends on. Surfaces touched: - src/lib/export.ts (3 formatters + helper) - src/app/api/export/measurements/route.ts - src/app/api/export/medications/route.ts (intake history block) - src/app/api/export/mood/route.ts - src/app/api/export/route.ts (legacy aggregate route) Tests: 5 new cases in src/lib/__tests__/export.test.ts covering Berlin / Auckland / New_York offsets, the no-userTz fallback, and the mood-entry case where the stored YYYY-MM-DD date column stays unchanged (see proposal §6.3 for the read-side rationale). --- src/app/api/export/measurements/route.ts | 14 +-- src/app/api/export/medications/route.ts | 36 ++++---- src/app/api/export/mood/route.ts | 14 +-- src/app/api/export/route.ts | 14 +-- src/lib/__tests__/export.test.ts | 103 ++++++++++++++++++++++- src/lib/export.ts | 46 +++++++++- src/lib/tz/__tests__/resolver.test.ts | 16 ++-- 7 files changed, 199 insertions(+), 44 deletions(-) diff --git a/src/app/api/export/measurements/route.ts b/src/app/api/export/measurements/route.ts index 9bb006351..3eb5e4355 100644 --- a/src/app/api/export/measurements/route.ts +++ b/src/app/api/export/measurements/route.ts @@ -23,6 +23,7 @@ import { auditLog } from "@/lib/auth/audit"; import { apiError, getClientIp } from "@/lib/api-response"; import { checkRateLimit } from "@/lib/rate-limit"; import { toCSV, formatMeasurementsForExport } from "@/lib/export"; +import { resolveUserTimezone } from "@/lib/tz/resolver"; import { NextRequest, NextResponse } from "next/server"; export const GET = apiHandler(async (request: NextRequest) => { @@ -37,12 +38,15 @@ export const GET = apiHandler(async (request: NextRequest) => { const { since, until } = parseRange(request.url); const where = buildWhere(user.id, { since, until }); - const measurements = await prisma.measurement.findMany({ - where, - orderBy: { measuredAt: "desc" }, - }); + const [measurements, userTz] = await Promise.all([ + prisma.measurement.findMany({ + where, + orderBy: { measuredAt: "desc" }, + }), + resolveUserTimezone(user.id), + ]); - const csv = toCSV(formatMeasurementsForExport(measurements)); + const csv = toCSV(formatMeasurementsForExport(measurements, userTz)); await auditLog("user.export.measurements", { userId: user.id, diff --git a/src/app/api/export/medications/route.ts b/src/app/api/export/medications/route.ts index 6c697dfa7..ab0114399 100644 --- a/src/app/api/export/medications/route.ts +++ b/src/app/api/export/medications/route.ts @@ -24,6 +24,7 @@ import { formatMedicationsForExport, formatIntakeEventsForExport, } from "@/lib/export"; +import { resolveUserTimezone } from "@/lib/tz/resolver"; import { NextRequest, NextResponse } from "next/server"; export const GET = apiHandler(async (request: NextRequest) => { @@ -48,23 +49,26 @@ export const GET = apiHandler(async (request: NextRequest) => { // without a matching migration); a wildcard `include: { schedules: true }` // would 500 on the missing column. Pin the columns instead — the export // shape doesn't need every future field anyway. - const medications = await prisma.medication.findMany({ - where: { userId: user.id }, - select: { - name: true, - dose: true, - active: true, - schedules: { - select: { - windowStart: true, - windowEnd: true, - label: true, - dose: true, + const [medications, userTz] = await Promise.all([ + prisma.medication.findMany({ + where: { userId: user.id }, + select: { + name: true, + dose: true, + active: true, + schedules: { + select: { + windowStart: true, + windowEnd: true, + label: true, + dose: true, + }, }, }, - }, - orderBy: { createdAt: "desc" }, - }); + orderBy: { createdAt: "desc" }, + }), + resolveUserTimezone(user.id), + ]); const sections: string[] = [ "# Medications", @@ -91,7 +95,7 @@ export const GET = apiHandler(async (request: NextRequest) => { }); intakeCount = events.length; sections.push("# Intake history"); - sections.push(toCSV(formatIntakeEventsForExport(events))); + sections.push(toCSV(formatIntakeEventsForExport(events, userTz))); } const body = sections.join("\n\n"); diff --git a/src/app/api/export/mood/route.ts b/src/app/api/export/mood/route.ts index 69a395827..6a6118bce 100644 --- a/src/app/api/export/mood/route.ts +++ b/src/app/api/export/mood/route.ts @@ -19,6 +19,7 @@ import { auditLog } from "@/lib/auth/audit"; import { apiError, getClientIp } from "@/lib/api-response"; import { checkRateLimit } from "@/lib/rate-limit"; import { toCSV, formatMoodEntriesForExport } from "@/lib/export"; +import { resolveUserTimezone } from "@/lib/tz/resolver"; import { NextRequest, NextResponse } from "next/server"; export const GET = apiHandler(async (request: NextRequest) => { @@ -50,12 +51,15 @@ export const GET = apiHandler(async (request: NextRequest) => { if (until) where.moodLoggedAt.lte = until; } - const entries = await prisma.moodEntry.findMany({ - where, - orderBy: { moodLoggedAt: "desc" }, - }); + const [entries, userTz] = await Promise.all([ + prisma.moodEntry.findMany({ + where, + orderBy: { moodLoggedAt: "desc" }, + }), + resolveUserTimezone(user.id), + ]); - const csv = toCSV(formatMoodEntriesForExport(entries)); + const csv = toCSV(formatMoodEntriesForExport(entries, userTz)); await auditLog("user.export.mood", { userId: user.id, diff --git a/src/app/api/export/route.ts b/src/app/api/export/route.ts index f78367fee..e1f678583 100644 --- a/src/app/api/export/route.ts +++ b/src/app/api/export/route.ts @@ -11,6 +11,7 @@ import { formatIntakeEventsForExport, formatMoodEntriesForExport, } from "@/lib/export"; +import { resolveUserTimezone } from "@/lib/tz/resolver"; import { NextRequest, NextResponse } from "next/server"; /** @@ -48,6 +49,7 @@ export const GET = apiHandler(async (request: NextRequest) => { } const userId = user.id; + const userTz = await resolveUserTimezone(userId); const data: Record = {}; @@ -58,8 +60,8 @@ export const GET = apiHandler(async (request: NextRequest) => { }); data.measurements = format === "csv" - ? toCSV(formatMeasurementsForExport(measurements)) - : formatMeasurementsForExport(measurements); + ? toCSV(formatMeasurementsForExport(measurements, userTz)) + : formatMeasurementsForExport(measurements, userTz); } if (type === "medications" || type === "all") { @@ -82,8 +84,8 @@ export const GET = apiHandler(async (request: NextRequest) => { }); data.intakeEvents = format === "csv" - ? toCSV(formatIntakeEventsForExport(events)) - : formatIntakeEventsForExport(events); + ? toCSV(formatIntakeEventsForExport(events, userTz)) + : formatIntakeEventsForExport(events, userTz); } if (type === "mood" || type === "all") { @@ -93,8 +95,8 @@ export const GET = apiHandler(async (request: NextRequest) => { }); data.moodEntries = format === "csv" - ? toCSV(formatMoodEntriesForExport(moodEntries)) - : formatMoodEntriesForExport(moodEntries); + ? toCSV(formatMoodEntriesForExport(moodEntries, userTz)) + : formatMoodEntriesForExport(moodEntries, userTz); } await auditLog("export.download", { diff --git a/src/lib/__tests__/export.test.ts b/src/lib/__tests__/export.test.ts index 6e85dbbfa..e530b5506 100644 --- a/src/lib/__tests__/export.test.ts +++ b/src/lib/__tests__/export.test.ts @@ -1,5 +1,10 @@ import { describe, it, expect } from "vitest"; -import { toCSV, formatMeasurementsForExport } from "../export"; +import { + toCSV, + formatMeasurementsForExport, + formatIntakeEventsForExport, + formatMoodEntriesForExport, +} from "../export"; describe("toCSV", () => { it("returns empty string for empty array", () => { @@ -88,4 +93,100 @@ describe("formatMeasurementsForExport", () => { glucoseContext: "FASTING", }); }); + + // v1.4.25 W7 — per-user timezone offset in exports (issue #167). + it("emits ISO-8601 with user-tz offset when userTz is provided", () => { + const measurements = [ + { + type: "WEIGHT", + value: 75.5, + unit: "kg", + // 09:00 UTC = 11:00 Europe/Berlin (CEST in May). + measuredAt: new Date("2026-05-15T09:00:00Z"), + source: "MANUAL", + notes: null, + }, + ]; + const result = formatMeasurementsForExport(measurements, "Europe/Berlin"); + expect(result[0].measuredAt).toBe("2026-05-15T11:00:00+02:00"); + }); + + it("emits the user's positive offset for Pacific/Auckland", () => { + const measurements = [ + { + type: "WEIGHT", + value: 75.5, + unit: "kg", + measuredAt: new Date("2026-05-15T09:00:00Z"), + source: "MANUAL", + notes: null, + }, + ]; + const result = formatMeasurementsForExport(measurements, "Pacific/Auckland"); + expect(result[0].measuredAt).toBe("2026-05-15T21:00:00+12:00"); + }); + + it("falls back to UTC Z suffix when no userTz is provided", () => { + const measurements = [ + { + type: "WEIGHT", + value: 75.5, + unit: "kg", + measuredAt: new Date("2026-05-15T09:00:00Z"), + source: "MANUAL", + notes: null, + }, + ]; + const result = formatMeasurementsForExport(measurements); + expect(result[0].measuredAt).toBe("2026-05-15T09:00:00.000Z"); + }); +}); + +describe("formatIntakeEventsForExport with userTz", () => { + it("emits scheduledFor and takenAt with offset", () => { + const events = [ + { + medication: { name: "Aspirin" }, + scheduledFor: new Date("2026-05-15T06:00:00Z"), + takenAt: new Date("2026-05-15T06:05:00Z"), + skipped: false, + source: "MANUAL", + }, + ]; + const result = formatIntakeEventsForExport(events, "Europe/Berlin"); + expect(result[0].scheduledFor).toBe("2026-05-15T08:00:00+02:00"); + expect(result[0].takenAt).toBe("2026-05-15T08:05:00+02:00"); + }); + + it("emits empty takenAt for skipped intakes", () => { + const events = [ + { + medication: { name: "Aspirin" }, + scheduledFor: new Date("2026-05-15T06:00:00Z"), + takenAt: null, + skipped: true, + source: "MANUAL", + }, + ]; + const result = formatIntakeEventsForExport(events, "Europe/Berlin"); + expect(result[0].takenAt).toBe(""); + }); +}); + +describe("formatMoodEntriesForExport with userTz", () => { + it("emits loggedAt with offset while preserving the stored date column", () => { + const entries = [ + { + date: "2026-05-15", + mood: "GREAT", + score: 5, + tags: null, + source: "MANUAL", + moodLoggedAt: new Date("2026-05-15T18:30:00Z"), + }, + ]; + const result = formatMoodEntriesForExport(entries, "America/New_York"); + expect(result[0].date).toBe("2026-05-15"); // Stored column unchanged. + expect(result[0].loggedAt).toBe("2026-05-15T14:30:00-04:00"); + }); }); diff --git a/src/lib/export.ts b/src/lib/export.ts index efad8a162..e37a950a2 100644 --- a/src/lib/export.ts +++ b/src/lib/export.ts @@ -1,6 +1,20 @@ /** * 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/resolver"; interface ExportableRecord { [key: string]: unknown; @@ -32,8 +46,18 @@ export function toCSV(records: ExportableRecord[]): string { return lines.join("\n"); } +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<{ @@ -45,12 +69,13 @@ export function formatMeasurementsForExport( notes: string | null; glucoseContext?: string | null; }>, + userTz?: string, ): ExportableRecord[] { return measurements.map((m) => ({ type: m.type, value: m.value, unit: m.unit, - measuredAt: m.measuredAt.toISOString(), + measuredAt: formatTimestamp(m.measuredAt, userTz), source: m.source, notes: m.notes ?? "", glucoseContext: m.glucoseContext ?? "", @@ -88,6 +113,8 @@ export function formatMedicationsForExport( /** * Format intake events for export. + * + * `userTz` (optional) — see `formatMeasurementsForExport`. */ export function formatIntakeEventsForExport( events: Array<{ @@ -97,11 +124,12 @@ export function formatIntakeEventsForExport( skipped: boolean; source: string; }>, + userTz?: string, ): ExportableRecord[] { return events.map((e) => ({ medication: e.medication.name, - scheduledFor: e.scheduledFor.toISOString(), - takenAt: e.takenAt?.toISOString() ?? "", + scheduledFor: formatTimestamp(e.scheduledFor, userTz), + takenAt: e.takenAt ? formatTimestamp(e.takenAt, userTz) : "", skipped: e.skipped, source: e.source, })); @@ -109,6 +137,15 @@ export function formatIntakeEventsForExport( /** * 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<{ @@ -119,6 +156,7 @@ export function formatMoodEntriesForExport( source: string; moodLoggedAt: Date; }>, + userTz?: string, ): ExportableRecord[] { return entries.map((e) => ({ date: e.date, @@ -126,6 +164,6 @@ export function formatMoodEntriesForExport( score: e.score, tags: e.tags ?? "", source: e.source, - loggedAt: e.moodLoggedAt.toISOString(), + loggedAt: formatTimestamp(e.moodLoggedAt, userTz), })); } diff --git a/src/lib/tz/__tests__/resolver.test.ts b/src/lib/tz/__tests__/resolver.test.ts index ca97074b6..8291e19bb 100644 --- a/src/lib/tz/__tests__/resolver.test.ts +++ b/src/lib/tz/__tests__/resolver.test.ts @@ -144,13 +144,15 @@ describe("resolveUserTimezone", () => { }); it("caches per-userId so two users do not collide", async () => { - vi.mocked(prisma.user.findUnique).mockImplementation(async (args) => { - const where = (args as { where: { id: string } }).where; - if (where.id === "user-1") { - return { timezone: "Pacific/Auckland" } as never; - } - return { timezone: "America/New_York" } as never; - }); + // Two distinct findUnique calls; we use mockResolvedValueOnce + // queued ahead of time. The cache hit on the third call + // bypasses the mock altogether. + vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({ + timezone: "Pacific/Auckland", + } as never); + vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({ + timezone: "America/New_York", + } as never); expect(await resolveUserTimezone("user-1")).toBe("Pacific/Auckland"); expect(await resolveUserTimezone("user-2")).toBe("America/New_York");