/** * — 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"; 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 (Google Calendar)", color: "#3b82f6" }, { name: "Travel (Manual)", color: "#f97316" }, { name: "Personal (ICS)", color: "#10b981" }, { name: "Conferences (Manual)", color: "#8b5cf6" }, ]; // Dense workday calendar across multiple cities const demoEvents: { day: number; title: string; source: number; desc: string; location: string | null; virtual: boolean; startH: number; startM: number; durationMin: number }[] = [ // --- Berlin Work Block (Days 3-10) --- { day: 3, title: "Team Standup", source: 0, desc: "Daily sync — Berlin engineering team", location: "Factory Berlin, Rheinsberger Str. 76/77", 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 with stakeholders", location: "Factory Berlin, Conference 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, Rosenthaler Str. 62, Berlin", virtual: false, startH: 12, startM: 30, durationMin: 60 }, { day: 7, title: "Sprint Planning", source: 0, desc: "Plan sprint 24 — local-first sync features", location: "Factory Berlin, Conference 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 — career growth, project scope", location: "Factory Berlin, Phone Booth 4", 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 }, // --- Travel: Berlin to Amsterdam (Days 12-14) --- { day: 12, title: "Train Berlin \u2192 Amsterdam", source: 1, desc: "ICE 643 Berlin Hbf \u2192 Amsterdam Centraal, Seat 64 Window", location: "Berlin Hauptbahnhof, Platform 11", virtual: false, startH: 7, startM: 15, durationMin: 390 }, { day: 12, title: "Hotel Check-in", source: 1, desc: "Hotel V Nesplein, Nes 49, Amsterdam", location: "Hotel V Nesplein, Amsterdam", virtual: false, startH: 14, startM: 30, durationMin: 30 }, { day: 13, title: "Partner Meeting", source: 0, desc: "On-site collaboration with Amsterdam design team", location: "WeWork Weteringschans, Amsterdam", virtual: false, startH: 10, startM: 0, durationMin: 180 }, { day: 13, title: "Canal District Walk", source: 2, desc: "Afternoon stroll along Prinsengracht and Jordaan", location: "Prinsengracht, Amsterdam", virtual: false, startH: 15, startM: 0, durationMin: 120 }, { day: 13, title: "Dinner at Moeders", source: 2, desc: "Traditional Dutch dinner — try the stamppot", location: "Restaurant Moeders, Rozengracht 251, Amsterdam", virtual: false, startH: 19, startM: 0, durationMin: 90 }, { day: 14, title: "Return Train Amsterdam \u2192 Berlin", source: 1, desc: "ICE 148 Amsterdam Centraal \u2192 Berlin Hbf, Seat 38 Aisle", location: "Amsterdam Centraal, Platform 7b", virtual: false, startH: 9, startM: 30, durationMin: 390 }, // --- Personal (Days 15-20) --- { day: 15, title: "Dinner with Friends", source: 2, desc: "Birthday dinner for Mia at the Italian place", location: "Il Casolare, Grimmstr. 30, Berlin", virtual: false, startH: 19, startM: 30, durationMin: 120 }, { day: 16, title: "Grocery Run", source: 2, desc: "Weekly groceries at the Saturday market", location: "Maybachufer Market, Berlin", virtual: false, startH: 10, startM: 0, durationMin: 60 }, { day: 17, title: "Weekend Hike", source: 2, desc: "Grunewald forest loop trail, ~14 km", location: "S-Bahn Grunewald, Berlin", virtual: false, startH: 8, startM: 0, durationMin: 300 }, { day: 19, title: "Dentist Appointment", source: 2, desc: "Regular checkup, Dr. Weber", location: "Zahnarzt Weber, Torstr. 140, Berlin", virtual: false, startH: 9, startM: 0, durationMin: 45 }, { day: 20, title: "Yoga Class", source: 2, desc: "Vinyasa flow — bring own mat", location: "Yoga Studio Kreuzberg, Oranienstr. 25, Berlin", virtual: false, startH: 7, startM: 30, durationMin: 75 }, { day: 20, title: "Book Club", source: 2, desc: "Discussing \"The Mushroom at the End of the World\" by Anna Tsing", location: "Shakespeare & Sons, Warschauer Str. 74, Berlin", virtual: false, startH: 19, startM: 0, durationMin: 90 }, // --- Work Wrap-up (Days 21-22) --- { day: 21, title: "Sprint Retro", source: 0, desc: "Sprint 23 retrospective — what worked, what didn't", location: "Factory Berlin, Conference Room 2", virtual: false, startH: 10, startM: 0, durationMin: 60 }, { day: 21, title: "Release Deploy", source: 0, desc: "Push v2.4.0 to production, monitor metrics", location: null, virtual: true, startH: 14, startM: 0, durationMin: 120 }, { day: 22, title: "Demo Day", source: 0, desc: "Sprint 23 showcase — live demos for stakeholders and community", location: "Factory Berlin, Main Hall", virtual: false, startH: 14, startM: 0, durationMin: 90 }, { day: 22, title: "Team Drinks", source: 2, desc: "Celebrate the release with the team", location: "Prater Garten, Kastanienallee 7-9, Berlin", virtual: false, startH: 17, startM: 30, durationMin: 120 }, // --- Conference: Web Summit (Days 24-27, Lisbon) --- { day: 24, title: "Flight Berlin \u2192 Lisbon", source: 1, desc: "TAP TP 571 BER \u2192 LIS, Gate B22", location: "BER Airport, Berlin", virtual: false, startH: 6, startM: 45, durationMin: 195 }, { day: 24, title: "Hotel Check-in Lisbon", source: 1, desc: "Hotel da Baixa, Rua da Prata 242, Lisbon", location: "Hotel da Baixa, Lisbon", virtual: false, startH: 13, startM: 0, durationMin: 30 }, { day: 25, title: "Web Summit Day 1", source: 3, desc: "Opening keynotes, startup pavilion, networking. Main stage + Centre Stage tracks.", location: "Altice Arena / FIL, Lisbon", virtual: false, startH: 9, startM: 0, durationMin: 540 }, { day: 25, title: "Speaker Dinner", source: 3, desc: "Invited speakers dinner at the riverside venue", location: "Ponto Final, Cacilhas, Lisbon", virtual: false, startH: 20, startM: 0, durationMin: 120 }, { day: 26, title: "Web Summit Day 2", source: 3, desc: "Panel: \"Local-First Software & the Future of Collaboration\". Our talk at 14:00 on Centre Stage.", location: "Altice Arena / FIL, Lisbon", virtual: false, startH: 9, startM: 0, durationMin: 540 }, { day: 26, title: "Networking Happy Hour", source: 3, desc: "Post-conference drinks with open-source community", location: "Time Out Market, Lisbon", virtual: false, startH: 18, startM: 30, durationMin: 120 }, { day: 27, title: "Lisbon City Tour", source: 2, desc: "Alfama neighborhood, Tram 28, Pasteis de Belem, Praca do Comercio", location: "Alfama, Lisbon", virtual: false, startH: 10, startM: 0, durationMin: 360 }, { day: 27, title: "Flight Lisbon \u2192 Berlin", source: 1, desc: "TAP TP 572 LIS \u2192 BER, Gate 41", location: "Lisbon Humberto Delgado 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 for each day of the month const knownNewMoon = new Date(2026, 0, 29).getTime(); // Jan 29, 2026 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; } } // Rough illumination: 0 at new, 1 at full 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.loadMonth(); } private getMoonEmoji(phase: string): string { const map: Record = { new_moon: "🌑", waxing_crescent: "🌒", first_quarter: "🌓", waxing_gibbous: "🌔", full_moon: "🌕", waning_gibbous: "🌖", last_quarter: "🌗", waning_crescent: "🌘", }; 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 => { const t = new Date(e.start_time); const timeStr = `${t.getHours()}:${String(t.getMinutes()).padStart(2, "0")}`; return `
${timeStr}${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("today")?.addEventListener("click", () => { this.currentDate = new Date(); if (this.space === "demo") { this.loadDemoData(); } else { this.loadMonth(); } }); 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);