Events stored UTC have always been interpreted in viewer-local TZ at the admin form and render layer. Adding an event-owned IANA timezone unblocks accurate scheduling and display regardless of the admin's or viewer's browser TZ. - Event.displayTimezone (default "America/Toronto") on the model. - displayTimezone added to admin create/update Zod schemas. - app/utils/timezones.js: zonedLocalToUTC, utcToZonedLocal, shortTimezoneName — Intl-based helpers, no new dependencies.
77 lines
2.6 KiB
JavaScript
77 lines
2.6 KiB
JavaScript
// Convert a datetime-local string ("YYYY-MM-DDTHH:MM") to a UTC Date,
|
|
// interpreting the wall-clock time in the given IANA timezone.
|
|
export function zonedLocalToUTC(localStr, tz) {
|
|
if (!localStr || !tz) return null;
|
|
const [datePart, timePart] = String(localStr).split("T");
|
|
if (!datePart || !timePart) return null;
|
|
const [y, mo, d] = datePart.split("-").map(Number);
|
|
const [h, mi] = timePart.split(":").map(Number);
|
|
if ([y, mo, d, h, mi].some((n) => Number.isNaN(n))) return null;
|
|
|
|
// Treat the components as if they are already UTC. The result's wall-clock
|
|
// in the target TZ will differ from what we want by exactly the TZ offset
|
|
// for that moment, so we measure that offset and subtract it.
|
|
const asUTC = new Date(Date.UTC(y, mo - 1, d, h, mi));
|
|
const parts = new Intl.DateTimeFormat("en-US", {
|
|
timeZone: tz,
|
|
year: "numeric",
|
|
month: "2-digit",
|
|
day: "2-digit",
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
second: "2-digit",
|
|
hour12: false,
|
|
}).formatToParts(asUTC);
|
|
const get = (type) => Number(parts.find((p) => p.type === type)?.value);
|
|
const observed = Date.UTC(
|
|
get("year"),
|
|
get("month") - 1,
|
|
get("day"),
|
|
get("hour") % 24,
|
|
get("minute"),
|
|
get("second"),
|
|
);
|
|
const offsetMs = observed - asUTC.getTime();
|
|
return new Date(asUTC.getTime() - offsetMs);
|
|
}
|
|
|
|
// Convert a UTC Date (or ISO string) to a datetime-local string
|
|
// ("YYYY-MM-DDTHH:MM") rendered in the given IANA timezone.
|
|
export function utcToZonedLocal(utc, tz) {
|
|
if (!utc || !tz) return "";
|
|
const d = utc instanceof Date ? utc : new Date(utc);
|
|
if (Number.isNaN(d.getTime())) return "";
|
|
const parts = new Intl.DateTimeFormat("en-US", {
|
|
timeZone: tz,
|
|
year: "numeric",
|
|
month: "2-digit",
|
|
day: "2-digit",
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
hour12: false,
|
|
}).formatToParts(d);
|
|
const get = (type) => parts.find((p) => p.type === type)?.value;
|
|
const year = get("year");
|
|
const month = get("month");
|
|
const day = get("day");
|
|
let hour = get("hour");
|
|
const minute = get("minute");
|
|
if (hour === "24") hour = "00";
|
|
return `${year}-${month}-${day}T${hour}:${minute}`;
|
|
}
|
|
|
|
// Short timezone label (e.g., "EDT", "PDT") for a Date in a given IANA TZ.
|
|
export function shortTimezoneName(date, tz) {
|
|
if (!date || !tz) return "";
|
|
const d = date instanceof Date ? date : new Date(date);
|
|
if (Number.isNaN(d.getTime())) return "";
|
|
try {
|
|
const parts = new Intl.DateTimeFormat("en-US", {
|
|
timeZone: tz,
|
|
timeZoneName: "short",
|
|
}).formatToParts(d);
|
|
return parts.find((p) => p.type === "timeZoneName")?.value || "";
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|