feat(export): emit ISO-8601 with user-tz offset instead of Z

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).
This commit is contained in:
Marc-André Bombeck committed 2026-05-14 11:55:06 +02:00
1 parent 25477e8aea
commit 96076dbc33
7 files changed
+199 -44

No files matched your search

+9 -5
View File
@@ -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,
+20 -16
View File
@@ -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");
+9 -5
View File
@@ -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,
+8 -6
View File
@@ -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<string, unknown> = {};
@@ -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", {