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 @@
-
-
-
-
-
-
-
-
-
-
-
+
-
-
-
- {{ formatParsedDate(parsedDate) }}
-
-
-
-
-
-
-
-
- {{ errorMessage }}
-
-
-
-
-
-
- Use traditional date picker
-
-
-
+
-
-
+
+
+
+
+ → {{ 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 @@