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.
This commit is contained in:
Jennie Robinson Faber 2026-05-19 10:44:03 +01:00
parent e6f05b5471
commit a76ba2f8c7

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,