From e6f05b54714002ff7f6cf619902a282d16b88329 Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Tue, 19 May 2026 10:39:27 +0100 Subject: [PATCH 1/6] feat(events): add displayTimezone field and zoned datetime helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- app/utils/timezones.js | 77 +++++++++++++++++++++++++++++++++++++++++ server/models/event.js | 2 ++ server/utils/schemas.js | 2 ++ 3 files changed, 81 insertions(+) create mode 100644 app/utils/timezones.js 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(), From a76ba2f8c7e5d6f152e887ec210ffa16e2bbda8d Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Tue, 19 May 2026 10:44:03 +0100 Subject: [PATCH 2/6] feat(admin-events): event timezone picker and zoned save/load Add a USelectMenu for displayTimezone in Event Details (defaults to America/Toronto). On submit, convert each datetime-local string (startDate, endDate, registrationDeadline, earlyBirdDeadline) from the event's TZ to a UTC ISO string so the wall-clock time the admin entered is preserved regardless of their browser TZ. On edit, render stored UTC back through the event's TZ so the round-trip is stable. Reuses TIMEZONE_OPTIONS from ~/config/timezones and the picker pattern from member/profile.vue. Auto-imported helpers from app/utils/timezones do the math via Intl. --- app/pages/admin/events/create.vue | 91 +++++++++++++++++++++++++++++-- 1 file changed, 85 insertions(+), 6 deletions(-) diff --git a/app/pages/admin/events/create.vue b/app/pages/admin/events/create.vue index 087899d..6d73edc 100644 --- a/app/pages/admin/events/create.vue +++ b/app/pages/admin/events/create.vue @@ -110,6 +110,23 @@

+
+ + +

+ Dates below are interpreted in this timezone. Attendees see the + event time in this zone. +

+
+
diff --git a/app/components/EventsMiniSidebar.vue b/app/components/EventsMiniSidebar.vue index df953d6..0a9aa0b 100644 --- a/app/components/EventsMiniSidebar.vue +++ b/app/components/EventsMiniSidebar.vue @@ -6,7 +6,7 @@
- {{ formatDate(event.startDate) }} + {{ formatDate(event) }} [] }, }); -const formatDate = (dateStr) => { - if (!dateStr) return ""; - const d = new Date(dateStr); - return d.toLocaleDateString("en-US", { month: "short", day: "numeric" }); +const formatDate = (event) => { + if (!event?.startDate) return ""; + return new Date(event.startDate).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + timeZone: event.displayTimezone || "America/Toronto", + }); }; diff --git a/app/pages/events/[slug].vue b/app/pages/events/[slug].vue index 1befb81..61117f9 100644 --- a/app/pages/events/[slug].vue +++ b/app/pages/events/[slug].vue @@ -131,6 +131,7 @@ :event-id="event._id || event.id" :event-start-date="event.startDate" :event-title="event.title" + :event-timezone="eventTimeZone" :user-email="memberData?.email" :user-name="memberData?.name" @success="handleTicketSuccess" diff --git a/app/pages/events/index.vue b/app/pages/events/index.vue index 66c90e9..bfcf9b3 100644 --- a/app/pages/events/index.vue +++ b/app/pages/events/index.vue @@ -34,8 +34,8 @@ :class="{ 'is-cancelled': event.isCancelled }" >
- {{ formatDate(event.startDate) }} - {{ formatTime(event.startDate) }} + {{ formatDate(event) }} + {{ formatTime(event) }}
@@ -152,18 +152,24 @@ const activeSeries = computed(() => { ); }); -const formatDate = (dateStr) => { - if (!dateStr) return ""; - const d = new Date(dateStr); - const opts = { month: "short", day: "numeric" }; - if (d.getFullYear() !== new Date().getFullYear()) opts.year = "numeric"; +const formatDate = (event) => { + if (!event?.startDate) return ""; + const tz = event.displayTimezone || "America/Toronto"; + const d = new Date(event.startDate); + const opts = { month: "short", day: "numeric", timeZone: tz }; + const dYear = d.toLocaleDateString("en-US", { year: "numeric", timeZone: tz }); + const nowYear = new Date().toLocaleDateString("en-US", { year: "numeric", timeZone: tz }); + if (dYear !== nowYear) opts.year = "numeric"; return d.toLocaleDateString("en-US", opts); }; -const formatTime = (dateStr) => { - if (!dateStr) return ""; - const d = new Date(dateStr); - return d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" }); +const formatTime = (event) => { + if (!event?.startDate) return ""; + return new Date(event.startDate).toLocaleTimeString("en-US", { + hour: "numeric", + minute: "2-digit", + timeZone: event.displayTimezone || "America/Toronto", + }); }; const formatLocation = (event) => { diff --git a/app/pages/member/dashboard.vue b/app/pages/member/dashboard.vue index de91d71..91d088b 100644 --- a/app/pages/member/dashboard.vue +++ b/app/pages/member/dashboard.vue @@ -60,9 +60,7 @@ :to="`/events/${evt.slug || evt._id}`" class="event-item" > - {{ - formatEventDate(evt.startDate) - }} + {{ formatEventDate(evt) }} {{ evt.title }} @@ -365,20 +363,22 @@ const getEventImageUrl = (featureImage) => { return ""; }; -const formatEventDate = (dateString) => { - const date = new Date(dateString); +const formatEventDate = (event) => { + if (!event?.startDate) return ""; return new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", - }).format(date); + timeZone: event.displayTimezone || "America/Toronto", + }).format(new Date(event.startDate)); }; -const formatEventTime = (dateString) => { - const date = new Date(dateString); +const formatEventTime = (event) => { + if (!event?.startDate) return ""; return new Intl.DateTimeFormat("en-US", { hour: "numeric", minute: "2-digit", - }).format(date); + timeZone: event.displayTimezone || "America/Toronto", + }).format(new Date(event.startDate)); }; const formatMemberSince = (dateString) => { From 75b1f84d1888d0626f80b1937fd117808f716f36 Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Tue, 19 May 2026 10:47:54 +0100 Subject: [PATCH 5/6] feat(emails): render event reminders in event displayTimezone Registration, waitlist, and series-pass confirmation emails formatted dates with Intl.DateTimeFormat in the server's local TZ. Switch to event.displayTimezone (fallback America/Toronto) so the email shows the event's intended wall-clock + zone suffix regardless of where the Resend worker runs. Inline formatters in three exports collapsed to two module-level helpers that take a timeZone argument. --- server/utils/resend.js | 116 +++++++++++++---------------------------- 1 file changed, 36 insertions(+), 80 deletions(-) diff --git a/server/utils/resend.js b/server/utils/resend.js index ce79e94..528ab94 100644 --- a/server/utils/resend.js +++ b/server/utils/resend.js @@ -2,6 +2,29 @@ import { Resend } from "resend"; const resend = new Resend(useRuntimeConfig().resendApiKey); +const formatEventDate = (dateString, timeZone = "America/Toronto") => { + const date = new Date(dateString); + return new Intl.DateTimeFormat("en-US", { + weekday: "long", + year: "numeric", + month: "long", + day: "numeric", + timeZone, + }).format(date); +}; + +const formatEventTime = (startDate, endDate, timeZone = "America/Toronto") => { + const start = new Date(startDate); + const end = new Date(endDate); + const timeFormat = new Intl.DateTimeFormat("en-US", { + hour: "numeric", + minute: "2-digit", + timeZoneName: "short", + timeZone, + }); + return `${timeFormat.format(start)} - ${timeFormat.format(end)}`; +}; + /** * Send event registration confirmation email * @param {Object} options - { requiresSignIn?: boolean } — when true, appends a @@ -9,29 +32,7 @@ const resend = new Resend(useRuntimeConfig().resendApiKey); * registered via the public form and did not receive an auto-login cookie. */ export async function sendEventRegistrationEmail(registration, eventData, options = {}) { - const formatDate = (dateString) => { - const date = new Date(dateString); - return new Intl.DateTimeFormat("en-US", { - weekday: "long", - year: "numeric", - month: "long", - day: "numeric", - }).format(date); - }; - - const formatTime = (startDate, endDate) => { - const start = new Date(startDate); - const end = new Date(endDate); - - const timeFormat = new Intl.DateTimeFormat("en-US", { - hour: "numeric", - minute: "2-digit", - timeZoneName: "short", - }); - - return `${timeFormat.format(start)} - ${timeFormat.format(end)}`; - }; - + const tz = eventData.displayTimezone || "America/Toronto"; const baseUrl = process.env.BASE_URL || "https://ghostguild.org"; const eventUrl = `${baseUrl}/events/${eventData.slug || eventData._id}`; const signInSection = options.requiresSignIn @@ -66,8 +67,8 @@ Paid: $${registration.amountPaid.toFixed(2)} CAD`; You're now registered for ${eventData.title}. -Date: ${formatDate(eventData.startDate)} -Time: ${formatTime(eventData.startDate, eventData.endDate)} +Date: ${formatEventDate(eventData.startDate, tz)} +Time: ${formatEventTime(eventData.startDate, eventData.endDate, tz)} Location: ${eventData.location} ${eventData.description ? `\n${eventData.description}\n` : ""}${ticketSection}${signInSection} View event: ${eventUrl} @@ -124,29 +125,7 @@ ${baseUrl}/events`, * Send waitlist notification email when a spot opens up */ export async function sendWaitlistNotificationEmail(waitlistEntry, eventData) { - const formatDate = (dateString) => { - const date = new Date(dateString); - return new Intl.DateTimeFormat("en-US", { - weekday: "long", - year: "numeric", - month: "long", - day: "numeric", - }).format(date); - }; - - const formatTime = (startDate, endDate) => { - const start = new Date(startDate); - const end = new Date(endDate); - - const timeFormat = new Intl.DateTimeFormat("en-US", { - hour: "numeric", - minute: "2-digit", - timeZoneName: "short", - }); - - return `${timeFormat.format(start)} - ${timeFormat.format(end)}`; - }; - + const tz = eventData.displayTimezone || "America/Toronto"; const baseUrl = process.env.BASE_URL || "https://ghostguild.org"; const eventUrl = `${baseUrl}/events/${eventData.slug || eventData._id}`; @@ -159,8 +138,8 @@ export async function sendWaitlistNotificationEmail(waitlistEntry, eventData) { A spot opened up for ${eventData.title}. -Date: ${formatDate(eventData.startDate)} -Time: ${formatTime(eventData.startDate, eventData.endDate)} +Date: ${formatEventDate(eventData.startDate, tz)} +Time: ${formatEventTime(eventData.startDate, eventData.endDate, tz)} Location: ${eventData.location} Register now: ${eventUrl} @@ -186,29 +165,6 @@ If you can no longer attend, ignore this email and the spot goes to the next per export async function sendSeriesPassConfirmation(options) { const { to, name, series, ticket, events, paymentId } = options; - const formatDate = (dateString) => { - const date = new Date(dateString); - return new Intl.DateTimeFormat("en-US", { - weekday: "long", - year: "numeric", - month: "long", - day: "numeric", - }).format(date); - }; - - const formatTime = (startDate, endDate) => { - const start = new Date(startDate); - const end = new Date(endDate); - - const timeFormat = new Intl.DateTimeFormat("en-US", { - hour: "numeric", - minute: "2-digit", - timeZoneName: "short", - }); - - return `${timeFormat.format(start)} - ${timeFormat.format(end)}`; - }; - const formatPrice = (price, currency = "CAD") => { if (price === 0) return "Free"; return new Intl.NumberFormat("en-CA", { @@ -223,13 +179,13 @@ export async function sendSeriesPassConfirmation(options) { : ""; const eventList = events - .map( - (evt, index) => - ` ${index + 1}. ${evt.title} - ${formatDate(evt.startDate)} - ${formatTime(evt.startDate, evt.endDate)} - ${evt.location}`, - ) + .map((evt, index) => { + const tz = evt.displayTimezone || "America/Toronto"; + return ` ${index + 1}. ${evt.title} + ${formatEventDate(evt.startDate, tz)} + ${formatEventTime(evt.startDate, evt.endDate, tz)} + ${evt.location}`; + }) .join("\n\n"); try { From 49f4eae11c64a9492582bf25e030d38f48c03f52 Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Tue, 19 May 2026 10:48:50 +0100 Subject: [PATCH 6/6] feat(events): admin list and homepage render events in displayTimezone Two more sites that used viewer-local formatting: the admin events index and the homepage event blocks. Switch both to take the event and pass event.displayTimezone to the formatter so admins see events at their intended wall-clock (and admins viewing across the world see the same time). --- app/pages/admin/events/index.vue | 20 ++++++++++++-------- app/pages/index.vue | 13 ++++++++----- 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/app/pages/admin/events/index.vue b/app/pages/admin/events/index.vue index 0cafd01..9cf458d 100644 --- a/app/pages/admin/events/index.vue +++ b/app/pages/admin/events/index.vue @@ -92,8 +92,8 @@ {{ event.eventType }} - {{ formatDate(event.startDate) }} - {{ formatTime(event.startDate) }} + {{ formatDate(event) }} + {{ formatTime(event) }} @@ -190,8 +190,8 @@ {{ event.eventType }} - {{ formatDate(event.startDate) }} - {{ formatTime(event.startDate) }} + {{ formatDate(event) }} + {{ formatTime(event) }} @@ -349,19 +349,23 @@ watch([searchQuery, typeFilter, seriesFilter], () => { pastPage.value = 1 }) -const formatDate = (dateString) => { - return new Date(dateString).toLocaleDateString('en-US', { +const formatDate = (event) => { + if (!event?.startDate) return '' + return new Date(event.startDate).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', + timeZone: event.displayTimezone || 'America/Toronto', }) } -const formatTime = (dateString) => { - return new Date(dateString).toLocaleTimeString('en-US', { +const formatTime = (event) => { + if (!event?.startDate) return '' + return new Date(event.startDate).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true, + timeZone: event.displayTimezone || 'America/Toronto', }) } diff --git a/app/pages/index.vue b/app/pages/index.vue index de89b01..a675749 100644 --- a/app/pages/index.vue +++ b/app/pages/index.vue @@ -42,7 +42,7 @@
- {{ formatDate(event.startDate) }} + {{ formatDate(event) }} {{ event.title @@ -168,10 +168,13 @@ const circleData = [ }, ]; -const formatDate = (dateStr) => { - if (!dateStr) return ""; - const d = new Date(dateStr); - return d.toLocaleDateString("en-US", { month: "short", day: "numeric" }); +const formatDate = (event) => { + if (!event?.startDate) return ""; + return new Date(event.startDate).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + timeZone: event.displayTimezone || "America/Toronto", + }); };