Merge branch 'feature/event-timezone' into main

This commit is contained in:
Jennie Robinson Faber 2026-05-19 13:54:48 +01:00
commit 877ef1a220
13 changed files with 318 additions and 159 deletions

View file

@ -231,6 +231,10 @@ const props = defineProps({
type: String, type: String,
required: true, required: true,
}, },
eventTimezone: {
type: String,
default: "America/Toronto",
},
userEmail: { userEmail: {
type: String, type: String,
default: null, default: null,
@ -415,6 +419,7 @@ const formatEventDate = (date) => {
month: "long", month: "long",
day: "numeric", day: "numeric",
year: "numeric", year: "numeric",
timeZone: props.eventTimezone || "America/Toronto",
}); });
}; };
</script> </script>

View file

@ -6,7 +6,7 @@
<div v-if="events?.length" class="em-rows"> <div v-if="events?.length" class="em-rows">
<div v-for="event in events" :key="event._id" class="em-item"> <div v-for="event in events" :key="event._id" class="em-item">
<div class="em-inset em-item-body"> <div class="em-inset em-item-body">
<span class="em-date">{{ formatDate(event.startDate) }}</span> <span class="em-date">{{ formatDate(event) }}</span>
<NuxtLink <NuxtLink
:to="`/events/${event.slug || event._id}`" :to="`/events/${event.slug || event._id}`"
class="em-title" class="em-title"
@ -37,10 +37,13 @@ defineProps({
events: { type: Array, default: () => [] }, events: { type: Array, default: () => [] },
}); });
const formatDate = (dateStr) => { const formatDate = (event) => {
if (!dateStr) return ""; if (!event?.startDate) return "";
const d = new Date(dateStr); return new Date(event.startDate).toLocaleDateString("en-US", {
return d.toLocaleDateString("en-US", { month: "short", day: "numeric" }); month: "short",
day: "numeric",
timeZone: event.displayTimezone || "America/Toronto",
});
}; };
</script> </script>

View file

@ -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 = () => { export const useEventDateUtils = () => {
const TIMEZONE = "America/Toronto"; const DEFAULT_TIMEZONE = "America/Toronto";
// Format a date to a specific format
const formatDate = (date, options = {}) => { const formatDate = (date, options = {}) => {
if (!date) return "";
const dateObj = date instanceof Date ? date : new Date(date); 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", { return new Intl.DateTimeFormat("en-US", {
...(weekday && { weekday }),
month, month,
day, day,
year, year,
...(timeZone && { timeZone }),
}).format(dateObj); }).format(dateObj);
}; };
// Format event date range const formatDateRange = (startDate, endDate, compact = false, timeZone) => {
const formatDateRange = (startDate, endDate, compact = false) => {
if (!startDate || !endDate) return "No dates"; if (!startDate || !endDate) return "No dates";
const start = new Date(startDate); const start = new Date(startDate);
const end = new Date(endDate); const end = new Date(endDate);
const startMonth = start.toLocaleDateString("en-US", { month: "short" }); const tzOpts = timeZone ? { timeZone } : {};
const endMonth = end.toLocaleDateString("en-US", { month: "short" }); const startMonth = start.toLocaleDateString("en-US", { month: "short", ...tzOpts });
const startDay = start.getDate(); const endMonth = end.toLocaleDateString("en-US", { month: "short", ...tzOpts });
const endDay = end.getDate(); const startDay = Number(
const year = end.getFullYear(); 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 (compact) {
if ( if (startMonthIdx === endMonthIdx && startYear === year) {
start.getMonth() === end.getMonth() &&
start.getFullYear() === end.getFullYear()
) {
return `${startMonth} ${startDay}-${endDay}`; return `${startMonth} ${startDay}-${endDay}`;
} }
return `${startMonth} ${startDay} - ${endMonth} ${endDay}`; return `${startMonth} ${startDay} - ${endMonth} ${endDay}`;
} }
if ( if (startMonthIdx === endMonthIdx && startYear === year) {
start.getMonth() === end.getMonth() &&
start.getFullYear() === end.getFullYear()
) {
return `${startMonth} ${startDay}-${endDay}, ${year}`; return `${startMonth} ${startDay}-${endDay}, ${year}`;
} else if (start.getFullYear() === end.getFullYear()) { } else if (startYear === year) {
return `${startMonth} ${startDay} - ${endMonth} ${endDay}, ${year}`; return `${startMonth} ${startDay} - ${endMonth} ${endDay}, ${year}`;
} else { } 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 isPastDate = (date) => {
const dateObj = date instanceof Date ? date : new Date(date); const dateObj = date instanceof Date ? date : new Date(date);
const now = new Date(); return dateObj < new Date();
return dateObj < now;
}; };
// Check if a date is today const isToday = (date, timeZone) => {
const isToday = (date) => {
const dateObj = date instanceof Date ? date : new Date(date); const dateObj = date instanceof Date ? date : new Date(date);
const today = new Date(); const today = new Date();
const opts = { year: "numeric", month: "2-digit", day: "2-digit", ...(timeZone && { timeZone }) };
return ( return (
dateObj.getDate() === today.getDate() && dateObj.toLocaleDateString("en-US", opts) ===
dateObj.getMonth() === today.getMonth() && today.toLocaleDateString("en-US", opts)
dateObj.getFullYear() === today.getFullYear()
); );
}; };
// Get a readable time string const formatTime = (date, includeSeconds = false, timeZone) => {
const formatTime = (date, includeSeconds = false) => {
const dateObj = date instanceof Date ? date : new Date(date); const dateObj = date instanceof Date ? date : new Date(date);
const options = { return new Intl.DateTimeFormat("en-US", {
hour: "2-digit", hour: "2-digit",
minute: "2-digit", minute: "2-digit",
...(includeSeconds && { second: "2-digit" }), ...(includeSeconds && { second: "2-digit" }),
}; ...(timeZone && { timeZone }),
return new Intl.DateTimeFormat("en-US", options).format(dateObj); }).format(dateObj);
}; };
return { return {
TIMEZONE, DEFAULT_TIMEZONE,
// Legacy alias for callers that hard-coded the constant.
TIMEZONE: DEFAULT_TIMEZONE,
formatDate, formatDate,
formatDateRange, formatDateRange,
isPastDate, isPastDate,

View file

@ -110,6 +110,23 @@
</p> </p>
</div> </div>
<div class="field">
<label> Event Timezone <span class="required">*</span> </label>
<USelectMenu
v-model="eventForm.displayTimezone"
:items="timezoneItems"
value-key="value"
searchable
searchable-placeholder="Search timezones..."
placeholder="Select a timezone"
class="w-full"
/>
<p class="help-text">
Dates below are interpreted in this timezone. Attendees see the
event time in this zone.
</p>
</div>
<div class="field"> <div class="field">
<label> Location <span class="required">*</span> </label> <label> Location <span class="required">*</span> </label>
<UInput <UInput
@ -601,6 +618,8 @@
</template> </template>
<script setup> <script setup>
import { TIMEZONE_OPTIONS } from "~/config/timezones";
definePageMeta({ definePageMeta({
layout: "admin", layout: "admin",
middleware: "admin", middleware: "admin",
@ -630,6 +649,7 @@ const eventForm = reactive({
startDate: "", startDate: "",
endDate: "", endDate: "",
eventType: "community", eventType: "community",
displayTimezone: "America/Toronto",
location: "", location: "",
isOnline: true, isOnline: true,
isVisible: true, isVisible: true,
@ -672,6 +692,38 @@ const formatForDatetimeLocal = (value) => {
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}; };
// Render the form's datetime fields in the event's display timezone.
const formatForEventTZ = (value) => {
if (!value) return "";
return utcToZonedLocal(value, eventForm.displayTimezone) || formatForDatetimeLocal(value);
};
const utcOffsetLabel = (tz) => {
try {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: tz,
timeZoneName: "longOffset",
}).formatToParts(new Date());
const name = parts.find((p) => p.type === "timeZoneName")?.value || "";
if (name === "GMT") return "UTC+00:00";
return name.replace("GMT", "UTC");
} catch {
return "";
}
};
const timezoneItems = computed(() => {
const list = TIMEZONE_OPTIONS.map((t) => {
const off = utcOffsetLabel(t.value);
return { ...t, label: off ? `${t.label} (${off})` : t.label };
});
const saved = eventForm.displayTimezone;
if (saved && !TIMEZONE_OPTIONS.some((t) => t.value === saved)) {
list.unshift({ label: saved, value: saved });
}
return list;
});
// Agenda management functions // Agenda management functions
const addAgendaItem = () => { const addAgendaItem = () => {
eventForm.agenda.push(""); eventForm.agenda.push("");
@ -725,14 +777,17 @@ function populateEditForm(payload) {
const event = payload?.data; const event = payload?.data;
if (!event) return; if (!event) return;
editingEvent.value = event; editingEvent.value = event;
// Pin the form's timezone first so subsequent date conversions use it.
eventForm.displayTimezone = event.displayTimezone || "America/Toronto";
Object.assign(eventForm, { Object.assign(eventForm, {
title: event.title, title: event.title,
description: event.description, description: event.description,
content: event.content || "", content: event.content || "",
featureImage: event.featureImage || null, featureImage: event.featureImage || null,
startDate: formatForDatetimeLocal(event.startDate), startDate: utcToZonedLocal(event.startDate, eventForm.displayTimezone),
endDate: formatForDatetimeLocal(event.endDate), endDate: utcToZonedLocal(event.endDate, eventForm.displayTimezone),
eventType: event.eventType, eventType: event.eventType,
displayTimezone: eventForm.displayTimezone,
location: event.location || "", location: event.location || "",
isOnline: event.isOnline, isOnline: event.isOnline,
isVisible: event.isVisible !== undefined ? event.isVisible : true, isVisible: event.isVisible !== undefined ? event.isVisible : true,
@ -743,7 +798,7 @@ function populateEditForm(payload) {
tags: event.tags || [], tags: event.tags || [],
maxAttendees: event.maxAttendees || "", maxAttendees: event.maxAttendees || "",
registrationRequired: event.registrationRequired, registrationRequired: event.registrationRequired,
registrationDeadline: formatForDatetimeLocal(event.registrationDeadline), registrationDeadline: utcToZonedLocal(event.registrationDeadline, eventForm.displayTimezone),
agenda: event.agenda || [], agenda: event.agenda || [],
tickets: event.tickets || { tickets: event.tickets || {
enabled: false, enabled: false,
@ -765,8 +820,9 @@ function populateEditForm(payload) {
}, },
}); });
if (event.tickets?.public?.earlyBirdDeadline) { if (event.tickets?.public?.earlyBirdDeadline) {
eventForm.tickets.public.earlyBirdDeadline = formatForDatetimeLocal( eventForm.tickets.public.earlyBirdDeadline = utcToZonedLocal(
event.tickets.public.earlyBirdDeadline, event.tickets.public.earlyBirdDeadline,
eventForm.displayTimezone,
); );
} }
} }
@ -911,15 +967,37 @@ const saveEvent = async (redirect = true) => {
// Individual series creation is handled through the series management page // Individual series creation is handled through the series management page
} }
const tz = eventForm.displayTimezone || "America/Toronto";
const toUTC = (v) => {
const d = zonedLocalToUTC(v, tz);
return d ? d.toISOString() : v;
};
const payload = {
...eventForm,
startDate: toUTC(eventForm.startDate),
endDate: toUTC(eventForm.endDate),
registrationDeadline: eventForm.registrationDeadline
? toUTC(eventForm.registrationDeadline)
: eventForm.registrationDeadline,
tickets: {
...eventForm.tickets,
public: {
...eventForm.tickets.public,
earlyBirdDeadline: eventForm.tickets.public.earlyBirdDeadline
? toUTC(eventForm.tickets.public.earlyBirdDeadline)
: eventForm.tickets.public.earlyBirdDeadline,
},
},
};
if (editingEvent.value) { if (editingEvent.value) {
await $fetch(`/api/admin/events/${editingEvent.value._id}`, { await $fetch(`/api/admin/events/${editingEvent.value._id}`, {
method: "PUT", method: "PUT",
body: eventForm, body: payload,
}); });
} else { } else {
await $fetch("/api/admin/events", { await $fetch("/api/admin/events", {
method: "POST", method: "POST",
body: eventForm, body: payload,
}); });
} }
@ -959,6 +1037,7 @@ const saveAndCreateAnother = async () => {
startDate: "", startDate: "",
endDate: "", endDate: "",
eventType: "community", eventType: "community",
displayTimezone: "America/Toronto",
location: "", location: "",
isOnline: true, isOnline: true,
isVisible: true, isVisible: true,

View file

@ -92,8 +92,8 @@
<span class="badge" :class="event.eventType">{{ event.eventType }}</span> <span class="badge" :class="event.eventType">{{ event.eventType }}</span>
</td> </td>
<td class="col-date"> <td class="col-date">
<span class="date-main">{{ formatDate(event.startDate) }}</span> <span class="date-main">{{ formatDate(event) }}</span>
<span class="date-time">{{ formatTime(event.startDate) }}</span> <span class="date-time">{{ formatTime(event) }}</span>
</td> </td>
<td> <td>
<span :class="['status-pill', `status-${getEventStatus(event).toLowerCase()}`]"> <span :class="['status-pill', `status-${getEventStatus(event).toLowerCase()}`]">
@ -190,8 +190,8 @@
<span class="badge" :class="event.eventType">{{ event.eventType }}</span> <span class="badge" :class="event.eventType">{{ event.eventType }}</span>
</td> </td>
<td class="col-date"> <td class="col-date">
<span class="date-main">{{ formatDate(event.startDate) }}</span> <span class="date-main">{{ formatDate(event) }}</span>
<span class="date-time">{{ formatTime(event.startDate) }}</span> <span class="date-time">{{ formatTime(event) }}</span>
</td> </td>
<td> <td>
<span :class="['status-pill', `status-${getEventStatus(event).toLowerCase()}`]"> <span :class="['status-pill', `status-${getEventStatus(event).toLowerCase()}`]">
@ -349,19 +349,23 @@ watch([searchQuery, typeFilter, seriesFilter], () => {
pastPage.value = 1 pastPage.value = 1
}) })
const formatDate = (dateString) => { const formatDate = (event) => {
return new Date(dateString).toLocaleDateString('en-US', { if (!event?.startDate) return ''
return new Date(event.startDate).toLocaleDateString('en-US', {
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',
year: 'numeric', year: 'numeric',
timeZone: event.displayTimezone || 'America/Toronto',
}) })
} }
const formatTime = (dateString) => { const formatTime = (event) => {
return new Date(dateString).toLocaleTimeString('en-US', { if (!event?.startDate) return ''
return new Date(event.startDate).toLocaleTimeString('en-US', {
hour: 'numeric', hour: 'numeric',
minute: '2-digit', minute: '2-digit',
hour12: true, hour12: true,
timeZone: event.displayTimezone || 'America/Toronto',
}) })
} }

View file

@ -131,6 +131,7 @@
:event-id="event._id || event.id" :event-id="event._id || event.id"
:event-start-date="event.startDate" :event-start-date="event.startDate"
:event-title="event.title" :event-title="event.title"
:event-timezone="eventTimeZone"
:user-email="memberData?.email" :user-email="memberData?.email"
:user-name="memberData?.name" :user-name="memberData?.name"
@success="handleTicketSuccess" @success="handleTicketSuccess"
@ -197,21 +198,29 @@ onMounted(async () => {
} }
}); });
const eventTimeZone = computed(
() => event.value?.displayTimezone || "America/Toronto",
);
const formatDate = (dateStr) => { const formatDate = (dateStr) => {
if (!dateStr) return "";
const d = new Date(dateStr); const d = new Date(dateStr);
return new Intl.DateTimeFormat("en-US", { return new Intl.DateTimeFormat("en-US", {
weekday: "long", weekday: "long",
year: "numeric", year: "numeric",
month: "long", month: "long",
day: "numeric", day: "numeric",
timeZone: eventTimeZone.value,
}).format(d); }).format(d);
}; };
const formatTime = (start, end) => { const formatTime = (start, end) => {
if (!start || !end) return "";
const fmt = new Intl.DateTimeFormat("en-US", { const fmt = new Intl.DateTimeFormat("en-US", {
hour: "numeric", hour: "numeric",
minute: "2-digit", minute: "2-digit",
timeZoneName: "short", timeZoneName: "short",
timeZone: eventTimeZone.value,
}); });
return `${fmt.format(new Date(start))} ${fmt.format(new Date(end))}`; return `${fmt.format(new Date(start))} ${fmt.format(new Date(end))}`;
}; };

View file

@ -34,8 +34,8 @@
:class="{ 'is-cancelled': event.isCancelled }" :class="{ 'is-cancelled': event.isCancelled }"
> >
<div class="event-date-col"> <div class="event-date-col">
<span class="event-date">{{ formatDate(event.startDate) }}</span> <span class="event-date">{{ formatDate(event) }}</span>
<span class="event-time">{{ formatTime(event.startDate) }}</span> <span class="event-time">{{ formatTime(event) }}</span>
</div> </div>
<div class="event-info"> <div class="event-info">
<div class="event-title"> <div class="event-title">
@ -152,18 +152,24 @@ const activeSeries = computed(() => {
); );
}); });
const formatDate = (dateStr) => { const formatDate = (event) => {
if (!dateStr) return ""; if (!event?.startDate) return "";
const d = new Date(dateStr); const tz = event.displayTimezone || "America/Toronto";
const opts = { month: "short", day: "numeric" }; const d = new Date(event.startDate);
if (d.getFullYear() !== new Date().getFullYear()) opts.year = "numeric"; 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); return d.toLocaleDateString("en-US", opts);
}; };
const formatTime = (dateStr) => { const formatTime = (event) => {
if (!dateStr) return ""; if (!event?.startDate) return "";
const d = new Date(dateStr); return new Date(event.startDate).toLocaleTimeString("en-US", {
return d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" }); hour: "numeric",
minute: "2-digit",
timeZone: event.displayTimezone || "America/Toronto",
});
}; };
const formatLocation = (event) => { const formatLocation = (event) => {

View file

@ -42,7 +42,7 @@
<div v-if="events?.length" class="event-list"> <div v-if="events?.length" class="event-list">
<div v-for="event in events" :key="event._id" class="event-item"> <div v-for="event in events" :key="event._id" class="event-item">
<div class="block-inset event-item-inner"> <div class="block-inset event-item-inner">
<span class="event-date">{{ formatDate(event.startDate) }}</span> <span class="event-date">{{ formatDate(event) }}</span>
<span class="event-title"> <span class="event-title">
<NuxtLink :to="`/events/${event.slug || event._id}`">{{ <NuxtLink :to="`/events/${event.slug || event._id}`">{{
event.title event.title
@ -168,10 +168,13 @@ const circleData = [
}, },
]; ];
const formatDate = (dateStr) => { const formatDate = (event) => {
if (!dateStr) return ""; if (!event?.startDate) return "";
const d = new Date(dateStr); return new Date(event.startDate).toLocaleDateString("en-US", {
return d.toLocaleDateString("en-US", { month: "short", day: "numeric" }); month: "short",
day: "numeric",
timeZone: event.displayTimezone || "America/Toronto",
});
}; };
</script> </script>

View file

@ -60,9 +60,7 @@
:to="`/events/${evt.slug || evt._id}`" :to="`/events/${evt.slug || evt._id}`"
class="event-item" class="event-item"
> >
<span class="event-date">{{ <span class="event-date">{{ formatEventDate(evt) }}</span>
formatEventDate(evt.startDate)
}}</span>
<span class="event-title">{{ evt.title }}</span> <span class="event-title">{{ evt.title }}</span>
<CircleBadge v-if="evt.circle" :circle="evt.circle" /> <CircleBadge v-if="evt.circle" :circle="evt.circle" />
</NuxtLink> </NuxtLink>
@ -365,20 +363,22 @@ const getEventImageUrl = (featureImage) => {
return ""; return "";
}; };
const formatEventDate = (dateString) => { const formatEventDate = (event) => {
const date = new Date(dateString); if (!event?.startDate) return "";
return new Intl.DateTimeFormat("en-US", { return new Intl.DateTimeFormat("en-US", {
month: "short", month: "short",
day: "numeric", day: "numeric",
}).format(date); timeZone: event.displayTimezone || "America/Toronto",
}).format(new Date(event.startDate));
}; };
const formatEventTime = (dateString) => { const formatEventTime = (event) => {
const date = new Date(dateString); if (!event?.startDate) return "";
return new Intl.DateTimeFormat("en-US", { return new Intl.DateTimeFormat("en-US", {
hour: "numeric", hour: "numeric",
minute: "2-digit", minute: "2-digit",
}).format(date); timeZone: event.displayTimezone || "America/Toronto",
}).format(new Date(event.startDate));
}; };
const formatMemberSince = (dateString) => { const formatMemberSince = (dateString) => {

77
app/utils/timezones.js Normal file
View file

@ -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 "";
}
}

View file

@ -18,6 +18,8 @@ const eventSchema = new mongoose.Schema({
enum: ["community", "workshop", "social", "showcase"], enum: ["community", "workshop", "social", "showcase"],
default: "community", default: "community",
}, },
// IANA timezone for interpreting datetime input and rendering the event time.
displayTimezone: { type: String, default: "America/Toronto" },
// Online-first location handling // Online-first location handling
location: { location: {
type: String, type: String,

View file

@ -2,6 +2,29 @@ import { Resend } from "resend";
const resend = new Resend(useRuntimeConfig().resendApiKey); 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 * Send event registration confirmation email
* @param {Object} options - { requiresSignIn?: boolean } when true, appends a * @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. * registered via the public form and did not receive an auto-login cookie.
*/ */
export async function sendEventRegistrationEmail(registration, eventData, options = {}) { export async function sendEventRegistrationEmail(registration, eventData, options = {}) {
const formatDate = (dateString) => { const tz = eventData.displayTimezone || "America/Toronto";
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 baseUrl = process.env.BASE_URL || "https://ghostguild.org"; const baseUrl = process.env.BASE_URL || "https://ghostguild.org";
const eventUrl = `${baseUrl}/events/${eventData.slug || eventData._id}`; const eventUrl = `${baseUrl}/events/${eventData.slug || eventData._id}`;
const signInSection = options.requiresSignIn const signInSection = options.requiresSignIn
@ -66,8 +67,8 @@ Paid: $${registration.amountPaid.toFixed(2)} CAD`;
You're now registered for ${eventData.title}. You're now registered for ${eventData.title}.
Date: ${formatDate(eventData.startDate)} Date: ${formatEventDate(eventData.startDate, tz)}
Time: ${formatTime(eventData.startDate, eventData.endDate)} Time: ${formatEventTime(eventData.startDate, eventData.endDate, tz)}
Location: ${eventData.location} Location: ${eventData.location}
${eventData.description ? `\n${eventData.description}\n` : ""}${ticketSection}${signInSection} ${eventData.description ? `\n${eventData.description}\n` : ""}${ticketSection}${signInSection}
View event: ${eventUrl} View event: ${eventUrl}
@ -124,29 +125,7 @@ ${baseUrl}/events`,
* Send waitlist notification email when a spot opens up * Send waitlist notification email when a spot opens up
*/ */
export async function sendWaitlistNotificationEmail(waitlistEntry, eventData) { export async function sendWaitlistNotificationEmail(waitlistEntry, eventData) {
const formatDate = (dateString) => { const tz = eventData.displayTimezone || "America/Toronto";
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 baseUrl = process.env.BASE_URL || "https://ghostguild.org"; const baseUrl = process.env.BASE_URL || "https://ghostguild.org";
const eventUrl = `${baseUrl}/events/${eventData.slug || eventData._id}`; 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}. A spot opened up for ${eventData.title}.
Date: ${formatDate(eventData.startDate)} Date: ${formatEventDate(eventData.startDate, tz)}
Time: ${formatTime(eventData.startDate, eventData.endDate)} Time: ${formatEventTime(eventData.startDate, eventData.endDate, tz)}
Location: ${eventData.location} Location: ${eventData.location}
Register now: ${eventUrl} 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) { export async function sendSeriesPassConfirmation(options) {
const { to, name, series, ticket, events, paymentId } = 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") => { const formatPrice = (price, currency = "CAD") => {
if (price === 0) return "Free"; if (price === 0) return "Free";
return new Intl.NumberFormat("en-CA", { return new Intl.NumberFormat("en-CA", {
@ -223,13 +179,13 @@ export async function sendSeriesPassConfirmation(options) {
: ""; : "";
const eventList = events const eventList = events
.map( .map((evt, index) => {
(evt, index) => const tz = evt.displayTimezone || "America/Toronto";
` ${index + 1}. ${evt.title} return ` ${index + 1}. ${evt.title}
${formatDate(evt.startDate)} ${formatEventDate(evt.startDate, tz)}
${formatTime(evt.startDate, evt.endDate)} ${formatEventTime(evt.startDate, evt.endDate, tz)}
${evt.location}`, ${evt.location}`;
) })
.join("\n\n"); .join("\n\n");
try { try {

View file

@ -173,6 +173,7 @@ export const adminEventCreateSchema = z.object({
description: z.string().min(1).max(50000), description: z.string().min(1).max(50000),
startDate: z.string().min(1), startDate: z.string().min(1),
endDate: z.string().min(1), endDate: z.string().min(1),
displayTimezone: z.string().max(100).optional(),
location: z.string().max(500).optional(), location: z.string().max(500).optional(),
maxAttendees: z.preprocess(emptyStringToUndefined, z.number().int().positive().optional().nullable()), maxAttendees: z.preprocess(emptyStringToUndefined, z.number().int().positive().optional().nullable()),
membersOnly: z.boolean().optional(), membersOnly: z.boolean().optional(),
@ -204,6 +205,7 @@ export const adminEventUpdateSchema = z.object({
description: z.string().min(1).max(50000), description: z.string().min(1).max(50000),
startDate: z.string().min(1), startDate: z.string().min(1),
endDate: z.string().min(1), endDate: z.string().min(1),
displayTimezone: z.string().max(100).optional(),
location: z.string().max(500).optional(), location: z.string().max(500).optional(),
maxAttendees: z.preprocess(emptyStringToUndefined, z.number().int().positive().optional().nullable()), maxAttendees: z.preprocess(emptyStringToUndefined, z.number().int().positive().optional().nullable()),
membersOnly: z.boolean().optional(), membersOnly: z.boolean().optional(),