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

@ -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) => {