- {{ 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/composables/useEventDateUtils.js b/app/composables/useEventDateUtils.js
index ab536b4..2aec85e 100644
--- a/app/composables/useEventDateUtils.js
+++ b/app/composables/useEventDateUtils.js
@@ -1,85 +1,98 @@
-// Utility composable for event date handling with timezone support
+// Utility composable for event date handling with timezone support.
+// Pass `{ timeZone: event.displayTimezone }` to render in the event's TZ.
export const useEventDateUtils = () => {
- const TIMEZONE = "America/Toronto";
+ const DEFAULT_TIMEZONE = "America/Toronto";
- // Format a date to a specific format
const formatDate = (date, options = {}) => {
+ if (!date) return "";
const dateObj = date instanceof Date ? date : new Date(date);
- const { month = "short", day = "numeric", year = "numeric" } = options;
+ if (isNaN(dateObj.getTime())) return "";
+ const {
+ month = "short",
+ day = "numeric",
+ year = "numeric",
+ weekday,
+ timeZone,
+ } = options;
return new Intl.DateTimeFormat("en-US", {
+ ...(weekday && { weekday }),
month,
day,
year,
+ ...(timeZone && { timeZone }),
}).format(dateObj);
};
- // Format event date range
- const formatDateRange = (startDate, endDate, compact = false) => {
+ const formatDateRange = (startDate, endDate, compact = false, timeZone) => {
if (!startDate || !endDate) return "No dates";
const start = new Date(startDate);
const end = new Date(endDate);
- const startMonth = start.toLocaleDateString("en-US", { month: "short" });
- const endMonth = end.toLocaleDateString("en-US", { month: "short" });
- const startDay = start.getDate();
- const endDay = end.getDate();
- const year = end.getFullYear();
+ const tzOpts = timeZone ? { timeZone } : {};
+ const startMonth = start.toLocaleDateString("en-US", { month: "short", ...tzOpts });
+ const endMonth = end.toLocaleDateString("en-US", { month: "short", ...tzOpts });
+ const startDay = Number(
+ start.toLocaleDateString("en-US", { day: "numeric", ...tzOpts }),
+ );
+ const endDay = Number(
+ end.toLocaleDateString("en-US", { day: "numeric", ...tzOpts }),
+ );
+ const year = Number(
+ end.toLocaleDateString("en-US", { year: "numeric", ...tzOpts }),
+ );
+ const startMonthIdx = startMonth; // compared as label string
+ const endMonthIdx = endMonth;
+ const startYear = Number(
+ start.toLocaleDateString("en-US", { year: "numeric", ...tzOpts }),
+ );
if (compact) {
- if (
- start.getMonth() === end.getMonth() &&
- start.getFullYear() === end.getFullYear()
- ) {
+ if (startMonthIdx === endMonthIdx && startYear === year) {
return `${startMonth} ${startDay}-${endDay}`;
}
return `${startMonth} ${startDay} - ${endMonth} ${endDay}`;
}
- if (
- start.getMonth() === end.getMonth() &&
- start.getFullYear() === end.getFullYear()
- ) {
+ if (startMonthIdx === endMonthIdx && startYear === year) {
return `${startMonth} ${startDay}-${endDay}, ${year}`;
- } else if (start.getFullYear() === end.getFullYear()) {
+ } else if (startYear === year) {
return `${startMonth} ${startDay} - ${endMonth} ${endDay}, ${year}`;
} else {
- return `${formatDate(startDate)} - ${formatDate(endDate)}`;
+ return `${formatDate(startDate, { timeZone })} - ${formatDate(endDate, { timeZone })}`;
}
};
- // Check if a date is in the past
const isPastDate = (date) => {
const dateObj = date instanceof Date ? date : new Date(date);
- const now = new Date();
- return dateObj < now;
+ return dateObj < new Date();
};
- // Check if a date is today
- const isToday = (date) => {
+ const isToday = (date, timeZone) => {
const dateObj = date instanceof Date ? date : new Date(date);
const today = new Date();
+ const opts = { year: "numeric", month: "2-digit", day: "2-digit", ...(timeZone && { timeZone }) };
return (
- dateObj.getDate() === today.getDate() &&
- dateObj.getMonth() === today.getMonth() &&
- dateObj.getFullYear() === today.getFullYear()
+ dateObj.toLocaleDateString("en-US", opts) ===
+ today.toLocaleDateString("en-US", opts)
);
};
- // Get a readable time string
- const formatTime = (date, includeSeconds = false) => {
+ const formatTime = (date, includeSeconds = false, timeZone) => {
const dateObj = date instanceof Date ? date : new Date(date);
- const options = {
+ return new Intl.DateTimeFormat("en-US", {
hour: "2-digit",
minute: "2-digit",
...(includeSeconds && { second: "2-digit" }),
- };
- return new Intl.DateTimeFormat("en-US", options).format(dateObj);
+ ...(timeZone && { timeZone }),
+ }).format(dateObj);
};
return {
- TIMEZONE,
+ DEFAULT_TIMEZONE,
+ // Legacy alias for callers that hard-coded the constant.
+ TIMEZONE: DEFAULT_TIMEZONE,
formatDate,
formatDateRange,
isPastDate,
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/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) => {
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/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 {
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(),