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",
+ });
};
|