import type { CalendarEvent } from "./parser"; const DAY_NAMES = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; const MONTH_NAMES = [ "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December", ]; export interface CalendarCallbacks { onPrevMonth: () => void; onNextMonth: () => void; onEventClick: (event: CalendarEvent) => void; } export interface CalendarController { /** Highlight an event chip in the calendar. Navigates to its month if needed. Returns true if month changed. */ selectEvent(event: CalendarEvent | null): boolean; } /** * Get the Monday-based day-of-week (0=Mon, 6=Sun). */ function mondayIndex(date: Date): number { return (date.getDay() + 6) % 7; } /** * Build a map from day-of-month to events for a given month. */ function eventsByDay(events: CalendarEvent[], year: number, month: number): Map { const map = new Map(); for (const ev of events) { if (ev.date.getFullYear() === year && ev.date.getMonth() === month) { const day = ev.date.getDate(); if (!map.has(day)) map.set(day, []); map.get(day)!.push(ev); } } return map; } function createPopover(container: HTMLElement, ev: CalendarEvent, anchor: HTMLElement): HTMLElement { const popover = container.createDiv({ cls: "cal-popover" }); const titleEl = popover.createDiv({ cls: "cal-popover-title" }); if (ev.url) { const a = titleEl.createEl("a", { text: ev.title, href: ev.url }); a.setAttr("target", "_blank"); } else { titleEl.setText(ev.title); } if (ev.soldOut) { titleEl.createSpan({ cls: "cal-sold-out", text: " (Sold out)" }); } // Date line const dateStr = ev.date.toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric", year: "numeric", }); const dateLine = ev.rawTime ? `${dateStr}, ${ev.rawTime}` : dateStr; popover.createDiv({ cls: "cal-popover-date", text: dateLine }); // Venue if (ev.venue) { const venueEl = popover.createDiv({ cls: "cal-popover-venue" }); if (ev.venueUrl) { const a = venueEl.createEl("a", { text: ev.venue, href: ev.venueUrl }); a.setAttr("target", "_blank"); } else { venueEl.setText(ev.venue); } if (ev.location) { venueEl.appendText(", "); if (ev.locationUrl) { const a = venueEl.createEl("a", { text: ev.location, href: ev.locationUrl }); a.setAttr("target", "_blank"); } else { venueEl.appendText(ev.location); } } } // Notes if (ev.notes) { popover.createDiv({ cls: "cal-popover-notes", text: ev.notes }); } // Position the popover near the anchor positionPopover(popover, anchor, container); return popover; } function positionPopover(popover: HTMLElement, anchor: HTMLElement, container: HTMLElement): void { requestAnimationFrame(() => { const anchorRect = anchor.getBoundingClientRect(); const containerRect = container.getBoundingClientRect(); const popoverRect = popover.getBoundingClientRect(); let top = anchorRect.bottom - containerRect.top + 4; let left = anchorRect.left - containerRect.left; if (left + popoverRect.width > containerRect.width) { left = containerRect.width - popoverRect.width - 8; } if (left < 0) left = 4; if (top + popoverRect.height > containerRect.height) { top = anchorRect.top - containerRect.top - popoverRect.height - 4; } popover.style.top = `${top}px`; popover.style.left = `${left}px`; }); } /** * Render a calendar month grid into the container. * Returns a CalendarController for programmatic interaction. */ export function renderCalendar( container: HTMLElement, currentMonth: Date, events: CalendarEvent[], callbacks: CalendarCallbacks, selectedEvent?: CalendarEvent | null, ): CalendarController { container.empty(); container.addClass("cal-container"); const year = currentMonth.getFullYear(); const month = currentMonth.getMonth(); // Header: < Month Year > const header = container.createDiv({ cls: "cal-header" }); const prevBtn = header.createEl("button", { cls: "cal-nav-btn", text: "\u2039" }); prevBtn.addEventListener("click", callbacks.onPrevMonth); header.createSpan({ cls: "cal-month-label", text: `${MONTH_NAMES[month]} ${year}` }); const nextBtn = header.createEl("button", { cls: "cal-nav-btn", text: "\u203A" }); nextBtn.addEventListener("click", callbacks.onNextMonth); // Day-of-week row const dowRow = container.createDiv({ cls: "cal-dow-row" }); for (const name of DAY_NAMES) { dowRow.createDiv({ cls: "cal-dow-cell", text: name }); } // Build grid const grid = container.createDiv({ cls: "cal-grid" }); const firstOfMonth = new Date(year, month, 1); const daysInMonth = new Date(year, month + 1, 0).getDate(); const startOffset = mondayIndex(firstOfMonth); const dayEvents = eventsByDay(events, year, month); // Track active popover let activePopover: HTMLElement | null = null; const removePopover = () => { if (activePopover) { activePopover.remove(); activePopover = null; } }; // Track chips by event title+date for programmatic selection const chipMap = new Map(); function eventKey(ev: CalendarEvent): string { return `${ev.title}::${ev.date.getTime()}`; } // Leading blank cells for (let i = 0; i < startOffset; i++) { grid.createDiv({ cls: "cal-cell cal-cell-empty" }); } // Day cells for (let day = 1; day <= daysInMonth; day++) { const cell = grid.createDiv({ cls: "cal-cell" }); cell.createDiv({ cls: "cal-day-number", text: String(day) }); const eventsForDay = dayEvents.get(day); if (eventsForDay) { cell.addClass("cal-cell-has-events"); const eventsContainer = cell.createDiv({ cls: "cal-cell-events" }); for (const ev of eventsForDay) { const isSelected = selectedEvent && eventKey(ev) === eventKey(selectedEvent); const cls = [ "cal-event-chip", ev.soldOut ? "cal-event-sold-out" : "", isSelected ? "cal-event-selected" : "", ].filter(Boolean).join(" "); const chip = eventsContainer.createDiv({ cls, text: ev.title }); chipMap.set(eventKey(ev), chip); // Click to select and notify chip.addEventListener("click", (e: MouseEvent) => { e.stopPropagation(); // Remove previous selection container.querySelectorAll(".cal-event-selected").forEach( (el) => el.classList.remove("cal-event-selected") ); chip.classList.add("cal-event-selected"); callbacks.onEventClick(ev); }); chip.addEventListener("mouseenter", () => { removePopover(); activePopover = createPopover(container, ev, chip); }); chip.addEventListener("mouseleave", (e: MouseEvent) => { setTimeout(() => { if (activePopover && !activePopover.contains(e.relatedTarget as Node)) { removePopover(); } }, 100); }); } } // Today highlight const now = new Date(); if (year === now.getFullYear() && month === now.getMonth() && day === now.getDate()) { cell.addClass("cal-cell-today"); } } // Trailing blank cells const totalCells = startOffset + daysInMonth; const trailingCells = totalCells % 7 === 0 ? 0 : 7 - (totalCells % 7); for (let i = 0; i < trailingCells; i++) { grid.createDiv({ cls: "cal-cell cal-cell-empty" }); } // Dismiss popover on background click container.addEventListener("click", (e: MouseEvent) => { if (activePopover && !activePopover.contains(e.target as Node)) { removePopover(); } }); // Scroll selected chip into view if present if (selectedEvent) { const selectedChip = chipMap.get(eventKey(selectedEvent)); if (selectedChip) { requestAnimationFrame(() => selectedChip.scrollIntoView({ block: "nearest" })); } } // Return controller const controller: CalendarController = { selectEvent(event: CalendarEvent | null): boolean { // Clear previous selection container.querySelectorAll(".cal-event-selected").forEach( (el) => el.classList.remove("cal-event-selected") ); if (!event) return false; // Check if event is in the current month const evYear = event.date.getFullYear(); const evMonth = event.date.getMonth(); if (evYear !== year || evMonth !== month) { // Need to navigate — caller should re-render with the new month return true; } const chip = chipMap.get(eventKey(event)); if (chip) { chip.classList.add("cal-event-selected"); chip.scrollIntoView({ block: "nearest" }); } return false; }, }; return controller; }