mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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:
1 parent
25477e8aea
commit
96076dbc33
7 files changed
+199
-44
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
+42
-4
@@ -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),
|
||||
}));
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
Reference in new issue
Block a user