/** * v1.4.25 W7 — per-user timezone resolver (server-only). * * 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. * * Two entry points live here: * * - `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. * * Both 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. * * The pure helpers (`isValidTimezone`, `listSupportedTimezones`, * `detectBrowserTimezone`, `formatInUserTz`, `userDayKey`, * `DEFAULT_TIMEZONE`) live in `./format` so client components can * import them without dragging Prisma + `node:module` into the browser * bundle. They are re-exported here so existing server-side callers * keep their `@/lib/tz/resolver` import path. */ import { prisma } from "@/lib/db"; import { DEFAULT_TIMEZONE, isValidTimezone } from "./format"; export { DEFAULT_TIMEZONE, isValidTimezone, isNearUtc, detectBrowserTimezone, formatInUserTz, userDayKey, hourInTz, } from "./format"; const BERLIN_DAY_FORMATTER = new Intl.DateTimeFormat("en-US", { timeZone: "Europe/Berlin", year: "numeric", month: "2-digit", day: "2-digit", }); /** * Format a Date as `YYYY-MM-DD` in `Europe/Berlin`. The seven Insight * status helpers (`bmi`, `blood-pressure`, `weight`, `pulse`, `mood`, * `medication-compliance`, `general`) all key their per-day cache on * this — the maintainer's primary tenant runs in Berlin and the cache * action row reads identically across helpers when the formatter lives * here. * * For per-user surfaces, prefer `userDayKey(date, tz)` from `./format`. * This helper exists for the cache-key path where the day-bucket is * intentionally Berlin-anchored regardless of the requesting user's * stored zone. */ export function toBerlinDayKey(date: Date): string { const { year, month, day } = toBerlinYmd(date); return `${year}-${month}-${day}`; } /** * Same formatter, but returns the numeric `{year, month, day}` parts * instead of the reassembled string. Used by `bucket-series.ts` for * `Date.UTC(year, month - 1, day)` arithmetic where parsing the string * back would be wasted work. */ export function toBerlinYmd(date: Date): { year: string; month: string; day: string; } { const parts = BERLIN_DAY_FORMATTER.formatToParts(date); const year = parts.find((part) => part.type === "year")?.value; const month = parts.find((part) => part.type === "month")?.value; const day = parts.find((part) => part.type === "day")?.value; if (!year || !month || !day) { throw new Error("Could not derive Berlin day key"); } return { year, month, day }; } const TTL_MS = 60_000; interface CacheEntry { tz: string; expiresAt: number; } const userTzCache = new Map(); let serverTzCache: CacheEntry | null = null; /** * 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; } /** * The zone the server actually uses for a stored `User.timezone` value: the * value itself when it is a usable IANA name, the instance default otherwise. * * Every response that reports an account's zone goes through this, so a * client never buckets days by a value the server itself has set aside. */ export async function resolveStoredTimezone( stored: string | null | undefined, ): Promise { return stored && isValidTimezone(stored) ? stored : resolveServerDefaultTimezone(); } /** * 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; }