Proof of concept for the other one
at main 285 lines 8.5 kB view raw
1import type { CalendarEvent } from "./parser"; 2 3const DAY_NAMES = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; 4const MONTH_NAMES = [ 5 "January", "February", "March", "April", "May", "June", 6 "July", "August", "September", "October", "November", "December", 7]; 8 9export interface CalendarCallbacks { 10 onPrevMonth: () => void; 11 onNextMonth: () => void; 12 onEventClick: (event: CalendarEvent) => void; 13} 14 15export interface CalendarController { 16 /** Highlight an event chip in the calendar. Navigates to its month if needed. Returns true if month changed. */ 17 selectEvent(event: CalendarEvent | null): boolean; 18} 19 20/** 21 * Get the Monday-based day-of-week (0=Mon, 6=Sun). 22 */ 23function mondayIndex(date: Date): number { 24 return (date.getDay() + 6) % 7; 25} 26 27/** 28 * Build a map from day-of-month to events for a given month. 29 */ 30function eventsByDay(events: CalendarEvent[], year: number, month: number): Map<number, CalendarEvent[]> { 31 const map = new Map<number, CalendarEvent[]>(); 32 for (const ev of events) { 33 if (ev.date.getFullYear() === year && ev.date.getMonth() === month) { 34 const day = ev.date.getDate(); 35 if (!map.has(day)) map.set(day, []); 36 map.get(day)!.push(ev); 37 } 38 } 39 return map; 40} 41 42function createPopover(container: HTMLElement, ev: CalendarEvent, anchor: HTMLElement): HTMLElement { 43 const popover = container.createDiv({ cls: "cal-popover" }); 44 45 const titleEl = popover.createDiv({ cls: "cal-popover-title" }); 46 if (ev.url) { 47 const a = titleEl.createEl("a", { text: ev.title, href: ev.url }); 48 a.setAttr("target", "_blank"); 49 } else { 50 titleEl.setText(ev.title); 51 } 52 53 if (ev.soldOut) { 54 titleEl.createSpan({ cls: "cal-sold-out", text: " (Sold out)" }); 55 } 56 57 // Date line 58 const dateStr = ev.date.toLocaleDateString("en-US", { 59 weekday: "long", 60 month: "long", 61 day: "numeric", 62 year: "numeric", 63 }); 64 const dateLine = ev.rawTime ? `${dateStr}, ${ev.rawTime}` : dateStr; 65 popover.createDiv({ cls: "cal-popover-date", text: dateLine }); 66 67 // Venue 68 if (ev.venue) { 69 const venueEl = popover.createDiv({ cls: "cal-popover-venue" }); 70 if (ev.venueUrl) { 71 const a = venueEl.createEl("a", { text: ev.venue, href: ev.venueUrl }); 72 a.setAttr("target", "_blank"); 73 } else { 74 venueEl.setText(ev.venue); 75 } 76 if (ev.location) { 77 venueEl.appendText(", "); 78 if (ev.locationUrl) { 79 const a = venueEl.createEl("a", { text: ev.location, href: ev.locationUrl }); 80 a.setAttr("target", "_blank"); 81 } else { 82 venueEl.appendText(ev.location); 83 } 84 } 85 } 86 87 // Notes 88 if (ev.notes) { 89 popover.createDiv({ cls: "cal-popover-notes", text: ev.notes }); 90 } 91 92 // Position the popover near the anchor 93 positionPopover(popover, anchor, container); 94 95 return popover; 96} 97 98function positionPopover(popover: HTMLElement, anchor: HTMLElement, container: HTMLElement): void { 99 requestAnimationFrame(() => { 100 const anchorRect = anchor.getBoundingClientRect(); 101 const containerRect = container.getBoundingClientRect(); 102 const popoverRect = popover.getBoundingClientRect(); 103 104 let top = anchorRect.bottom - containerRect.top + 4; 105 let left = anchorRect.left - containerRect.left; 106 107 if (left + popoverRect.width > containerRect.width) { 108 left = containerRect.width - popoverRect.width - 8; 109 } 110 if (left < 0) left = 4; 111 112 if (top + popoverRect.height > containerRect.height) { 113 top = anchorRect.top - containerRect.top - popoverRect.height - 4; 114 } 115 116 popover.style.top = `${top}px`; 117 popover.style.left = `${left}px`; 118 }); 119} 120 121/** 122 * Render a calendar month grid into the container. 123 * Returns a CalendarController for programmatic interaction. 124 */ 125export function renderCalendar( 126 container: HTMLElement, 127 currentMonth: Date, 128 events: CalendarEvent[], 129 callbacks: CalendarCallbacks, 130 selectedEvent?: CalendarEvent | null, 131): CalendarController { 132 container.empty(); 133 container.addClass("cal-container"); 134 135 const year = currentMonth.getFullYear(); 136 const month = currentMonth.getMonth(); 137 138 // Header: < Month Year > 139 const header = container.createDiv({ cls: "cal-header" }); 140 const prevBtn = header.createEl("button", { cls: "cal-nav-btn", text: "\u2039" }); 141 prevBtn.addEventListener("click", callbacks.onPrevMonth); 142 header.createSpan({ cls: "cal-month-label", text: `${MONTH_NAMES[month]} ${year}` }); 143 const nextBtn = header.createEl("button", { cls: "cal-nav-btn", text: "\u203A" }); 144 nextBtn.addEventListener("click", callbacks.onNextMonth); 145 146 // Day-of-week row 147 const dowRow = container.createDiv({ cls: "cal-dow-row" }); 148 for (const name of DAY_NAMES) { 149 dowRow.createDiv({ cls: "cal-dow-cell", text: name }); 150 } 151 152 // Build grid 153 const grid = container.createDiv({ cls: "cal-grid" }); 154 const firstOfMonth = new Date(year, month, 1); 155 const daysInMonth = new Date(year, month + 1, 0).getDate(); 156 const startOffset = mondayIndex(firstOfMonth); 157 158 const dayEvents = eventsByDay(events, year, month); 159 160 // Track active popover 161 let activePopover: HTMLElement | null = null; 162 const removePopover = () => { 163 if (activePopover) { 164 activePopover.remove(); 165 activePopover = null; 166 } 167 }; 168 169 // Track chips by event title+date for programmatic selection 170 const chipMap = new Map<string, HTMLElement>(); 171 172 function eventKey(ev: CalendarEvent): string { 173 return `${ev.title}::${ev.date.getTime()}`; 174 } 175 176 // Leading blank cells 177 for (let i = 0; i < startOffset; i++) { 178 grid.createDiv({ cls: "cal-cell cal-cell-empty" }); 179 } 180 181 // Day cells 182 for (let day = 1; day <= daysInMonth; day++) { 183 const cell = grid.createDiv({ cls: "cal-cell" }); 184 cell.createDiv({ cls: "cal-day-number", text: String(day) }); 185 186 const eventsForDay = dayEvents.get(day); 187 if (eventsForDay) { 188 cell.addClass("cal-cell-has-events"); 189 const eventsContainer = cell.createDiv({ cls: "cal-cell-events" }); 190 191 for (const ev of eventsForDay) { 192 const isSelected = selectedEvent && eventKey(ev) === eventKey(selectedEvent); 193 const cls = [ 194 "cal-event-chip", 195 ev.soldOut ? "cal-event-sold-out" : "", 196 isSelected ? "cal-event-selected" : "", 197 ].filter(Boolean).join(" "); 198 199 const chip = eventsContainer.createDiv({ cls, text: ev.title }); 200 chipMap.set(eventKey(ev), chip); 201 202 // Click to select and notify 203 chip.addEventListener("click", (e: MouseEvent) => { 204 e.stopPropagation(); 205 // Remove previous selection 206 container.querySelectorAll(".cal-event-selected").forEach( 207 (el) => el.classList.remove("cal-event-selected") 208 ); 209 chip.classList.add("cal-event-selected"); 210 callbacks.onEventClick(ev); 211 }); 212 213 chip.addEventListener("mouseenter", () => { 214 removePopover(); 215 activePopover = createPopover(container, ev, chip); 216 }); 217 218 chip.addEventListener("mouseleave", (e: MouseEvent) => { 219 setTimeout(() => { 220 if (activePopover && !activePopover.contains(e.relatedTarget as Node)) { 221 removePopover(); 222 } 223 }, 100); 224 }); 225 } 226 } 227 228 // Today highlight 229 const now = new Date(); 230 if (year === now.getFullYear() && month === now.getMonth() && day === now.getDate()) { 231 cell.addClass("cal-cell-today"); 232 } 233 } 234 235 // Trailing blank cells 236 const totalCells = startOffset + daysInMonth; 237 const trailingCells = totalCells % 7 === 0 ? 0 : 7 - (totalCells % 7); 238 for (let i = 0; i < trailingCells; i++) { 239 grid.createDiv({ cls: "cal-cell cal-cell-empty" }); 240 } 241 242 // Dismiss popover on background click 243 container.addEventListener("click", (e: MouseEvent) => { 244 if (activePopover && !activePopover.contains(e.target as Node)) { 245 removePopover(); 246 } 247 }); 248 249 // Scroll selected chip into view if present 250 if (selectedEvent) { 251 const selectedChip = chipMap.get(eventKey(selectedEvent)); 252 if (selectedChip) { 253 requestAnimationFrame(() => selectedChip.scrollIntoView({ block: "nearest" })); 254 } 255 } 256 257 // Return controller 258 const controller: CalendarController = { 259 selectEvent(event: CalendarEvent | null): boolean { 260 // Clear previous selection 261 container.querySelectorAll(".cal-event-selected").forEach( 262 (el) => el.classList.remove("cal-event-selected") 263 ); 264 265 if (!event) return false; 266 267 // Check if event is in the current month 268 const evYear = event.date.getFullYear(); 269 const evMonth = event.date.getMonth(); 270 if (evYear !== year || evMonth !== month) { 271 // Need to navigate — caller should re-render with the new month 272 return true; 273 } 274 275 const chip = chipMap.get(eventKey(event)); 276 if (chip) { 277 chip.classList.add("cal-event-selected"); 278 chip.scrollIntoView({ block: "nearest" }); 279 } 280 return false; 281 }, 282 }; 283 284 return controller; 285}