Proof of concept for the other one
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}