PageSourceSearch

https://www.atchisonkansas.net/js/events.js?v=20260509v11

js atchisonkansas.net collected 2026-10-02 11:19:50 UTC 53,698 bytes, 1,287 lines download raw bytes

1/**
2 * Atchison Area Chamber of Commerce
3 * Events page
4 *
5 * Connects to the unified `events` collection in `visit-atchison-admin`.
6 * Mirrors the parsing/visibility/recurrence model used on Visit Atchison so
7 * that the chamber and visit sites always agree on:
8 *  - filter by `status == 'published'`
9 *  - per-site visibility flag `siteVisibility.chamber !== false`
10 *  - flexible date parsing (Firestore Timestamp OR "YYYY-MM-DD" string)
11 *  - times stored separately in `startTime` / `endTime` strings
12 *  - location is `venue` + `address` (legacy `location` honored as a fallback)
13 *  - canonical EVENT_CATEGORIES list
14 *  - `recurring` object with frequency = weekly | monthly-date | monthly-day,
15 *    expanded into upcoming instances for the next 3 months (just like
16 *    Visit Atchison's public events page)
17 */
18
19import { db, COLLECTIONS, EVENT_CATEGORIES } from './firebase-config.js';
20import { collection, query, where, getDocs } from 'https://www.gstatic.com/firebasejs/10.12.2/firebase-firestore.js';
21
22/**
23 * "Today" is the Central (America/Chicago) calendar date, not the browser's —
24 * a visitor in another timezone must see the same day's events the chamber
25 * does. Same approach as Visit Atchison's centralDateKey.
26 */
27const CENTRAL_DATE_FMT = new Intl.DateTimeFormat('en-CA', {
28    timeZone: 'America/Chicago', year: 'numeric', month: '2-digit', day: '2-digit'
29});
30function centralDateKey(date) {
31    return CENTRAL_DATE_FMT.format(date || new Date());
32}
33
34let allEvents = [];
35let filteredEvents = [];
36let currentView = 'list';
37let currentMonth = todayMonthAnchor();
38let currentFilters = { search: '', category: '' };
39
40const eventsList = document.getElementById('events-list');
41const calendarView = document.getElementById('calendar-view');
42const calendarGrid = document.getElementById('calendar-grid');
43const calendarMonthTitle = document.getElementById('calendar-month-title');
44const listViewBtn = document.getElementById('list-view-btn');
45const calendarViewBtn = document.getElementById('calendar-view-btn');
46const searchInput = document.getElementById('event-search');
47const categoryFilter = document.getElementById('event-category');
48const eventModal = document.getElementById('event-modal');
49
50document.addEventListener('DOMContentLoaded', async () => {
51    await loadEvents();
52    initFilters();
53    initViewToggle();
54    initCalendarNav();
55    initModal();
56});
57
58// ==========================================
59// DATE / RECURRENCE HELPERS
60// ==========================================
61
62/** Returns today (the Central calendar date), normalized to local midnight. */
63function todayLocal() {
64    const t = parseLocalDate(centralDateKey());
65    if (t) return t;
66    const fallback = new Date();
67    fallback.setHours(0, 0, 0, 0);
68    return fallback;
69}
70
71/** Returns the first-of-this-month, used as the calendar's default anchor. */
72function todayMonthAnchor() {
73    const t = new Date();
74    return new Date(t.getFullYear(), t.getMonth(), 1);
75}
76
77/**
78 * Parse "YYYY-MM-DD" strings into a local-midnight Date — same shape Visit
79 * Atchison uses, so chamber and visit interpret the same string identically.
80 */
81function parseLocalDate(dateString) {
82    if (!dateString) return null;
83    const parts = String(dateString).split('-');
84    if (parts.length < 3) return null;
85    const y = parseInt(parts[0], 10);
86    const m = parseInt(parts[1], 10);
87    const d = parseInt(parts[2], 10);
88    if ([y, m, d].some(n => Number.isNaN(n))) return null;
89    return new Date(y, m - 1, d);
90}
91
92/**
93 * Coerce any date-like value (Firestore Timestamp, Date, or "YYYY-MM-DD"
94 * string) into a Date. Returns null if it can't be parsed.
95 */
96function eventDateFrom(value) {
97    if (!value) return null;
98    if (value.toDate && typeof value.toDate === 'function') return value.toDate();
99    if (value instanceof Date) return value;
100    return parseLocalDate(value);
101}
102
103/**
104 * Expand each recurring event into upcoming instances over the next 3 months
105 * (same window Visit Atchison uses). The original event is REPLACED with a
106 * synthetic copy whose startDate/endDate point to the next upcoming
107 * occurrence — so weekly bingo whose anchor date is in March still appears
108 * on this week's listings without leaking the past anchor date.
109 *
110 * Every instance carries `isRecurringInstance: true` and `originalEventId`
111 * for any future need to deduplicate; today we just render the next-up copy.
112 *
113 * If the admin set a real date range (e.g. Nov 7–28) plus recurrence (Saturdays),
114 * we only count occurrences *inside* that range. Otherwise the engine would
115 * use the first matching weekday in the 3-month horizon and show e.g. May
116 * for a November-only campaign.
117 */
118function expandRecurringEvents(events) {
119    const today = todayLocal();
120    const horizon = new Date(today);
121    horizon.setMonth(horizon.getMonth() + 3);
122
123    const out = [];
124
125    events.forEach(event => {
126        if (!event.recurring || !event.recurring.frequency) {
127            out.push(event);
128            return;
129        }
130
131        const instances = computeRecurringInstances(event, today, horizon);
132
133        if (!instances.length) {
134            // No upcoming occurrences — keep original so admin can still see it
135            // if needed (will normally be filtered out as past).
136            out.push(event);
137            return;
138        }
139
140        const nextDate = instances[0];
141        let newEnd;
142        if (event.endDate && event.startDate && event.endDate.getTime() > event.startDate.getTime()) {
143            newEnd = new Date(nextDate.getTime() + (event.endDate.getTime() - event.startDate.getTime()));
144            if (newEnd.getTime() > event.endDate.getTime()) {
145                newEnd = new Date(event.endDate);
146            }
147        } else {
148            newEnd = nextDate;
149        }
150        out.push({
151            ...event,
152            startDate: nextDate,
153            endDate: newEnd,
154            nextOccurrence: nextDate,
155            // Keep recurring reference so the badge / "every Tuesday" label still works
156            recurring: event.recurring
157        });
158    });
159
160    return out;
161}
162
163/**
164 * True when the event is a multi-day span (e.g. festival week, not a single
165 * all-day on one calendar day). Used to clip recurring instance math to the
166 * publicized campaign window.
167 */
168function isMultiDayCampaignRange(event) {
169    if (!event.startDate || !event.endDate) return false;
170    const a = new Date(event.startDate);
171    a.setHours(0, 0, 0, 0);
172    const b = new Date(event.endDate);
173    b.setHours(0, 0, 0, 0);
174    return b.getTime() > a.getTime();
175}
176
177/**
178 * Drop generated recurrence dates outside [startDate, endDate] so e.g. weekly
179 * Saturdays in November do not get replaced by the first Saturday in May.
180 */
181function filterRecurringInstancesToEventRange(event, instances) {
182    if (!instances.length || !isMultiDayCampaignRange(event)) {
183        return instances;
184    }
185    const rangeStart = new Date(event.startDate);
186    rangeStart.setHours(0, 0, 0, 0);
187    const rangeEnd = new Date(event.endDate);
188    rangeEnd.setHours(23, 59, 59, 999);
189    return instances
190        .filter(d => d.getTime() >= rangeStart.getTime() && d.getTime() <= rangeEnd.getTime())
191        .sort((a, b) => a - b);
192}
193
194/* Coerce recurring config to safe values so bad data (a weekday stored as the
195   string "1", an out-of-range value, an invalid startDate, or a zero/negative
196   weekOfMonth) can never spin one of the loops below forever and hang the
197   page (RESULT_CODE_HUNG). Mirrors Visit Atchison's expandRecurringEvents. */
198const toWeekday = (v, def = 0) => {
199    const n = Math.trunc(Number(v));
200    return Number.isFinite(n) && n >= 0 && n <= 6 ? n : def;
201};
202const toInt = (v, def) => {
203    const n = Math.trunc(Number(v));
204    return Number.isFinite(n) ? n : def;
205};
206const safeDate = (v, fallback) => {
207    const d = new Date(v);
208    return isNaN(d.getTime()) ? new Date(fallback) : d;
209};
210// weekday may be a single value (0-6) or a comma-separated list ("1,3,5")
211// for multi-day weekly events. Returns the valid weekdays (possibly empty).
212const parseWeekdays = (v) => {
213    const list = String(v ?? '').split(',')
214        .map(s => s.trim()).filter(s => s !== '')
215        .map(s => Math.trunc(Number(s)))
216        .filter(n => Number.isFinite(n) && n >= 0 && n <= 6);
217    return [...new Set(list)];
218};
219
220function computeRecurringInstances(event, today, horizon) {
221    const recurring = event.recurring;
222    const baseDate = safeDate(event.startDate || today, today);
223    const instances = [];
224
225    if (recurring.frequency === 'weekly') {
226        // No stored weekday → the anchor date's weekday (previous behavior).
227        // A stored value must parse to 0-6 (or a list of them) or it's ignored.
228        let weekdays = parseWeekdays(recurring.weekday);
229        if (!weekdays.length) weekdays = [toWeekday(baseDate.getDay(), 0)];
230        const cursor = new Date(today);
231        // Walk day-by-day through the 3-month horizon (~92 days) capturing the
232        // matching weekdays; the guard keeps the loop finite whatever the data.
233        let guard = 0;
234        while (cursor <= horizon && guard++ < 200) {
235            if (weekdays.includes(cursor.getDay())) {
236                instances.push(new Date(cursor));
237            }
238            cursor.setDate(cursor.getDate() + 1);
239        }
240    } else if (recurring.frequency === 'monthly-date') {
241        const targetDay = Math.max(1, toInt(recurring.dayOfMonth, baseDate.getDate()) || 1);
242        const cursor = new Date(today.getFullYear(), today.getMonth(), 1);
243        for (let i = 0; i < 4; i++) {
244            const month = new Date(cursor);
245            const daysInMonth = new Date(month.getFullYear(), month.getMonth() + 1, 0).getDate();
246            const day = Math.min(targetDay, daysInMonth);
247            const candidate = new Date(month.getFullYear(), month.getMonth(), day);
248            if (candidate >= today && candidate <= horizon) {
249                instances.push(candidate);
250            }
251            cursor.setMonth(cursor.getMonth() + 1);
252        }
253    } else if (recurring.frequency === 'monthly-day') {
254        const targetWeekday = toWeekday(recurring.weekday, 0);
255        const weekOfMonth = toInt(recurring.weekOfMonth, 1) || 1; // -1 = last
256        const cursor = new Date(today.getFullYear(), today.getMonth(), 1);
257        for (let i = 0; i < 4; i++) {
258            const month = new Date(cursor);
259            let candidate;
260            if (weekOfMonth === -1) {
261                candidate = new Date(month.getFullYear(), month.getMonth() + 1, 0);
262                let guard = 0;
263                while (candidate.getDay() !== targetWeekday && guard++ < 7) {
264                    candidate.setDate(candidate.getDate() - 1);
265                }
266            } else {
267                candidate = new Date(month.getFullYear(), month.getMonth(), 1);
268                let count = 0;
269                let guard = 0;
270                while (count < weekOfMonth && guard++ < 366) {
271                    if (candidate.getDay() === targetWeekday) {
272                        count++;
273                        if (count === weekOfMonth) break;
274                    }
275                    candidate.setDate(candidate.getDate() + 1);
276                }
277            }
278            if (candidate >= today && candidate <= horizon) {
279                instances.push(candidate);
280            }
281            cursor.setMonth(cursor.getMonth() + 1);
282        }
283    }
284
285    return filterRecurringInstancesToEventRange(event, instances);
286}
287
288/** Human-readable "every Tuesday" / "First Saturday" / "15th of the month" label. */
289function getRecurringText(recurring) {
290    if (!recurring || !recurring.frequency) return '';
291    const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
292    const ordinals = { 1: '1st', 2: '2nd', 3: '3rd', 4: '4th' };
293    const dayWithSuffix = (n) => {
294        if (n >= 11 && n <= 13) return `${n}th`;
295        switch (n % 10) {
296            case 1: return `${n}st`;
297            case 2: return `${n}nd`;
298            case 3: return `${n}rd`;
299            default: return `${n}th`;
300        }
301    };
302
303    switch (recurring.frequency) {
304        case 'weekly':
305            return `Every ${days[recurring.weekday ?? 0]}`;
306        case 'monthly-date':
307            return `${dayWithSuffix(recurring.dayOfMonth ?? 1)} of every month`;
308        case 'monthly-day': {
309            const weekLabel = recurring.weekOfMonth === -1 ? 'Last' : (ordinals[recurring.weekOfMonth] || '1st');
310            return `${weekLabel} ${days[recurring.weekday ?? 0]} of every month`;
311        }
312        default:
313            return '';
314    }
315}
316
317/**
318 * For events that started before today but extend past today (multi-day
319 * festivals etc.), use today as the effective sort/grouping date so they
320 * show up in the current month, not the historical month.
321 */
322function effectiveStartDate(event) {
323    const today = todayLocal();
324    if (event.startDate && event.startDate < today && event.endDate && event.endDate >= today) {
325        return today;
326    }
327    return event.startDate;
328}
329
330/**
331 * Flyer / cover image URL from Firestore (field names have varied in admin
332 * and exports: flyerUrl, imageUrl, nested objects, etc.).
333 */
334function eventImageUrl(e) {
335    if (!e || typeof e !== 'object') return '';
336    const asString = (v) => (typeof v === 'string' && v.trim() ? v.trim() : '');
337    const candidates = [
338        e.flyerUrl, e.flyerURL, e.flyer, e.flyerImage,
339        e.image, e.imageUrl, e.imageURL, e.photo, e.photoUrl, e.photoURL,
340        e.coverImage, e.coverUrl, e.posterUrl, e.eventImage, e.eventImageUrl
341    ];
342    for (const c of candidates) {
343        if (asString(c)) return asString(c);
344        if (c && typeof c === 'object' && asString(c.url)) return asString(c.url);
345        if (c && typeof c === 'object' && asString(c.src)) return asString(c.src);
346    }
347    return '';
348}
349
350// ==========================================
351// DATA LOADING
352// ==========================================
353
354async function loadEvents() {
355    try {
356        const eventsRef = collection(db, COLLECTIONS.EVENTS);
357        const q = query(eventsRef, where('status', '==', 'published'));
358        const snapshot = await getDocs(q);
359
360        const today = todayLocal();
361
362        const list = [];
363        snapshot.forEach(d => {
364            const data = d.data();
365            // Per-site visibility: only skip if explicitly hidden from chamber.
366            if (data.siteVisibility && data.siteVisibility.chamber === false) return;
367
368            const startDate = eventDateFrom(data.startDate);
369            const endDate = eventDateFrom(data.endDate) || startDate;
370            const isRecurring = !!(data.recurring && data.recurring.frequency);
371
372            // Recurring events always stay in the pool — expansion below picks
373            // the next occurrence. Non-recurring events that have already
374            // ended are dropped here.
375            if (!isRecurring) {
376                if (!endDate) return;
377                if (endDate < today) return;
378            }
379
380            list.push({
381                id: d.id,
382                ...data,
383                startDate,
384                endDate
385            });
386        });
387
388        // Replace recurring events with their next-occurrence projection so
389        // they sort and group correctly (matches Visit Atchison admin).
390        const expanded = expandRecurringEvents(list);
391
392        // Final filter: drop anything still in the past after expansion
393        // (recurring with no upcoming instance).
394        const upcoming = expanded.filter(e => {
395            const end = e.endDate || e.startDate;
396            return end && end >= today;
397        });
398
399        upcoming.sort((a, b) => {
400            const aT = effectiveStartDate(a)?.getTime?.() || 0;
401            const bT = effectiveStartDate(b)?.getTime?.() || 0;
402            if (aT !== bT) return aT - bT;
403            // Same date — order by time
404            const aTime = a.startTime || '23:59';
405            const bTime = b.startTime || '23:59';
406            return String(aTime).localeCompare(String(bTime));
407        });
408
409        allEvents = upcoming;
410        filteredEvents = [...allEvents];
411
412        const loadingMsg = document.getElementById('events-loading');
413        if (loadingMsg) loadingMsg.remove();
414
415        showFeaturedEvent();
416        if (currentView === 'list') {
417            renderEventsList();
418        } else {
419            renderCalendar();
420        }
421    } catch (error) {
422        console.error('Error loading events:', error);
423        showError('Unable to load events. Please try again later.');
424    }
425}
426
427// ==========================================
428// FEATURED EVENT
429// ==========================================
430
431function showFeaturedEvent() {
432    const featuredContainer = document.getElementById('featured-event');
433    if (!featuredContainer) return;
434
435    // Pick the SOONEST upcoming featured event so what you see in the banner
436    // matches what's coming next (the previous code took the first match in
437    // the array, which could surface a late-summer event ahead of next week).
438    const featuredCandidates = allEvents.filter(e => e.featured || e.isFeatured);
439    const featured = featuredCandidates[0] || allEvents[0];
440
441    if (!featured) {
442        featuredContainer.style.display = 'none';
443        return;
444    }
445
446    featuredContainer.style.display = 'grid';
447
448    const img = document.getElementById('featured-event-image');
449    const imgSrc = eventImageUrl(featured);
450    if (imgSrc) {
451        img.src = imgSrc;
452        img.alt = featured.title || 'Event';
453        img.style.display = '';
454    } else {
455        img.removeAttribute('src');
456        img.alt = '';
457        img.style.display = 'none';
458    }
459
460    document.getElementById('featured-event-title').textContent = featured.title || 'Event';
461    document.getElementById('featured-event-description').textContent =
462        featured.shortDescription || featured.description || '';
463    document.getElementById('featured-event-date').textContent =
464        formatEventDateTimeLine(featured);
465
466    const locationWrap = document.getElementById('featured-event-location-wrap');
467    const locationEl = document.getElementById('featured-event-location');
468    const locText = locationLine(featured);
469    if (locText) {
470        locationWrap.style.display = 'flex';
471        locationEl.textContent = locText;
472    } else {
473        locationWrap.style.display = 'none';
474    }
475
476    const linkEl = document.getElementById('featured-event-link');
477    linkEl.onclick = (e) => {
478        e.preventDefault();
479        openEventModal(featured.id);
480    };
481}
482
483// ==========================================
484// LIST VIEW
485// ==========================================
486
487function renderEventsList() {
488    const existing = eventsList.querySelectorAll('.event-card, .no-results, .section-title');
489    existing.forEach(el => el.remove());
490
491    if (filteredEvents.length === 0) {
492        eventsList.innerHTML = `
493            <div class="no-results">
494                <svg class="no-results__icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
495                    <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
496                </svg>
497                <h3 class="no-results__title">No Events Found</h3>
498                <p class="no-results__text">There are no upcoming events matching your criteria.</p>
499                <button class="btn btn--primary" onclick="window.clearFilters()">Clear Filters</button>
500            </div>
501        `;
502        return;
503    }
504
505    const eventsByMonth = groupEventsByMonth(filteredEvents);
506
507    eventsByMonth.forEach((events, monthKey) => {
508        const header = document.createElement('h3');
509        header.className = 'section-title';
510        header.style.marginTop = 'var(--space-8)';
511        header.style.marginBottom = 'var(--space-4)';
512        header.textContent = monthKey;
513        eventsList.appendChild(header);
514
515        events.forEach(event => {
516            const card = createEventCard(event);
517            eventsList.appendChild(card);
518        });
519    });
520}
521
522function createEventCard(event) {
523    const card = document.createElement('article');
524    card.className = 'event-card';
525    card.dataset.eventId = event.id;
526
527    const date = effectiveStartDate(event) || event.startDate;
528    const monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
529    const cat = categoryInfo(event.category);
530    const timeLine = formatTimeRange(event);
531    const locText = locationLine(event);
532    const description = event.shortDescription || event.description || '';
533    const recurringText = getRecurringText(event.recurring);
534    const thumbSrc = eventImageUrl(event);
535
536    card.innerHTML = `
537        <div class="event-card__date">
538            <span class="event-card__month">${monthNames[date.getMonth()]}</span>
539            <span class="event-card__day">${date.getDate()}</span>
540            <span class="event-card__year">${date.getFullYear()}</span>
541        </div>
542        ${thumbSrc ? `
543            <div class="event-card__thumb-wrap">
544                <img src="${escapeAttr(thumbSrc)}" alt="${escapeAttr(event.title || 'Event')}" class="event-card__thumb" width="120" height="150" loading="lazy" decoding="async" />
545            </div>
546        ` : ''}
547        <div class="event-card__content">
548            <span class="event-card__category">
549                ${cat.icon} ${escapeHtml(cat.label)}
550                ${recurringText ? `<span class="event-card__recurring" title="Recurring event">↻ ${escapeHtml(recurringText)}</span>` : ''}
551            </span>
552            <h3 class="event-card__title">${escapeHtml(event.title || 'Event')}</h3>
553            ${description ? `<p class="event-card__description">${escapeHtml(description)}</p>` : ''}
554            <div class="event-card__meta">
555                ${timeLine ? `
556                    <div class="event-card__meta-item">
557                        <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
558                            <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
559                        </svg>
560                        <span>${escapeHtml(timeLine)}</span>
561                    </div>
562                ` : ''}
563                ${locText ? `
564                    <div class="event-card__meta-item">
565                        <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
566                            <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"/>
567                            <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"/>
568                        </svg>
569                        <span>${escapeHtml(locText)}</span>
570                    </div>
571                ` : ''}
572            </div>
573        </div>
574        <div class="event-card__actions">
575            <button class="btn btn--primary" onclick="window.openEventModal('${event.id}')">
576                View Details
577            </button>
578            ${event.registrationUrl ? `
579                <a href="${escapeAttr(event.registrationUrl)}" target="_blank" rel="noopener" class="btn btn--outline">
580                    Register
581                </a>
582            ` : ''}
583        </div>
584    `;
585
586    return card;
587}
588
589// ==========================================
590// CALENDAR VIEW
591// ==========================================
592
593function renderCalendar() {
594    const year = currentMonth.getFullYear();
595    const month = currentMonth.getMonth();
596
597    const monthNames = ['January', 'February', 'March', 'April', 'May', 'June',
598                        'July', 'August', 'September', 'October', 'November', 'December'];
599    calendarMonthTitle.textContent = `${monthNames[month]} ${year}`;
600
601    calendarGrid.innerHTML = '';
602
603    const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
604    dayNames.forEach(day => {
605        const header = document.createElement('div');
606        header.className = 'calendar-day-header';
607        header.textContent = day;
608        calendarGrid.appendChild(header);
609    });
610
611    const firstDay = new Date(year, month, 1);
612    const lastDay = new Date(year, month + 1, 0);
613    const startDayOfWeek = firstDay.getDay();
614    const totalDays = lastDay.getDate();
615
616    const monthEvents = filteredEvents.filter(e => {
617        const d = effectiveStartDate(e) || e.startDate;
618        return d && d.getMonth() === month && d.getFullYear() === year;
619    });
620
621    const eventsByDay = {};
622    monthEvents.forEach(event => {
623        const d = effectiveStartDate(event) || event.startDate;
624        const day = d.getDate();
625        if (!eventsByDay[day]) eventsByDay[day] = [];
626        eventsByDay[day].push(event);
627    });
628
629    const prevMonth = new Date(year, month, 0);
630    const prevMonthDays = prevMonth.getDate();
631    for (let i = startDayOfWeek - 1; i >= 0; i--) {
632        const dayEl = document.createElement('div');
633        dayEl.className = 'calendar-day calendar-day--other-month';
634        dayEl.innerHTML = `<span class="calendar-day__number">${prevMonthDays - i}</span>`;
635        calendarGrid.appendChild(dayEl);
636    }
637
638    const today = new Date();
639    for (let day = 1; day <= totalDays; day++) {
640        const isToday = day === today.getDate() && month === today.getMonth() && year === today.getFullYear();
641        const dayEl = document.createElement('div');
642        dayEl.className = `calendar-day${isToday ? ' calendar-day--today' : ''}`;
643
644        let eventsHtml = '';
645        const dayEvents = eventsByDay[day] || [];
646        const maxVisible = 3;
647
648        dayEvents.slice(0, maxVisible).forEach(event => {
649            const cls = `calendar-event--${event.category || 'community'}`;
650            const recurMark = event.recurring && event.recurring.frequency ? '↻ ' : '';
651            eventsHtml += `
652                <div class="calendar-event ${cls}" onclick="window.openEventModal('${event.id}')" title="${escapeAttr(event.title || 'Event')}">
653                    ${recurMark}${escapeHtml(event.title || 'Event')}
654                </div>
655            `;
656        });
657
658        if (dayEvents.length > maxVisible) {
659            eventsHtml += `<div class="calendar-day__more">+${dayEvents.length - maxVisible} more</div>`;
660        }
661
662        dayEl.innerHTML = `
663            <span class="calendar-day__number">${day}</span>
664            ${eventsHtml}
665        `;
666        calendarGrid.appendChild(dayEl);
667    }
668
669    const totalCells = calendarGrid.children.length;
670    const remainingCells = 42 - totalCells;
671    for (let day = 1; day <= remainingCells; day++) {
672        const dayEl = document.createElement('div');
673        dayEl.className = 'calendar-day calendar-day--other-month';
674        dayEl.innerHTML = `<span class="calendar-day__number">${day}</span>`;
675        calendarGrid.appendChild(dayEl);
676    }
677}
678
679// ==========================================
680// VIEW + NAV
681// ==========================================
682
683function initViewToggle() {
684    listViewBtn.addEventListener('click', () => {
685        currentView = 'list';
686        listViewBtn.classList.add('view-toggle__btn--active');
687        calendarViewBtn.classList.remove('view-toggle__btn--active');
688        eventsList.classList.remove('events-list--hidden');
689        calendarView.classList.add('events-list--hidden');
690        renderEventsList();
691    });
692
693    calendarViewBtn.addEventListener('click', () => {
694        currentView = 'calendar';
695        calendarViewBtn.classList.add('view-toggle__btn--active');
696        listViewBtn.classList.remove('view-toggle__btn--active');
697        calendarView.classList.remove('events-list--hidden');
698        eventsList.classList.add('events-list--hidden');
699
700        // Always snap to today's month when re-entering the calendar so the
701        // user never sees a stale month from a previous session.
702        currentMonth = todayMonthAnchor();
703        renderCalendar();
704    });
705}
706
707function initCalendarNav() {
708    document.getElementById('prev-month').addEventListener('click', () => {
709        currentMonth.setMonth(currentMonth.getMonth() - 1);
710        renderCalendar();
711    });
712
713    document.getElementById('next-month').addEventListener('click', () => {
714        currentMonth.setMonth(currentMonth.getMonth() + 1);
715        renderCalendar();
716    });
717
718    const todayBtn = document.getElementById('calendar-today-btn');
719    if (todayBtn) {
720        todayBtn.addEventListener('click', () => {
721            currentMonth = todayMonthAnchor();
722            renderCalendar();
723        });
724    }
725}
726
727// ==========================================
728// FILTERS
729// ==========================================
730
731function initFilters() {
732    let searchTimeout;
733    searchInput.addEventListener('input', (e) => {
734        clearTimeout(searchTimeout);
735        searchTimeout = setTimeout(() => {
736            currentFilters.search = e.target.value.toLowerCase().trim();
737            applyFilters();
738        }, 300);
739    });
740
741    categoryFilter.addEventListener('change', (e) => {
742        currentFilters.category = e.target.value;
743        applyFilters();
744    });
745}
746
747function applyFilters() {
748    filteredEvents = allEvents.filter(event => {
749        if (currentFilters.search) {
750            const fields = [
751                event.title,
752                event.description,
753                event.shortDescription,
754                event.venue,
755                event.address,
756                event.location, // legacy
757                event.category,
758                Array.isArray(event.tags) ? event.tags.join(' ') : ''
759            ].filter(Boolean).join(' ').toLowerCase();
760            if (!fields.includes(currentFilters.search)) return false;
761        }
762
763        if (currentFilters.category && event.category !== currentFilters.category) {
764            return false;
765        }
766
767        return true;
768    });
769
770    if (currentView === 'list') {
771        renderEventsList();
772    } else {
773        renderCalendar();
774    }
775}
776
777window.clearFilters = function () {
778    currentFilters = { search: '', category: '' };
779    if (searchInput) searchInput.value = '';
780    if (categoryFilter) categoryFilter.value = '';
781    filteredEvents = [...allEvents];
782
783    if (currentView === 'list') {
784        renderEventsList();
785    } else {
786        renderCalendar();
787    }
788};
789
790// ==========================================
791// MODAL
792// ==========================================
793
794function initModal() {
795    const closeBtn = document.getElementById('event-modal-close');
796    closeBtn.addEventListener('click', closeEventModal);
797
798    eventModal.addEventListener('click', (e) => {
799        if (e.target === eventModal) closeEventModal();
800    });
801
802    document.addEventListener('keydown', (e) => {
803        if (e.key === 'Escape' && eventModal.classList.contains('event-modal--open')) {
804            closeEventModal();
805        }
806    });
807}
808
809window.openEventModal = function (eventId) {
810    const event = allEvents.find(e => e.id === eventId);
811    if (!event) return;
812
813    const modalContent = document.getElementById('event-modal-content');
814    const cat = categoryInfo(event.category);
815    const dateLine = formatEventDateLine(event);
816    const timeLine = formatTimeRange(event);
817    const locText = locationLine(event);
818    const imgSrc = eventImageUrl(event);
819    const longDescription = event.description || '';
820    const recurringText = getRecurringText(event.recurring);
821
822    modalContent.innerHTML = `
823        <button class="event-modal__close" id="event-modal-close-inner" aria-label="Close modal">
824            <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="none" viewBox="0 0 24 24" stroke="currentColor">
825                <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
826            </svg>
827        </button>
828
829        ${imgSrc ? `
830            <div class="event-modal__media">
831                <img src="${escapeAttr(imgSrc)}" alt="${escapeAttr(event.title || 'Event')}" class="event-modal__image">
832            </div>
833        ` : ''}
834
835        <div class="event-modal__body">
836            <span class="event-modal__category">
837                ${cat.icon} ${escapeHtml(cat.label)}
838                ${recurringText ? `<span class="event-modal__recurring">↻ ${escapeHtml(recurringText)}</span>` : ''}
839            </span>
840            <h2 class="event-modal__title">${escapeHtml(event.title || 'Event')}</h2>
841
842            <div class="event-modal__meta">
843                <div class="event-modal__meta-item">
844                    <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
845                        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
846                    </svg>
847                    <span>${escapeHtml(dateLine)}</span>
848                </div>
849                ${timeLine ? `
850                    <div class="event-modal__meta-item">
851                        <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
852                            <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
853                        </svg>
854                        <span>${escapeHtml(timeLine)}</span>
855                    </div>
856                ` : ''}
857                ${locText ? `
858                    <div class="event-modal__meta-item">
859                        <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
860                            <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"/>
861                            <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"/>
862                        </svg>
863                        <span>${escapeHtml(locText)}</span>
864                    </div>
865                ` : ''}
866                ${event.cost ? `
867                    <div class="event-modal__meta-item">
868                        <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
869                            <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
870                        </svg>
871                        <span>${escapeHtml(event.cost)}</span>
872                    </div>
873                ` : ''}
874            </div>
875
876            ${longDescription ? `
877                <div class="event-modal__description">
878                    <p>${escapeHtml(longDescription)}</p>
879                </div>
880            ` : ''}
881
882            <div class="event-modal__actions">
883                ${event.registrationUrl ? `
884                    <a href="${escapeAttr(event.registrationUrl)}" target="_blank" rel="noopener" class="btn btn--primary">
885                        Register Now
886                    </a>
887                ` : ''}
888                ${locText ? `
889                    <a href="https://maps.google.com/?q=${encodeURIComponent(locText)}" target="_blank" rel="noopener" class="btn btn--outline">
890                        Get Directions
891                    </a>
892                ` : ''}
893                <button class="btn btn--outline" onclick="window.addToCalendar('${event.id}')">
894                    Add to Calendar
895                </button>
896            </div>
897        </div>
898    `;
899
900    document.getElementById('event-modal-close-inner').addEventListener('click', closeEventModal);
901    eventModal.classList.add('event-modal--open');
902    document.body.style.overflow = 'hidden';
903};
904
905function closeEventModal() {
906    eventModal.classList.remove('event-modal--open');
907    document.body.style.overflow = '';
908}
909
910// ==========================================
911// ADD TO CALENDAR (device-aware)
912// ==========================================
913//
914// Goal: do the *right* thing automatically per device, instead of always
915// dumping a .ics file the user has to figure out what to do with.
916//
917//   iOS / iPadOS / macOS  →  download .ics  (Apple Calendar imports natively)
918//   Android               →  open Google Calendar event URL  (opens app or web)
919//   Windows / Linux       →  popover menu (Google / Outlook.com / Microsoft 365 /
920//                            Apple Calendar .ics / Yahoo Calendar)
921//
922// Centralised so the same `addToCalendar(id)` button keeps working from the
923// modal — no more guessing whether a downloaded .ics will help an Android user.
924
925/** Tiny UA sniff. Good enough for routing add-to-calendar UX. */
926function detectCalendarPlatform() {
927    if (typeof navigator === 'undefined') return 'desktop';
928    const ua = navigator.userAgent || '';
929    const isIPadOS = /Macintosh/.test(ua) && navigator.maxTouchPoints > 1; // iPadOS lies as Mac
930    const isIOS = /iPhone|iPad|iPod/.test(ua) || isIPadOS;
931    const isMac = /Macintosh/.test(ua) && !isIPadOS;
932    const isAndroid = /Android/.test(ua);
933    if (isIOS || isMac) return 'apple';
934    if (isAndroid) return 'android';
935    return 'desktop';
936}
937
938/** Resolve start/end to real Date objects, defaulting to a 1-hour event. */
939function resolveEventTimes(event) {
940    const start = combineDateAndTime(event.startDate, event.startTime) || event.startDate;
941    let end = combineDateAndTime(event.endDate || event.startDate, event.endTime || event.startTime);
942    if (!end || end <= start) {
943        end = new Date(start.getTime() + 60 * 60 * 1000);
944    }
945    return { start, end };
946}
947
948/** Compact UTC stamp e.g. 20260601T140000Z used by Google/Yahoo. */
949function formatICSDate(date) {
950    return date.toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z';
951}
952
953/** ISO with a 'Z' suffix used by Outlook deeplinks. */
954function formatOutlookDate(date) {
955    return date.toISOString().split('.')[0] + 'Z';
956}
957
958function icsEscape(text) {
959    return String(text || '')
960        .replace(/\\/g, '\\\\')
961        .replace(/\n/g, '\\n')
962        .replace(/,/g, '\\,')
963        .replace(/;/g, '\\;');
964}
965
966/** Build the per-provider deeplink URLs. */
967function buildCalendarLinks(event) {
968    const { start, end } = resolveEventTimes(event);
969    const title = event.title || 'Event';
970    const description = event.description || event.shortDescription || '';
971    const location = locationLine(event) || '';
972
973    return {
974        google:
975            'https://calendar.google.com/calendar/render?action=TEMPLATE'
976            + `&text=${encodeURIComponent(title)}`
977            + `&dates=${formatICSDate(start)}/${formatICSDate(end)}`
978            + `&details=${encodeURIComponent(description)}`
979            + `&location=${encodeURIComponent(location)}`,
980        outlook:
981            'https://outlook.live.com/calendar/0/deeplink/compose'
982            + '?path=/calendar/action/compose&rru=addevent'
983            + `&subject=${encodeURIComponent(title)}`
984            + `&body=${encodeURIComponent(description)}`
985            + `&location=${encodeURIComponent(location)}`
986            + `&startdt=${encodeURIComponent(formatOutlookDate(start))}`
987            + `&enddt=${encodeURIComponent(formatOutlookDate(end))}`,
988        office365:
989            'https://outlook.office.com/calendar/0/deeplink/compose'
990            + '?path=/calendar/action/compose&rru=addevent'
991            + `&subject=${encodeURIComponent(title)}`
992            + `&body=${encodeURIComponent(description)}`
993            + `&location=${encodeURIComponent(location)}`
994            + `&startdt=${encodeURIComponent(formatOutlookDate(start))}`
995            + `&enddt=${encodeURIComponent(formatOutlookDate(end))}`,
996        yahoo:
997            'https://calendar.yahoo.com/?v=60&view=d&type=20'
998            + `&title=${encodeURIComponent(title)}`
999            + `&st=${formatICSDate(start)}`
1000            + `&et=${formatICSDate(end)}`
1001            + `&desc=${encodeURIComponent(description)}`
1002            + `&in_loc=${encodeURIComponent(location)}`,
1003        ics: () => downloadIcsFile(event, start, end)
1004    };
1005}
1006
1007/** Trigger a .ics download for native Apple Calendar / fallback consumers. */
1008function downloadIcsFile(event, start, end) {
1009    const title = event.title || 'Event';
1010    const description = event.description || event.shortDescription || '';
1011    const location = locationLine(event) || '';
1012    const safeId = (event.id || String(Date.now())).replace(/[^a-z0-9]/gi, '');
1013
1014    const ics = [
1015        'BEGIN:VCALENDAR',
1016        'VERSION:2.0',
1017        'PRODID:-//Atchison Chamber//Events//EN',
1018        'CALSCALE:GREGORIAN',
1019        'METHOD:PUBLISH',
1020        'BEGIN:VEVENT',
1021        `UID:${safeId}@atchisonkansas.net`,
1022        `DTSTAMP:${formatICSDate(new Date())}`,
1023        `DTSTART:${formatICSDate(start)}`,
1024        `DTEND:${formatICSDate(end)}`,
1025        `SUMMARY:${icsEscape(title)}`,
1026        `DESCRIPTION:${icsEscape(description)}`,
1027        `LOCATION:${icsEscape(location)}`,
1028        'END:VEVENT',
1029        'END:VCALENDAR'
1030    ].join('\r\n');
1031
1032    const blob = new Blob([ics], { type: 'text/calendar;charset=utf-8' });
1033    const url = URL.createObjectURL(blob);
1034    const a = document.createElement('a');
1035    a.href = url;
1036    a.download = `${title.replace(/[^a-z0-9]/gi, '_') || 'event'}.ics`;
1037    document.body.appendChild(a);
1038    a.click();
1039    document.body.removeChild(a);
1040    setTimeout(() => URL.revokeObjectURL(url), 1000);
1041}
1042
1043window.addToCalendar = function (eventId) {
1044    const event = allEvents.find(e => e.id === eventId);
1045    if (!event) return;
1046
1047    const platform = detectCalendarPlatform();
1048    const links = buildCalendarLinks(event);
1049
1050    if (platform === 'apple') {
1051        // iPhone / iPad / Mac → tap once → Apple Calendar opens with the event
1052        links.ics();
1053    } else if (platform === 'android') {
1054        // Android → opens the Google Calendar app if installed, web otherwise
1055        window.open(links.google, '_blank', 'noopener');
1056    } else {
1057        // Windows / Linux desktop → user picks their calendar provider
1058        showAddToCalendarMenu(event, links);
1059    }
1060};
1061
1062function showAddToCalendarMenu(event, links) {
1063    document.getElementById('add-to-calendar-popover')?.remove();
1064
1065    const overlay = document.createElement('div');
1066    overlay.id = 'add-to-calendar-popover';
1067    overlay.className = 'atc-popover';
1068    overlay.setAttribute('role', 'dialog');
1069    overlay.setAttribute('aria-modal', 'true');
1070    overlay.setAttribute('aria-label', 'Add to calendar');
1071    overlay.innerHTML = `
1072        <div class="atc-popover__backdrop" data-action="cancel"></div>
1073        <div class="atc-popover__menu" role="menu">
1074            <div class="atc-popover__header">
1075                <div class="atc-popover__title">Add to Calendar</div>
1076                <div class="atc-popover__subtitle">${escapeHtml(event.title || 'Event')}</div>
1077            </div>
1078            <button class="atc-popover__option" data-action="google" role="menuitem">
1079                <span class="atc-popover__icon" aria-hidden="true">
1080                    <svg viewBox="0 0 24 24" width="22" height="22" fill="none">
1081                        <rect x="3" y="4" width="18" height="17" rx="3" fill="#fff" stroke="#e5e7eb" stroke-width="1.5"/>
1082                        <path d="M3 8h18" stroke="#e5e7eb" stroke-width="1.5"/>
1083                        <text x="12" y="17" text-anchor="middle" font-family="Arial, sans-serif" font-size="9" font-weight="700" fill="#4285F4">G</text>
1084                    </svg>
1085                </span>
1086                <span class="atc-popover__label">Google Calendar</span>
1087            </button>
1088            <button class="atc-popover__option" data-action="apple" role="menuitem">
1089                <span class="atc-popover__icon" aria-hidden="true">
1090                    <svg viewBox="0 0 24 24" width="22" height="22" fill="none">
1091                        <rect x="3" y="4" width="18" height="17" rx="3" fill="#fff" stroke="#e5e7eb" stroke-width="1.5"/>
1092                        <path d="M3 8h18" stroke="#e5e7eb" stroke-width="1.5"/>
1093                        <text x="12" y="17" text-anchor="middle" font-family="Arial, sans-serif" font-size="10" fill="#111">⌘</text>
1094                    </svg>
1095                </span>
1096                <span class="atc-popover__label">Apple Calendar <span class="atc-popover__badge">.ics</span></span>
1097            </button>
1098            <button class="atc-popover__option" data-action="outlook" role="menuitem">
1099                <span class="atc-popover__icon" aria-hidden="true">
1100                    <svg viewBox="0 0 24 24" width="22" height="22" fill="none">
1101                        <rect x="3" y="4" width="18" height="17" rx="3" fill="#0078D4"/>
1102                        <text x="12" y="17" text-anchor="middle" font-family="Arial, sans-serif" font-size="10" font-weight="700" fill="#fff">O</text>
1103                    </svg>
1104                </span>
1105                <span class="atc-popover__label">Outlook.com</span>
1106            </button>
1107            <button class="atc-popover__option" data-action="office365" role="menuitem">
1108                <span class="atc-popover__icon" aria-hidden="true">
1109                    <svg viewBox="0 0 24 24" width="22" height="22" fill="none">
1110                        <rect x="3" y="4" width="18" height="17" rx="3" fill="#D83B01"/>
1111                        <text x="12" y="17" text-anchor="middle" font-family="Arial, sans-serif" font-size="9" font-weight="700" fill="#fff">365</text>
1112                    </svg>
1113                </span>
1114                <span class="atc-popover__label">Microsoft 365</span>
1115            </button>
1116            <button class="atc-popover__option" data-action="yahoo" role="menuitem">
1117                <span class="atc-popover__icon" aria-hidden="true">
1118                    <svg viewBox="0 0 24 24" width="22" height="22" fill="none">
1119                        <rect x="3" y="4" width="18" height="17" rx="3" fill="#6001D2"/>
1120                        <text x="12" y="17" text-anchor="middle" font-family="Arial, sans-serif" font-size="10" font-weight="700" fill="#fff">Y!</text>
1121                    </svg>
1122                </span>
1123                <span class="atc-popover__label">Yahoo Calendar</span>
1124            </button>
1125            <button class="atc-popover__cancel" data-action="cancel">Cancel</button>
1126        </div>
1127    `;
1128    document.body.appendChild(overlay);
1129    document.body.style.overflow = 'hidden';
1130    requestAnimationFrame(() => overlay.classList.add('atc-popover--open'));
1131
1132    function closeMenu() {
1133        overlay.classList.remove('atc-popover--open');
1134        document.body.style.overflow = eventModal?.classList.contains('event-modal--open') ? 'hidden' : '';
1135        setTimeout(() => overlay.remove(), 180);
1136        document.removeEventListener('keydown', escClose);
1137    }
1138
1139    function escClose(ev) {
1140        if (ev.key === 'Escape') {
1141            ev.stopPropagation();
1142            closeMenu();
1143        }
1144    }
1145
1146    overlay.addEventListener('click', (ev) => {
1147        const action = ev.target.closest('[data-action]')?.dataset?.action;
1148        if (!action) return;
1149        if (action === 'cancel') {
1150            closeMenu();
1151            return;
1152        }
1153        switch (action) {
1154            case 'google':    window.open(links.google,    '_blank', 'noopener'); break;
1155            case 'outlook':   window.open(links.outlook,   '_blank', 'noopener'); break;
1156            case 'office365': window.open(links.office365, '_blank', 'noopener'); break;
1157            case 'yahoo':     window.open(links.yahoo,     '_blank', 'noopener'); break;
1158            case 'apple':     links.ics(); break;
1159        }
1160        closeMenu();
1161    });
1162
1163    document.addEventListener('keydown', escClose);
1164    overlay.querySelector('.atc-popover__option')?.focus();
1165}
1166
1167// ==========================================
1168// HELPERS
1169// ==========================================
1170
1171function groupEventsByMonth(events) {
1172    const groups = new Map();
1173    const monthNames = ['January', 'February', 'March', 'April', 'May', 'June',
1174                        'July', 'August', 'September', 'October', 'November', 'December'];
1175
1176    events.forEach(event => {
1177        const d = effectiveStartDate(event) || event.startDate;
1178        if (!d) return;
1179        const key = `${monthNames[d.getMonth()]} ${d.getFullYear()}`;
1180        if (!groups.has(key)) groups.set(key, []);
1181        groups.get(key).push(event);
1182    });
1183
1184    return groups;
1185}
1186
1187function formatEventDateLine(event) {
1188    const opts = { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' };
1189    if (!event.startDate) return '';
1190    const startStr = event.startDate.toLocaleDateString('en-US', opts);
1191    if (!event.endDate || event.startDate.toDateString() === event.endDate.toDateString()) {
1192        return startStr;
1193    }
1194    return `${startStr} – ${event.endDate.toLocaleDateString('en-US', opts)}`;
1195}
1196
1197function formatEventDateTimeLine(event) {
1198    const datePart = formatEventDateLine(event);
1199    const timePart = formatTimeRange(event);
1200    return [datePart, timePart].filter(Boolean).join(' • ');
1201}
1202
1203/**
1204 * Convert a 24-hour ("18:30"), 12-hour ("6:30 PM"), or already-formatted
1205 * time string into "h:mm AM/PM". Returns the original string if it can't
1206 * be confidently parsed, so legacy free-form values still display.
1207 */
1208function formatTime12(time) {
1209    if (!time) return '';
1210    if (/AM|PM/i.test(time)) {
1211        return time.replace(/am|pm/i, m => m.toUpperCase());
1212    }
1213    const m = String(time).match(/^(\d{1,2}):(\d{2})/);
1214    if (!m) return time;
1215    let h = parseInt(m[1], 10);
1216    const minutes = m[2];
1217    if (Number.isNaN(h)) return time;
1218    const ampm = h >= 12 ? 'PM' : 'AM';
1219    h = h % 12 || 12;
1220    return `${h}:${minutes} ${ampm}`;
1221}
1222
1223function formatTimeRange(event) {
1224    const start = formatTime12(event.startTime);
1225    const end = formatTime12(event.endTime);
1226    if (start && end && end !== start) return `${start} – ${end}`;
1227    return start || end || '';
1228}
1229
1230function locationLine(event) {
1231    const venueAddress = [];
1232    if (event.venue) venueAddress.push(event.venue);
1233    if (event.address) venueAddress.push(event.address);
1234    if (venueAddress.length) return venueAddress.join(' · ');
1235    return event.location || '';
1236}
1237
1238function categoryInfo(id) {
1239    const found = EVENT_CATEGORIES.find(c => c.id === id);
1240    if (found) return found;
1241    return { id: id || 'event', label: id ? id[0].toUpperCase() + id.slice(1) : 'Event', icon: '📅' };
1242}
1243
1244function combineDateAndTime(date, time) {
1245    if (!date) return null;
1246    if (!time) return new Date(date);
1247    const m24 = String(time).match(/^(\d{1,2}):(\d{2})/);
1248    const ampm = String(time).match(/(AM|PM)/i);
1249    if (!m24) return new Date(date);
1250    let h = parseInt(m24[1], 10);
1251    const min = parseInt(m24[2], 10);
1252    if (ampm) {
1253        const isPm = ampm[1].toUpperCase() === 'PM';
1254        if (isPm && h < 12) h += 12;
1255        if (!isPm && h === 12) h = 0;
1256    }
1257    const out = new Date(date);
1258    out.setHours(h, min, 0, 0);
1259    return out;
1260}
1261
1262function escapeHtml(text) {
1263    if (text == null) return '';
1264    const div = document.createElement('div');
1265    div.textContent = String(text);
1266    return div.innerHTML;
1267}
1268
1269function escapeAttr(text) {
1270    return String(text || '').replace(/"/g, '&quot;');
1271}
1272
1273function showError(message) {
1274    const loadingMsg = document.getElementById('events-loading');
1275    if (loadingMsg) loadingMsg.remove();
1276
1277    eventsList.innerHTML = `
1278        <div class="no-results">
1279            <svg class="no-results__icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
1280                <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
1281            </svg>
1282            <h3 class="no-results__title">Something Went Wrong</h3>
1283            <p class="no-results__text">${escapeHtml(message)}</p>
1284            <button class="btn btn--primary" onclick="location.reload()">Try Again</button>
1285        </div>
1286    `;
1287}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.