diff --git a/src/lib/tz/__tests__/resolver.test.ts b/src/lib/tz/__tests__/resolver.test.ts new file mode 100644 index 000000000..ca97074b6 --- /dev/null +++ b/src/lib/tz/__tests__/resolver.test.ts @@ -0,0 +1,266 @@ +/** + * v1.4.25 W7 — resolver tests. + * + * The cache-hit / cache-miss assertions assume the test file is run + * in isolation. Vitest's default per-file isolation gives us that. + * Each test imports the module fresh, so the module-level caches + * don't leak across cases. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// Mock prisma BEFORE importing the resolver. +vi.mock("@/lib/db", () => ({ + prisma: { + user: { findUnique: vi.fn() }, + appSettings: { findUnique: vi.fn() }, + }, +})); + +import { prisma } from "@/lib/db"; +import { + DEFAULT_TIMEZONE, + detectBrowserTimezone, + formatInUserTz, + invalidateServerDefaultTimezone, + invalidateUserTimezone, + isValidTimezone, + resolveServerDefaultTimezone, + resolveUserTimezone, + userDayKey, +} from "../resolver"; + +beforeEach(() => { + vi.clearAllMocks(); + invalidateUserTimezone("user-1"); + invalidateUserTimezone("user-2"); + invalidateServerDefaultTimezone(); +}); + +describe("isValidTimezone", () => { + it("accepts canonical IANA zones", () => { + expect(isValidTimezone("Europe/Berlin")).toBe(true); + expect(isValidTimezone("America/New_York")).toBe(true); + expect(isValidTimezone("Pacific/Auckland")).toBe(true); + expect(isValidTimezone("UTC")).toBe(true); + }); + + it("rejects obvious nonsense", () => { + expect(isValidTimezone("")).toBe(false); + expect(isValidTimezone("Not/A_Zone")).toBe(false); + expect(isValidTimezone("Europe/Berlin' OR 1=1")).toBe(false); + }); + + it("rejects strings longer than 64 chars", () => { + expect(isValidTimezone("A".repeat(65))).toBe(false); + }); +}); + +describe("resolveServerDefaultTimezone", () => { + it("returns Europe/Berlin when AppSettings is empty", async () => { + vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null); + expect(await resolveServerDefaultTimezone()).toBe("Europe/Berlin"); + }); + + it("returns the configured value when set", async () => { + vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({ + defaultUserTimezone: "Asia/Tokyo", + } as never); + expect(await resolveServerDefaultTimezone()).toBe("Asia/Tokyo"); + }); + + it("falls back when the configured value is invalid", async () => { + vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({ + defaultUserTimezone: "Mars/Olympus_Mons", + } as never); + expect(await resolveServerDefaultTimezone()).toBe("Europe/Berlin"); + }); + + it("hits the cache on the second call", async () => { + vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({ + defaultUserTimezone: "Asia/Tokyo", + } as never); + await resolveServerDefaultTimezone(); + await resolveServerDefaultTimezone(); + await resolveServerDefaultTimezone(); + expect(prisma.appSettings.findUnique).toHaveBeenCalledTimes(1); + }); + + it("re-reads after invalidation", async () => { + vi.mocked(prisma.appSettings.findUnique).mockResolvedValueOnce({ + defaultUserTimezone: "Asia/Tokyo", + } as never); + vi.mocked(prisma.appSettings.findUnique).mockResolvedValueOnce({ + defaultUserTimezone: "America/New_York", + } as never); + + expect(await resolveServerDefaultTimezone()).toBe("Asia/Tokyo"); + invalidateServerDefaultTimezone(); + expect(await resolveServerDefaultTimezone()).toBe("America/New_York"); + }); +}); + +describe("resolveUserTimezone", () => { + it("returns User.timezone when present", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + timezone: "Pacific/Auckland", + } as never); + expect(await resolveUserTimezone("user-1")).toBe("Pacific/Auckland"); + }); + + it("falls back to the server default when the user row is missing", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue(null); + vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({ + defaultUserTimezone: "Asia/Tokyo", + } as never); + expect(await resolveUserTimezone("user-1")).toBe("Asia/Tokyo"); + }); + + it("falls back to Europe/Berlin when neither user nor server default exist", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue(null); + vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null); + expect(await resolveUserTimezone("user-1")).toBe("Europe/Berlin"); + }); + + it("hits the cache on the second call", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + timezone: "Pacific/Auckland", + } as never); + await resolveUserTimezone("user-1"); + await resolveUserTimezone("user-1"); + expect(prisma.user.findUnique).toHaveBeenCalledTimes(1); + }); + + it("re-reads after invalidation", async () => { + 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"); + invalidateUserTimezone("user-1"); + expect(await resolveUserTimezone("user-1")).toBe("America/New_York"); + }); + + 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; + }); + + expect(await resolveUserTimezone("user-1")).toBe("Pacific/Auckland"); + expect(await resolveUserTimezone("user-2")).toBe("America/New_York"); + expect(await resolveUserTimezone("user-1")).toBe("Pacific/Auckland"); + expect(prisma.user.findUnique).toHaveBeenCalledTimes(2); + }); + + it("returns the server default for empty userId", async () => { + vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null); + expect(await resolveUserTimezone("")).toBe("Europe/Berlin"); + }); +}); + +describe("detectBrowserTimezone", () => { + it("returns a non-empty string", () => { + expect(detectBrowserTimezone().length).toBeGreaterThan(0); + }); +}); + +describe("formatInUserTz", () => { + // 2026-05-11T09:05:00Z — a Monday morning in May (CEST in Berlin, + // NZST in Auckland is UTC+12 in May with DST off). + const instant = new Date("2026-05-11T09:05:00.000Z"); + + it("emits ISO-8601 with offset for Europe/Berlin (CEST in May)", () => { + expect(formatInUserTz(instant, "Europe/Berlin", "iso-with-offset")).toBe( + "2026-05-11T11:05:00+02:00", + ); + }); + + it("emits ISO-8601 with offset for Pacific/Auckland (NZST in May)", () => { + // May 2026 = NZST = UTC+12 (DST ends in early April for NZ). + expect(formatInUserTz(instant, "Pacific/Auckland", "iso-with-offset")).toBe( + "2026-05-11T21:05:00+12:00", + ); + }); + + it("emits ISO-8601 with offset for America/New_York (EDT in May)", () => { + // May = EDT = UTC-04:00. + expect(formatInUserTz(instant, "America/New_York", "iso-with-offset")).toBe( + "2026-05-11T05:05:00-04:00", + ); + }); + + it("emits ISO-8601 for UTC with +00:00", () => { + expect(formatInUserTz(instant, "UTC", "iso-with-offset")).toBe( + "2026-05-11T09:05:00+00:00", + ); + }); + + it("falls back to Europe/Berlin when handed an invalid tz", () => { + expect(formatInUserTz(instant, "Mars/Olympus", "iso-with-offset")).toBe( + "2026-05-11T11:05:00+02:00", + ); + }); + + it("formats wall-clock without timezone marker", () => { + expect(formatInUserTz(instant, "Pacific/Auckland", "wall-clock")).toBe( + "21:05", + ); + }); + + it("formats datetime as YYYY-MM-DD HH:MM", () => { + expect(formatInUserTz(instant, "Pacific/Auckland", "datetime")).toBe( + "2026-05-11 21:05", + ); + }); + + it("formats date-only as YYYY-MM-DD", () => { + expect(formatInUserTz(instant, "Pacific/Auckland", "date")).toBe( + "2026-05-11", + ); + }); + + it("round-trips Berlin vs Auckland — same instant, different days", () => { + // 2026-05-10T22:30:00Z — 23 May in Auckland already, still 10 May + // in Berlin (00:30 next day actually, so 11 May Berlin). + // Pick a clearer case: 12:30 UTC = 14:30 Berlin = 00:30 Auckland (next day). + const lateNight = new Date("2026-05-10T12:30:00.000Z"); + expect(formatInUserTz(lateNight, "Europe/Berlin", "date")).toBe( + "2026-05-10", + ); + expect(formatInUserTz(lateNight, "Pacific/Auckland", "date")).toBe( + "2026-05-11", + ); + }); + + it("honours DST: Berlin in January is CET = +01:00", () => { + const jan = new Date("2026-01-15T09:05:00.000Z"); + expect(formatInUserTz(jan, "Europe/Berlin", "iso-with-offset")).toBe( + "2026-01-15T10:05:00+01:00", + ); + }); +}); + +describe("userDayKey", () => { + it("matches the Berlin reference when tz=Europe/Berlin", () => { + const instant = new Date("2025-10-25T21:30:00Z"); // Sat 23:30 Berlin + expect(userDayKey(instant, "Europe/Berlin")).toBe("2025-10-25"); + }); + + it("returns the next day when the user is east of UTC by enough", () => { + const instant = new Date("2026-05-10T14:50:00Z"); // 23:50 Tokyo + expect(userDayKey(instant, "Asia/Tokyo")).toBe("2026-05-10"); + expect(userDayKey(instant, "Pacific/Auckland")).toBe("2026-05-11"); + }); +}); + +describe("DEFAULT_TIMEZONE", () => { + it("is Europe/Berlin", () => { + expect(DEFAULT_TIMEZONE).toBe("Europe/Berlin"); + }); +}); diff --git a/src/lib/tz/resolver.ts b/src/lib/tz/resolver.ts new file mode 100644 index 000000000..8c7810d35 --- /dev/null +++ b/src/lib/tz/resolver.ts @@ -0,0 +1,338 @@ +/** + * v1.4.25 W7 — per-user timezone resolver. + * + * HealthLog historically pinned every display surface to the + * `DISPLAY_TIMEZONE` constant ("Europe/Berlin"). The per-user + * timezone feature (Option B in `.planning/feature-user-timezone.md`) + * threads the user's stored zone through every surface that renders + * a date or buckets by day — exports, charts, AI Coach snapshot, + * doctor-report PDF, reminders. + * + * The resolver is intentionally narrow: + * + * - `resolveUserTimezone(userId)` reads `User.timezone` and falls + * back to the server-wide default. The User column has a NOT + * NULL default ("Europe/Berlin") in the schema, so the fallback + * only kicks in if a row is somehow missing — defensive, not + * load-bearing. + * + * - `resolveServerDefaultTimezone()` reads the singleton + * `AppSettings.defaultUserTimezone` and falls back to + * "Europe/Berlin" when the operator never set one. This is what + * the registration handler hands a new account when the client + * doesn't send a browser-detected zone. + * + * - `detectBrowserTimezone()` is the client-only helper that + * returns `Intl.DateTimeFormat().resolvedOptions().timeZone`. + * Used by the signup form and the profile picker's "use my + * browser zone" button. + * + * - `formatInUserTz(date, tz, format?)` is the server-side + * formatter. Three named formats keep the call sites tight: + * "iso-with-offset" for exports, "wall-clock" for chart labels, + * "datetime" for PDFs / notifications. + * + * Both resolver entry points share a 60-second TTL cache keyed by + * `userId` / `"__server__"`. The hot path (Coach snapshot, export + * with thousands of rows) would otherwise hammer Prisma. Bust the + * cache on the rare writes via `invalidateUserTimezone(userId)` + * (called from the profile PUT and the admin PUT). The cache is + * module-level intentionally — every Next.js route handler shares + * the same process, and the per-user write path already invalidates. + */ +import { prisma } from "@/lib/db"; + +export const DEFAULT_TIMEZONE = "Europe/Berlin"; + +const TTL_MS = 60_000; + +interface CacheEntry { + tz: string; + expiresAt: number; +} + +const userTzCache = new Map(); +let serverTzCache: CacheEntry | null = null; + +/** + * Validate a timezone string by asking `Intl.DateTimeFormat` to use it. + * Returns `true` if the runtime accepts the zone, `false` otherwise. + * Cheap (microseconds) — call freely at write paths. + */ +export function isValidTimezone(tz: string): boolean { + if (!tz || typeof tz !== "string" || tz.length === 0 || tz.length > 64) { + return false; + } + try { + new Intl.DateTimeFormat("en-US", { timeZone: tz }); + return true; + } catch { + return false; + } +} + +/** + * The list of every IANA zone the current Node runtime accepts. + * Returns an empty array on engines older than Node 22 / V8 12.5 + * where `Intl.supportedValuesOf` is unavailable — callers should + * fall back to free-text input plus `isValidTimezone()` validation. + */ +export function listSupportedTimezones(): string[] { + const supportedValuesOf = ( + Intl as unknown as { + supportedValuesOf?: (key: string) => string[]; + } + ).supportedValuesOf; + if (typeof supportedValuesOf !== "function") return []; + try { + return supportedValuesOf("timeZone"); + } catch { + return []; + } +} + +/** + * Resolve the server-wide default timezone — the value handed to new + * accounts on signup when the client did not send a browser-detected + * zone. Reads the singleton `AppSettings.defaultUserTimezone`. Falls + * back to "Europe/Berlin" when the column is NULL. + * + * Cached for 60 s in process. The admin write path calls + * `invalidateServerDefaultTimezone()` on change. + */ +export async function resolveServerDefaultTimezone(): Promise { + const now = Date.now(); + if (serverTzCache && serverTzCache.expiresAt > now) { + return serverTzCache.tz; + } + let tz = DEFAULT_TIMEZONE; + try { + const settings = await prisma.appSettings.findUnique({ + where: { id: "singleton" }, + select: { defaultUserTimezone: true }, + }); + if (settings?.defaultUserTimezone && isValidTimezone(settings.defaultUserTimezone)) { + tz = settings.defaultUserTimezone; + } + } catch { + // app_settings may not exist on a brand-new DB (first-user + // bootstrap before any settings write). Fall through to the + // hard-coded default — every existing instance has the row. + } + serverTzCache = { tz, expiresAt: now + TTL_MS }; + return tz; +} + +/** + * Resolve the per-user display timezone. Reads `User.timezone`. Falls + * back to the server default and then to "Europe/Berlin". Cached for + * 60 s in process. The profile PUT calls `invalidateUserTimezone()` + * on change. + * + * Returns the server default for unknown userIds — the caller is + * expected to gate on auth upstream, so an unknown id here is a + * server-side oddity (test harness, unauthenticated background job) + * and the server default is the safest interpretation. + */ +export async function resolveUserTimezone(userId: string): Promise { + if (!userId) return resolveServerDefaultTimezone(); + const now = Date.now(); + const cached = userTzCache.get(userId); + if (cached && cached.expiresAt > now) { + return cached.tz; + } + let tz: string | null = null; + try { + const row = await prisma.user.findUnique({ + where: { id: userId }, + select: { timezone: true }, + }); + if (row?.timezone && isValidTimezone(row.timezone)) { + tz = row.timezone; + } + } catch { + tz = null; + } + const resolved = tz ?? (await resolveServerDefaultTimezone()); + userTzCache.set(userId, { tz: resolved, expiresAt: now + TTL_MS }); + return resolved; +} + +/** + * Drop the cached value for `userId`. Call from any write path that + * mutates `User.timezone` so the next read picks up the new zone + * within one event loop tick rather than waiting for the 60-s TTL. + */ +export function invalidateUserTimezone(userId: string): void { + userTzCache.delete(userId); +} + +/** Drop the cached server-default zone. Call from the admin PUT. */ +export function invalidateServerDefaultTimezone(): void { + serverTzCache = null; +} + +/** + * Client-only helper. Reads the browser's resolved timezone via the + * `Intl` API. Returns the string the browser hands us — no + * validation, no fallback — because the consumer (signup form, + * profile picker hint) is going to round-trip the value through the + * server's `isValidTimezone()` check anyway. Safe on every modern + * browser back to Edge 18 / Safari 11 / Chrome 24. + */ +export function detectBrowserTimezone(): string { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone; + } catch { + return DEFAULT_TIMEZONE; + } +} + +export type FormatInUserTzShape = + | "iso-with-offset" + | "wall-clock" + | "datetime" + | "date"; + +/** + * Format an instant in the user's timezone. + * + * - "iso-with-offset" — `2026-05-11T11:05:00+02:00` (no + * milliseconds, sortable, machine-parseable). Issue #167 fix: + * replaces `.toISOString()` in CSV/JSON exports. + * + * - "wall-clock" — `11:05` (24h, no seconds). Used by chart axis + * labels and notification preambles. + * + * - "datetime" — `2026-05-11 11:05` (24h, locale-independent for + * audit/PDF tables). Equivalent to the `formatters.dateTime` + * helper but with a per-call timezone instead of the global + * `DISPLAY_TIMEZONE`. + * + * - "date" — `2026-05-11` (ISO date in the user's zone). Used as a + * day-bucket key parallel to the legacy `berlinDayKey()`. + */ +export function formatInUserTz( + date: Date, + tz: string, + format: FormatInUserTzShape = "iso-with-offset", +): string { + const safeTz = isValidTimezone(tz) ? tz : DEFAULT_TIMEZONE; + switch (format) { + case "iso-with-offset": + return formatIsoWithOffset(date, safeTz); + case "wall-clock": + return new Intl.DateTimeFormat("en-GB", { + timeZone: safeTz, + hour: "2-digit", + minute: "2-digit", + hour12: false, + }).format(date); + case "datetime": { + const parts = wallClockParts(date, safeTz); + return `${parts.year}-${parts.month}-${parts.day} ${parts.hour}:${parts.minute}`; + } + case "date": { + const parts = wallClockParts(date, safeTz); + return `${parts.year}-${parts.month}-${parts.day}`; + } + } +} + +/** + * Day-bucket key in the user's timezone. Mirrors `berlinDayKey()` but + * parameterised. Used as a stable Map key for daily aggregation so a + * 23:30-local reading lands on today's bucket regardless of UTC + * offset. The legacy `berlinDayKey()` stays for surfaces that are + * not yet user-scoped (admin tables, audit log viewer). + */ +export function userDayKey(date: Date, tz: string): string { + return formatInUserTz(date, tz, "date"); +} + +interface WallClockParts { + year: string; + month: string; + day: string; + hour: string; + minute: string; + second: string; +} + +function wallClockParts(date: Date, tz: string): WallClockParts { + const fmt = new Intl.DateTimeFormat("en-CA", { + timeZone: tz, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + }); + const parts = fmt.formatToParts(date); + const get = (type: Intl.DateTimeFormatPartTypes): string => + parts.find((p) => p.type === type)?.value ?? "00"; + // Some engines render midnight as "24:00". + let hour = get("hour"); + if (hour === "24") hour = "00"; + return { + year: get("year"), + month: get("month"), + day: get("day"), + hour, + minute: get("minute"), + second: get("second"), + }; +} + +/** + * Format a Date as `YYYY-MM-DDTHH:MM:SS±HH:MM` for the requested + * timezone. Sortable, lossless w.r.t. the UTC instant, and reads + * correctly in Excel / LibreOffice (the CSV-export bug in issue #167 + * was triggered by `Z` getting stripped by Excel before display, so + * UTC-with-offset values are misread as local). Seconds are + * preserved (not milliseconds — the export only carries timestamps + * at second resolution anyway). + */ +function formatIsoWithOffset(date: Date, tz: string): string { + const parts = wallClockParts(date, tz); + const offsetMinutes = tzOffsetMinutes(date, tz); + const sign = offsetMinutes >= 0 ? "+" : "-"; + const abs = Math.abs(offsetMinutes); + const offH = String(Math.floor(abs / 60)).padStart(2, "0"); + const offM = String(abs % 60).padStart(2, "0"); + return `${parts.year}-${parts.month}-${parts.day}T${parts.hour}:${parts.minute}:${parts.second}${sign}${offH}:${offM}`; +} + +/** + * Compute the UTC offset of a timezone at a specific instant, in + * minutes. Positive east of UTC, negative west. Honours DST because + * `Intl.DateTimeFormat` does — we reconstruct the offset from the + * difference between the wall-clock parts and the UTC parts. + */ +function tzOffsetMinutes(date: Date, tz: string): number { + const fmt = new Intl.DateTimeFormat("en-CA", { + timeZone: tz, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + }); + const parts = fmt.formatToParts(date); + const get = (type: Intl.DateTimeFormatPartTypes): number => + Number(parts.find((p) => p.type === type)?.value ?? "0"); + const hour = get("hour") === 24 ? 0 : get("hour"); + const asIfUtc = Date.UTC( + get("year"), + get("month") - 1, + get("day"), + hour, + get("minute"), + get("second"), + ); + return Math.round((asIfUtc - date.getTime()) / 60000); +}