diff --git a/app/utils/timezones.js b/app/utils/timezones.js new file mode 100644 index 0000000..f31b9b5 --- /dev/null +++ b/app/utils/timezones.js @@ -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 ""; + } +} diff --git a/server/models/event.js b/server/models/event.js index 763b00b..aae2d00 100644 --- a/server/models/event.js +++ b/server/models/event.js @@ -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, diff --git a/server/utils/schemas.js b/server/utils/schemas.js index 7345224..8e7416b 100644 --- a/server/utils/schemas.js +++ b/server/utils/schemas.js @@ -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(),