From 4a05e917152e41669b4456e5636ef2befac72f3a Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Thu, 21 May 2026 17:50:56 +0100 Subject: [PATCH] feat(admin-events): form layout overhaul + agenda input + date input rewrite Admin event create form: - Wraps body in a form-layout/form-main container for upcoming sidebar work. - Bigger autoresize textareas for description/content; adds an Agenda textarea (one item per line, persisted as event.agenda). - Reorganises settings into Event Settings + conditional Cancellation Message sections. - Pulls event-type options from EVENT_TYPES; location becomes optional; passes displayTimezone through to NaturalDateInput. NaturalDateInput: rewritten to a single always-visible UInput with chrono parsing and trailing status icon, instead of toggling between input and parsed-summary blocks. Cleaner state model (rawInput / parsedDate / isValid / hasError) and timezone-aware update emission. --- app/components/NaturalDateInput.vue | 478 +++++++++++--------------- app/pages/admin/events/create.vue | 510 +++++++++++++++------------- 2 files changed, 474 insertions(+), 514 deletions(-) diff --git a/app/components/NaturalDateInput.vue b/app/components/NaturalDateInput.vue index 187c034..2e266f7 100644 --- a/app/components/NaturalDateInput.vue +++ b/app/components/NaturalDateInput.vue @@ -1,80 +1,40 @@ + +

+ → {{ previewText }} +

+

+ {{ errorMessage }} +

@@ -82,227 +42,197 @@ import * as chrono from "chrono-node"; const props = defineProps({ - modelValue: { - type: String, - default: "", - }, + modelValue: { type: String, default: "" }, placeholder: { type: String, - default: 'e.g., "tomorrow at 3pm", "next Friday at 9am", "in 2 hours"', - }, - inputClass: { - type: [String, Object], - default: "", - }, - required: { - type: Boolean, - default: false, + default: 'e.g., "tomorrow at 3pm", "Nov 22 at 3pm"', }, + displayTimezone: { type: String, default: "" }, + required: { type: Boolean, default: false }, }); const emit = defineEmits(["update:modelValue"]); -const naturalInput = ref(""); -const parsedDate = ref(null); -const isValidParse = ref(false); +const rawInput = ref(""); +const isValid = ref(false); const hasError = ref(false); const errorMessage = ref(""); -const datetimeValue = ref(""); -const isEditing = ref(false); +// previewDate holds the parsed value as a UTC Date so we can format it in +// arbitrary timezones without re-parsing. Source of truth for the preview. +const previewDate = ref(null); -// Initialize with current value -onMounted(() => { - if (props.modelValue) { - const date = new Date(props.modelValue); - if (!isNaN(date.getTime())) { - parsedDate.value = date; - datetimeValue.value = formatForDatetimeLocal(date); - isValidParse.value = true; - } - } +const trailingState = computed(() => { + if (!rawInput.value.trim()) return undefined; + if (hasError.value) return "error"; + if (isValid.value) return "success"; + return undefined; }); -// Watch for external changes to modelValue -watch( - () => props.modelValue, - (newValue) => { - if (newValue && newValue !== formatForDatetimeLocal(parsedDate.value)) { - const date = new Date(newValue); - if (!isNaN(date.getTime())) { - parsedDate.value = date; - datetimeValue.value = formatForDatetimeLocal(date); - isValidParse.value = true; - naturalInput.value = ""; // Clear natural input when set externally - isEditing.value = false; - } - } else if (!newValue) { - reset(); - } - }, -); - -const parseNaturalInput = () => { - const input = naturalInput.value.trim(); - - if (!input) { - reset(); - return; - } - - try { - // Parse with chrono-node - const results = chrono.parse(input); - - if (results.length > 0) { - const result = results[0]; - const date = result.date(); - - // Validate the parsed date - if (date && !isNaN(date.getTime())) { - parsedDate.value = date; - isValidParse.value = true; - hasError.value = false; - datetimeValue.value = formatForDatetimeLocal(date); - emit("update:modelValue", formatForDatetimeLocal(date)); - } else { - setError("Could not parse this date format"); - } - } else { - setError( - 'Could not understand this date format. Try something like "tomorrow at 3pm" or "next Friday"', - ); - } - } catch (error) { - setError("Error parsing date"); - } -}; - -// UInput's fall-through @input listener fires unreliably (the Nuxt UI Input -// template merges $attrs with an explicit @input and can drop keystrokes). -// update:model-value is a declared emit and fires for every change. -const onNaturalInputChange = (value) => { - naturalInput.value = value; - parseNaturalInput(); -}; - -const onBlur = () => { - // Always re-parse on blur so the final typed value wins, even if an - // intermediate state produced a stale parse. - if (naturalInput.value.trim()) { - parseNaturalInput(); - } - if (isValidParse.value) { - isEditing.value = false; - } -}; - -const onDatetimeChange = () => { - if (datetimeValue.value) { - const date = new Date(datetimeValue.value); - if (!isNaN(date.getTime())) { - parsedDate.value = date; - isValidParse.value = true; - hasError.value = false; - naturalInput.value = ""; // Clear natural input when using traditional picker - isEditing.value = false; - emit("update:modelValue", datetimeValue.value); - } - } else { - reset(); - } -}; - -const reset = () => { - parsedDate.value = null; - isValidParse.value = false; - hasError.value = false; - errorMessage.value = ""; - emit("update:modelValue", ""); -}; - -const setError = (message) => { - isValidParse.value = false; - hasError.value = true; - errorMessage.value = message; - parsedDate.value = null; -}; - -const clearAndEdit = () => { - // Pre-fill the input with a readable string so the user doesn't start over. - naturalInput.value = parsedDate.value - ? parsedDate.value.toLocaleString("en-US", { - month: "long", - day: "numeric", - year: "numeric", - hour: "numeric", - minute: "2-digit", - hour12: true, - }) - : ""; - parsedDate.value = null; - isValidParse.value = false; - hasError.value = false; - // Prevent auto-hide while editing: chrono re-parses the pre-filled string - // on the first keystroke, which would otherwise hide the input. - isEditing.value = true; -}; - -const formatForDatetimeLocal = (date) => { - if (!date) return ""; - // Format as YYYY-MM-DDTHH:MM for datetime-local input - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, "0"); - const day = String(date.getDate()).padStart(2, "0"); - const hours = String(date.getHours()).padStart(2, "0"); - const minutes = String(date.getMinutes()).padStart(2, "0"); - return `${year}-${month}-${day}T${hours}:${minutes}`; -}; - -const formatParsedDate = (date) => { - if (!date) return ""; - - const now = new Date(); - const isToday = date.toDateString() === now.toDateString(); - const tomorrow = new Date(now); - tomorrow.setDate(tomorrow.getDate() + 1); - const isTomorrow = date.toDateString() === tomorrow.toDateString(); - - const timeStr = date.toLocaleString("en-US", { +const previewText = computed(() => { + if (!previewDate.value) return ""; + const tz = activeTZ(); + const date = new Intl.DateTimeFormat("en-US", { + timeZone: tz, + weekday: "short", + month: "short", + day: "numeric", + year: "numeric", hour: "numeric", minute: "2-digit", hour12: true, - }); + }).format(previewDate.value); + const abbr = shortTimezoneName(previewDate.value, tz); + return abbr ? `${date} ${abbr}` : date; +}); - if (isToday) { - return `Today at ${timeStr}`; - } else if (isTomorrow) { - return `Tomorrow at ${timeStr}`; - } else { - return date.toLocaleString("en-US", { - weekday: "long", - year: "numeric", - month: "long", - day: "numeric", - hour: "numeric", - minute: "2-digit", - hour12: true, - }); +const activeTZ = () => + props.displayTimezone || Intl.DateTimeFormat().resolvedOptions().timeZone; + +// Seed the input from modelValue without triggering chrono. The parent's +// value is canonical — we just render it as a chrono-friendly readable +// string so the user can backspace and tweak in place. +const seedFromModelValue = () => { + if (!props.modelValue) { + rawInput.value = ""; + isValid.value = false; + hasError.value = false; + errorMessage.value = ""; + previewDate.value = null; + return; } + const tz = activeTZ(); + const utc = zonedLocalToUTC(props.modelValue, tz); + if (!utc) return; + previewDate.value = utc; + isValid.value = true; + hasError.value = false; + errorMessage.value = ""; + rawInput.value = readableSeed(utc, tz); +}; + +onMounted(seedFromModelValue); + +watch( + () => props.modelValue, + (next) => { + const tz = activeTZ(); + const expected = previewDate.value + ? utcToZonedLocal(previewDate.value, tz) + : ""; + if (next === expected) return; + seedFromModelValue(); + }, +); + +watch( + () => props.displayTimezone, + () => { + // Re-interpret the current input under the new TZ so the preview and + // emitted value reflect the new timezone semantics. + if (rawInput.value.trim()) parse(rawInput.value); + }, +); + +const onInputChange = (value) => { + rawInput.value = value; + parse(value); +}; + +const parse = (input) => { + const trimmed = input.trim(); + if (!trimmed) { + isValid.value = false; + hasError.value = false; + errorMessage.value = ""; + previewDate.value = null; + emit("update:modelValue", ""); + return; + } + const tz = activeTZ(); + let results; + try { + results = chrono.parse(trimmed, referenceNowInTZ(tz)); + } catch { + setError("Couldn't read that — try \"tomorrow 3pm\" or \"Nov 22 3pm\""); + return; + } + if (!results.length) { + setError("Couldn't read that — try \"tomorrow 3pm\" or \"Nov 22 3pm\""); + return; + } + const date = results[0].date(); + if (!date || Number.isNaN(date.getTime())) { + setError("Couldn't read that date"); + return; + } + // chrono returned a Date whose browser-local components match what the + // user typed in the event timezone (because we shifted the reference). + // Read those components as wall-clock in displayTimezone. + const localStr = browserComponentsToString(date); + const utc = zonedLocalToUTC(localStr, tz); + if (!utc) { + setError("Couldn't parse this date"); + return; + } + isValid.value = true; + hasError.value = false; + errorMessage.value = ""; + previewDate.value = utc; + emit("update:modelValue", localStr); +}; + +const setError = (msg) => { + isValid.value = false; + hasError.value = true; + errorMessage.value = msg; + previewDate.value = null; + emit("update:modelValue", ""); +}; + +// Build a Date object whose browser-local components equal the current +// wall-clock in the given IANA timezone, so chrono's "tomorrow"/"next +// Friday" anchor to the event TZ rather than the editor's browser TZ. +const referenceNowInTZ = (tz) => { + const nowStr = utcToZonedLocal(new Date(), tz); + if (!nowStr) return new Date(); + const [d, t] = nowStr.split("T"); + const [y, mo, day] = d.split("-").map(Number); + const [h, mi] = t.split(":").map(Number); + return new Date(y, mo - 1, day, h, mi); +}; + +const browserComponentsToString = (date) => { + const y = date.getFullYear(); + const mo = String(date.getMonth() + 1).padStart(2, "0"); + const d = String(date.getDate()).padStart(2, "0"); + const h = String(date.getHours()).padStart(2, "0"); + const mi = String(date.getMinutes()).padStart(2, "0"); + return `${y}-${mo}-${d}T${h}:${mi}`; +}; + +const readableSeed = (utc, tz) => { + // Format chosen to round-trip cleanly through chrono.parse. + return new Intl.DateTimeFormat("en-US", { + timeZone: tz, + month: "short", + day: "numeric", + year: "numeric", + hour: "numeric", + minute: "2-digit", + hour12: true, + }).format(utc); }; diff --git a/app/pages/admin/events/create.vue b/app/pages/admin/events/create.vue index 6d73edc..b388e9b 100644 --- a/app/pages/admin/events/create.vue +++ b/app/pages/admin/events/create.vue @@ -27,6 +27,8 @@
+
+

Basic Information

@@ -38,6 +40,7 @@ placeholder="Enter a clear, descriptive event title" required :color="fieldErrors.title ? 'error' : undefined" + :ui="{ base: 'title-input' }" class="w-full" />

@@ -60,7 +63,8 @@ v-model="eventForm.description" placeholder="Provide a clear description of what attendees can expect from this event" required - :rows="4" + :rows="8" + autoresize :color="fieldErrors.description ? 'error' : undefined" class="w-full" /> @@ -77,7 +81,8 @@

@@ -85,6 +90,21 @@ requirements

+ +
+ + +

+ One agenda item per line. Help attendees know what to expect + during the event. +

+
@@ -97,12 +117,7 @@

@@ -128,18 +143,13 @@

- + -

- {{ fieldErrors.location }} -

-

+

Video conference link, Slack channel (#channel-name), or 'TBD' if the platform is undecided

@@ -149,6 +159,7 @@ @@ -161,6 +172,7 @@ @@ -187,6 +199,7 @@

@@ -196,6 +209,87 @@

+ +
+

Event Settings

+ +
+
+ + + +
+ +
+ + + + + +
+
+
+ + +
+
+ + +

+ This message will be displayed to users viewing the event page +

+
+
+ + + @@ -594,9 +577,9 @@ @@ -619,6 +602,7 @@