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, '"'); 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.