/** * — temporal coordination calendar. * * Month grid view with event dots, lunar phase overlay, * event creation, and source filtering. * Mobile: tapping a day opens a day-detail panel with full event list. */ 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 expandedDay = ""; // mobile day-detail panel private error = ""; constructor() { super(); this.shadow = this.attachShadow({ mode: "open" }); } connectedCallback() { this.space = this.getAttribute("space") || "demo"; if (this.space === "demo") { this.loadDemoData(); return; } this.loadMonth(); this.render(); } 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" }, ]; const demoEvents: { day: number; title: string; source: number; desc: string; location: string | null; virtual: boolean; startH: number; startM: number; durationMin: number }[] = [ { day: 3, title: "Team Standup", source: 0, desc: "Daily sync — Berlin engineering team", location: "Factory Berlin", virtual: false, startH: 9, startM: 30, durationMin: 30 }, { day: 3, title: "Code Review Session", source: 0, desc: "Review PRs from the weekend batch", location: "Factory Berlin", virtual: false, startH: 14, startM: 0, durationMin: 60 }, { day: 5, title: "Product Review", source: 0, desc: "Quarterly product roadmap review", location: "Factory Berlin, Room 3", virtual: false, startH: 10, startM: 0, durationMin: 90 }, { day: 5, title: "Lunch with Alex", source: 2, desc: "Catch up over Vietnamese food", location: "District Mot, Berlin", virtual: false, startH: 12, startM: 30, durationMin: 60 }, { day: 7, title: "Sprint Planning", source: 0, desc: "Plan sprint 24 — local-first sync", location: "Factory Berlin, Room 1", virtual: false, startH: 10, startM: 0, durationMin: 120 }, { day: 7, title: "Architecture Deep-Dive", source: 0, desc: "CRDT merge strategy for offline-first mobile", location: "Factory Berlin", virtual: false, startH: 15, startM: 0, durationMin: 90 }, { day: 8, title: "Client Call NYC", source: 0, desc: "Sync with NYC partner team on API integration", location: null, virtual: true, startH: 16, startM: 0, durationMin: 60 }, { day: 10, title: "1:1 with Manager", source: 0, desc: "Monthly check-in", location: "Factory Berlin", virtual: false, startH: 11, startM: 0, durationMin: 45 }, { day: 10, title: "Deploy Prep", source: 0, desc: "Pre-release checklist and staging verification", location: null, virtual: true, startH: 15, startM: 30, durationMin: 60 }, { day: 12, title: "Train Berlin → Amsterdam", source: 1, desc: "ICE 643 Berlin Hbf → Amsterdam Centraal", location: "Berlin Hauptbahnhof", virtual: false, startH: 7, startM: 15, durationMin: 390 }, { day: 12, title: "Hotel Check-in", source: 1, desc: "Hotel V Nesplein, Amsterdam", location: "Hotel V Nesplein", virtual: false, startH: 14, startM: 30, durationMin: 30 }, { day: 13, title: "Partner Meeting", source: 0, desc: "On-site with Amsterdam design team", location: "WeWork Weteringschans", virtual: false, startH: 10, startM: 0, durationMin: 180 }, { day: 13, title: "Canal District Walk", source: 2, desc: "Afternoon along Prinsengracht and Jordaan", location: "Prinsengracht, Amsterdam", virtual: false, startH: 15, startM: 0, durationMin: 120 }, { day: 14, title: "Return Train", source: 1, desc: "ICE 148 Amsterdam → Berlin", location: "Amsterdam Centraal", virtual: false, startH: 9, startM: 30, durationMin: 390 }, { day: 15, title: "Dinner with Friends", source: 2, desc: "Birthday dinner for Mia", location: "Il Casolare, Berlin", virtual: false, startH: 19, startM: 30, durationMin: 120 }, { day: 17, title: "Weekend Hike", source: 2, desc: "Grunewald forest loop trail, ~14 km", location: "S-Bahn Grunewald", virtual: false, startH: 8, startM: 0, durationMin: 300 }, { day: 19, title: "Dentist", source: 2, desc: "Regular checkup, Dr. Weber", location: "Torstr. 140, Berlin", virtual: false, startH: 9, startM: 0, durationMin: 45 }, { day: 20, title: "Yoga Class", source: 2, desc: "Vinyasa flow", location: "Yoga Studio Kreuzberg", virtual: false, startH: 7, startM: 30, durationMin: 75 }, { day: 20, title: "Book Club", source: 2, desc: "\"The Mushroom at the End of the World\"", location: "Shakespeare & Sons, Berlin", virtual: false, startH: 19, startM: 0, durationMin: 90 }, { day: 21, title: "Sprint Retro", source: 0, desc: "Sprint 23 retrospective", location: "Factory Berlin, Room 2", virtual: false, startH: 10, startM: 0, durationMin: 60 }, { day: 21, title: "Release Deploy", source: 0, desc: "Push v2.4.0 to production", location: null, virtual: true, startH: 14, startM: 0, durationMin: 120 }, { day: 22, title: "Demo Day", source: 0, desc: "Sprint 23 showcase for stakeholders", location: "Factory Berlin, Main Hall", virtual: false, startH: 14, startM: 0, durationMin: 90 }, { day: 24, title: "Flight → Lisbon", source: 1, desc: "TAP TP 571 BER → LIS", location: "BER Airport", virtual: false, startH: 6, startM: 45, durationMin: 195 }, { day: 25, title: "Web Summit Day 1", source: 3, desc: "Opening keynotes, startup pavilion", location: "Altice Arena, Lisbon", virtual: false, startH: 9, startM: 0, durationMin: 540 }, { day: 26, title: "Web Summit Day 2", source: 3, desc: "Panel: Local-First Software", location: "Altice Arena, Lisbon", virtual: false, startH: 9, startM: 0, durationMin: 540 }, { day: 27, title: "Lisbon City Tour", source: 2, desc: "Alfama, Tram 28, Pasteis de Belem", location: "Alfama, Lisbon", virtual: false, startH: 10, startM: 0, durationMin: 360 }, { day: 27, title: "Flight → Berlin", source: 1, desc: "TAP TP 572 LIS → BER", location: "Lisbon Airport", virtual: false, startH: 19, startM: 30, durationMin: 195 }, ]; this.events = demoEvents.map((e, i) => { const startDate = new Date(year, month, e.day, e.startH, e.startM); const endDate = new Date(startDate.getTime() + e.durationMin * 60000); const src = sources[e.source]; return { id: `demo-${i + 1}`, title: e.title, start_time: startDate.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, }; }); this.sources = sources; // Compute lunar phases const knownNewMoon = new Date(2026, 0, 29).getTime(); const cycle = 29.53; const daysInMonth = new Date(year, month + 1, 0).getDate(); 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 d = 1; d <= daysInMonth; d++) { const dateStr = `${year}-${String(month + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}`; const dayTime = new Date(year, month, 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) { this.currentDate = new Date(this.currentDate.getFullYear(), this.currentDate.getMonth() + delta, 1); this.expandedDay = ""; 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 formatTime(iso: string): string { const d = new Date(iso); return `${d.getHours()}:${String(d.getMinutes()).padStart(2, "0")}`; } 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)}
` : ""} ${this.sources.length > 0 ? `
${this.sources.map(s => `${this.esc(s.name)}`).join("")}
` : ""}
${["S", "M", "T", "W", "T", "F", "S"].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 isExpanded = dateStr === this.expandedDay; 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, 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("")} ` : ""}
`; // Insert day-detail panel after the day's grid row if expanded if (isExpanded) { // Calculate how many cells are in this row so far const cellIndex = firstDay + d - 1; // 0-based const posInRow = cellIndex % 7; // We need to fill remaining cells in the row, then insert detail if (posInRow === 6 || d === daysInMonth) { // End of row or last day — insert detail panel right here html += this.renderDayDetail(dateStr, dayEvents); } // Otherwise, we'll insert after the row ends (handled below) } } // Handle expanded day detail that wasn't at row end if (this.expandedDay) { const expD = parseInt(this.expandedDay.split("-")[2]); const cellIndex = firstDay + expD - 1; const posInRow = cellIndex % 7; if (posInRow !== 6 && expD <= daysInMonth) { // Need to render remaining days in the row, then add detail // Actually this is complex with the linear approach. Let's use a simpler approach: // Re-render with detail after the full row. } } // Next month padding const totalCells = firstDay + daysInMonth; const remaining = (7 - (totalCells % 7)) % 7; for (let i = 1; i <= remaining; i++) { html += `
${i}
`; } // Append day detail at the very end (below grid) for simplicity on mobile if (this.expandedDay) { const dayEvents = this.events.filter(e => e.start_time && e.start_time.startsWith(this.expandedDay)); html += this.renderDayDetail(this.expandedDay, dayEvents); } return html; } 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(); }); // Day cell tap → expand day detail panel 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(); }); }); // Event clicks in day detail or labels → 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);