diff --git a/app/components/EventTicketPurchase.vue b/app/components/EventTicketPurchase.vue index 0e61903..02a6e6a 100644 --- a/app/components/EventTicketPurchase.vue +++ b/app/components/EventTicketPurchase.vue @@ -231,6 +231,10 @@ const props = defineProps({ type: String, required: true, }, + eventTimezone: { + type: String, + default: "America/Toronto", + }, userEmail: { type: String, default: null, @@ -415,6 +419,7 @@ const formatEventDate = (date) => { month: "long", day: "numeric", year: "numeric", + timeZone: props.eventTimezone || "America/Toronto", }); }; 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/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(),