823 lines
42 KiB
TypeScript
823 lines
42 KiB
TypeScript
/**
|
|
* <folk-calendar-view> — 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<string, { phase: string; illumination: number }> = {};
|
|
private showLunar = true;
|
|
private selectedDate = "";
|
|
private selectedEvent: any = null;
|
|
private expandedDay = ""; // mobile day-detail panel
|
|
private error = "";
|
|
private filteredSources = new Set<string>();
|
|
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<string, { phase: string; illumination: number }> = {};
|
|
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<string, string> = {
|
|
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 = `
|
|
<style>
|
|
:host { display: block; font-family: system-ui, -apple-system, sans-serif; color: #e0e0e0; padding: 0.5rem; }
|
|
* { box-sizing: border-box; }
|
|
|
|
.nav { display: flex; gap: 8px; margin-bottom: 12px; align-items: center; min-height: 36px; flex-wrap: wrap; }
|
|
.nav-btn { padding: 6px 12px; border-radius: 6px; border: 1px solid rgba(255,255,255,0.12); background: transparent; color: #94a3b8; cursor: pointer; font-size: 14px; -webkit-tap-highlight-color: transparent; }
|
|
.nav-btn:hover { color: #e2e8f0; border-color: rgba(255,255,255,0.2); }
|
|
.nav-btn.active { border-color: #6366f1; color: #6366f1; }
|
|
.nav-title { font-size: 15px; font-weight: 600; flex: 1; text-align: center; color: #e2e8f0; }
|
|
.nav-primary { padding: 6px 14px; border-radius: 6px; border: none; background: #4f46e5; color: #fff; font-weight: 600; cursor: pointer; font-size: 12px; }
|
|
|
|
/* View switcher */
|
|
.view-switch { display: flex; gap: 2px; margin-bottom: 12px; background: #16161e; border-radius: 8px; padding: 3px; border: 1px solid #222; }
|
|
.view-switch-btn { flex: 1; padding: 5px 10px; border-radius: 6px; border: none; background: transparent; color: #64748b; cursor: pointer; font-size: 12px; font-weight: 500; transition: all 0.15s; }
|
|
.view-switch-btn:hover { color: #94a3b8; }
|
|
.view-switch-btn.active { background: #4f46e5; color: #fff; }
|
|
|
|
.sources { display: flex; gap: 6px; margin-bottom: 10px; flex-wrap: wrap; }
|
|
.src-badge { font-size: 10px; padding: 3px 8px; border-radius: 10px; border: 1px solid #333; cursor: pointer; transition: opacity 0.15s; user-select: none; }
|
|
.src-badge:hover { filter: brightness(1.2); }
|
|
.src-badge.filtered { opacity: 0.3; text-decoration: line-through; }
|
|
|
|
.weekdays { display: grid; grid-template-columns: repeat(7, 1fr); gap: 2px; margin-bottom: 4px; }
|
|
.wd { text-align: center; font-size: 11px; color: #64748b; padding: 4px; font-weight: 600; }
|
|
|
|
.grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 2px; }
|
|
.day {
|
|
background: #16161e; border: 1px solid #222; border-radius: 6px;
|
|
min-height: 80px; padding: 6px; cursor: pointer; position: relative;
|
|
-webkit-tap-highlight-color: transparent;
|
|
}
|
|
.day:hover { border-color: #444; }
|
|
.day.today { border-color: #6366f1; background: rgba(99,102,241,0.06); }
|
|
.day.expanded { border-color: #6366f1; background: rgba(99,102,241,0.1); }
|
|
.day.other { opacity: 0.3; }
|
|
.day-num { font-size: 12px; font-weight: 600; margin-bottom: 2px; display: flex; justify-content: space-between; }
|
|
.moon { font-size: 10px; opacity: 0.7; }
|
|
.dots { display: flex; flex-wrap: wrap; gap: 1px; }
|
|
.dot { width: 6px; height: 6px; border-radius: 50%; display: inline-block; margin: 1px; }
|
|
.ev-label { font-size: 9px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: #aaa; line-height: 1.4; padding: 1px 3px; border-radius: 3px; cursor: pointer; }
|
|
.ev-label:hover { background: rgba(255,255,255,0.08); }
|
|
.ev-time { color: #666; font-size: 8px; margin-right: 2px; }
|
|
|
|
/* Day detail panel */
|
|
.day-detail { grid-column: 1 / -1; background: #1a1a2e; border: 1px solid #334155; border-radius: 8px; padding: 12px; }
|
|
.dd-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
|
|
.dd-date { font-size: 14px; font-weight: 600; color: #e2e8f0; }
|
|
.dd-close { background: none; border: none; color: #64748b; font-size: 18px; cursor: pointer; padding: 4px 8px; }
|
|
.dd-event { display: flex; gap: 8px; align-items: flex-start; padding: 8px; border-radius: 6px; margin-bottom: 4px; cursor: pointer; -webkit-tap-highlight-color: transparent; }
|
|
.dd-event:hover { background: rgba(255,255,255,0.05); }
|
|
.dd-color { width: 4px; border-radius: 2px; align-self: stretch; flex-shrink: 0; }
|
|
.dd-info { flex: 1; min-width: 0; }
|
|
.dd-title { font-size: 13px; font-weight: 500; color: #e2e8f0; }
|
|
.dd-meta { font-size: 11px; color: #94a3b8; margin-top: 2px; }
|
|
.dd-empty { font-size: 12px; color: #64748b; padding: 8px 0; }
|
|
|
|
/* Event modal */
|
|
.modal-bg { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; z-index: 1000; }
|
|
.modal { background: #1e1e2e; border: 1px solid #333; border-radius: 12px; padding: 20px; max-width: 400px; width: 90%; }
|
|
.modal-title { font-size: 16px; font-weight: 600; margin-bottom: 12px; }
|
|
.modal-field { font-size: 13px; color: #aaa; margin-bottom: 6px; }
|
|
.modal-close { float: right; background: none; border: none; color: #888; font-size: 18px; cursor: pointer; }
|
|
|
|
/* ── Day View ── */
|
|
.day-view { position: relative; }
|
|
.day-view-header { font-size: 13px; color: #94a3b8; margin-bottom: 8px; font-weight: 500; }
|
|
.day-allday { background: #16161e; border: 1px solid #222; border-radius: 8px; padding: 8px; margin-bottom: 8px; }
|
|
.day-allday-label { font-size: 10px; color: #64748b; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 4px; }
|
|
.timeline { position: relative; border-left: 1px solid #222; margin-left: 44px; }
|
|
.hour-row { display: flex; min-height: 48px; border-bottom: 1px solid rgba(255,255,255,0.04); position: relative; }
|
|
.hour-label { position: absolute; left: -48px; top: -7px; width: 40px; text-align: right; font-size: 10px; color: #4a5568; font-variant-numeric: tabular-nums; }
|
|
.hour-content { flex: 1; position: relative; padding-left: 8px; }
|
|
.tl-event {
|
|
position: absolute; left: 8px; right: 8px; border-radius: 6px;
|
|
padding: 4px 8px; font-size: 11px; overflow: hidden; cursor: pointer;
|
|
border-left: 3px solid; z-index: 1; transition: opacity 0.15s;
|
|
}
|
|
.tl-event:hover { opacity: 0.85; }
|
|
.tl-event-title { font-weight: 600; color: #e2e8f0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
.tl-event-time { font-size: 10px; color: #94a3b8; }
|
|
.tl-event-loc { font-size: 10px; color: #64748b; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
.now-line { position: absolute; left: 0; right: 0; height: 2px; background: #ef4444; z-index: 5; }
|
|
.now-dot { position: absolute; left: -5px; top: -3px; width: 8px; height: 8px; border-radius: 50%; background: #ef4444; }
|
|
|
|
/* ── Week View ── */
|
|
.week-view { overflow-x: auto; }
|
|
.week-header { display: grid; grid-template-columns: 44px repeat(7, 1fr); gap: 0; margin-bottom: 0; }
|
|
.week-day-header {
|
|
text-align: center; padding: 8px 4px; font-size: 11px; color: #64748b; font-weight: 600;
|
|
border-bottom: 1px solid #222; cursor: pointer;
|
|
}
|
|
.week-day-header:hover { color: #e2e8f0; }
|
|
.week-day-header.today { color: #6366f1; border-bottom-color: #6366f1; }
|
|
.week-day-num { font-size: 16px; font-weight: 700; display: block; }
|
|
.week-day-name { font-size: 10px; text-transform: uppercase; letter-spacing: 0.05em; }
|
|
.week-grid { display: grid; grid-template-columns: 44px repeat(7, 1fr); }
|
|
.week-time-label { font-size: 10px; color: #4a5568; text-align: right; padding-right: 6px; padding-top: 0; font-variant-numeric: tabular-nums; height: 48px; }
|
|
.week-cell { border-left: 1px solid rgba(255,255,255,0.04); border-bottom: 1px solid rgba(255,255,255,0.04); min-height: 48px; position: relative; }
|
|
.week-cell.today { background: rgba(99,102,241,0.04); }
|
|
.week-event {
|
|
position: absolute; left: 2px; right: 2px; border-radius: 4px;
|
|
padding: 2px 4px; font-size: 10px; overflow: hidden; cursor: pointer;
|
|
border-left: 2px solid; z-index: 1;
|
|
}
|
|
.week-event:hover { opacity: 0.85; }
|
|
.week-event-title { font-weight: 600; color: #e2e8f0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
|
|
/* Mobile */
|
|
@media (max-width: 768px) {
|
|
:host { padding: 0.25rem; }
|
|
.day { min-height: 52px; padding: 4px; }
|
|
.day-num { font-size: 11px; }
|
|
.ev-label { display: none; }
|
|
.dot { width: 5px; height: 5px; }
|
|
.moon { font-size: 8px; }
|
|
.nav-title { font-size: 13px; }
|
|
.nav { gap: 4px; }
|
|
.sources { gap: 4px; }
|
|
.src-badge { font-size: 9px; padding: 2px 6px; }
|
|
.wd { font-size: 10px; padding: 3px; }
|
|
.week-view { font-size: 10px; }
|
|
}
|
|
@media (max-width: 480px) {
|
|
.day { min-height: 44px; padding: 3px; }
|
|
.day-num { font-size: 10px; }
|
|
.wd { font-size: 9px; padding: 2px; }
|
|
.nav { flex-wrap: wrap; justify-content: center; }
|
|
.nav-title { width: 100%; order: -1; margin-bottom: 4px; }
|
|
}
|
|
</style>
|
|
|
|
${this.error ? `<div style="color:#ef5350;text-align:center;padding:8px">${this.esc(this.error)}</div>` : ""}
|
|
|
|
<div class="nav">
|
|
<button class="nav-btn" id="prev">\u2190</button>
|
|
<button class="nav-btn" id="today">Today</button>
|
|
<span class="nav-title">${viewLabel}</span>
|
|
<button class="nav-btn ${this.showLunar ? "active" : ""}" id="toggle-lunar">\u{1F319}</button>
|
|
<button class="nav-btn" id="next">\u2192</button>
|
|
</div>
|
|
|
|
<div class="view-switch">
|
|
<button class="view-switch-btn ${this.viewMode === "day" ? "active" : ""}" data-view="day">Day</button>
|
|
<button class="view-switch-btn ${this.viewMode === "week" ? "active" : ""}" data-view="week">Week</button>
|
|
<button class="view-switch-btn ${this.viewMode === "month" ? "active" : ""}" data-view="month">Month</button>
|
|
</div>
|
|
|
|
${this.sources.length > 0 ? `<div class="sources">
|
|
${this.sources.map(s => `<span class="src-badge ${this.filteredSources.has(s.name) ? "filtered" : ""}" data-source="${this.esc(s.name)}" style="border-color:${s.color || "#666"};color:${s.color || "#aaa"}">${this.esc(s.name)}</span>`).join("")}
|
|
</div>` : ""}
|
|
|
|
${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 `
|
|
<div class="weekdays">
|
|
${["S", "M", "T", "W", "T", "F", "S"].map(d => `<div class="wd">${d}</div>`).join("")}
|
|
</div>
|
|
<div class="grid">
|
|
${this.renderDays(year, month)}
|
|
</div>`;
|
|
}
|
|
|
|
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 += `<div class="day other"><div class="day-num">${prevDays - i}</div></div>`;
|
|
}
|
|
|
|
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 += `<div class="day ${isToday ? "today" : ""} ${isExpanded ? "expanded" : ""}" data-date="${ds}">
|
|
<div class="day-num">
|
|
<span>${d}</span>
|
|
${this.showLunar && lunar ? `<span class="moon">${this.getMoonEmoji(lunar.phase)}</span>` : ""}
|
|
</div>
|
|
${dayEvents.length > 0 ? `
|
|
<div class="dots">
|
|
${dayEvents.slice(0, 4).map(e => `<span class="dot" style="background:${e.source_color || "#6366f1"}"></span>`).join("")}
|
|
${dayEvents.length > 4 ? `<span style="font-size:8px;color:#888">+${dayEvents.length - 4}</span>` : ""}
|
|
</div>
|
|
${dayEvents.slice(0, 2).map(e => {
|
|
return `<div class="ev-label" style="border-left:2px solid ${e.source_color || "#6366f1"}" data-event-id="${e.id}"><span class="ev-time">${this.formatTime(e.start_time)}</span>${this.esc(e.title)}</div>`;
|
|
}).join("")}
|
|
` : ""}
|
|
</div>`;
|
|
|
|
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 += `<div class="day other"><div class="day-num">${i}</div></div>`;
|
|
}
|
|
|
|
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 += `<div class="hour-row">
|
|
<span class="hour-label">${label}</span>
|
|
<div class="hour-content"></div>
|
|
</div>`;
|
|
}
|
|
|
|
// 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 += `<div class="tl-event" data-event-id="${ev.id}" style="
|
|
top: ${topPx}px; height: ${heightPx}px;
|
|
background: ${bgColor}; border-left-color: ${ev.source_color || "#6366f1"};
|
|
">
|
|
<div class="tl-event-title">${this.esc(ev.title)}</div>
|
|
<div class="tl-event-time">${this.formatTime(ev.start_time)} \u2013 ${this.formatTime(ev.end_time)}</div>
|
|
${ev.location_name ? `<div class="tl-event-loc">${this.esc(ev.location_name)}</div>` : ""}
|
|
</div>`;
|
|
}
|
|
|
|
// 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 = `<div class="now-line" style="top:${nowPx}px"><div class="now-dot"></div></div>`;
|
|
}
|
|
}
|
|
|
|
return `
|
|
<div class="day-view">
|
|
<div class="day-view-header">
|
|
${this.showLunar && lunar ? `${this.getMoonEmoji(lunar.phase)} ${Math.round(lunar.illumination * 100)}% illuminated \u00B7 ` : ""}
|
|
${dayEvents.length} event${dayEvents.length !== 1 ? "s" : ""}
|
|
</div>
|
|
${allDay.length > 0 ? `<div class="day-allday">
|
|
<div class="day-allday-label">All Day</div>
|
|
${allDay.map(e => `<div class="dd-event" data-event-id="${e.id}">
|
|
<div class="dd-color" style="background:${e.source_color || "#6366f1"}"></div>
|
|
<div class="dd-info"><div class="dd-title">${this.esc(e.title)}</div></div>
|
|
</div>`).join("")}
|
|
</div>` : ""}
|
|
<div class="timeline" style="height:${(END_HOUR - START_HOUR + 1) * HOUR_HEIGHT}px">
|
|
${hoursHtml}
|
|
${eventsHtml}
|
|
${nowHtml}
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
// ── 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 = `<div class="week-day-header" style="border-bottom:none"></div>`;
|
|
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 += `<div class="week-day-header ${isToday ? "today" : ""}" data-date="${ds}" data-day-click="true">
|
|
<span class="week-day-name">${day.toLocaleDateString("default", { weekday: "short" })}</span>
|
|
<span class="week-day-num">${day.getDate()}</span>
|
|
</div>`;
|
|
}
|
|
|
|
// 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 += `<div class="week-time-label">${label}</div>`;
|
|
for (let i = 0; i < 7; i++) {
|
|
const ds = this.dateStr(days[i]);
|
|
const isToday = ds === todayStr;
|
|
gridHtml += `<div class="week-cell ${isToday ? "today" : ""}" data-col="${i}" data-hour="${h}"></div>`;
|
|
}
|
|
}
|
|
|
|
// 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 += `<div class="week-event" data-event-id="${ev.id}" style="
|
|
top: ${topPx}px; height: ${heightPx}px; left: ${colLeft}; width: ${colWidth};
|
|
background: ${bgColor}; border-left-color: ${ev.source_color || "#6366f1"};
|
|
">
|
|
<div class="week-event-title">${this.esc(ev.title)}</div>
|
|
</div>`;
|
|
}
|
|
}
|
|
|
|
// 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 = `<div class="now-line" style="top:${nowPx}px;left:44px;right:0"><div class="now-dot"></div></div>`;
|
|
}
|
|
}
|
|
|
|
return `
|
|
<div class="week-view">
|
|
<div class="week-header">${headerHtml}</div>
|
|
<div style="position:relative;overflow-y:auto;max-height:600px;">
|
|
<div class="week-grid" style="position:relative;height:${totalHeight}px">
|
|
${gridHtml}
|
|
</div>
|
|
${eventsOverlay}
|
|
${nowHtml}
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
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 `<div class="day-detail">
|
|
<div class="dd-header">
|
|
<span class="dd-date">${label}</span>
|
|
<button class="dd-close" id="dd-close">\u2715</button>
|
|
</div>
|
|
${dayEvents.length === 0 ? `<div class="dd-empty">No events</div>` :
|
|
dayEvents.sort((a, b) => a.start_time.localeCompare(b.start_time)).map(e => `
|
|
<div class="dd-event" data-event-id="${e.id}">
|
|
<div class="dd-color" style="background:${e.source_color || "#6366f1"}"></div>
|
|
<div class="dd-info">
|
|
<div class="dd-title">${this.esc(e.title)}</div>
|
|
<div class="dd-meta">${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" : ""}</div>
|
|
</div>
|
|
</div>
|
|
`).join("")}
|
|
</div>`;
|
|
}
|
|
|
|
private renderEventModal(): string {
|
|
const e = this.selectedEvent;
|
|
return `
|
|
<div class="modal-bg" id="modal-overlay">
|
|
<div class="modal">
|
|
<button class="modal-close" id="modal-close">\u2715</button>
|
|
<div class="modal-title">${this.esc(e.title)}</div>
|
|
${e.description ? `<div class="modal-field">${this.esc(e.description)}</div>` : ""}
|
|
<div class="modal-field">${new Date(e.start_time).toLocaleString()}${e.end_time ? ` \u2013 ${new Date(e.end_time).toLocaleString()}` : ""}</div>
|
|
${e.location_name ? `<div class="modal-field">\u{1F4CD} ${this.esc(e.location_name)}</div>` : ""}
|
|
${e.source_name ? `<div class="modal-field" style="margin-top:8px"><span class="src-badge" style="border-color:${e.source_color || "#666"};color:${e.source_color || "#aaa"}">${this.esc(e.source_name)}</span></div>` : ""}
|
|
${e.is_virtual ? `<div class="modal-field">\u{1F4BB} ${this.esc(e.virtual_platform || "Virtual")} ${e.virtual_url ? `<a href="${e.virtual_url}" target="_blank" style="color:#6366f1">Join</a>` : ""}</div>` : ""}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
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);
|