feat(events): add displayTimezone field and zoned datetime helpers

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.
This commit is contained in:
Jennie Robinson Faber 2026-05-19 10:39:27 +01:00
parent 9e4030ccfd
commit e6f05b5471
3 changed files with 81 additions and 0 deletions

77
app/utils/timezones.js Normal file
View file

@ -0,0 +1,77 @@
// 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 "";
}
}

View file

@ -18,6 +18,8 @@ const eventSchema = new mongoose.Schema({
enum: ["community", "workshop", "social", "showcase"],
default: "community",
},
// IANA timezone for interpreting datetime input and rendering the event time.
displayTimezone: { type: String, default: "America/Toronto" },
// Online-first location handling
location: {
type: String,

View file

@ -173,6 +173,7 @@ export const adminEventCreateSchema = z.object({
description: z.string().min(1).max(50000),
startDate: z.string().min(1),
endDate: z.string().min(1),
displayTimezone: z.string().max(100).optional(),
location: z.string().max(500).optional(),
maxAttendees: z.preprocess(emptyStringToUndefined, z.number().int().positive().optional().nullable()),
membersOnly: z.boolean().optional(),
@ -204,6 +205,7 @@ export const adminEventUpdateSchema = z.object({
description: z.string().min(1).max(50000),
startDate: z.string().min(1),
endDate: z.string().min(1),
displayTimezone: z.string().max(100).optional(),
location: z.string().max(500).optional(),
maxAttendees: z.preprocess(emptyStringToUndefined, z.number().int().positive().optional().nullable()),
membersOnly: z.boolean().optional(),