mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
feat(tz): per-user timezone resolver + formatInUserTz helper
v1.4.25 W7 phase 2 — adds src/lib/tz/resolver.ts with:
- resolveUserTimezone(userId) reads User.timezone with a 60s
process-level cache and falls back to the server default.
- resolveServerDefaultTimezone() reads AppSettings.defaultUserTimezone
with the same TTL, falling back to 'Europe/Berlin'.
- detectBrowserTimezone() — client-only Intl helper.
- formatInUserTz(date, tz, shape) emits ISO-8601-with-offset (for
exports — issue #167 fix), wall-clock (for chart axis labels),
datetime (for PDFs / notifications), and date-only (parallel to
berlinDayKey but parameterised).
- userDayKey(date, tz) — day-bucket helper parallel to
berlinDayKey() but user-scoped.
- isValidTimezone() / listSupportedTimezones() for write-path
validation and the picker UI.
29-test suite covers cache hit/miss, fallback chain, invalid tz
handling, DST (Berlin Jan = +01:00, May = +02:00), Pacific/Auckland
(UTC+12), America/New_York (UTC-04 in May), and the Berlin-vs-Auckland
midnight round-trip that makes the per-user feature visible.
This commit is contained in:
1 parent
3e9881f3fd
commit
9e551b7868
2 files changed
+604
No files matched your search
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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<string, CacheEntry>();
|
||||
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<string> {
|
||||
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<string> {
|
||||
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);
|
||||
}
|
||||
Reference in new issue
Block a user