/** * — temporal coordination calendar. * * Month grid view with event dots, lunar phase overlay, * event creation, and source filtering. */ class FolkCalendarView extends HTMLElement { private shadow: ShadowRoot; private space = ""; private currentDate = new Date(); private events: any[] = []; private sources: any[] = []; private lunarData: Record = {}; private showLunar = true; private selectedDate = ""; private selectedEvent: any = null; private error = ""; constructor() { super(); this.shadow = this.attachShadow({ mode: "open" }); } connectedCallback() { this.space = this.getAttribute("space") || "demo"; this.loadMonth(); this.render(); } private getApiBase(): string { const path = window.location.pathname; const match = path.match(/^\/([^/]+)\/cal/); return match ? `/${match[1]}/cal` : ""; } private async loadMonth() { const year = this.currentDate.getFullYear(); const month = this.currentDate.getMonth(); const start = `${year}-${String(month + 1).padStart(2, "0")}-01`; const lastDay = new Date(year, month + 1, 0).getDate(); const end = `${year}-${String(month + 1).padStart(2, "0")}-${String(lastDay).padStart(2, "0")}`; const base = this.getApiBase(); try { const [eventsRes, sourcesRes, lunarRes] = await Promise.all([ fetch(`${base}/api/events?start=${start}&end=${end}`), fetch(`${base}/api/sources`), fetch(`${base}/api/lunar?start=${start}&end=${end}`), ]); if (eventsRes.ok) { const data = await eventsRes.json(); this.events = data.results || []; } if (sourcesRes.ok) { const data = await sourcesRes.json(); this.sources = data.results || []; } if (lunarRes.ok) { this.lunarData = await lunarRes.json(); } } catch { /* offline fallback */ } this.render(); } private navigate(delta: number) { this.currentDate = new Date(this.currentDate.getFullYear(), this.currentDate.getMonth() + delta, 1); this.loadMonth(); } private getMoonEmoji(phase: string): string { const map: Record = { new_moon: "\u{1F311}", waxing_crescent: "\u{1F312}", first_quarter: "\u{1F313}", waxing_gibbous: "\u{1F314}", full_moon: "\u{1F315}", waning_gibbous: "\u{1F316}", last_quarter: "\u{1F317}", waning_crescent: "\u{1F318}", }; return map[phase] || ""; } private render() { const year = this.currentDate.getFullYear(); const month = this.currentDate.getMonth(); const monthName = this.currentDate.toLocaleString("default", { month: "long" }); this.shadow.innerHTML = ` ${this.error ? `
${this.esc(this.error)}
` : ""}
${monthName} ${year}
${this.sources.length > 0 ? `
${this.sources.map(s => `${this.esc(s.name)}`).join("")}
` : ""}
${["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"].map(d => `
${d}
`).join("")}
${this.renderDays(year, month)}
${this.selectedEvent ? this.renderEventModal() : ""} `; this.attachListeners(); } private renderDays(year: number, month: number): string { const firstDay = new Date(year, month, 1).getDay(); const daysInMonth = new Date(year, month + 1, 0).getDate(); const today = new Date(); const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`; let html = ""; // Previous month padding const prevDays = new Date(year, month, 0).getDate(); for (let i = firstDay - 1; i >= 0; i--) { html += `
${prevDays - i}
`; } for (let d = 1; d <= daysInMonth; d++) { const dateStr = `${year}-${String(month + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}`; const isToday = dateStr === todayStr; const dayEvents = this.events.filter(e => e.start_time && e.start_time.startsWith(dateStr)); const lunar = this.lunarData[dateStr]; html += `
${d} ${this.showLunar && lunar ? `${this.getMoonEmoji(lunar.phase)}` : ""}
${dayEvents.length > 0 ? `
${dayEvents.slice(0, 3).map(e => ``).join("")} ${dayEvents.length > 3 ? `+${dayEvents.length - 3}` : ""}
${dayEvents.slice(0, 2).map(e => `
${this.esc(e.title)}
`).join("")} ` : ""}
`; } // Next month padding const totalCells = firstDay + daysInMonth; const remaining = (7 - (totalCells % 7)) % 7; for (let i = 1; i <= remaining; i++) { html += `
${i}
`; } return html; } private renderEventModal(): string { const e = this.selectedEvent; return ` `; } private attachListeners() { this.shadow.getElementById("prev")?.addEventListener("click", () => this.navigate(-1)); this.shadow.getElementById("next")?.addEventListener("click", () => this.navigate(1)); this.shadow.getElementById("toggle-lunar")?.addEventListener("click", () => { this.showLunar = !this.showLunar; this.render(); }); this.shadow.querySelectorAll("[data-event]").forEach(el => { el.addEventListener("click", (e) => { e.stopPropagation(); const data = JSON.parse((el as HTMLElement).dataset.event!); this.selectedEvent = this.events.find(ev => ev.id === data.id); this.render(); }); }); this.shadow.getElementById("modal-overlay")?.addEventListener("click", (e) => { if ((e.target as HTMLElement).id === "modal-overlay") { this.selectedEvent = null; this.render(); } }); this.shadow.getElementById("modal-close")?.addEventListener("click", () => { this.selectedEvent = null; this.render(); }); } private esc(s: string): string { const d = document.createElement("div"); d.textContent = s || ""; return d.innerHTML; } } customElements.define("folk-calendar-view", FolkCalendarView);