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

View file

@ -6,7 +6,7 @@
<div v-if="events?.length" class="em-rows">
<div v-for="event in events" :key="event._id" class="em-item">
<div class="em-inset em-item-body">
<span class="em-date">{{ formatDate(event.startDate) }}</span>
<span class="em-date">{{ formatDate(event) }}</span>
<NuxtLink
:to="`/events/${event.slug || event._id}`"
class="em-title"
@ -37,10 +37,13 @@ defineProps({
events: { type: Array, default: () => [] },
});
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",
});
};
</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 = () => {
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,

View file

@ -110,6 +110,23 @@
</p>
</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">
<label> Location <span class="required">*</span> </label>
<UInput
@ -601,6 +618,8 @@
</template>
<script setup>
import { TIMEZONE_OPTIONS } from "~/config/timezones";
definePageMeta({
layout: "admin",
middleware: "admin",
@ -630,6 +649,7 @@ const eventForm = reactive({
startDate: "",
endDate: "",
eventType: "community",
displayTimezone: "America/Toronto",
location: "",
isOnline: 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())}`;
};
// 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
const addAgendaItem = () => {
eventForm.agenda.push("");
@ -725,14 +777,17 @@ function populateEditForm(payload) {
const event = payload?.data;
if (!event) return;
editingEvent.value = event;
// Pin the form's timezone first so subsequent date conversions use it.
eventForm.displayTimezone = event.displayTimezone || "America/Toronto";
Object.assign(eventForm, {
title: event.title,
description: event.description,
content: event.content || "",
featureImage: event.featureImage || null,
startDate: formatForDatetimeLocal(event.startDate),
endDate: formatForDatetimeLocal(event.endDate),
startDate: utcToZonedLocal(event.startDate, eventForm.displayTimezone),
endDate: utcToZonedLocal(event.endDate, eventForm.displayTimezone),
eventType: event.eventType,
displayTimezone: eventForm.displayTimezone,
location: event.location || "",
isOnline: event.isOnline,
isVisible: event.isVisible !== undefined ? event.isVisible : true,
@ -743,7 +798,7 @@ function populateEditForm(payload) {
tags: event.tags || [],
maxAttendees: event.maxAttendees || "",
registrationRequired: event.registrationRequired,
registrationDeadline: formatForDatetimeLocal(event.registrationDeadline),
registrationDeadline: utcToZonedLocal(event.registrationDeadline, eventForm.displayTimezone),
agenda: event.agenda || [],
tickets: event.tickets || {
enabled: false,
@ -765,8 +820,9 @@ function populateEditForm(payload) {
},
});
if (event.tickets?.public?.earlyBirdDeadline) {
eventForm.tickets.public.earlyBirdDeadline = formatForDatetimeLocal(
eventForm.tickets.public.earlyBirdDeadline = utcToZonedLocal(
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
}
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) {
await $fetch(`/api/admin/events/${editingEvent.value._id}`, {
method: "PUT",
body: eventForm,
body: payload,
});
} else {
await $fetch("/api/admin/events", {
method: "POST",
body: eventForm,
body: payload,
});
}
@ -959,6 +1037,7 @@ const saveAndCreateAnother = async () => {
startDate: "",
endDate: "",
eventType: "community",
displayTimezone: "America/Toronto",
location: "",
isOnline: true,
isVisible: true,

View file

@ -92,8 +92,8 @@
<span class="badge" :class="event.eventType">{{ event.eventType }}</span>
</td>
<td class="col-date">
<span class="date-main">{{ formatDate(event.startDate) }}</span>
<span class="date-time">{{ formatTime(event.startDate) }}</span>
<span class="date-main">{{ formatDate(event) }}</span>
<span class="date-time">{{ formatTime(event) }}</span>
</td>
<td>
<span :class="['status-pill', `status-${getEventStatus(event).toLowerCase()}`]">
@ -190,8 +190,8 @@
<span class="badge" :class="event.eventType">{{ event.eventType }}</span>
</td>
<td class="col-date">
<span class="date-main">{{ formatDate(event.startDate) }}</span>
<span class="date-time">{{ formatTime(event.startDate) }}</span>
<span class="date-main">{{ formatDate(event) }}</span>
<span class="date-time">{{ formatTime(event) }}</span>
</td>
<td>
<span :class="['status-pill', `status-${getEventStatus(event).toLowerCase()}`]">
@ -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',
})
}

View file

@ -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"
@ -197,21 +198,29 @@ onMounted(async () => {
}
});
const eventTimeZone = computed(
() => event.value?.displayTimezone || "America/Toronto",
);
const formatDate = (dateStr) => {
if (!dateStr) return "";
const d = new Date(dateStr);
return new Intl.DateTimeFormat("en-US", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
timeZone: eventTimeZone.value,
}).format(d);
};
const formatTime = (start, end) => {
if (!start || !end) return "";
const fmt = new Intl.DateTimeFormat("en-US", {
hour: "numeric",
minute: "2-digit",
timeZoneName: "short",
timeZone: eventTimeZone.value,
});
return `${fmt.format(new Date(start))} ${fmt.format(new Date(end))}`;
};

View file

@ -34,8 +34,8 @@
:class="{ 'is-cancelled': event.isCancelled }"
>
<div class="event-date-col">
<span class="event-date">{{ formatDate(event.startDate) }}</span>
<span class="event-time">{{ formatTime(event.startDate) }}</span>
<span class="event-date">{{ formatDate(event) }}</span>
<span class="event-time">{{ formatTime(event) }}</span>
</div>
<div class="event-info">
<div class="event-title">
@ -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) => {

View file

@ -42,7 +42,7 @@
<div v-if="events?.length" class="event-list">
<div v-for="event in events" :key="event._id" class="event-item">
<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">
<NuxtLink :to="`/events/${event.slug || event._id}`">{{
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",
});
};
</script>

View file

@ -60,9 +60,7 @@
:to="`/events/${evt.slug || evt._id}`"
class="event-item"
>
<span class="event-date">{{
formatEventDate(evt.startDate)
}}</span>
<span class="event-date">{{ formatEventDate(evt) }}</span>
<span class="event-title">{{ evt.title }}</span>
<CircleBadge v-if="evt.circle" :circle="evt.circle" />
</NuxtLink>
@ -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) => {

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