const SELECTORS = { calendarToggle: '.calendar-toggle', calendarViewForm: '#calendar-view', calendarExpandToggle: '[data-calendar-expand]', colorPicker: '[data-colorpicker]', colorPickerColor: '[data-colorpicker-color]', colorPickerHex: '[data-colorpicker-hex]', colorPickerRandom: '[data-colorpicker-random]', calendarPicker: '[data-calendar-picker]', calendarPickerInput: '[data-calendar-picker-input]', calendarPickerToggle: '[data-calendar-picker-toggle]', calendarPickerMenu: '[data-calendar-picker-menu]', calendarPickerLabel: '[data-calendar-picker-label]', calendarPickerColorDot: '[data-calendar-picker-color]', calendarPickerOption: '[data-calendar-picker-option]', eventAllDayToggle: '[data-all-day-toggle]', eventStartInput: '[data-event-start]', eventEndInput: '[data-event-end]', eventTitleInput: 'input[name="title"]', eventLocationInput: 'input[name="location"]', modalDialog: 'dialog', modalContent: '#modal', modalClassSource: '[data-modal-class]', modalAside: '#modal-aside', modalAsideList: '[data-modal-aside-list]', modalExpand: '[data-modal-expand]', monthDay: '.calendar.month .day', monthDayEvent: 'a.event', monthDayMore: '[data-day-more]', monthDayMoreWrap: '.more-events', monthlyMode: '[data-monthly-mode]', monthlyDays: '[data-monthly-days]', monthlyWeekday: '[data-monthly-weekday]', naturalEventInput: '[data-natural-event-input]', recurrenceFrequency: '[data-recurrence-frequency]', recurrenceInterval: '[data-recurrence-interval]', recurrenceUnit: '[data-recurrence-unit]', recurrenceSection: '[data-recurrence-section]', tabsRoot: '[data-tabs]', tabButton: '[role=\"tab\"]', tabPanel: '[role=\"tabpanel\"]', attendeesRoot: '[data-attendees]', attendeesList: '[data-attendees-list]', attendeeTemplate: 'template[data-attendee-template]', attendeeLookup: '[data-attendee-lookup]', attendeeSuggestions: '#attendee-suggestions', attendeeAddManual: '[data-attendee-add-manual]', attendeePick: '[data-attendee-pick]', attendeeRemove: '[data-attendee-remove]', attendeeRow: '[data-attendee-row]', attendeeRole: '[data-attendee-role]', attendeeName: '[data-attendee-name]', attendeeOptional: '[data-attendee-optional]', attendeeDisplay: '[data-attendee-display]', attendeeVerified: '[data-attendee-verified]', attendeeEmail: '[data-attendee-email]', attendeeUri: '[data-attendee-uri]', }; function syncModalRootClass(modal) { if (!modal) return; const previous = (modal.dataset.appliedClass || '') .split(/\s+/) .filter(Boolean); if (previous.length) { modal.classList.remove(...previous); } const source = modal.querySelector(SELECTORS.modalClassSource); const next = (source?.dataset?.modalClass || '') .split(/\s+/) .filter(Boolean); if (next.length) { modal.classList.add(...next); } modal.dataset.appliedClass = next.join(' '); } function clearModalRootClass(modal) { if (!modal) return; const previous = (modal.dataset.appliedClass || '') .split(/\s+/) .filter(Boolean); if (previous.length) { modal.classList.remove(...previous); } modal.dataset.appliedClass = ''; } function renderModalAside(dialog, items = [], show = false) { if (!dialog) return; const aside = dialog.querySelector(SELECTORS.modalAside); if (!aside) return; const signature = `${show ? '1' : '0'}|${items.map((item) => `${item.label}:${item.value}`).join('|')}`; if (aside.dataset.asideSignature === signature) { return; } const list = aside.querySelector(SELECTORS.modalAsideList); if (list) { const existing = new Map( Array.from(list.querySelectorAll('li[data-aside-key]')).map((li) => [li.dataset.asideKey, li]) ); const usedKeys = new Set(); items.forEach(({ label, value }) => { const key = String(label || '').trim().toLowerCase().replace(/\s+/g, '-'); usedKeys.add(key); let li = existing.get(key); if (!li) { li = document.createElement('li'); li.dataset.asideKey = key; li.classList.add('is-visible'); const strong = document.createElement('strong'); const text = document.createElement('span'); li.appendChild(strong); li.appendChild(text); list.appendChild(li); li.addEventListener('animationend', () => { li.classList.remove('is-visible'); }, { once: true }); } const strong = li.querySelector('strong'); const text = li.querySelector('span'); const nextLabel = `${label}:`; const nextValue = ` ${value}`; if (strong && strong.textContent !== nextLabel) { strong.textContent = nextLabel; } if (text && text.textContent !== nextValue) { text.textContent = nextValue; } }); existing.forEach((li, key) => { if (!usedKeys.has(key)) { li.remove(); } }); } aside.classList.toggle('is-visible', show); aside.setAttribute('aria-hidden', show ? 'false' : 'true'); aside.dataset.asideSignature = signature; } function clearModalAside(dialog) { renderModalAside(dialog, [], false); } function initEventAllDayToggles(root = document) { const toDate = (value) => { if (!value) return ''; return value.split('T')[0]; }; const withTime = (date, time) => { if (!date) return ''; return `${date}T${time}`; }; root.querySelectorAll(SELECTORS.eventAllDayToggle).forEach((toggleHost) => { const toggle = toggleHost.matches('input[type="checkbox"]') ? toggleHost : toggleHost.querySelector('input[type="checkbox"]'); if (!toggle || toggle.__allDayWired) return; toggle.__allDayWired = true; const form = toggle.closest('form') || toggleHost.closest('form'); if (!form) return; const start = form.querySelector(SELECTORS.eventStartInput); const end = form.querySelector(SELECTORS.eventEndInput); if (!start || !end) return; const apply = () => { if (toggle.checked) { if (start.type === 'datetime-local') { start.dataset.datetimeValue = start.value; } if (end.type === 'datetime-local') { end.dataset.datetimeValue = end.value; } const startDate = toDate(start.value); const endDate = toDate(end.value); start.type = 'date'; end.type = 'date'; if (startDate) start.value = startDate; if (endDate) end.value = endDate; if (start.value && end.value && end.value < start.value) { end.value = start.value; } } else { const startDate = toDate(start.value); const endDate = toDate(end.value); const startTime = (start.dataset.datetimeValue || '').split('T')[1] || '09:00'; const endTime = (end.dataset.datetimeValue || '').split('T')[1] || '10:00'; start.type = 'datetime-local'; end.type = 'datetime-local'; start.value = start.dataset.datetimeValue || withTime(startDate, startTime); end.value = end.dataset.datetimeValue || withTime(endDate || startDate, endTime); } }; toggle.addEventListener('change', apply); apply(); }); } /** * * recurrence preset selector */ function initRecurrenceControls(root = document) { const sections = root.querySelectorAll(SELECTORS.recurrenceSection); if (!sections.length) return; const select = root.querySelector(SELECTORS.recurrenceFrequency); const intervalRow = root.querySelector(SELECTORS.recurrenceInterval); const intervalUnit = root.querySelector(SELECTORS.recurrenceUnit); const monthModes = root.querySelectorAll(SELECTORS.monthlyMode); const monthDays = root.querySelector(SELECTORS.monthlyDays); const monthWeekday = root.querySelector(SELECTORS.monthlyWeekday); if (!select) return; const unitMap = { daily: 'day', weekly: 'week', monthly: 'month', yearly: 'year', }; const applyMonthlyMode = () => { if (!monthDays || !monthWeekday) return; const modeInput = Array.from(monthModes).find((input) => input.checked); const mode = modeInput?.value || 'days'; if (mode === 'weekday') { monthDays.classList.add('hidden'); monthWeekday.classList.remove('hidden'); } else { monthDays.classList.remove('hidden'); monthWeekday.classList.add('hidden'); } }; const apply = () => { const value = select.value; const show = value !== ''; sections.forEach((section) => { const type = section.getAttribute('data-recurrence-section'); if (!show) { section.classList.add('hidden'); return; } section.classList.toggle('hidden', type !== value); }); if (intervalRow) { intervalRow.classList.toggle('hidden', !show); } if (intervalUnit) { const unit = unitMap[value] || 'day'; intervalUnit.textContent = unit ? `${unit}(s)` : ''; } if (value === 'monthly') { applyMonthlyMode(); } }; select.addEventListener('change', apply); monthModes.forEach((input) => input.addEventListener('change', applyMonthlyMode)); apply(); } /** * * natural-language event parser (create modal header input) */ function initNaturalEventParser(root = document) { const monthMap = { jan: 0, january: 0, feb: 1, february: 1, mar: 2, march: 2, apr: 3, april: 3, may: 4, jun: 5, june: 5, jul: 6, july: 6, aug: 7, august: 7, sep: 8, sept: 8, september: 8, oct: 9, october: 9, nov: 10, november: 10, dec: 11, december: 11, }; const pad = (value) => String(value).padStart(2, '0'); const toLocalDateInputValue = (date) => ( `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` ); const toLocalDatetimeInputValue = (date) => ( `${toLocalDateInputValue(date)}T${pad(date.getHours())}:${pad(date.getMinutes())}` ); const readDateInputValue = (value) => { if (!value || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return null; const [year, month, day] = value.split('-').map((n) => parseInt(n, 10)); if ([year, month, day].some((n) => Number.isNaN(n))) return null; return new Date(year, month - 1, day, 0, 0, 0, 0); }; const readDatetimeInputValue = (value) => { if (!value || !value.includes('T')) return null; const [datePart, timePart] = value.split('T'); if (!datePart || !timePart) return null; const [year, month, day] = datePart.split('-').map((n) => parseInt(n, 10)); const [hours, minutes] = timePart.split(':').map((n) => parseInt(n, 10)); if ([year, month, day, hours, minutes].some((n) => Number.isNaN(n))) return null; return new Date(year, month - 1, day, hours, minutes, 0, 0); }; const formatDayLong = (date) => ( date.toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' }) ); const formatTimeShort = (date) => ( date .toLocaleTimeString(undefined, { hour: 'numeric', minute: date.getMinutes() === 0 ? undefined : '2-digit', }) .toLowerCase() .replace(/\./g, '') .replace(/\s+/g, '') ); const formatDateSummary = (startDate, endDate, allDay) => { if (!startDate) return ''; const startDay = formatDayLong(startDate); if (allDay) { if (endDate && endDate.toDateString() !== startDate.toDateString()) { return `${startDay} to ${formatDayLong(endDate)}, all day`; } return `${startDay}, all day`; } const startTime = formatTimeShort(startDate); if (!endDate) { return `${startDay}, ${startTime}`; } const endTime = formatTimeShort(endDate); if (endDate.toDateString() === startDate.toDateString()) { return `${startDay}, ${startTime} to ${endTime}`; } return `${startDay}, ${startTime} to ${formatDayLong(endDate)}, ${endTime}`; }; const parseDurationMinutes = (text) => { const match = text.match(/\bfor\s+(\d+)\s*(minutes?|mins?|hours?|hrs?)\b/i); if (!match) return 60; const count = parseInt(match[1], 10); const unit = (match[2] || '').toLowerCase(); if (Number.isNaN(count) || count <= 0) return 60; if (unit.startsWith('hour') || unit.startsWith('hr')) { return count * 60; } return count; }; const parseDateToken = (text, now) => { const lower = text.toLowerCase(); const todayMatch = lower.match(/\btoday\b/); if (todayMatch) { const date = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0); return { date, index: todayMatch.index ?? 0 }; } const tomorrowMatch = lower.match(/\btomorrow\b/); if (tomorrowMatch) { const date = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 0, 0, 0, 0); return { date, index: tomorrowMatch.index ?? 0 }; } const monthMatch = text.match(/\b(?:on\s+)?([A-Za-z]{3,9})\.?\s+(\d{1,2})(?:,\s*(\d{4}))?\b/); if (monthMatch) { const monthToken = (monthMatch[1] || '').toLowerCase(); const month = monthMap[monthToken]; const day = parseInt(monthMatch[2], 10); const explicitYear = monthMatch[3] ? parseInt(monthMatch[3], 10) : null; if (month !== undefined && !Number.isNaN(day) && day >= 1 && day <= 31) { const thisYear = now.getFullYear(); let date = new Date(explicitYear || thisYear, month, day, 0, 0, 0, 0); if (!explicitYear) { const today = new Date(thisYear, now.getMonth(), now.getDate(), 0, 0, 0, 0); if (date < today) { date = new Date(thisYear + 1, month, day, 0, 0, 0, 0); } } return { date, index: monthMatch.index ?? 0 }; } } const numericMatch = text.match(/\b(?:on\s+)?(\d{1,2})\/(\d{1,2})(?:\/(\d{2,4}))?\b/); if (numericMatch) { const month = parseInt(numericMatch[1], 10) - 1; const day = parseInt(numericMatch[2], 10); const yearToken = numericMatch[3]; const thisYear = now.getFullYear(); let year = thisYear; if (yearToken) { year = parseInt(yearToken, 10); if (year < 100) year += 2000; } if (!Number.isNaN(month) && !Number.isNaN(day)) { let date = new Date(year, month, day, 0, 0, 0, 0); if (!yearToken) { const today = new Date(thisYear, now.getMonth(), now.getDate(), 0, 0, 0, 0); if (date < today) { date = new Date(thisYear + 1, month, day, 0, 0, 0, 0); } } return { date, index: numericMatch.index ?? 0 }; } } return null; }; const isAtTimeCandidate = (candidate) => { const normalized = String(candidate || '') .trim() .toLowerCase() .replace(/\./g, ''); if (normalized === '') return false; if (normalized === 'noon' || normalized === 'midnight') return true; // 1-2 digit + a|p|am|pm (with/without space), e.g. 3p, 3 p, 3pm, 3 pm if (/^\d{1,2}\s*(a|p|am|pm)$/.test(normalized)) { return true; } // h:mm / hh:mm with optional a|p|am|pm, or plain 24-hour format (e.g. 15:30) if (/^\d{1,2}:\d{2}(?:\s*(a|p|am|pm))?$/.test(normalized)) { return true; } return false; }; const parseTimeToken = (text) => { const lowered = text.toLowerCase(); const noonIndex = lowered.indexOf(' at noon'); if (noonIndex !== -1) { return { hours: 12, minutes: 0, index: noonIndex }; } const midnightIndex = lowered.indexOf(' at midnight'); if (midnightIndex !== -1) { return { hours: 0, minutes: 0, index: midnightIndex }; } const match = text.match(/\bat\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm|a|p)?\b/i); if (!match) return null; let hours = parseInt(match[1], 10); const minutes = parseInt(match[2] || '0', 10); const meridiem = (match[3] || '').toLowerCase().replace(/\./g, ''); if (Number.isNaN(hours) || Number.isNaN(minutes)) return null; if (minutes < 0 || minutes > 59) return null; if (meridiem) { if (hours < 1 || hours > 12) return null; if (hours === 12 && meridiem.startsWith('a')) hours = 0; if (hours < 12 && meridiem.startsWith('p')) hours += 12; } else if (hours > 23) { return null; } return { hours, minutes, index: match.index ?? 0, }; }; const parseRelativeToken = (text, now) => { const addOffset = (days, minutes) => { const date = new Date(now.getTime()); date.setMinutes(date.getMinutes() + (days * 24 * 60) + minutes); return date; }; let match = text.match(/\bin\s+(\d+)\s+days?(?:\s+and\s+(\d+)\s+minutes?)?\b/i); if (match) { const days = parseInt(match[1], 10); const minutes = parseInt(match[2] || '0', 10); if (!Number.isNaN(days) && !Number.isNaN(minutes)) { return { date: addOffset(days, minutes), index: match.index ?? 0, }; } } match = text.match(/\b(\d+)\s+days?(?:\s+and\s+(\d+)\s+minutes?)?\s+from\s+now\b/i); if (match) { const days = parseInt(match[1], 10); const minutes = parseInt(match[2] || '0', 10); if (!Number.isNaN(days) && !Number.isNaN(minutes)) { return { date: addOffset(days, minutes), index: match.index ?? 0, }; } } match = text.match(/\bin\s+(\d+)\s+minutes?\b/i); if (match) { const minutes = parseInt(match[1], 10); if (!Number.isNaN(minutes)) { return { date: addOffset(0, minutes), index: match.index ?? 0, }; } } match = text.match(/\b(\d+)\s+minutes?\s+from\s+now\b/i); if (match) { const minutes = parseInt(match[1], 10); if (!Number.isNaN(minutes)) { return { date: addOffset(0, minutes), index: match.index ?? 0, }; } } return null; }; const weekdayCodeMap = { su: 'SU', sun: 'SU', sunday: 'SU', sundays: 'SU', mo: 'MO', mon: 'MO', monday: 'MO', mondays: 'MO', tu: 'TU', tue: 'TU', tues: 'TU', tuesday: 'TU', tuesdays: 'TU', we: 'WE', wed: 'WE', wednesday: 'WE', wednesdays: 'WE', th: 'TH', thu: 'TH', thur: 'TH', thurs: 'TH', thursday: 'TH', thursdays: 'TH', fr: 'FR', fri: 'FR', friday: 'FR', fridays: 'FR', sa: 'SA', sat: 'SA', saturday: 'SA', saturdays: 'SA', }; const weekdayNameMap = { SU: 'Sunday', MO: 'Monday', TU: 'Tuesday', WE: 'Wednesday', TH: 'Thursday', FR: 'Friday', SA: 'Saturday', }; const weekdayCodeFromDate = (date) => ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'][date.getDay()]; const parseRecurrenceToken = (text) => { const repeatMatch = text.match(/\brepeats?\b([\s\S]*)$/i); if (!repeatMatch) return null; const tail = (repeatMatch[1] || '').trim().toLowerCase(); const recurrence = { frequency: null, interval: 1, weekdays: [], index: repeatMatch.index ?? 0, }; const everyNumbered = tail.match(/\bevery\s+(\d+)\s*(day|week|month|year)s?\b/i); if (everyNumbered) { const count = parseInt(everyNumbered[1], 10); const unit = everyNumbered[2].toLowerCase(); if (!Number.isNaN(count) && count > 0) { recurrence.interval = count; } recurrence.frequency = { day: 'daily', week: 'weekly', month: 'monthly', year: 'yearly', }[unit] || null; } else if (/\b(daily|every day)\b/i.test(tail)) { recurrence.frequency = 'daily'; } else if (/\b(weekly|every week)\b/i.test(tail)) { recurrence.frequency = 'weekly'; } else if (/\b(monthly|every month)\b/i.test(tail)) { recurrence.frequency = 'monthly'; } else if (/\b(yearly|every year)\b/i.test(tail)) { recurrence.frequency = 'yearly'; } if (!recurrence.frequency) { return null; } if (recurrence.frequency === 'weekly') { const onMatch = tail.match(/\bon\s+(.+)$/i); if (onMatch) { const weekdays = []; const tokens = onMatch[1] .replace(/[,/]/g, ' ') .replace(/\band\b/g, ' ') .split(/\s+/) .map((token) => token.trim().toLowerCase().replace(/[^a-z]/g, '')) .filter(Boolean); tokens.forEach((token) => { const code = weekdayCodeMap[token]; if (code && !weekdays.includes(code)) { weekdays.push(code); } }); recurrence.weekdays = weekdays; } } return recurrence; }; const parseLocationToken = (text) => { const lower = text.toLowerCase(); const index = lower.lastIndexOf(' at '); if (index === -1) return null; let candidate = text.slice(index + 4).trim().replace(/[.,;]+$/, ''); if (!candidate) return null; // If the phrase ends with a dangling "at", trim it first. // Example: "... at 11am at" -> candidate "11am" candidate = candidate.replace(/\s+at\s*$/i, '').trim(); if (!candidate) return null; // If the extracted candidate starts with a time token, strip it. // Example: "11am at Pediatric Alliance" -> "Pediatric Alliance" const leadingTimeMatch = candidate.match(/^(?:\d{1,2}(?::\d{2})?\s*(?:a|p|am|pm)|\d{1,2}:\d{2}|noon|midnight)\b/i); if (leadingTimeMatch) { candidate = candidate .slice(leadingTimeMatch[0].length) .replace(/^\s*at\s*/i, '') .trim(); } if (!candidate) return null; if (isAtTimeCandidate(candidate)) return null; if (/\bfor\s+\d+\s*(minutes?|mins?|hours?|hrs?)\b/i.test(candidate)) return null; return candidate; }; const parseTitleToken = (text, dateToken, timeToken, relativeToken, recurrenceToken, locationToken) => { const boundaries = []; if (dateToken) boundaries.push(dateToken.index); if (timeToken) boundaries.push(timeToken.index); if (relativeToken) boundaries.push(relativeToken.index); if (recurrenceToken) boundaries.push(recurrenceToken.index); const lower = text.toLowerCase(); const durationIndex = lower.indexOf(' for '); if (durationIndex !== -1) boundaries.push(durationIndex); if (locationToken) { const locationIndex = lower.lastIndexOf(' at '); if (locationIndex !== -1) boundaries.push(locationIndex); } if (!boundaries.length) { return text.trim(); } const end = Math.min(...boundaries); return text.slice(0, end).trim().replace(/[,:;.\-]+$/, '').trim(); }; const parseNaturalEventText = (text, baseDate) => { const trimmed = (text || '').trim(); if (!trimmed) return null; const relativeToken = parseRelativeToken(trimmed, baseDate); const recurrenceToken = parseRecurrenceToken(trimmed); const dateToken = parseDateToken(trimmed, baseDate); const timeToken = parseTimeToken(trimmed); const durationMinutes = parseDurationMinutes(trimmed); const location = parseLocationToken(trimmed); const title = parseTitleToken(trimmed, dateToken, timeToken, relativeToken, recurrenceToken, location); const allDay = /\ball(?:\s|-)?day\b/i.test(trimmed); return { title, location, dateToken, timeToken, relativeToken, recurrenceToken, durationMinutes, allDay, }; }; const findFormFromNaturalInput = (input) => { const dialog = input.closest('dialog'); if (dialog) { return dialog.querySelector('#event-form'); } const modal = input.closest(SELECTORS.modalContent); if (modal) { return modal.querySelector('#event-form'); } return document.querySelector('#event-form'); }; const enableNaturalCollapsedMode = (input) => { const dialog = input.closest('dialog'); const modal = dialog?.querySelector(SELECTORS.modalContent); if (!dialog || !modal || modal.classList.contains('natural-expanded')) return; modal.classList.add('natural-collapsed'); renderModalAside(dialog, [], true); }; const clearRecurrenceInputs = (form) => { const frequencyInput = form.querySelector('select[name="repeat_frequency"]'); const intervalInput = form.querySelector('input[name="repeat_interval"]'); const weekdayInputs = Array.from(form.querySelectorAll('input[name="repeat_weekdays[]"]')); const monthlyModeInputs = Array.from(form.querySelectorAll('input[name="repeat_monthly_mode"]')); const monthDayInputs = Array.from(form.querySelectorAll('input[name="repeat_month_days[]"]')); if (frequencyInput) { frequencyInput.value = ''; frequencyInput.dispatchEvent(new Event('change', { bubbles: true })); } if (intervalInput) { intervalInput.value = '1'; } weekdayInputs.forEach((input) => { input.checked = false; }); monthlyModeInputs.forEach((input) => { input.checked = input.value === 'days'; }); monthDayInputs.forEach((input) => { input.checked = false; }); }; const applyRecurrenceInputs = (form, recurrenceToken, anchorDate) => { const frequencyInput = form.querySelector('select[name="repeat_frequency"]'); const intervalInput = form.querySelector('input[name="repeat_interval"]'); const weekdayInputs = Array.from(form.querySelectorAll('input[name="repeat_weekdays[]"]')); const monthlyModeInputs = Array.from(form.querySelectorAll('input[name="repeat_monthly_mode"]')); const monthDayInputs = Array.from(form.querySelectorAll('input[name="repeat_month_days[]"]')); if (!frequencyInput || !recurrenceToken?.frequency) { return; } frequencyInput.value = recurrenceToken.frequency; frequencyInput.dispatchEvent(new Event('change', { bubbles: true })); if (intervalInput) { intervalInput.value = String(recurrenceToken.interval || 1); } if (recurrenceToken.frequency === 'weekly') { let weekdays = recurrenceToken.weekdays || []; if (!weekdays.length && anchorDate) { weekdays = [weekdayCodeFromDate(anchorDate)]; } weekdayInputs.forEach((input) => { input.checked = weekdays.includes(input.value); }); } else { weekdayInputs.forEach((input) => { input.checked = false; }); } if (recurrenceToken.frequency === 'monthly') { monthlyModeInputs.forEach((input) => { input.checked = input.value === 'days'; }); const dayOfMonth = anchorDate ? anchorDate.getDate() : null; monthDayInputs.forEach((input) => { input.checked = dayOfMonth ? Number(input.value) === dayOfMonth : false; }); } else { monthDayInputs.forEach((input) => { input.checked = false; }); } }; const recurrenceSummary = (recurrenceToken, anchorDate) => { if (!recurrenceToken?.frequency) return ''; const interval = recurrenceToken.interval || 1; if (recurrenceToken.frequency === 'daily') { return interval === 1 ? 'Daily' : `Every ${interval} days`; } if (recurrenceToken.frequency === 'weekly') { const days = (recurrenceToken.weekdays || []).length ? recurrenceToken.weekdays : (anchorDate ? [weekdayCodeFromDate(anchorDate)] : []); const dayLabels = days.map((code) => weekdayNameMap[code] || code); const head = interval === 1 ? 'Every week' : `Every ${interval} weeks`; return dayLabels.length ? `${head} on ${dayLabels.join(' and ')}` : head; } if (recurrenceToken.frequency === 'monthly') { return interval === 1 ? 'Monthly' : `Every ${interval} months`; } if (recurrenceToken.frequency === 'yearly') { return interval === 1 ? 'Yearly' : `Every ${interval} years`; } return ''; }; const applyParsedData = (input) => { const dialog = input.closest('dialog'); const modal = dialog?.querySelector(SELECTORS.modalContent); const form = findFormFromNaturalInput(input); if (!form) return; const titleInput = form.querySelector(SELECTORS.eventTitleInput); const locationInput = form.querySelector(SELECTORS.eventLocationInput); const startInput = form.querySelector(SELECTORS.eventStartInput); const endInput = form.querySelector(SELECTORS.eventEndInput); const allDayToggleHost = form.querySelector(SELECTORS.eventAllDayToggle); const allDayToggle = allDayToggleHost?.matches('input[type="checkbox"]') ? allDayToggleHost : allDayToggleHost?.querySelector('input[type="checkbox"]'); if (!titleInput || !startInput || !endInput) return; const parsed = parseNaturalEventText(input.value, new Date()); if (!parsed) { if (modal?.classList.contains('natural-collapsed')) { renderModalAside(dialog, [], true); } return; } if (parsed.title) { titleInput.value = parsed.title; } if (locationInput) { if (parsed.location) { locationInput.value = parsed.location; input.dataset.nlLocationApplied = '1'; } else if (input.dataset.nlLocationApplied === '1') { // Clear stale parser-written values (e.g. interim "at 3" while typing "at 3pm") locationInput.value = ''; input.dataset.nlLocationApplied = '0'; } } const existingStart = startInput.type === 'date' ? readDateInputValue(startInput.value) : readDatetimeInputValue(startInput.value); const baseDate = parsed.relativeToken?.date ? new Date(parsed.relativeToken.date.getTime()) : parsed.dateToken?.date ? new Date(parsed.dateToken.date.getTime()) : (existingStart ? new Date(existingStart.getTime()) : new Date()); if (parsed.allDay) { if (allDayToggle && !allDayToggle.checked) { allDayToggle.checked = true; allDayToggle.dispatchEvent(new Event('change', { bubbles: true })); } const dateOnly = toLocalDateInputValue(baseDate); startInput.value = dateOnly; endInput.value = dateOnly; } else if (parsed.dateToken || parsed.timeToken || parsed.relativeToken) { if (allDayToggle?.checked) { allDayToggle.checked = false; allDayToggle.dispatchEvent(new Event('change', { bubbles: true })); } const hours = parsed.timeToken?.hours ?? (existingStart ? existingStart.getHours() : 9); const minutes = parsed.timeToken?.minutes ?? (existingStart ? existingStart.getMinutes() : 0); baseDate.setHours(hours, minutes, 0, 0); const end = new Date(baseDate.getTime()); end.setMinutes(end.getMinutes() + parsed.durationMinutes); startInput.value = toLocalDatetimeInputValue(baseDate); endInput.value = toLocalDatetimeInputValue(end); } if (parsed.recurrenceToken) { applyRecurrenceInputs(form, parsed.recurrenceToken, baseDate); input.dataset.nlRecurrenceApplied = '1'; } else if (input.dataset.nlRecurrenceApplied === '1') { clearRecurrenceInputs(form); input.dataset.nlRecurrenceApplied = '0'; } const summaryItems = []; if (parsed.title) { summaryItems.push({ label: 'Title', value: parsed.title }); } if (parsed.location) { summaryItems.push({ label: 'Location', value: parsed.location }); } if (parsed.dateToken || parsed.timeToken || parsed.relativeToken || parsed.allDay) { const summaryStart = startInput.type === 'date' ? readDateInputValue(startInput.value) : readDatetimeInputValue(startInput.value); const summaryEnd = endInput.type === 'date' ? readDateInputValue(endInput.value) : readDatetimeInputValue(endInput.value); const allDayState = Boolean(allDayToggle?.checked); const summaryDate = formatDateSummary(summaryStart, summaryEnd, allDayState); if (summaryDate) { summaryItems.push({ label: 'Date', value: summaryDate }); } } if (parsed.recurrenceToken) { const repeat = recurrenceSummary(parsed.recurrenceToken, baseDate); if (repeat) { summaryItems.push({ label: 'Repeat', value: repeat }); } } if (modal?.classList.contains('natural-collapsed')) { renderModalAside(dialog, summaryItems, true); } }; if (!document.__naturalEventDelegated) { document.__naturalEventDelegated = true; document.addEventListener('input', (event) => { const input = event.target?.closest?.(SELECTORS.naturalEventInput); if (!input) return; applyParsedData(input); }); document.addEventListener('change', (event) => { const input = event.target?.closest?.(SELECTORS.naturalEventInput); if (!input) return; applyParsedData(input); }); document.addEventListener('click', (event) => { const button = event.target?.closest?.(SELECTORS.modalExpand); if (!button) return; const dialog = button.closest('dialog'); const modal = dialog?.querySelector(SELECTORS.modalContent); if (!dialog || !modal) return; modal.classList.remove('natural-collapsed'); modal.classList.add('natural-expanded'); clearModalAside(dialog); }); } root.querySelectorAll(SELECTORS.naturalEventInput).forEach((input) => { enableNaturalCollapsedMode(input); if (input.value?.trim()) { applyParsedData(input); } }); } /** * * modal behaviors (backdrop close + url restore) */ function initModalHandlers(root = document) { const dialog = root.querySelector(SELECTORS.modalDialog); if (!dialog || dialog.__modalWired) return; dialog.__modalWired = true; dialog.addEventListener('click', (event) => { if (event.target === dialog) { dialog.close(); } }); dialog.addEventListener('close', () => { const modal = dialog.querySelector(SELECTORS.modalContent); if (!modal) return; const isEvent = modal.querySelector('[data-modal-kind="event"]'); const prevUrl = modal.dataset.prevUrl; modal.classList.remove('natural-collapsed', 'natural-expanded'); modal.innerHTML = ''; clearModalRootClass(modal); clearModalAside(dialog); if (isEvent && prevUrl) { history.replaceState({}, '', prevUrl); } }); } /** * tabs (simple modal panels) */ function initTabs(root = document) { root.querySelectorAll(SELECTORS.tabsRoot).forEach((tabs) => { if (tabs.__tabsWired) return; tabs.__tabsWired = true; const tabEls = Array.from(tabs.querySelectorAll(SELECTORS.tabButton)); const panels = Array.from(tabs.querySelectorAll(SELECTORS.tabPanel)); if (!tabEls.length || !panels.length) return; const getPanelForTab = (tab, index) => { const controls = tab.getAttribute('aria-controls'); if (controls) { const panelById = tabs.querySelector(`[role="tabpanel"]#${controls}`); if (panelById) return panelById; } return panels[index] || null; }; const panelByTab = new Map(); tabEls.forEach((tab, index) => { const panel = getPanelForTab(tab, index); if (panel) { panelByTab.set(tab, panel); } //