/** * — temporal coordination calendar. * * Three views: Month grid, Week timeline, Day timeline. * View switcher, event dots, lunar phase overlay, * event creation, source filtering, and day-detail panels. */ class FolkCalendarView extends HTMLElement { private shadow: ShadowRoot; private space = ""; private currentDate = new Date(); private viewMode: "month" | "week" | "day" = "month"; private events: any[] = []; private sources: any[] = []; private lunarData: Record = {}; private showLunar = true; private selectedDate = ""; private selectedEvent: any = null; private expandedDay = ""; // mobile day-detail panel private error = ""; private filteredSources = new Set(); private boundKeyHandler: ((e: KeyboardEvent) => void) | null = null; constructor() { super(); this.shadow = this.attachShadow({ mode: "open" }); } connectedCallback() { this.space = this.getAttribute("space") || "demo"; this.boundKeyHandler = (e: KeyboardEvent) => this.handleKeydown(e); document.addEventListener("keydown", this.boundKeyHandler); if (this.space === "demo") { this.loadDemoData(); return; } this.loadMonth(); this.render(); } disconnectedCallback() { if (this.boundKeyHandler) { document.removeEventListener("keydown", this.boundKeyHandler); this.boundKeyHandler = null; } } private handleKeydown(e: KeyboardEvent) { if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return; switch (e.key) { case "ArrowLeft": e.preventDefault(); this.navigate(-1); break; case "ArrowRight": e.preventDefault(); this.navigate(1); break; case "1": this.viewMode = "day"; this.render(); break; case "2": this.viewMode = "week"; this.render(); break; case "3": this.viewMode = "month"; this.render(); break; case "t": case "T": this.currentDate = new Date(); this.expandedDay = ""; if (this.space === "demo") { this.loadDemoData(); } else { this.loadMonth(); } break; case "l": case "L": this.showLunar = !this.showLunar; this.render(); break; } } private loadDemoData() { const now = new Date(); const year = now.getFullYear(); const month = now.getMonth(); const sources = [ { name: "Work", color: "#3b82f6" }, { name: "Travel", color: "#f97316" }, { name: "Personal", color: "#10b981" }, { name: "Conferences", color: "#8b5cf6" }, ]; // Helper to create dates relative to current month // monthDelta: -1 = last month, 0 = this month, 1 = next month const rel = (monthDelta: number, day: number, hour: number, min: number) => { return new Date(year, month + monthDelta, day, hour, min); }; const demoEvents: { start: Date; durationMin: number; title: string; source: number; desc: string; location: string | null; virtual: boolean; lat?: number; lng?: number }[] = [ // ── LAST MONTH ── { start: rel(-1, 18, 10, 0), durationMin: 90, title: "Sprint 22 Review", source: 0, desc: "Sprint review with Berlin engineering team", location: "Factory Berlin", virtual: false, lat: 52.5030, lng: 13.3345 }, { start: rel(-1, 19, 14, 0), durationMin: 60, title: "1:1 with Manager", source: 0, desc: "Quarterly check-in", location: "Factory Berlin", virtual: false }, { start: rel(-1, 20, 9, 0), durationMin: 480, title: "EthBerlin Hackathon Day 1", source: 3, desc: "Web3 hackathon at Factory Kreuzberg", location: "Factory G\u00f6rlitzer Park", virtual: false, lat: 52.4934, lng: 13.4278 }, { start: rel(-1, 21, 9, 0), durationMin: 480, title: "EthBerlin Hackathon Day 2", source: 3, desc: "Judging and demos", location: "Factory G\u00f6rlitzer Park", virtual: false, lat: 52.4934, lng: 13.4278 }, { start: rel(-1, 22, 18, 0), durationMin: 120, title: "Birthday Dinner \u2014 Marco", source: 2, desc: "Italian dinner at La Focacceria", location: "La Focacceria, Kreuzberg", virtual: false, lat: 52.4940, lng: 13.4100 }, { start: rel(-1, 25, 7, 30), durationMin: 75, title: "Morning Yoga", source: 2, desc: "Vinyasa flow", location: "Yoga Studio Kreuzberg", virtual: false, lat: 52.4960, lng: 13.4088 }, { start: rel(-1, 26, 16, 0), durationMin: 60, title: "Client Call NYC", source: 0, desc: "Q4 API integration sync", location: null, virtual: true }, { start: rel(-1, 28, 8, 0), durationMin: 300, title: "Weekend Hike \u2014 Spreewald", source: 2, desc: "Kayak + hike in biosphere reserve", location: "L\u00fcbbenau, Spreewald", virtual: false, lat: 51.8644, lng: 13.7669 }, // ── THIS MONTH ── { start: rel(0, 1, 9, 0), durationMin: 30, title: "Team Standup", source: 0, desc: "Daily sync \u2014 Berlin engineering team", location: "Factory Berlin", virtual: false, lat: 52.5030, lng: 13.3345 }, { start: rel(0, 1, 14, 0), durationMin: 60, title: "Code Review Session", source: 0, desc: "Review PRs from the weekend batch", location: "Factory Berlin", virtual: false }, { start: rel(0, 2, 9, 0), durationMin: 30, title: "Team Standup", source: 0, desc: "Daily sync", location: "Factory Berlin", virtual: false }, { start: rel(0, 2, 11, 0), durationMin: 60, title: "Design System Sync", source: 0, desc: "Component library review with design team", location: null, virtual: true }, { start: rel(0, 3, 9, 0), durationMin: 30, title: "Team Standup", source: 0, desc: "Daily sync", location: "Factory Berlin", virtual: false }, { start: rel(0, 3, 15, 0), durationMin: 90, title: "Architecture Deep-Dive", source: 0, desc: "CRDT merge strategy for offline-first mobile", location: "Factory Berlin", virtual: false }, { start: rel(0, 4, 10, 0), durationMin: 90, title: "Product Review", source: 0, desc: "Quarterly product roadmap review", location: "Factory Berlin, Room 3", virtual: false }, { start: rel(0, 4, 12, 30), durationMin: 60, title: "Lunch with Alex", source: 2, desc: "Catch up over Vietnamese food", location: "District Mot, Berlin", virtual: false, lat: 52.5244, lng: 13.4023 }, { start: rel(0, 5, 10, 0), durationMin: 120, title: "Sprint Planning", source: 0, desc: "Plan sprint 24 \u2014 local-first sync", location: "Factory Berlin, Room 1", virtual: false }, { start: rel(0, 7, 8, 0), durationMin: 300, title: "Weekend Hike \u2014 Grunewald", source: 2, desc: "Forest loop trail, ~14 km", location: "S-Bahn Grunewald", virtual: false, lat: 52.4730, lng: 13.2260 }, { start: rel(0, 8, 16, 0), durationMin: 60, title: "Client Call NYC", source: 0, desc: "Sync with NYC partner team on API integration", location: null, virtual: true }, { start: rel(0, 9, 7, 30), durationMin: 75, title: "Morning Yoga", source: 2, desc: "Vinyasa flow", location: "Yoga Studio Kreuzberg", virtual: false }, { start: rel(0, 10, 11, 0), durationMin: 45, title: "1:1 with Manager", source: 0, desc: "Monthly check-in", location: "Factory Berlin", virtual: false }, { start: rel(0, 10, 15, 30), durationMin: 60, title: "Deploy Prep", source: 0, desc: "Pre-release checklist and staging verification", location: null, virtual: true }, { start: rel(0, 12, 7, 15), durationMin: 390, title: "Train Berlin \u2192 Amsterdam", source: 1, desc: "ICE 643 Berlin Hbf \u2192 Amsterdam Centraal", location: "Berlin Hauptbahnhof", virtual: false, lat: 52.5251, lng: 13.3694 }, { start: rel(0, 12, 14, 30), durationMin: 30, title: "Hotel Check-in", source: 1, desc: "Hotel V Nesplein, Amsterdam", location: "Hotel V Nesplein", virtual: false, lat: 52.3667, lng: 4.8945 }, { start: rel(0, 13, 10, 0), durationMin: 180, title: "Partner Meeting", source: 0, desc: "On-site with Amsterdam design team", location: "WeWork Weteringschans", virtual: false, lat: 52.3603, lng: 4.8880 }, { start: rel(0, 13, 15, 0), durationMin: 120, title: "Canal District Walk", source: 2, desc: "Afternoon along Prinsengracht and Jordaan", location: "Prinsengracht, Amsterdam", virtual: false, lat: 52.3738, lng: 4.8820 }, { start: rel(0, 14, 9, 30), durationMin: 390, title: "Return Train Amsterdam \u2192 Berlin", source: 1, desc: "ICE 148 Amsterdam \u2192 Berlin", location: "Amsterdam Centraal", virtual: false, lat: 52.3791, lng: 4.9003 }, { start: rel(0, 15, 19, 30), durationMin: 120, title: "Dinner with Friends", source: 2, desc: "Birthday dinner for Mia", location: "Il Casolare, Kreuzberg", virtual: false, lat: 52.4900, lng: 13.4200 }, { start: rel(0, 16, 7, 30), durationMin: 75, title: "Morning Yoga", source: 2, desc: "Vinyasa flow", location: "Yoga Studio Kreuzberg", virtual: false }, { start: rel(0, 17, 10, 0), durationMin: 60, title: "Sprint Retro", source: 0, desc: "Sprint 23 retrospective", location: "Factory Berlin, Room 2", virtual: false }, { start: rel(0, 17, 14, 0), durationMin: 120, title: "Release Deploy", source: 0, desc: "Push v2.4.0 to production", location: null, virtual: true }, { start: rel(0, 18, 14, 0), durationMin: 90, title: "Demo Day", source: 0, desc: "Sprint 23 showcase for stakeholders", location: "Factory Berlin, Main Hall", virtual: false }, { start: rel(0, 19, 9, 0), durationMin: 45, title: "Dentist", source: 2, desc: "Regular checkup, Dr. Weber", location: "Torstr. 140, Berlin", virtual: false, lat: 52.5308, lng: 13.3970 }, { start: rel(0, 20, 19, 0), durationMin: 90, title: "Book Club", source: 2, desc: "\"The Mushroom at the End of the World\"", location: "Shakespeare & Sons, Berlin", virtual: false, lat: 52.4925, lng: 13.4310 }, { start: rel(0, 21, 14, 0), durationMin: 60, title: "c-base Open Tuesday", source: 2, desc: "Weekly open hackerspace session", location: "c-base, Berlin", virtual: false, lat: 52.5130, lng: 13.4200 }, { start: rel(0, 22, 6, 45), durationMin: 195, title: "Flight \u2192 Lisbon", source: 1, desc: "TAP TP 571 BER \u2192 LIS", location: "BER Airport", virtual: false, lat: 52.3667, lng: 13.5033 }, { start: rel(0, 23, 9, 0), durationMin: 540, title: "Web Summit Day 1", source: 3, desc: "Opening keynotes, startup pavilion", location: "Altice Arena, Lisbon", virtual: false, lat: 38.7685, lng: -9.0943 }, { start: rel(0, 24, 9, 0), durationMin: 540, title: "Web Summit Day 2", source: 3, desc: "Panel: Local-First Software", location: "Altice Arena, Lisbon", virtual: false, lat: 38.7685, lng: -9.0943 }, { start: rel(0, 25, 10, 0), durationMin: 360, title: "Lisbon City Tour", source: 2, desc: "Alfama, Tram 28, Past\u00e9is de Bel\u00e9m", location: "Alfama, Lisbon", virtual: false, lat: 38.7118, lng: -9.1300 }, { start: rel(0, 25, 19, 30), durationMin: 195, title: "Flight \u2192 Berlin", source: 1, desc: "TAP TP 572 LIS \u2192 BER", location: "Lisbon Airport", virtual: false, lat: 38.7756, lng: -9.1354 }, { start: rel(0, 26, 18, 0), durationMin: 180, title: "Hackathon \u2014 c-base", source: 2, desc: "Local-first data sync hackathon", location: "c-base, Berlin", virtual: false, lat: 52.5130, lng: 13.4200 }, { start: rel(0, 27, 9, 0), durationMin: 30, title: "Team Standup", source: 0, desc: "Post-travel sync", location: "Factory Berlin", virtual: false }, { start: rel(0, 28, 15, 0), durationMin: 90, title: "Architecture Review", source: 0, desc: "Review local-first sync architecture", location: "Factory Berlin", virtual: false }, // ── NEXT MONTH ── { start: rel(1, 2, 10, 0), durationMin: 120, title: "Sprint 25 Planning", source: 0, desc: "Plan next sprint", location: "Factory Berlin", virtual: false }, { start: rel(1, 3, 6, 0), durationMin: 180, title: "Flight \u2192 Barcelona", source: 1, desc: "VY 1862 BER \u2192 BCN", location: "BER Airport", virtual: false, lat: 52.3667, lng: 13.5033 }, { start: rel(1, 3, 14, 0), durationMin: 240, title: "Team Retreat Day 1", source: 0, desc: "Strategy offsite at Barcel\u00f3 Sants", location: "Barcel\u00f3 Sants, Barcelona", virtual: false, lat: 41.3795, lng: 2.1405 }, { start: rel(1, 4, 9, 0), durationMin: 480, title: "Team Retreat Day 2", source: 0, desc: "Workshops + Sagrada Familia visit", location: "Barcelona", virtual: false, lat: 41.4036, lng: 2.1744 }, { start: rel(1, 5, 15, 0), durationMin: 180, title: "Flight \u2192 Berlin", source: 1, desc: "VY 1863 BCN \u2192 BER", location: "BCN Airport", virtual: false, lat: 41.2974, lng: 2.0833 }, { start: rel(1, 8, 19, 0), durationMin: 120, title: "Berlin Philharmonic", source: 2, desc: "Brahms Symphony No. 4", location: "Berliner Philharmonie", virtual: false, lat: 52.5103, lng: 13.3699 }, { start: rel(1, 12, 10, 0), durationMin: 120, title: "Brussels Workshop", source: 3, desc: "EU Digital Commons Working Group", location: "Brussels", virtual: false, lat: 50.8503, lng: 4.3517 }, ]; this.events = demoEvents.map((e, i) => { const endDate = new Date(e.start.getTime() + e.durationMin * 60000); const src = sources[e.source]; return { id: `demo-${i + 1}`, title: e.title, start_time: e.start.toISOString(), end_time: endDate.toISOString(), source_color: src.color, source_name: src.name, description: e.desc, location_name: e.location || undefined, is_virtual: e.virtual, virtual_platform: e.virtual ? "Jitsi" : undefined, virtual_url: e.virtual ? "#" : undefined, latitude: e.lat, longitude: e.lng, }; }); this.sources = sources; // Compute lunar phases for all 3 months const knownNewMoon = new Date(2026, 0, 29).getTime(); const cycle = 29.53; const phaseNames: [string, number][] = [ ["new_moon", 1.85], ["waxing_crescent", 7.38], ["first_quarter", 11.07], ["waxing_gibbous", 14.76], ["full_moon", 16.62], ["waning_gibbous", 22.15], ["last_quarter", 25.84], ["waning_crescent", 29.53], ]; const lunar: Record = {}; for (let m = month - 1; m <= month + 1; m++) { const actualYear = m < 0 ? year - 1 : (m > 11 ? year + 1 : year); const actualMonth = ((m % 12) + 12) % 12; const daysInMonth = new Date(actualYear, actualMonth + 1, 0).getDate(); for (let d = 1; d <= daysInMonth; d++) { const dateStr = `${actualYear}-${String(actualMonth + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}`; const dayTime = new Date(actualYear, actualMonth, d).getTime(); const daysSinceNew = ((dayTime - knownNewMoon) / 86400000) % cycle; const normalizedDays = daysSinceNew < 0 ? daysSinceNew + cycle : daysSinceNew; let phaseName = "new_moon"; for (const [name, threshold] of phaseNames) { if (normalizedDays < threshold) { phaseName = name; break; } } const illumination = Math.round((1 - Math.cos(2 * Math.PI * normalizedDays / cycle)) / 2 * 100) / 100; lunar[dateStr] = { phase: phaseName, illumination }; } } this.lunarData = lunar; 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) { if (this.viewMode === "day") { this.currentDate = new Date(this.currentDate.getFullYear(), this.currentDate.getMonth(), this.currentDate.getDate() + delta); } else if (this.viewMode === "week") { this.currentDate = new Date(this.currentDate.getFullYear(), this.currentDate.getMonth(), this.currentDate.getDate() + delta * 7); } else { this.currentDate = new Date(this.currentDate.getFullYear(), this.currentDate.getMonth() + delta, 1); } this.expandedDay = ""; if (this.space !== "demo") { this.loadMonth(); } else { this.render(); } } 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 formatTime(iso: string): string { const d = new Date(iso); return `${d.getHours()}:${String(d.getMinutes()).padStart(2, "0")}`; } private dateStr(d: Date): string { return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; } private getEventsForDate(dateStr: string): any[] { return this.events.filter(e => e.start_time && e.start_time.startsWith(dateStr) && !this.filteredSources.has(e.source_name)); } private toggleSource(name: string) { if (this.filteredSources.has(name)) { this.filteredSources.delete(name); } else { this.filteredSources.add(name); } this.render(); } private render() { const viewLabel = this.getViewLabel(); this.shadow.innerHTML = ` ${this.error ? `
${this.esc(this.error)}
` : ""}
${this.sources.length > 0 ? `
${this.sources.map(s => `${this.esc(s.name)}`).join("")}
` : ""} ${this.viewMode === "month" ? this.renderMonth() : ""} ${this.viewMode === "week" ? this.renderWeek() : ""} ${this.viewMode === "day" ? this.renderDay() : ""} ${this.selectedEvent ? this.renderEventModal() : ""} `; this.attachListeners(); } private getViewLabel(): string { const d = this.currentDate; if (this.viewMode === "day") { return d.toLocaleDateString("default", { weekday: "long", month: "long", day: "numeric", year: "numeric" }); } if (this.viewMode === "week") { const weekStart = new Date(d); weekStart.setDate(d.getDate() - d.getDay()); const weekEnd = new Date(weekStart); weekEnd.setDate(weekStart.getDate() + 6); const startLabel = weekStart.toLocaleDateString("default", { month: "short", day: "numeric" }); const endLabel = weekEnd.toLocaleDateString("default", { month: "short", day: "numeric", year: "numeric" }); return `${startLabel} \u2013 ${endLabel}`; } return d.toLocaleString("default", { month: "long", year: "numeric" }); } // ── Month View ── private renderMonth(): string { const year = this.currentDate.getFullYear(); const month = this.currentDate.getMonth(); return `
${["S", "M", "T", "W", "T", "F", "S"].map(d => `
${d}
`).join("")}
${this.renderDays(year, month)}
`; } 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 = this.dateStr(today); let html = ""; 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 ds = `${year}-${String(month + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}`; const isToday = ds === todayStr; const isExpanded = ds === this.expandedDay; const dayEvents = this.getEventsForDate(ds); const lunar = this.lunarData[ds]; html += `
${d} ${this.showLunar && lunar ? `${this.getMoonEmoji(lunar.phase)}` : ""}
${dayEvents.length > 0 ? `
${dayEvents.slice(0, 4).map(e => ``).join("")} ${dayEvents.length > 4 ? `+${dayEvents.length - 4}` : ""}
${dayEvents.slice(0, 2).map(e => { return `
${this.formatTime(e.start_time)}${this.esc(e.title)}
`; }).join("")} ` : ""}
`; if (isExpanded) { const cellIndex = firstDay + d - 1; const posInRow = cellIndex % 7; if (posInRow === 6 || d === daysInMonth) { html += this.renderDayDetail(ds, dayEvents); } } } if (this.expandedDay) { const expD = parseInt(this.expandedDay.split("-")[2]); const cellIndex = firstDay + expD - 1; const posInRow = cellIndex % 7; if (posInRow !== 6 && expD <= daysInMonth) { /* detail already appended below */ } } const totalCells = firstDay + daysInMonth; const remaining = (7 - (totalCells % 7)) % 7; for (let i = 1; i <= remaining; i++) { html += `
${i}
`; } if (this.expandedDay) { const dayEvents = this.getEventsForDate(this.expandedDay); html += this.renderDayDetail(this.expandedDay, dayEvents); } return html; } // ── Day View ── private renderDay(): string { const ds = this.dateStr(this.currentDate); const dayEvents = this.getEventsForDate(ds); const lunar = this.lunarData[ds]; const now = new Date(); const isToday = this.dateStr(now) === ds; const HOUR_HEIGHT = 48; const START_HOUR = 6; const END_HOUR = 23; // Separate all-day vs timed events const allDay = dayEvents.filter(e => { const start = new Date(e.start_time); const end = new Date(e.end_time); return (end.getTime() - start.getTime()) >= 86400000; }); const timed = dayEvents.filter(e => { const start = new Date(e.start_time); const end = new Date(e.end_time); return (end.getTime() - start.getTime()) < 86400000; }).sort((a, b) => a.start_time.localeCompare(b.start_time)); // Hour rows let hoursHtml = ""; for (let h = START_HOUR; h <= END_HOUR; h++) { const label = h === 0 ? "12 AM" : h < 12 ? `${h} AM` : h === 12 ? "12 PM" : `${h - 12} PM`; hoursHtml += `
${label}
`; } // Position timed events let eventsHtml = ""; for (const ev of timed) { const start = new Date(ev.start_time); const end = new Date(ev.end_time); const startMin = start.getHours() * 60 + start.getMinutes(); const endMin = end.getHours() * 60 + end.getMinutes(); const duration = Math.max(endMin - startMin, 30); const topPx = ((startMin - START_HOUR * 60) / 60) * HOUR_HEIGHT; const heightPx = Math.max((duration / 60) * HOUR_HEIGHT, 24); const bgColor = ev.source_color ? `${ev.source_color}18` : "#6366f118"; eventsHtml += `
${this.esc(ev.title)}
${this.formatTime(ev.start_time)} \u2013 ${this.formatTime(ev.end_time)}
${ev.location_name ? `
${this.esc(ev.location_name)}
` : ""}
`; } // Now indicator let nowHtml = ""; if (isToday) { const nowMin = now.getHours() * 60 + now.getMinutes(); const nowPx = ((nowMin - START_HOUR * 60) / 60) * HOUR_HEIGHT; if (nowPx >= 0 && nowPx <= (END_HOUR - START_HOUR + 1) * HOUR_HEIGHT) { nowHtml = `
`; } } return `
${this.showLunar && lunar ? `${this.getMoonEmoji(lunar.phase)} ${Math.round(lunar.illumination * 100)}% illuminated \u00B7 ` : ""} ${dayEvents.length} event${dayEvents.length !== 1 ? "s" : ""}
${allDay.length > 0 ? `
All Day
${allDay.map(e => `
${this.esc(e.title)}
`).join("")}
` : ""}
${hoursHtml} ${eventsHtml} ${nowHtml}
`; } // ── Week View ── private renderWeek(): string { const d = this.currentDate; const weekStart = new Date(d.getFullYear(), d.getMonth(), d.getDate() - d.getDay()); const today = new Date(); const todayStr = this.dateStr(today); const HOUR_HEIGHT = 48; const START_HOUR = 7; const END_HOUR = 22; const totalHeight = (END_HOUR - START_HOUR + 1) * HOUR_HEIGHT; // Header row let headerHtml = `
`; const days: Date[] = []; for (let i = 0; i < 7; i++) { const day = new Date(weekStart); day.setDate(weekStart.getDate() + i); days.push(day); const ds = this.dateStr(day); const isToday = ds === todayStr; headerHtml += `
${day.toLocaleDateString("default", { weekday: "short" })} ${day.getDate()}
`; } // Time grid let gridHtml = ""; for (let h = START_HOUR; h <= END_HOUR; h++) { const label = h === 0 ? "12a" : h < 12 ? `${h}a` : h === 12 ? "12p" : `${h - 12}p`; gridHtml += `
${label}
`; for (let i = 0; i < 7; i++) { const ds = this.dateStr(days[i]); const isToday = ds === todayStr; gridHtml += `
`; } } // Overlay events onto week grid let eventsOverlay = ""; for (let i = 0; i < 7; i++) { const ds = this.dateStr(days[i]); const dayEvents = this.getEventsForDate(ds); for (const ev of dayEvents) { const start = new Date(ev.start_time); const end = new Date(ev.end_time); if ((end.getTime() - start.getTime()) >= 86400000) continue; // skip all-day const startMin = start.getHours() * 60 + start.getMinutes(); const endMin = end.getHours() * 60 + end.getMinutes(); const duration = Math.max(endMin - startMin, 20); const topPx = ((startMin - START_HOUR * 60) / 60) * HOUR_HEIGHT; const heightPx = Math.max((duration / 60) * HOUR_HEIGHT, 18); const bgColor = ev.source_color ? `${ev.source_color}20` : "#6366f120"; // Column position: each column is 1/7 of the remaining width after the time label const colLeft = `calc(44px + ${i} * ((100% - 44px) / 7) + 2px)`; const colWidth = `calc((100% - 44px) / 7 - 4px)`; eventsOverlay += `
${this.esc(ev.title)}
`; } } // Now indicator for week view let nowHtml = ""; const nowDay = days.findIndex(day => this.dateStr(day) === todayStr); if (nowDay >= 0) { const nowMin = today.getHours() * 60 + today.getMinutes(); const nowPx = ((nowMin - START_HOUR * 60) / 60) * HOUR_HEIGHT; if (nowPx >= 0 && nowPx <= totalHeight) { nowHtml = `
`; } } return `
${headerHtml}
${gridHtml}
${eventsOverlay} ${nowHtml}
`; } private renderDayDetail(dateStr: string, dayEvents: any[]): string { const d = new Date(dateStr + "T00:00:00"); const label = d.toLocaleDateString("default", { weekday: "long", month: "long", day: "numeric" }); return `
${label}
${dayEvents.length === 0 ? `
No events
` : dayEvents.sort((a, b) => a.start_time.localeCompare(b.start_time)).map(e => `
${this.esc(e.title)}
${this.formatTime(e.start_time)}${e.end_time ? ` \u2013 ${this.formatTime(e.end_time)}` : ""}${e.location_name ? ` \u00B7 ${this.esc(e.location_name)}` : ""}${e.is_virtual ? " \u00B7 Virtual" : ""}
`).join("")}
`; } 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("today")?.addEventListener("click", () => { this.currentDate = new Date(); this.expandedDay = ""; if (this.space === "demo") { this.loadDemoData(); } else { this.loadMonth(); } }); this.shadow.getElementById("toggle-lunar")?.addEventListener("click", () => { this.showLunar = !this.showLunar; this.render(); }); // Source filter toggles this.shadow.querySelectorAll("[data-source]").forEach(el => { el.addEventListener("click", (e) => { e.stopPropagation(); this.toggleSource((el as HTMLElement).dataset.source!); }); }); // View switcher this.shadow.querySelectorAll("[data-view]").forEach(el => { el.addEventListener("click", () => { this.viewMode = (el as HTMLElement).dataset.view as any; this.expandedDay = ""; this.render(); }); }); // Day cell tap → expand day detail panel (month view) this.shadow.querySelectorAll(".day:not(.other)").forEach(el => { el.addEventListener("click", () => { const date = (el as HTMLElement).dataset.date; if (!date) return; this.expandedDay = this.expandedDay === date ? "" : date; this.render(); }); }); // Week day header click → switch to day view this.shadow.querySelectorAll("[data-day-click]").forEach(el => { el.addEventListener("click", () => { const ds = (el as HTMLElement).dataset.date; if (!ds) return; const parts = ds.split("-"); this.currentDate = new Date(parseInt(parts[0]), parseInt(parts[1]) - 1, parseInt(parts[2])); this.viewMode = "day"; this.render(); }); }); // Event clicks → open modal this.shadow.querySelectorAll("[data-event-id]").forEach(el => { el.addEventListener("click", (e) => { e.stopPropagation(); const id = (el as HTMLElement).dataset.eventId; this.selectedEvent = this.events.find(ev => ev.id === id); this.render(); }); }); // Close day detail this.shadow.getElementById("dd-close")?.addEventListener("click", (e) => { e.stopPropagation(); this.expandedDay = ""; this.render(); }); // Modal close 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);