rspace-online/modules/rcal/components/folk-calendar-view.ts

2078 lines
102 KiB
TypeScript

/**
* <folk-calendar-view> — spatio-temporal coordination calendar.
*
* Five views: Day timeline, Week timeline, Month grid, Season (3 mini-months), Year (12 mini-months).
* Unified layout: calendar always primary, map auto-shows as a floating/minimizable panel,
* lunar phases overlay directly onto the calendar, scroll/trackpad zoom controls granularity.
* Features: temporal-spatial zoom coupling, event markers with transit polylines,
* lunar phase overlay, source filtering, and day-detail panels.
*/
// ── Granularity Constants ──
const TEMPORAL_LABELS = ["Moment","Hour","Day","Week","Month","Season","Year","Decade","Century","Cosmic"];
const SPATIAL_LABELS = ["Planet","Continent","Bioregion","Country","Region","City","Neighborhood","Address","Coordinates"];
const T_TO_S = [8, 7, 7, 5, 3, 3, 1, 1, 0, 0]; // temporal → spatial coupling
const S_TO_ZOOM = [2, 4, 5, 6, 8, 11, 14, 16, 18]; // spatial → Leaflet zoom
const T_TO_VIEW: Record<number, "day"|"week"|"month"|"season"|"year"|"multi-year"> = {
2: "day", 3: "week", 4: "month", 5: "season", 6: "year", 7: "multi-year"
};
const VIEW_VARIANTS: Record<string, number> = {
day: 2, week: 1, month: 2, season: 1, year: 2, "multi-year": 1
};
// ── Leaflet CDN Loader ──
let _leafletReady = false;
let _leafletPromise: Promise<void> | null = null;
function ensureLeaflet(): Promise<void> {
if (_leafletReady && typeof (window as any).L !== "undefined") return Promise.resolve();
if (_leafletPromise) return _leafletPromise;
_leafletPromise = new Promise<void>((resolve, reject) => {
const s = document.createElement("script");
s.src = "https://unpkg.com/leaflet@1.9.4/dist/leaflet.js";
s.onload = () => { _leafletReady = true; resolve(); };
s.onerror = () => reject(new Error("Leaflet load failed"));
document.head.appendChild(s);
});
return _leafletPromise;
}
// ── Lunar Phase Computation (no external deps) ──
const SYNODIC_CYCLE = 29.53059;
const KNOWN_NEW_MOON_MS = new Date(2024, 0, 11, 11, 57).getTime();
const PHASE_THRESHOLDS: [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 MOON_EMOJI: 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}",
};
function lunarPhaseForDate(d: Date): { phase: string; illumination: number; age: number; emoji: string } {
const raw = ((d.getTime() - KNOWN_NEW_MOON_MS) / 86400000) % SYNODIC_CYCLE;
const age = raw < 0 ? raw + SYNODIC_CYCLE : raw;
let phase = "new_moon";
for (const [name, threshold] of PHASE_THRESHOLDS) {
if (age < threshold) { phase = name; break; }
}
const illumination = Math.round((1 - Math.cos(2 * Math.PI * age / SYNODIC_CYCLE)) / 2 * 100) / 100;
return { phase, illumination, age, emoji: MOON_EMOJI[phase] || "" };
}
function findNewMoon(fromDate: Date, direction: -1 | 1): Date {
let minIllum = 1;
let minDate = new Date(fromDate);
for (let i = 0; i <= 32; i++) {
const check = new Date(fromDate.getTime() + direction * i * 86400000);
const info = lunarPhaseForDate(check);
if (info.illumination < minIllum) { minIllum = info.illumination; minDate = check; }
if (info.illumination > minIllum + 0.05 && minIllum < 0.05) break;
}
return minDate;
}
function getSynodicMonth(date: Date) {
const startDate = findNewMoon(date, -1);
const endDate = findNewMoon(new Date(startDate.getTime() + 86400000), 1);
const durationDays = (endDate.getTime() - startDate.getTime()) / 86400000;
const offsets: [number, string][] = [
[0, "new_moon"], [0.125, "waxing_crescent"], [0.25, "first_quarter"],
[0.375, "waxing_gibbous"], [0.5, "full_moon"], [0.625, "waning_gibbous"],
[0.75, "last_quarter"], [0.875, "waning_crescent"],
];
const phases = offsets.map(([frac, phase]) => ({
date: new Date(startDate.getTime() + frac * durationDays * 86400000),
phase, emoji: MOON_EMOJI[phase] || "",
}));
return { startDate, endDate, durationDays, phases };
}
function leafletZoomToSpatial(zoom: number): number {
if (zoom <= 2) return 0; if (zoom <= 4) return 1; if (zoom <= 5) return 2;
if (zoom <= 7) return 3; if (zoom <= 9) return 4; if (zoom <= 12) return 5;
if (zoom <= 15) return 6; if (zoom <= 17) return 7; return 8;
}
// ── Component ──
class FolkCalendarView extends HTMLElement {
private shadow: ShadowRoot;
private space = "";
private currentDate = new Date();
private viewMode: "month" | "week" | "day" | "season" | "year" | "multi-year" = "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 = "";
private error = "";
private filteredSources = new Set<string>();
private boundKeyHandler: ((e: KeyboardEvent) => void) | null = null;
// Spatio-temporal state
private temporalGranularity = 4; // MONTH
private spatialGranularity: number | null = null; // null = auto-coupled
private zoomCoupled = true;
// Map panel state (replaces old tab system)
private mapPanelState: "minimized" | "floating" | "docked" = "floating";
private lunarOverlayExpanded = false;
private _wheelTimer: ReturnType<typeof setTimeout> | null = null;
// Transition state
private _pendingTransition: 'nav-left' | 'nav-right' | 'zoom-in' | 'zoom-out' | null = null;
private _ghostHtml: string | null = null;
private _transitionActive = false;
private viewVariant = 0;
// Leaflet map (preserved across re-renders)
private leafletMap: any = null;
private mapContainer: HTMLDivElement | null = null;
private mapMarkerLayer: any = null;
private transitLineLayer: any = 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;
}
if (this._wheelTimer) { clearTimeout(this._wheelTimer); this._wheelTimer = null; }
if (this.leafletMap) {
this.leafletMap.remove();
this.leafletMap = null;
this.mapContainer = null;
}
}
// ── Keyboard ──
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.setTemporalGranularity(2); break;
case "2": this.setTemporalGranularity(3); break;
case "3": this.setTemporalGranularity(4); break;
case "4": this.setTemporalGranularity(5); break;
case "5": this.setTemporalGranularity(6); break;
case "6": this.setTemporalGranularity(7); break;
case "v": case "V": {
const maxVariant = VIEW_VARIANTS[this.viewMode] || 1;
if (maxVariant > 1) {
this.viewVariant = (this.viewVariant + 1) % maxVariant;
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;
case "+": case "=": e.preventDefault(); this.zoomIn(); break;
case "-": case "_": e.preventDefault(); this.zoomOut(); break;
case "m": case "M":
// Cycle: floating → docked → minimized → floating
if (this.mapPanelState === "floating") this.mapPanelState = "docked";
else if (this.mapPanelState === "docked") this.mapPanelState = "minimized";
else this.mapPanelState = "floating";
this.render();
break;
case "c": case "C":
this.zoomCoupled = !this.zoomCoupled;
if (this.zoomCoupled) { this.spatialGranularity = null; this.syncMapToSpatial(); }
this.render();
break;
}
}
// ── Zoom & Coupling ──
private setTemporalGranularity(n: number) {
const oldGranularity = this.temporalGranularity;
n = Math.max(2, Math.min(7, n));
if (n !== oldGranularity) {
const calPane = this.shadow.getElementById('calendar-pane');
this._ghostHtml = calPane?.innerHTML || null;
this._pendingTransition = n < oldGranularity ? 'zoom-in' : 'zoom-out';
this.viewVariant = 0;
}
this.temporalGranularity = n;
const view = T_TO_VIEW[n];
if (view) this.viewMode = view;
if (this.zoomCoupled) { this.spatialGranularity = null; this.syncMapToSpatial(); }
this.expandedDay = "";
this.render();
}
private zoomIn() { this.setTemporalGranularity(this.temporalGranularity - 1); }
private zoomOut() { if (this.temporalGranularity < 7) this.setTemporalGranularity(this.temporalGranularity + 1); }
private getEffectiveSpatialIndex(): number {
if (this.zoomCoupled) return T_TO_S[this.temporalGranularity];
return this.spatialGranularity ?? T_TO_S[4];
}
private getEffectiveLeafletZoom(): number {
return S_TO_ZOOM[this.getEffectiveSpatialIndex()];
}
private syncMapToSpatial() {
if (!this.leafletMap) return;
const zoom = this.getEffectiveLeafletZoom();
const center = this.computeMapCenter();
this.leafletMap.flyTo(center, zoom, { duration: 0.8 });
}
private computeMapCenter(): [number, number] {
const located = this.events.filter(e =>
e.latitude != null && e.longitude != null && !this.filteredSources.has(e.source_name));
if (located.length === 0) return [52.52, 13.405];
let sumLat = 0, sumLng = 0;
for (const e of located) { sumLat += e.latitude; sumLng += e.longitude; }
return [sumLat / located.length, sumLng / located.length];
}
// ── Demo Data ──
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 rel = (monthDelta: number, day: number, hour: number, min: number) =>
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; breadcrumb?: string;
}[] = [
// ── 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, breadcrumb: "Earth > Europe > Germany > Berlin" },
{ start: rel(-1, 19, 14, 0), durationMin: 60, title: "1:1 with Manager", source: 0, desc: "Quarterly check-in", location: "Factory Berlin", virtual: false, breadcrumb: "Earth > Europe > Germany > Berlin" },
{ 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, breadcrumb: "Earth > Europe > Germany > Berlin > Kreuzberg" },
{ 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, breadcrumb: "Earth > Europe > Germany > Berlin > Kreuzberg" },
{ 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, breadcrumb: "Earth > Europe > Germany > Berlin > Kreuzberg" },
{ 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, breadcrumb: "Earth > Europe > Germany > Berlin > Kreuzberg" },
{ 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, breadcrumb: "Earth > Europe > Germany > Brandenburg > Spreewald" },
// ── 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, breadcrumb: "Earth > Europe > Germany > Berlin" },
{ 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, breadcrumb: "Earth > Europe > Germany > Berlin" },
{ start: rel(0, 2, 9, 0), durationMin: 30, title: "Team Standup", source: 0, desc: "Daily sync", location: "Factory Berlin", virtual: false, breadcrumb: "Earth > Europe > Germany > Berlin" },
{ 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, breadcrumb: "Earth > Europe > Germany > Berlin" },
{ 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, breadcrumb: "Earth > Europe > Germany > Berlin" },
{ 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, breadcrumb: "Earth > Europe > Germany > Berlin" },
{ 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, breadcrumb: "Earth > Europe > Germany > Berlin > Mitte" },
{ 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, breadcrumb: "Earth > Europe > Germany > Berlin" },
{ 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, breadcrumb: "Earth > Europe > Germany > Berlin > Grunewald" },
{ 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, breadcrumb: "Earth > Europe > Germany > Berlin > Kreuzberg" },
{ start: rel(0, 10, 11, 0), durationMin: 45, title: "1:1 with Manager", source: 0, desc: "Monthly check-in", location: "Factory Berlin", virtual: false, breadcrumb: "Earth > Europe > Germany > Berlin" },
{ 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, breadcrumb: "Earth > Europe > Germany > Berlin" },
{ 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, breadcrumb: "Earth > Europe > Netherlands > Amsterdam" },
{ 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, breadcrumb: "Earth > Europe > Netherlands > Amsterdam" },
{ 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, breadcrumb: "Earth > Europe > Netherlands > Amsterdam" },
{ 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, breadcrumb: "Earth > Europe > Netherlands > Amsterdam" },
{ 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, breadcrumb: "Earth > Europe > Germany > Berlin > Kreuzberg" },
{ start: rel(0, 16, 7, 30), durationMin: 75, title: "Morning Yoga", source: 2, desc: "Vinyasa flow", location: "Yoga Studio Kreuzberg", virtual: false, breadcrumb: "Earth > Europe > Germany > Berlin > Kreuzberg" },
{ start: rel(0, 17, 10, 0), durationMin: 60, title: "Sprint Retro", source: 0, desc: "Sprint 23 retrospective", location: "Factory Berlin, Room 2", virtual: false, breadcrumb: "Earth > Europe > Germany > Berlin" },
{ 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, breadcrumb: "Earth > Europe > Germany > Berlin" },
{ 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, breadcrumb: "Earth > Europe > Germany > Berlin > Mitte" },
{ 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, breadcrumb: "Earth > Europe > Germany > Berlin > Kreuzberg" },
{ 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, breadcrumb: "Earth > Europe > Germany > Berlin > Mitte" },
{ 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, breadcrumb: "Earth > Europe > Germany > Berlin" },
{ 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, breadcrumb: "Earth > Europe > Portugal > Lisbon" },
{ 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, breadcrumb: "Earth > Europe > Portugal > Lisbon" },
{ 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, breadcrumb: "Earth > Europe > Portugal > Lisbon" },
{ 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, breadcrumb: "Earth > Europe > Portugal > Lisbon" },
{ 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, breadcrumb: "Earth > Europe > Germany > Berlin > Mitte" },
{ start: rel(0, 27, 9, 0), durationMin: 30, title: "Team Standup", source: 0, desc: "Post-travel sync", location: "Factory Berlin", virtual: false, breadcrumb: "Earth > Europe > Germany > Berlin" },
{ start: rel(0, 28, 15, 0), durationMin: 90, title: "Architecture Review", source: 0, desc: "Review local-first sync architecture", location: "Factory Berlin", virtual: false, breadcrumb: "Earth > Europe > Germany > Berlin" },
// ── 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, breadcrumb: "Earth > Europe > Germany > Berlin" },
{ 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, breadcrumb: "Earth > Europe > Germany > Berlin" },
{ 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, breadcrumb: "Earth > Europe > Spain > Barcelona" },
{ 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, breadcrumb: "Earth > Europe > Spain > Barcelona" },
{ 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, breadcrumb: "Earth > Europe > Spain > Barcelona" },
{ 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, breadcrumb: "Earth > Europe > Germany > Berlin > Tiergarten" },
{ 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, breadcrumb: "Earth > Europe > Belgium > Brussels" },
];
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,
location_breadcrumb: e.breadcrumb || null,
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 visible range (±6 months for season/year views)
const lunar: Record<string, { phase: string; illumination: number }> = {};
for (let m = month - 6; m <= month + 6; m++) {
const actualYear = m < 0 ? year - 1 : (m > 11 ? year + 1 : year);
const actualMonth = ((m % 12) + 12) % 12;
const dim = new Date(actualYear, actualMonth + 1, 0).getDate();
for (let d = 1; d <= dim; d++) {
const ds = `${actualYear}-${String(actualMonth + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
if (lunar[ds]) continue;
const info = lunarPhaseForDate(new Date(actualYear, actualMonth, d));
lunar[ds] = { phase: info.phase, illumination: info.illumination };
}
}
this.lunarData = lunar;
this.render();
}
// ── API ──
private getApiBase(): string {
// When on the rcal page directly, extract from URL
const match = window.location.pathname.match(/^(\/[^/]+)?\/rcal/);
if (match) return match[0];
// When embedded as a canvas shape, use the space attribute
if (this.space) return `/${this.space}/rcal`;
return "";
}
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();
}
// ── Navigation ──
private navigate(delta: number) {
const calPane = this.shadow.getElementById('calendar-pane');
this._ghostHtml = calPane?.innerHTML || null;
this._pendingTransition = delta > 0 ? 'nav-left' : 'nav-right';
switch (this.viewMode) {
case "day":
this.currentDate = new Date(this.currentDate.getFullYear(), this.currentDate.getMonth(), this.currentDate.getDate() + delta);
break;
case "week":
this.currentDate = new Date(this.currentDate.getFullYear(), this.currentDate.getMonth(), this.currentDate.getDate() + delta * 7);
break;
case "month":
this.currentDate = new Date(this.currentDate.getFullYear(), this.currentDate.getMonth() + delta, 1);
break;
case "season":
this.currentDate = new Date(this.currentDate.getFullYear(), this.currentDate.getMonth() + delta * 3, 1);
break;
case "year":
this.currentDate = new Date(this.currentDate.getFullYear() + delta, this.currentDate.getMonth(), 1);
break;
case "multi-year":
this.currentDate = new Date(this.currentDate.getFullYear() + delta * 9, this.currentDate.getMonth(), 1);
break;
}
this.expandedDay = "";
if (this.space !== "demo") { this.loadMonth(); } else { this.render(); }
}
// ── Helpers ──
private getMoonEmoji(phase: string): string { return MOON_EMOJI[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 getSpatialLabel(breadcrumb: string | null | undefined, level: number): string {
if (!breadcrumb) return "";
const parts = breadcrumb.split(" > ").map(s => s.trim());
// SPATIAL_LABELS: Planet(0), Continent(1), Bioregion(2), Country(3), Region(4), City(5), Neighborhood(6), Address(7), Coordinates(8)
// Breadcrumb: "Earth > Europe > Germany > Berlin > Kreuzberg" → indices 0,1,2,3,4
// Map: Country(3)→idx 2, Region(4)→idx 3, City(5)→idx 3-4, Neighborhood(6)→idx 4
const indexMap: Record<number, number> = { 0: 0, 1: 1, 2: 1, 3: 2, 4: 3, 5: 3, 6: 4, 7: 4, 8: 4 };
const partIdx = indexMap[level] ?? 2;
return parts[partIdx] || parts[parts.length - 1] || "";
}
private getCurrentSpatialLabel(): string {
return SPATIAL_LABELS[this.getEffectiveSpatialIndex()];
}
private getUniqueSpatialLabels(events: any[], level: number, max: number): string[] {
const labels = new Set<string>();
for (const e of events) {
if (labels.size >= max) break;
const lbl = this.getSpatialLabel(e.location_breadcrumb, level);
if (lbl) labels.add(lbl);
}
return Array.from(labels);
}
private toggleSource(name: string) {
if (this.filteredSources.has(name)) { this.filteredSources.delete(name); }
else { this.filteredSources.add(name); }
this.render();
}
// ── Transitions ──
private playTransition(calPane: HTMLElement, direction: string, oldHtml: string) {
if (this._transitionActive) return;
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
this._transitionActive = true;
// Create ghost overlay with old content
const ghost = document.createElement('div');
ghost.className = 'transition-ghost';
ghost.innerHTML = oldHtml;
// Wrap new content in enter wrapper
const enterWrap = document.createElement('div');
enterWrap.className = 'transition-enter';
while (calPane.firstChild) enterWrap.appendChild(calPane.firstChild);
calPane.appendChild(enterWrap);
calPane.appendChild(ghost);
// Apply direction-based animation classes
const animMap: Record<string, [string, string]> = {
'nav-left': ['ghost-slide-left', 'enter-slide-left'],
'nav-right': ['ghost-slide-right', 'enter-slide-right'],
'zoom-in': ['ghost-zoom-in', 'enter-zoom-in'],
'zoom-out': ['ghost-zoom-out', 'enter-zoom-out'],
};
const [ghostAnim, enterAnim] = animMap[direction] || animMap['nav-left'];
ghost.classList.add(ghostAnim);
enterWrap.classList.add(enterAnim);
const cleanup = () => {
if (!ghost.parentNode) return;
ghost.remove();
while (enterWrap.firstChild) calPane.insertBefore(enterWrap.firstChild, enterWrap);
enterWrap.remove();
this._transitionActive = false;
};
ghost.addEventListener('animationend', cleanup, { once: true });
setTimeout(cleanup, 400); // safety fallback
}
// ── Main Render ──
private render() {
// Preserve map container across re-renders
if (this.mapContainer && this.mapContainer.parentElement) {
this.mapContainer.remove();
}
const isDocked = this.mapPanelState === "docked";
this.shadow.innerHTML = `
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" crossorigin="">
<style>${this.getStyles()}</style>
${this.error ? `<div class="error">${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">${this.getViewLabel()}</span>
<button class="nav-btn ${this.showLunar ? "active" : ""}" id="toggle-lunar">\u{1F319}</button>
<button class="nav-btn" id="next">\u2192</button>
</div>
${this.renderLunarOverlay()}
${this.renderZoomController()}
${this.renderSources()}
<div class="main-layout ${isDocked ? "main-layout--docked" : ""}">
<div class="calendar-pane" id="calendar-pane">
${this.renderCalendarContent()}
</div>
${this.renderMapPanel()}
</div>
<div class="kbd-hint">
<kbd>+</kbd>/<kbd>-</kbd> zoom &bull;
<kbd>\u2190</kbd>/<kbd>\u2192</kbd> nav &bull;
<kbd>t</kbd> today &bull;
<kbd>1-6</kbd> view &bull;
<kbd>v</kbd> variant &bull;
<kbd>m</kbd> map &bull;
<kbd>c</kbd> coupling &bull;
<kbd>l</kbd> lunar &bull;
<kbd>scroll</kbd> zoom
</div>
${this.selectedEvent ? this.renderEventModal() : ""}
`;
this.attachListeners();
// Play transition if pending
if (this._pendingTransition && this._ghostHtml) {
const calPaneEl = this.shadow.getElementById('calendar-pane');
if (calPaneEl) this.playTransition(calPaneEl, this._pendingTransition, this._ghostHtml);
}
this._pendingTransition = null;
this._ghostHtml = null;
// Initialize or update map when not minimized
if (this.mapPanelState !== "minimized") {
this.initOrUpdateMap();
}
}
private getViewLabel(): string {
const d = this.currentDate;
switch (this.viewMode) {
case "day":
return d.toLocaleDateString("default", { weekday: "long", month: "long", day: "numeric", year: "numeric" });
case "week": {
const ws = new Date(d); ws.setDate(d.getDate() - d.getDay());
const we = new Date(ws); we.setDate(ws.getDate() + 6);
return `${ws.toLocaleDateString("default", { month: "short", day: "numeric" })} \u2013 ${we.toLocaleDateString("default", { month: "short", day: "numeric", year: "numeric" })}`;
}
case "month":
return d.toLocaleString("default", { month: "long", year: "numeric" });
case "season": {
const q = Math.floor(d.getMonth() / 3);
return `${["Winter","Spring","Summer","Autumn"][q]} ${d.getFullYear()}`;
}
case "year":
return `${d.getFullYear()}`;
case "multi-year": {
const centerYear = d.getFullYear();
const startYear = centerYear - 4;
const endYear = centerYear + 4;
return `${startYear} \u2013 ${endYear}`;
}
default:
return d.toLocaleString("default", { month: "long", year: "numeric" });
}
}
// ── Lunar Overlay (replaces old Lunar tab) ──
private renderLunarOverlay(): string {
if (!this.showLunar) return "";
const phase = lunarPhaseForDate(this.currentDate);
const dayNum = Math.floor(phase.age) + 1;
const illumPct = Math.round(phase.illumination * 100);
const phaseName = phase.phase.replace(/_/g, " ");
const chevron = this.lunarOverlayExpanded ? "\u25B2" : "\u25BC";
let expandedHtml = "";
if (this.lunarOverlayExpanded) {
const synodic = getSynodicMonth(this.currentDate);
const elapsed = this.currentDate.getTime() - synodic.startDate.getTime();
const total = synodic.endDate.getTime() - synodic.startDate.getTime();
const progress = Math.max(0, Math.min(1, elapsed / total));
expandedHtml = `
<div class="lunar-expanded">
<div class="synodic-section">
<div class="synodic-labels">
<span>\u{1F311} ${synodic.startDate.toLocaleDateString("default", { month: "short", day: "numeric" })}</span>
<span>\u{1F311} ${synodic.endDate.toLocaleDateString("default", { month: "short", day: "numeric" })}</span>
</div>
<div class="synodic-bar">
<div class="synodic-fill" style="width:${progress * 100}%"></div>
${synodic.phases.map(p => {
const pProg = (p.date.getTime() - synodic.startDate.getTime()) / total;
return `<span class="synodic-marker" style="left:${pProg * 100}%"
title="${p.phase.replace(/_/g, " ")} \u2014 ${p.date.toLocaleDateString("default", { month: "short", day: "numeric" })}">${p.emoji}</span>`;
}).join("")}
</div>
</div>
<div class="phase-chips">
${synodic.phases.map(p => {
const isCurrent = p.phase === phase.phase;
const isPast = p.date < this.currentDate && !isCurrent;
return `<span class="phase-chip ${isCurrent ? "current" : ""} ${isPast ? "past" : ""}"
title="${p.date.toLocaleDateString("default", { weekday: "short", month: "short", day: "numeric" })}">
${p.emoji} <span class="phase-chip-label">${p.phase.replace(/_/g, " ")}</span>
</span>`;
}).join("")}
</div>
</div>`;
}
return `<div class="lunar-overlay">
<div class="lunar-summary" id="lunar-summary">
<span class="lunar-summary-phase">${phase.emoji} ${phaseName}</span>
<span class="lunar-summary-stats">${illumPct}% illuminated</span>
<span class="lunar-summary-stats">Day ${dayNum}/29</span>
<span class="lunar-summary-chevron">${chevron}</span>
</div>
${expandedHtml}
</div>`;
}
// ── Zoom Controller ──
private renderZoomController(): string {
const levels = [
{ idx: 2, label: "Day" }, { idx: 3, label: "Week" },
{ idx: 4, label: "Month" }, { idx: 5, label: "Season" },
{ idx: 6, label: "Year" }, { idx: 7, label: "Years" },
];
const canIn = this.temporalGranularity > 2;
const canOut = this.temporalGranularity < 7;
const spatialLabel = SPATIAL_LABELS[this.getEffectiveSpatialIndex()];
const maxVariant = VIEW_VARIANTS[this.viewMode] || 1;
return `<div class="zoom-ctrl">
<button class="zoom-btn" id="zoom-in" ${!canIn ? "disabled" : ""} title="Zoom in (+)">+</button>
<div class="zoom-track">
${levels.map(l => `<div class="zoom-tick" data-zoom="${l.idx}">
<div class="zoom-tick-dot ${this.temporalGranularity === l.idx ? "active" : ""}"></div>
<div class="zoom-tick-label ${this.temporalGranularity === l.idx ? "active" : ""}">${l.label}</div>
</div>`).join("")}
</div>
<button class="zoom-btn" id="zoom-out" ${!canOut ? "disabled" : ""} title="Zoom out (\u2212)">&#x2212;</button>
${maxVariant > 1 ? `<div class="variant-indicator" title="Press v to toggle variant">
${Array.from({length: maxVariant}, (_, i) =>
`<span class="variant-dot ${i === this.viewVariant ? "active" : ""}"></span>`
).join("")}
</div>` : ""}
<button class="coupling-btn ${this.zoomCoupled ? "coupled" : ""}" id="toggle-coupling"
title="${this.zoomCoupled ? "Unlink spatial zoom (c)" : "Link spatial zoom (c)"}">
${this.zoomCoupled ? "\u{1F517}" : "\u{1F513}"} ${spatialLabel}
</button>
</div>`;
}
// ── Sources ──
private renderSources(): string {
if (this.sources.length === 0) return "";
return `<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>`;
}
// ── Map Panel (floating / docked / minimized) ──
private renderMapPanel(): string {
if (this.mapPanelState === "minimized") {
return `<button class="map-fab" id="map-fab" title="Show map (m)">\u{1F5FA}</button>`;
}
const mode = this.mapPanelState;
const spatialLabel = SPATIAL_LABELS[this.getEffectiveSpatialIndex()];
return `<div class="map-panel map-panel--${mode}" id="map-panel">
<div class="map-panel-header">
<span class="map-panel-title">\u{1F5FA} ${spatialLabel}</span>
<div class="map-panel-controls">
${mode === "floating"
? `<button class="map-ctrl-btn" id="map-dock" title="Dock side-by-side">\u{2B1C}</button>`
: `<button class="map-ctrl-btn" id="map-float" title="Float">\u{1F5D7}</button>`}
<button class="map-ctrl-btn" id="map-minimize" title="Minimize">\u2212</button>
</div>
</div>
<div class="map-body" id="map-host">
<div class="map-overlay-label" id="map-spatial-label">${spatialLabel}</div>
</div>
</div>`;
}
// ── Calendar Content Router ──
private renderCalendarContent(): string {
switch (this.viewMode) {
case "day": return this.viewVariant === 1 ? this.renderDayHorizontal() : this.renderDay();
case "week": return this.renderWeek();
case "month": return this.viewVariant === 1 ? this.renderMonthTransposed() : this.renderMonth();
case "season": return this.renderSeason();
case "year": return this.viewVariant === 1 ? this.renderYearVertical() : this.renderYear();
case "multi-year": return this.renderMultiYear();
default: return this.renderMonth();
}
}
// ── 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}" data-drop-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, 5).map(e => `<span class="dot" style="background:${e.source_color || "#6366f1"}"></span>`).join("")}
${dayEvents.length > 5 ? `<span style="font-size:8px;color:#888">+${dayEvents.length - 5}</span>` : ""}
</div>
${dayEvents.slice(0, 2).map(e => {
const evColor = e.source_color || "#6366f1";
const city = this.getSpatialLabel(e.location_breadcrumb, 5);
const cityHtml = city ? `<span class="ev-loc">${this.esc(city)}</span>` : "";
const virtualHtml = e.is_virtual ? `<span class="ev-virtual" title="Virtual">\u{1F4BB}</span>` : "";
return `<div class="ev-label" style="border-left:2px solid ${evColor};background:${evColor}10" data-event-id="${e.id}">${e.rToolSource === "rSchedule" ? '<span class="ev-bell">&#128276;</span>' : ""}${virtualHtml}<span class="ev-time">${this.formatTime(e.start_time)}</span>${this.esc(e.title)}${cityHtml}</div>`;
}).join("")}
` : ""}
</div>`;
if (isExpanded) {
const cellIndex = firstDay + d - 1;
const posInRow = cellIndex % 7;
if (posInRow === 6 || d === daysInMonth) {
html += this.renderDayDetail(ds, dayEvents);
}
}
}
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;
}
// ── Season View (3 mini-months) ──
private renderSeason(): string {
const year = this.currentDate.getFullYear();
const quarter = Math.floor(this.currentDate.getMonth() / 3);
const months = [quarter * 3, quarter * 3 + 1, quarter * 3 + 2];
const seasonName = ["Winter", "Spring", "Summer", "Autumn"][quarter];
const monthsHtml = months.map(m => {
const dim = new Date(year, m + 1, 0).getDate();
const monthEvents: any[] = [];
for (let d = 1; d <= dim; d++) {
const ds = `${year}-${String(m + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
monthEvents.push(...this.getEventsForDate(ds));
}
const cities = this.getUniqueSpatialLabels(monthEvents, 5, 4);
const citiesHtml = cities.length > 0
? `<div class="season-cities">${cities.map(c => `<span class="season-city-chip">${this.esc(c)}</span>`).join("")}</div>`
: "";
return `<div class="season-month-wrap">${this.renderMiniMonth(year, m)}${citiesHtml}</div>`;
}).join("");
return `
<div class="season-header">${seasonName} ${year} <span class="season-q">Q${quarter + 1}</span></div>
<div class="season-grid">
${monthsHtml}
</div>
<div class="mini-hint">Click any day to zoom in</div>`;
}
// ── Year View (12 mini-months) ──
private renderYear(): string {
const year = this.currentDate.getFullYear();
return `
<div class="year-grid">
${Array.from({length: 12}, (_, i) => this.renderMiniMonth(year, i)).join("")}
</div>`;
}
// ── Day Horizontal View (variant 1) ──
private renderDayHorizontal(): 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_WIDTH = 80;
const START_HOUR = 6;
const END_HOUR = 23;
const totalWidth = (END_HOUR - START_HOUR + 1) * HOUR_WIDTH;
const timed = dayEvents.filter(e => {
const s = new Date(e.start_time), en = new Date(e.end_time);
return (en.getTime() - s.getTime()) < 86400000;
}).sort((a, b) => a.start_time.localeCompare(b.start_time));
let hoursHtml = "";
for (let h = START_HOUR; h <= END_HOUR; h++) {
const label = h === 0 ? "12a" : h < 12 ? `${h}a` : h === 12 ? "12p" : `${h - 12}p`;
hoursHtml += `<div class="dh-hour" style="left:${(h - START_HOUR) * HOUR_WIDTH}px;width:${HOUR_WIDTH}px">${label}</div>`;
}
let eventsHtml = "";
for (const ev of timed) {
const start = new Date(ev.start_time), 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 leftPx = ((startMin - START_HOUR * 60) / 60) * HOUR_WIDTH;
const widthPx = Math.max((duration / 60) * HOUR_WIDTH, 60);
const bgColor = ev.source_color ? `${ev.source_color}24` : "#6366f124";
const virtualBadge = ev.is_virtual ? `<span class="dh-virtual" title="Virtual">\u{1F4BB}</span>` : "";
eventsHtml += `<div class="dh-event" data-event-id="${ev.id}" style="
left:${leftPx}px;width:${widthPx}px;background:${bgColor};border-top:3px solid ${ev.source_color || "#6366f1"}">
<div class="tl-event-title">${virtualBadge}${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="dh-event-loc">${this.esc(ev.location_name)}</div>` : ""}
</div>`;
}
let nowHtml = "";
if (isToday) {
const nowMin = now.getHours() * 60 + now.getMinutes();
const nowPx = ((nowMin - START_HOUR * 60) / 60) * HOUR_WIDTH;
if (nowPx >= 0 && nowPx <= totalWidth) {
nowHtml = `<div class="dh-now" style="left:${nowPx}px"></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" : ""} \u00B7 Horizontal
</div>
<div class="dh-container" style="width:${totalWidth}px">
<div class="dh-hours">${hoursHtml}</div>
<div class="dh-events">${eventsHtml}${nowHtml}</div>
</div>
</div>`;
}
// ── Month Transposed View (variant 1) ──
private renderMonthTransposed(): string {
const year = this.currentDate.getFullYear();
const month = this.currentDate.getMonth();
const daysInMonth = new Date(year, month + 1, 0).getDate();
const today = new Date();
const todayStr = this.dateStr(today);
const dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
// Group days by day-of-week, tracking which week they fall in
const firstDate = new Date(year, month, 1);
const firstDow = firstDate.getDay();
const numWeeks = Math.ceil((firstDow + daysInMonth) / 7);
// Build header: week numbers
let headerHtml = `<div class="mt-day-name"></div>`;
for (let w = 0; w < numWeeks; w++) {
// Get the Monday of this week for ISO week number
const weekStart = new Date(year, month, 1 - firstDow + w * 7);
const onejan = new Date(weekStart.getFullYear(), 0, 1);
const weekNum = Math.ceil(((weekStart.getTime() - onejan.getTime()) / 86400000 + onejan.getDay() + 1) / 7);
headerHtml += `<div class="mt-week-header">W${weekNum}</div>`;
}
// Build rows: one per day-of-week
let rowsHtml = "";
for (let dow = 0; dow < 7; dow++) {
rowsHtml += `<div class="mt-row"><div class="mt-day-name">${dayNames[dow]}</div>`;
for (let w = 0; w < numWeeks; w++) {
const dayOfMonth = 1 - firstDow + w * 7 + dow;
if (dayOfMonth < 1 || dayOfMonth > daysInMonth) {
rowsHtml += `<div class="mt-cell empty"></div>`;
continue;
}
const ds = `${year}-${String(month + 1).padStart(2, "0")}-${String(dayOfMonth).padStart(2, "0")}`;
const isToday = ds === todayStr;
const dayEvents = this.getEventsForDate(ds);
const isWeekend = dow === 0 || dow === 6;
const cities = this.getUniqueSpatialLabels(dayEvents, 5, 2);
const mtTooltip = dayEvents.length > 0 ? `${dayEvents.length} event${dayEvents.length > 1 ? 's' : ''}${cities.length ? ' \u00B7 ' + cities.join(', ') : ''}` : '';
// Stacked color bar
let mtBarHtml = "";
if (dayEvents.length > 0) {
const mtColors: Record<string, number> = {};
for (const e of dayEvents) { const c = e.source_color || "#6366f1"; mtColors[c] = (mtColors[c] || 0) + 1; }
const segs = Object.entries(mtColors).map(([c, n]) => `<span class="mt-seg" style="background:${c};flex:${n}"></span>`).join("");
mtBarHtml = `<span class="mt-color-bar">${segs}</span>`;
}
rowsHtml += `<div class="mt-cell ${isToday ? "today" : ""} ${isWeekend ? "weekend" : ""}" data-date="${ds}" title="${mtTooltip}">
<span class="mt-num">${dayOfMonth}</span>
${mtBarHtml}
${dayEvents.length > 0 ? `<span class="mt-count-num">${dayEvents.length}</span>` : ""}
</div>`;
}
rowsHtml += `</div>`;
}
return `
<div class="month-transposed">
<div class="mt-header-row">${headerHtml}</div>
${rowsHtml}
</div>`;
}
// ── Year Vertical View (variant 1 — kalnext style) ──
private renderYearVertical(): string {
const year = this.currentDate.getFullYear();
const today = new Date();
const todayStr = this.dateStr(today);
let html = `<div class="year-vertical">`;
for (let m = 0; m < 12; m++) {
const monthName = new Date(year, m, 1).toLocaleDateString("default", { month: "short" });
const dim = new Date(year, m + 1, 0).getDate();
const monthEvents: any[] = [];
let daysHtml = "";
for (let d = 1; d <= dim; d++) {
const ds = `${year}-${String(m + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
const isToday = ds === todayStr;
const dayEvents = this.getEventsForDate(ds);
monthEvents.push(...dayEvents);
const dow = new Date(year, m, d).getDay();
const isWeekend = dow === 0 || dow === 6;
// Multi-dot support (up to 3)
let dotsHtml = "";
if (dayEvents.length > 0) {
const colors = dayEvents.slice(0, 3).map(e => e.source_color || "#6366f1");
dotsHtml = `<span class="yv-dots">${colors.map(c => `<span class="yv-dot" style="background:${c}"></span>`).join("")}</span>`;
}
daysHtml += `<div class="yv-day ${isToday ? "today" : ""} ${isWeekend ? "weekend" : ""}" data-mini-date="${ds}" title="${d} ${monthName}${dayEvents.length ? ` (${dayEvents.length} events)` : ""}">
${d}
${dotsHtml}
</div>`;
}
const countries = this.getUniqueSpatialLabels(monthEvents, 3, 2);
const countryHtml = countries.length > 0 ? `<span class="yv-country">${countries.join(", ")}</span>` : "";
html += `<div class="yv-month" data-mini-month="${m}">
<div class="yv-label">${monthName}${countryHtml}</div>
<div class="yv-days">${daysHtml}</div>
</div>`;
}
html += `</div>`;
return html;
}
// ── Multi-Year View (3x3 grid) ──
private renderMultiYear(): string {
const centerYear = this.currentDate.getFullYear();
const startYear = centerYear - 4;
let html = `<div class="multi-year-grid">`;
for (let i = 0; i < 9; i++) {
const y = startYear + i;
const isCurrent = y === new Date().getFullYear();
let monthsHtml = "";
for (let m = 0; m < 12; m++) {
monthsHtml += this.renderMicroMonth(y, m);
}
html += `<div class="my-year ${isCurrent ? "current" : ""}" data-my-year="${y}">
<div class="my-year-label">${y}</div>
<div class="my-months">${monthsHtml}</div>
</div>`;
}
html += `</div>`;
return html;
}
private renderMicroMonth(year: number, month: number): string {
const monthInitials = ["J","F","M","A","M","J","J","A","S","O","N","D"];
const dim = new Date(year, month + 1, 0).getDate();
const allEvents: any[] = [];
for (let d = 1; d <= dim; d++) {
const ds = `${year}-${String(month + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
allEvents.push(...this.getEventsForDate(ds));
}
const eventCount = allEvents.length;
const maxBar = 8;
const barWidth = Math.min(eventCount, maxBar);
const barPct = (barWidth / maxBar) * 100;
// Group by source_color for stacked segments
const colorCounts: Record<string, number> = {};
for (const e of allEvents) {
const c = e.source_color || "#6366f1";
colorCounts[c] = (colorCounts[c] || 0) + 1;
}
const countries = this.getUniqueSpatialLabels(allEvents, 3, 3);
const tooltipExtra = countries.length > 0 ? ` \u00B7 ${countries.join(", ")}` : "";
let barHtml = "";
if (eventCount > 0) {
const segments = Object.entries(colorCounts).map(([color, count]) => {
const segPct = (count / eventCount) * 100;
return `<span class="micro-seg" style="background:${color};width:${segPct}%"></span>`;
}).join("");
barHtml = `<span class="micro-bar-stack" style="width:${barPct}%">${segments}</span>`;
}
return `<div class="micro-month" data-micro-click="${year}-${month}" title="${new Date(year, month, 1).toLocaleDateString("default", { month: "long", year: "numeric" })}${eventCount ? ` (${eventCount} events)${tooltipExtra}` : ""}">
<span class="micro-label">${monthInitials[month]}</span>
${barHtml}
${eventCount > 0 ? `<span class="micro-count">${eventCount}</span>` : ""}
</div>`;
}
// ── Mini-Month (shared by Season & Year views) ──
private renderMiniMonth(year: number, month: number): string {
const monthName = new Date(year, month, 1).toLocaleDateString("default", { month: "short" });
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);
const isCurrentMonth = year === today.getFullYear() && month === today.getMonth();
let daysHtml = "";
for (let i = 0; i < firstDay; i++) { daysHtml += `<div class="mini-day empty"></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 dayEvents = this.getEventsForDate(ds);
const hasEvents = dayEvents.length > 0;
const isBusy = dayEvents.length >= 3;
const titleParts = dayEvents.slice(0, 3).map(e => e.title);
const tooltipText = hasEvents ? `${dayEvents.length} event${dayEvents.length > 1 ? 's' : ''}: ${titleParts.join(', ')}` : '';
const busyStyle = isBusy ? `background:rgba(99,102,241,0.08);` : '';
// Up to 3 stacked dots with individual colors
let dotsHtml = "";
if (hasEvents) {
const uniqueColors = dayEvents.slice(0, 3).map(e => e.source_color || '#6366f1');
dotsHtml = `<span class="mini-dots">${uniqueColors.map(c => `<span class="mini-dot" style="background:${c}"></span>`).join("")}</span>`;
}
daysHtml += `<div class="mini-day ${isToday ? "today" : ""}" data-mini-date="${ds}"
style="${busyStyle}" title="${tooltipText}">${d}${dotsHtml}</div>`;
}
return `<div class="mini-month ${isCurrentMonth ? "current" : ""}" data-mini-month="${month}">
<div class="mini-month-title">${monthName}</div>
<div class="mini-wd-row">${["S","M","T","W","T","F","S"].map(d => `<div class="mini-wd">${d}</div>`).join("")}</div>
<div class="mini-grid">${daysHtml}</div>
</div>`;
}
// ── 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;
const allDay = dayEvents.filter(e => {
const s = new Date(e.start_time), en = new Date(e.end_time);
return (en.getTime() - s.getTime()) >= 86400000;
});
const timed = dayEvents.filter(e => {
const s = new Date(e.start_time), en = new Date(e.end_time);
return (en.getTime() - s.getTime()) < 86400000;
}).sort((a, b) => a.start_time.localeCompare(b.start_time));
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>`;
}
let eventsHtml = "";
for (const ev of timed) {
const start = new Date(ev.start_time), 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}20` : "#6366f120";
const showDesc = heightPx >= 60 && ev.description;
const descPreview = showDesc ? (ev.description.length > 60 ? ev.description.slice(0, 57) + "..." : ev.description) : "";
const neighborhood = this.getSpatialLabel(ev.location_breadcrumb, 6);
const virtualBadge = ev.is_virtual ? `<span class="tl-virtual" title="${this.esc(ev.virtual_platform || 'Virtual')}">\u{1F4BB} ${this.esc(ev.virtual_platform || 'Virtual')}</span>` : "";
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)}${virtualBadge}</div>
${ev.location_name ? `<div class="tl-event-loc">${this.esc(ev.location_name)}</div>` : ""}
${neighborhood ? `<span class="tl-breadcrumb">${this.esc(neighborhood)}</span>` : ""}
${showDesc ? `<div class="tl-event-desc">${this.esc(descPreview)}</div>` : ""}
</div>`;
}
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;
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);
headerHtml += `<div class="week-day-header ${ds === todayStr ? "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>`;
}
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++) {
gridHtml += `<div class="week-cell ${this.dateStr(days[i]) === todayStr ? "today" : ""}"></div>`;
}
}
let eventsOverlay = "";
for (let i = 0; i < 7; i++) {
const ds = this.dateStr(days[i]);
for (const ev of this.getEventsForDate(ds)) {
const start = new Date(ev.start_time), end = new Date(ev.end_time);
if ((end.getTime() - start.getTime()) >= 86400000) continue;
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}28` : "#6366f128";
const colLeft = `calc(44px + ${i} * ((100% - 44px) / 7) + 2px)`;
const colWidth = `calc((100% - 44px) / 7 - 4px)`;
const showMeta = heightPx >= 36;
const timeStr = `${this.formatTime(ev.start_time)}\u2013${this.formatTime(ev.end_time)}`;
const locName = ev.location_name ? this.esc(ev.location_name) : "";
const virtualBadge = ev.is_virtual ? `<span class="wk-virtual" title="Virtual">\u{1F4BB}</span>` : "";
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">${virtualBadge}${this.esc(ev.title)}</div>
${showMeta ? `<div class="week-event-meta"><span class="week-event-time">${timeStr}</span>${locName ? `<span class="week-event-loc">${locName}</span>` : ""}</div>` : ""}
</div>`;
}
}
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>`;
}
// ── Day Detail Panel ──
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 => {
const ddDesc = e.description ? (e.description.length > 80 ? e.description.slice(0, 77) + "..." : e.description) : "";
const srcTag = e.source_name ? `<span class="dd-source" style="border-color:${e.source_color || '#666'};color:${e.source_color || '#aaa'}">${this.esc(e.source_name)}</span>` : "";
return `<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)}${srcTag}</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>
${ddDesc ? `<div class="dd-desc">${this.esc(ddDesc)}</div>` : ""}
</div>
</div>`;
}).join("")}
</div>`;
}
// ── Event Modal ──
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.location_breadcrumb ? `<div class="modal-field" style="font-size:11px;color:#64748b">${this.esc(e.location_breadcrumb)}</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>` : ""}
${e.latitude != null ? `<div class="modal-field" style="font-size:11px;color:#64748b">\u{1F4CD} ${e.latitude.toFixed(4)}, ${e.longitude.toFixed(4)}</div>` : ""}
</div>
</div>`;
}
// ── Reminder Drop Handler ──
private getScheduleApiBase(): string {
const path = window.location.pathname;
const match = path.match(/^(\/[^/]+)/);
return match ? `${match[1]}/rschedule` : "/rschedule";
}
private async handleReminderDrop(e: DragEvent, dropDate: string) {
let title = "";
let sourceModule: string | null = null;
let sourceEntityId: string | null = null;
let sourceLabel: string | null = null;
let sourceColor: string | null = null;
// Try cross-module format first
const rspaceData = e.dataTransfer?.getData("application/rspace-item");
if (rspaceData) {
try {
const parsed = JSON.parse(rspaceData);
title = parsed.title || "";
sourceModule = parsed.module || null;
sourceEntityId = parsed.entityId || null;
sourceLabel = parsed.label || null;
sourceColor = parsed.color || null;
} catch { /* fall through to text/plain */ }
}
// Fall back to plain text
if (!title) {
title = e.dataTransfer?.getData("text/plain") || "";
}
if (!title.trim()) return;
// Prompt for quick confirmation
const confirmed = confirm(`Create reminder "${title}" on ${dropDate}?`);
if (!confirmed) return;
const remindAt = new Date(dropDate + "T09:00:00").getTime();
const base = this.getScheduleApiBase();
try {
await fetch(`${base}/api/reminders`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title: title.trim(),
remindAt,
allDay: true,
syncToCalendar: true,
sourceModule,
sourceEntityId,
sourceLabel,
sourceColor,
}),
});
// Reload events to show the new calendar entry
if (this.space === "demo") { this.loadDemoData(); } else { this.loadMonth(); }
} catch (err) {
console.error("[rCal] Failed to create reminder:", err);
}
}
// ── Attach Listeners ──
private attachListeners() {
const $ = (id: string) => this.shadow.getElementById(id);
const $$ = (sel: string) => this.shadow.querySelectorAll(sel);
// Nav
$("prev")?.addEventListener("click", () => this.navigate(-1));
$("next")?.addEventListener("click", () => this.navigate(1));
$("today")?.addEventListener("click", () => {
this.currentDate = new Date(); this.expandedDay = "";
if (this.space === "demo") { this.loadDemoData(); } else { this.loadMonth(); }
});
$("toggle-lunar")?.addEventListener("click", () => { this.showLunar = !this.showLunar; this.render(); });
// Lunar overlay expand/collapse
$("lunar-summary")?.addEventListener("click", () => {
this.lunarOverlayExpanded = !this.lunarOverlayExpanded;
this.render();
});
// Map panel controls
$("map-fab")?.addEventListener("click", () => { this.mapPanelState = "floating"; this.render(); });
$("map-minimize")?.addEventListener("click", () => { this.mapPanelState = "minimized"; this.render(); });
$("map-dock")?.addEventListener("click", () => {
this.mapPanelState = "docked";
this.render();
});
$("map-float")?.addEventListener("click", () => {
this.mapPanelState = "floating";
this.render();
});
// Zoom controller
$("zoom-in")?.addEventListener("click", () => this.zoomIn());
$("zoom-out")?.addEventListener("click", () => this.zoomOut());
$$("[data-zoom]").forEach(el => {
el.addEventListener("click", () => {
this.setTemporalGranularity(parseInt((el as HTMLElement).dataset.zoom!));
});
});
$("toggle-coupling")?.addEventListener("click", () => {
this.zoomCoupled = !this.zoomCoupled;
if (this.zoomCoupled) { this.spatialGranularity = null; this.syncMapToSpatial(); }
this.render();
});
// Source filters
$$("[data-source]").forEach(el => {
el.addEventListener("click", (e) => {
e.stopPropagation();
this.toggleSource((el as HTMLElement).dataset.source!);
});
});
// Month view: day cell tap
$$(".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();
});
});
// Month view: drop-on-date for reminders
$$("[data-drop-date]").forEach(el => {
el.addEventListener("dragover", (e) => {
e.preventDefault();
(el as HTMLElement).classList.add("drop-target");
});
el.addEventListener("dragleave", () => {
(el as HTMLElement).classList.remove("drop-target");
});
el.addEventListener("drop", (e) => {
e.preventDefault();
e.stopPropagation();
(el as HTMLElement).classList.remove("drop-target");
const dropDate = (el as HTMLElement).dataset.dropDate;
if (!dropDate) return;
this.handleReminderDrop(e as DragEvent, dropDate);
});
});
// Week day header click → switch to day view
$$("[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.setTemporalGranularity(2); // Day
});
});
// Mini-month day click → zoom to day
$$("[data-mini-date]").forEach(el => {
el.addEventListener("click", (e) => {
e.stopPropagation();
const ds = (el as HTMLElement).dataset.miniDate;
if (!ds) return;
const parts = ds.split("-");
this.currentDate = new Date(parseInt(parts[0]), parseInt(parts[1]) - 1, parseInt(parts[2]));
this.setTemporalGranularity(2); // Day
});
});
// Mini-month title click → zoom to month
$$("[data-mini-month]").forEach(el => {
el.addEventListener("click", () => {
const m = parseInt((el as HTMLElement).dataset.miniMonth!);
this.currentDate = new Date(this.currentDate.getFullYear(), m, 1);
this.setTemporalGranularity(4); // Month
});
});
// Event clicks → modal
$$("[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
$("dd-close")?.addEventListener("click", (e) => {
e.stopPropagation(); this.expandedDay = ""; this.render();
});
// Modal close
$("modal-overlay")?.addEventListener("click", (e) => {
if ((e.target as HTMLElement).id === "modal-overlay") { this.selectedEvent = null; this.render(); }
});
$("modal-close")?.addEventListener("click", () => { this.selectedEvent = null; this.render(); });
// Multi-year: click year tile → zoom to year
$$("[data-my-year]").forEach(el => {
el.addEventListener("click", (e) => {
e.stopPropagation();
const y = parseInt((el as HTMLElement).dataset.myYear!);
this.currentDate = new Date(y, this.currentDate.getMonth(), 1);
this.setTemporalGranularity(6);
});
});
// Multi-year: click micro-month → zoom to month
$$("[data-micro-click]").forEach(el => {
el.addEventListener("click", (e) => {
e.stopPropagation();
const parts = (el as HTMLElement).dataset.microClick!.split("-");
this.currentDate = new Date(parseInt(parts[0]), parseInt(parts[1]), 1);
this.setTemporalGranularity(4);
});
});
// Transposed month cell click → zoom to day
$$(".mt-cell:not(.empty)").forEach(el => {
el.addEventListener("click", () => {
const date = (el as HTMLElement).dataset.date;
if (!date) return;
const parts = date.split("-");
this.currentDate = new Date(parseInt(parts[0]), parseInt(parts[1]) - 1, parseInt(parts[2]));
this.setTemporalGranularity(2);
});
});
// Scroll/trackpad zoom on calendar pane
const calPane = $("calendar-pane");
if (calPane) {
calPane.addEventListener("wheel", (e: WheelEvent) => {
// Skip if target is inside the map panel (Leaflet handles its own zoom)
const mapPanel = this.shadow.getElementById("map-panel");
if (mapPanel && mapPanel.contains(e.target as Node)) return;
e.preventDefault();
// Debounce: 120ms
if (this._wheelTimer) clearTimeout(this._wheelTimer);
this._wheelTimer = setTimeout(() => {
if (e.deltaY > 0) {
this.zoomOut(); // scroll down → zoom out
} else if (e.deltaY < 0) {
this.zoomIn(); // scroll up → zoom in
}
this._wheelTimer = null;
}, 120);
}, { passive: false });
}
}
// ── Leaflet Map ──
private async initOrUpdateMap() {
await ensureLeaflet();
const L = (window as any).L;
if (!L) return;
const host = this.shadow.getElementById("map-host");
if (!host) return;
if (!this.mapContainer) {
this.mapContainer = document.createElement("div");
this.mapContainer.style.width = "100%";
this.mapContainer.style.height = "100%";
this.mapContainer.style.minHeight = "250px";
this.mapContainer.style.borderRadius = "8px";
}
host.appendChild(this.mapContainer);
if (!this.leafletMap) {
const center = this.computeMapCenter();
const zoom = this.getEffectiveLeafletZoom();
this.leafletMap = L.map(this.mapContainer, {
center, zoom, zoomControl: false,
});
L.tileLayer("https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png", {
attribution: '\u00A9 <a href="https://www.openstreetmap.org/copyright">OSM</a> \u00A9 <a href="https://carto.com/">CARTO</a>',
subdomains: "abcd",
maxZoom: 19,
}).addTo(this.leafletMap);
this.mapMarkerLayer = L.layerGroup().addTo(this.leafletMap);
this.transitLineLayer = L.layerGroup().addTo(this.leafletMap);
// Manual zoom → update spatial granularity (when uncoupled)
this.leafletMap.on("zoomend", () => {
if (!this.zoomCoupled) {
this.spatialGranularity = leafletZoomToSpatial(this.leafletMap.getZoom());
const label = this.shadow.getElementById("map-spatial-label");
if (label) label.textContent = SPATIAL_LABELS[this.spatialGranularity];
}
});
} else {
setTimeout(() => this.leafletMap?.invalidateSize(), 50);
}
this.updateMapMarkers();
this.updateTransitLines();
}
private updateMapMarkers() {
const L = (window as any).L;
if (!L || !this.mapMarkerLayer) return;
this.mapMarkerLayer.clearLayers();
const located = this.events.filter(e =>
e.latitude != null && e.longitude != null && !this.filteredSources.has(e.source_name));
for (const ev of located) {
const marker = L.circleMarker([ev.latitude, ev.longitude], {
radius: 6, color: ev.source_color || "#6366f1",
fillColor: ev.source_color || "#6366f1", fillOpacity: 0.7, weight: 2,
});
marker.bindPopup(`<div style="font-size:13px;color:#1a1a2e">
<div style="font-weight:600">${this.esc(ev.title)}</div>
${ev.location_name ? `<div style="color:#666;font-size:11px">${this.esc(ev.location_name)}</div>` : ""}
<div style="color:#888;font-size:11px">${new Date(ev.start_time).toLocaleDateString("default", { month: "short", day: "numeric" })} ${this.formatTime(ev.start_time)}</div>
</div>`);
this.mapMarkerLayer.addLayer(marker);
}
}
private updateTransitLines() {
const L = (window as any).L;
if (!L || !this.transitLineLayer) return;
this.transitLineLayer.clearLayers();
const sorted = this.events
.filter(e => e.latitude != null && e.longitude != null && !this.filteredSources.has(e.source_name))
.sort((a, b) => new Date(a.start_time).getTime() - new Date(b.start_time).getTime());
if (sorted.length < 2) return;
for (let i = 0; i < sorted.length - 1; i++) {
const curr = sorted[i], next = sorted[i + 1];
if (Math.abs(curr.latitude - next.latitude) < 0.01 && Math.abs(curr.longitude - next.longitude) < 0.01) continue;
const isTravel = curr.source_name === "Travel" || next.source_name === "Travel";
const line = L.polyline(
[[curr.latitude, curr.longitude], [next.latitude, next.longitude]],
{ color: isTravel ? "#f97316" : "#94a3b8", weight: isTravel ? 3 : 2,
opacity: isTravel ? 0.8 : 0.4, dashArray: isTravel ? "8, 8" : "4, 8" }
);
line.bindTooltip(
`${this.esc(curr.location_name || curr.title)} \u2192 ${this.esc(next.location_name || next.title)}`,
{ sticky: true }
);
this.transitLineLayer.addLayer(line);
}
}
// ── Styles ──
private getStyles(): string {
return `
:host { display: block; font-family: system-ui, -apple-system, sans-serif; color: #e0e0e0; padding: 0.5rem; }
* { box-sizing: border-box; }
.error { color: #ef5350; text-align: center; padding: 8px; }
/* ── Nav ── */
.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; }
/* ── Lunar Overlay ── */
.lunar-overlay { background: #16161e; border: 1px solid #222; border-radius: 8px; padding: 0; margin-bottom: 12px; overflow: hidden; }
.lunar-summary { display: flex; align-items: center; gap: 12px; padding: 8px 12px; cursor: pointer; user-select: none; transition: background 0.15s; }
.lunar-summary:hover { background: rgba(255,255,255,0.04); }
.lunar-summary-phase { font-size: 13px; font-weight: 600; color: #e2e8f0; text-transform: capitalize; white-space: nowrap; }
.lunar-summary-stats { font-size: 11px; color: #94a3b8; white-space: nowrap; }
.lunar-summary-chevron { margin-left: auto; font-size: 10px; color: #64748b; }
.lunar-expanded { border-top: 1px solid #222; padding: 12px; }
.phase-chips { display: flex; gap: 6px; overflow-x: auto; padding: 8px 0 4px; }
.phase-chip { display: inline-flex; align-items: center; gap: 4px; padding: 4px 10px; border-radius: 16px; border: 1px solid #333; font-size: 11px; color: #94a3b8; white-space: nowrap; flex-shrink: 0; transition: all 0.15s; }
.phase-chip.current { border-color: #6366f1; color: #818cf8; background: rgba(99,102,241,0.08); }
.phase-chip.past { opacity: 0.4; }
.phase-chip-label { text-transform: capitalize; }
/* ── Zoom Controller ── */
.zoom-ctrl { display: flex; align-items: center; gap: 6px; margin-bottom: 12px; padding: 8px 10px; background: #16161e; border: 1px solid #222; border-radius: 8px; flex-wrap: wrap; }
.zoom-btn { width: 28px; height: 28px; border-radius: 50%; border: 1px solid #333; background: transparent; color: #94a3b8; cursor: pointer; font-size: 16px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.zoom-btn:hover { border-color: #6366f1; color: #e2e8f0; }
.zoom-btn:disabled { opacity: 0.3; cursor: not-allowed; }
.zoom-btn:disabled:hover { border-color: #333; color: #94a3b8; }
.zoom-track { flex: 1; display: flex; align-items: center; gap: 0; min-width: 200px; }
.zoom-tick { flex: 1; text-align: center; cursor: pointer; padding: 4px 0; }
.zoom-tick-dot { width: 12px; height: 12px; border-radius: 50%; border: 2px solid #333; background: #16161e; margin: 0 auto; transition: all 0.15s; }
.zoom-tick-dot.active { border-color: #6366f1; background: #4f46e5; transform: scale(1.2); }
.zoom-tick-label { font-size: 9px; color: #4a5568; margin-top: 3px; transition: color 0.15s; }
.zoom-tick-label.active { color: #818cf8; font-weight: 600; }
.coupling-btn { padding: 4px 10px; border-radius: 12px; border: 1px solid #333; background: transparent; color: #64748b; cursor: pointer; font-size: 11px; transition: all 0.15s; white-space: nowrap; flex-shrink: 0; }
.coupling-btn:hover { border-color: #555; color: #94a3b8; }
.coupling-btn.coupled { border-color: #6366f1; color: #818cf8; background: rgba(99,102,241,0.08); }
/* ── Sources ── */
.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; }
/* ── Main Layout ── */
.main-layout { position: relative; min-height: 400px; }
.main-layout--docked { display: grid; grid-template-columns: 1fr 400px; gap: 8px; min-height: 500px; }
.calendar-pane { overflow: auto; min-width: 0; }
/* ── Map Panel ── */
.map-panel { background: #0d1117; border: 1px solid #333; border-radius: 12px; overflow: hidden; display: flex; flex-direction: column; }
.map-panel--floating { position: absolute; bottom: 8px; right: 8px; width: 380px; height: 320px; resize: both; z-index: 100; box-shadow: 0 8px 32px rgba(0,0,0,0.5); }
.map-panel--docked { min-height: 400px; }
.map-panel-header { display: flex; align-items: center; justify-content: space-between; padding: 6px 10px; background: #16161e; border-bottom: 1px solid #222; cursor: default; flex-shrink: 0; }
.map-panel-title { font-size: 12px; font-weight: 500; color: #94a3b8; }
.map-panel-controls { display: flex; gap: 4px; }
.map-ctrl-btn { width: 24px; height: 24px; border-radius: 4px; border: 1px solid #333; background: transparent; color: #94a3b8; cursor: pointer; font-size: 12px; display: flex; align-items: center; justify-content: center; }
.map-ctrl-btn:hover { border-color: #6366f1; color: #e2e8f0; }
.map-body { flex: 1; position: relative; min-height: 200px; }
.map-overlay-label { position: absolute; top: 8px; right: 8px; z-index: 500; background: rgba(22,22,30,0.9); border: 1px solid #333; border-radius: 6px; padding: 4px 10px; font-size: 11px; color: #94a3b8; pointer-events: none; }
.map-fab { position: absolute; bottom: 16px; right: 16px; width: 44px; height: 44px; border-radius: 50%; border: 1px solid #333; background: #16161e; color: #e2e8f0; cursor: pointer; font-size: 20px; display: flex; align-items: center; justify-content: center; z-index: 100; box-shadow: 0 4px 16px rgba(0,0,0,0.4); transition: all 0.15s; }
.map-fab:hover { border-color: #6366f1; background: #1e1e2e; transform: scale(1.1); }
/* ── Synodic (reused in overlay) ── */
.synodic-section { margin: 0 0 8px; }
.synodic-labels { display: flex; justify-content: space-between; font-size: 11px; color: #64748b; margin-bottom: 6px; }
.synodic-bar { height: 14px; background: #222; border-radius: 7px; overflow: visible; position: relative; }
.synodic-fill { height: 100%; background: linear-gradient(to right, #1a1a2e, #e2e8f0, #1a1a2e); border-radius: 7px; transition: width 0.3s; }
.synodic-marker { position: absolute; top: -2px; font-size: 12px; transform: translateX(-50%); pointer-events: none; }
/* ── Month Grid ── */
.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; }
.ev-bell { margin-right: 2px; font-size: 8px; }
.ev-loc { color: #64748b; font-size: 7px; margin-left: 3px; }
.ev-virtual { font-size: 8px; margin-right: 2px; vertical-align: middle; }
/* ── Drop Target ── */
.day.drop-target { background: rgba(245,158,11,0.15); border: 2px dashed #f59e0b; }
/* ── 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; }
.dd-desc { font-size: 11px; color: #64748b; margin-top: 3px; line-height: 1.4; }
.dd-source { font-size: 9px; padding: 1px 6px; border-radius: 8px; border: 1px solid; margin-left: 6px; vertical-align: middle; }
/* ── 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; }
.tl-event-desc { font-size: 9px; color: #64748b; margin-top: 2px; line-height: 1.3; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tl-breadcrumb { font-size: 8px; color: #4a5568; background: rgba(255,255,255,0.04); padding: 1px 5px; border-radius: 3px; margin-top: 2px; display: inline-block; }
.tl-virtual { font-size: 9px; color: #818cf8; margin-left: 6px; }
.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; 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; }
.week-event-meta { font-size: 9px; color: #94a3b8; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.week-event-time { color: #64748b; }
.week-event-loc { margin-left: 3px; color: #4a5568; }
.wk-virtual { font-size: 9px; margin-right: 2px; vertical-align: middle; }
/* ── Season View ── */
.season-header { text-align: center; margin-bottom: 12px; font-size: 16px; font-weight: 600; color: #e2e8f0; }
.season-q { font-size: 12px; color: #64748b; font-weight: 400; margin-left: 4px; }
.season-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
.season-month-wrap { display: flex; flex-direction: column; gap: 4px; }
.season-cities { display: flex; flex-wrap: wrap; gap: 3px; padding: 2px 4px; }
.season-city-chip { font-size: 9px; color: #94a3b8; background: rgba(255,255,255,0.04); border: 1px solid #333; border-radius: 8px; padding: 1px 6px; white-space: nowrap; }
/* ── Year View ── */
.year-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; }
/* ── Mini-Month (shared) ── */
.mini-month { background: #16161e; border: 1px solid #222; border-radius: 8px; padding: 8px; cursor: pointer; transition: border-color 0.15s; }
.mini-month:hover { border-color: #444; }
.mini-month.current { border-color: #6366f1; background: rgba(99,102,241,0.06); }
.mini-month-title { font-size: 12px; font-weight: 600; text-align: center; color: #e2e8f0; margin-bottom: 4px; }
.mini-wd-row { display: grid; grid-template-columns: repeat(7, 1fr); gap: 1px; margin-bottom: 2px; }
.mini-wd { text-align: center; font-size: 8px; color: #4a5568; font-weight: 600; }
.mini-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 1px; }
.mini-day { position: relative; display: flex; align-items: center; justify-content: center; font-size: 10px; color: #94a3b8; border-radius: 3px; aspect-ratio: 1; cursor: pointer; }
.mini-day:hover { background: rgba(255,255,255,0.08); }
.mini-day.today { background: #4f46e5; color: #fff; font-weight: 700; }
.mini-day.empty { cursor: default; }
.mini-dots { position: absolute; bottom: 1px; left: 50%; transform: translateX(-50%); display: flex; gap: 1px; }
.mini-dot { width: 3px; height: 3px; border-radius: 50%; flex-shrink: 0; }
.mini-hint { text-align: center; font-size: 11px; color: #4a5568; margin-top: 8px; }
/* ── Transition Animations ── */
.calendar-pane { position: relative; overflow: hidden; }
.transition-ghost {
position: absolute; inset: 0; z-index: 10; pointer-events: none;
will-change: transform, opacity;
}
.transition-enter {
will-change: transform, opacity;
}
/* Ghost exits */
.ghost-slide-left { animation: ghostSlideLeft 280ms ease-out forwards; }
.ghost-slide-right { animation: ghostSlideRight 280ms ease-out forwards; }
.ghost-zoom-in { animation: ghostZoomIn 320ms ease-in-out forwards; }
.ghost-zoom-out { animation: ghostZoomOut 320ms ease-in-out forwards; }
/* New content enters */
.enter-slide-left { animation: enterSlideLeft 280ms ease-out forwards; }
.enter-slide-right { animation: enterSlideRight 280ms ease-out forwards; }
.enter-zoom-in { animation: enterZoomIn 320ms ease-in-out forwards; }
.enter-zoom-out { animation: enterZoomOut 320ms ease-in-out forwards; }
@keyframes ghostSlideLeft { from { transform: translateX(0); opacity: 1; } to { transform: translateX(-100%); opacity: 0; } }
@keyframes ghostSlideRight { from { transform: translateX(0); opacity: 1; } to { transform: translateX(100%); opacity: 0; } }
@keyframes ghostZoomIn { from { transform: scale(1); opacity: 1; } to { transform: scale(1.3); opacity: 0; } }
@keyframes ghostZoomOut { from { transform: scale(1); opacity: 1; } to { transform: scale(0.7); opacity: 0; } }
@keyframes enterSlideLeft { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
@keyframes enterSlideRight { from { transform: translateX(-100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
@keyframes enterZoomIn { from { transform: scale(0.7); opacity: 0; } to { transform: scale(1); opacity: 1; } }
@keyframes enterZoomOut { from { transform: scale(1.3); opacity: 0; } to { transform: scale(1); opacity: 1; } }
/* ── Variant Indicator ── */
.variant-indicator { display: flex; gap: 4px; align-items: center; padding: 0 6px; }
.variant-dot { width: 6px; height: 6px; border-radius: 50%; border: 1px solid #555; background: transparent; transition: all 0.15s; }
.variant-dot.active { background: #6366f1; border-color: #6366f1; }
/* ── Horizontal Day View ── */
.dh-container { overflow-x: auto; overflow-y: hidden; position: relative; min-height: 180px; padding: 0 0 8px; }
.dh-hours { position: relative; height: 24px; border-bottom: 1px solid #222; }
.dh-hour { position: absolute; top: 0; height: 24px; font-size: 10px; color: #4a5568; text-align: center; border-left: 1px solid rgba(255,255,255,0.04); line-height: 24px; }
.dh-events { position: relative; min-height: 140px; padding-top: 8px; }
.dh-event { position: absolute; top: 32px; height: auto; min-height: 48px; border-radius: 6px; padding: 4px 8px; font-size: 11px; overflow: hidden; cursor: pointer; border-top: 3px solid; z-index: 1; }
.dh-event:hover { opacity: 0.85; }
.dh-now { position: absolute; top: 0; bottom: 0; width: 2px; background: #ef4444; z-index: 5; }
.dh-now::before { content: ""; position: absolute; top: -3px; left: -3px; width: 8px; height: 8px; border-radius: 50%; background: #ef4444; }
.dh-event-loc { font-size: 9px; color: #64748b; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.dh-virtual { font-size: 9px; margin-right: 2px; vertical-align: middle; }
/* ── Month Transposed View ── */
.month-transposed { overflow-x: auto; }
.mt-header-row { display: flex; gap: 2px; margin-bottom: 4px; }
.mt-week-header { flex: 1; min-width: 48px; text-align: center; font-size: 10px; color: #4a5568; font-weight: 600; padding: 4px; }
.mt-row { display: flex; gap: 2px; margin-bottom: 2px; }
.mt-day-name { width: 40px; flex-shrink: 0; font-size: 11px; color: #64748b; font-weight: 600; display: flex; align-items: center; justify-content: flex-end; padding-right: 6px; }
.mt-cell { flex: 1; min-width: 48px; min-height: 36px; background: #16161e; border: 1px solid #222; border-radius: 4px; display: flex; align-items: center; justify-content: center; gap: 4px; cursor: pointer; position: relative; }
.mt-cell:hover { border-color: #444; }
.mt-cell.today { border-color: #6366f1; background: rgba(99,102,241,0.06); }
.mt-cell.weekend { background: rgba(255,255,255,0.02); }
.mt-cell.empty { background: transparent; border-color: transparent; cursor: default; }
.mt-num { font-size: 12px; color: #94a3b8; font-weight: 500; }
.mt-color-bar { display: flex; height: 3px; border-radius: 2px; overflow: hidden; width: 100%; position: absolute; bottom: 2px; left: 0; }
.mt-seg { height: 100%; min-width: 2px; }
.mt-count-num { font-size: 9px; color: #94a3b8; font-weight: 600; }
/* ── Year Vertical View ── */
.year-vertical { max-height: 600px; overflow-y: auto; }
.yv-month { display: flex; align-items: flex-start; gap: 8px; padding: 6px 0; border-bottom: 1px solid #1a1a2e; cursor: pointer; }
.yv-month:hover { background: rgba(255,255,255,0.02); }
.yv-label { width: 36px; flex-shrink: 0; font-size: 11px; font-weight: 600; color: #94a3b8; text-align: right; padding-top: 2px; }
.yv-days { display: flex; flex-wrap: wrap; gap: 2px; flex: 1; }
.yv-day { position: relative; width: 22px; height: 22px; display: flex; align-items: center; justify-content: center; font-size: 9px; color: #64748b; border-radius: 3px; cursor: pointer; }
.yv-day:hover { background: rgba(255,255,255,0.08); color: #e2e8f0; }
.yv-day.today { background: #4f46e5; color: #fff; font-weight: 700; }
.yv-day.weekend { opacity: 0.5; }
.yv-dots { position: absolute; bottom: 1px; left: 50%; transform: translateX(-50%); display: flex; gap: 1px; }
.yv-dot { width: 3px; height: 3px; border-radius: 50%; flex-shrink: 0; }
.yv-country { font-size: 9px; color: #4a5568; margin-left: 6px; font-weight: 400; }
/* ── Multi-Year View ── */
.multi-year-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
.my-year { background: #16161e; border: 1px solid #222; border-radius: 8px; padding: 8px; cursor: pointer; transition: border-color 0.15s; }
.my-year:hover { border-color: #444; }
.my-year.current { border-color: #6366f1; background: rgba(99,102,241,0.06); }
.my-year-label { font-size: 14px; font-weight: 700; text-align: center; color: #e2e8f0; margin-bottom: 6px; }
.my-months { display: grid; grid-template-columns: repeat(4, 1fr); gap: 2px; }
.micro-month { display: flex; align-items: center; gap: 2px; padding: 2px 3px; border-radius: 3px; cursor: pointer; overflow: hidden; }
.micro-month:hover { background: rgba(255,255,255,0.08); }
.micro-label { font-size: 8px; color: #4a5568; font-weight: 600; width: 8px; flex-shrink: 0; }
.micro-bar-stack { height: 3px; border-radius: 2px; flex-shrink: 0; display: flex; overflow: hidden; }
.micro-seg { height: 100%; min-width: 1px; }
.micro-count { font-size: 7px; color: #64748b; flex-shrink: 0; }
/* ── Keyboard Hint ── */
.kbd-hint { text-align: center; font-size: 10px; color: #333; margin-top: 12px; padding-top: 8px; border-top: 1px solid #1a1a2e; }
.kbd-hint kbd { padding: 1px 4px; background: #16161e; border: 1px solid #222; border-radius: 3px; font-family: inherit; font-size: 9px; }
/* ── Mobile ── */
@media (max-width: 768px) {
:host { padding: 0.25rem; }
.main-layout--docked { grid-template-columns: 1fr; }
.map-panel--floating { width: 100%; left: 0; right: 0; bottom: 0; border-radius: 12px 12px 0 0; }
.year-grid { grid-template-columns: repeat(3, 1fr); }
.season-grid { grid-template-columns: 1fr; }
.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; }
.season-cities, .week-event-meta, .tl-event-desc, .yv-country { display: none; }
.week-view { font-size: 10px; }
.zoom-track { min-width: 150px; }
.zoom-ctrl { gap: 4px; padding: 6px 8px; }
.coupling-btn { font-size: 10px; padding: 3px 8px; }
.lunar-summary { gap: 8px; flex-wrap: wrap; }
.phase-chips { gap: 4px; }
.phase-chip { padding: 3px 8px; font-size: 10px; }
.multi-year-grid { grid-template-columns: repeat(3, 1fr); gap: 6px; }
.mt-cell { min-width: 36px; min-height: 30px; }
.mt-day-name { width: 30px; 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; }
.year-grid { grid-template-columns: repeat(2, 1fr); }
.multi-year-grid { grid-template-columns: repeat(2, 1fr); }
.mini-day { font-size: 8px; }
.mini-month-title { font-size: 10px; }
.ev-loc, .dd-desc { display: none; }
}
`;
}
// ── Escape HTML ──
private esc(s: string): string {
const d = document.createElement("div");
d.textContent = s || "";
return d.innerHTML;
}
}
customElements.define("folk-calendar-view", FolkCalendarView);