/**
 * <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"> = {
	2: "day", 3: "week", 4: "month", 5: "season", 6: "year"
};

// ── 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" = "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;

	// 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 "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) {
		n = Math.max(2, Math.min(6, n));
		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() { 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) {
		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;
		}
		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 toggleSource(name: string) {
		if (this.filteredSources.has(name)) { this.filteredSources.delete(name); }
		else { this.filteredSources.add(name); }
		this.render();
	}

	// ── 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-5</kbd> view &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();

		// 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()}`;
			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" },
		];
		const canIn = this.temporalGranularity > 2;
		const canOut = this.temporalGranularity < 6;
		const spatialLabel = SPATIAL_LABELS[this.getEffectiveSpatialIndex()];

		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>
			<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.renderDay();
			case "week": return this.renderWeek();
			case "month": return this.renderMonth();
			case "season": return this.renderSeason();
			case "year": return this.renderYear();
			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}">
				<div class="day-num">
					<span>${d}</span>
					${this.showLunar && lunar ? `<span class="moon">${this.getMoonEmoji(lunar.phase)}</span>` : ""}
				</div>
				${dayEvents.length > 0 ? `
					<div class="dots">
						${dayEvents.slice(0, 4).map(e => `<span class="dot" style="background:${e.source_color || "#6366f1"}"></span>`).join("")}
						${dayEvents.length > 4 ? `<span style="font-size:8px;color:#888">+${dayEvents.length - 4}</span>` : ""}
					</div>
					${dayEvents.slice(0, 2).map(e =>
						`<div class="ev-label" style="border-left:2px solid ${e.source_color || "#6366f1"}" data-event-id="${e.id}"><span class="ev-time">${this.formatTime(e.start_time)}</span>${this.esc(e.title)}</div>`
					).join("")}
				` : ""}
			</div>`;

			if (isExpanded) {
				const cellIndex = firstDay + d - 1;
				const posInRow = cellIndex % 7;
				if (posInRow === 6 || d === daysInMonth) {
					html += this.renderDayDetail(ds, dayEvents);
				}
			}
		}

		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];

		return `
		<div class="season-header">${seasonName} ${year} <span class="season-q">Q${quarter + 1}</span></div>
		<div class="season-grid">
			${months.map(m => this.renderMiniMonth(year, m)).join("")}
		</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>`;
	}

	// ── 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;

			daysHtml += `<div class="mini-day ${isToday ? "today" : ""}" data-mini-date="${ds}"
				title="${hasEvents ? dayEvents.length + ' event' + (dayEvents.length > 1 ? 's' : '') : ''}">${d}${hasEvents ? `<span class="mini-dot" style="background:${dayEvents[0].source_color || '#6366f1'}"></span>` : ""}</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}18` : "#6366f118";

			eventsHtml += `<div class="tl-event" data-event-id="${ev.id}" style="
				top:${topPx}px;height:${heightPx}px;background:${bgColor};border-left-color:${ev.source_color || "#6366f1"}">
				<div class="tl-event-title">${this.esc(ev.title)}</div>
				<div class="tl-event-time">${this.formatTime(ev.start_time)} \u2013 ${this.formatTime(ev.end_time)}</div>
				${ev.location_name ? `<div class="tl-event-loc">${this.esc(ev.location_name)}</div>` : ""}
			</div>`;
		}

		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}20` : "#6366f120";
				const colLeft = `calc(44px + ${i} * ((100% - 44px) / 7) + 2px)`;
				const colWidth = `calc((100% - 44px) / 7 - 4px)`;
				eventsOverlay += `<div class="week-event" data-event-id="${ev.id}" style="
					top:${topPx}px;height:${heightPx}px;left:${colLeft};width:${colWidth};
					background:${bgColor};border-left-color:${ev.source_color || "#6366f1"}">
					<div class="week-event-title">${this.esc(ev.title)}</div>
				</div>`;
			}
		}

		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 => `
				<div class="dd-event" data-event-id="${e.id}">
					<div class="dd-color" style="background:${e.source_color || "#6366f1"}"></div>
					<div class="dd-info">
						<div class="dd-title">${this.esc(e.title)}</div>
						<div class="dd-meta">${this.formatTime(e.start_time)}${e.end_time ? ` \u2013 ${this.formatTime(e.end_time)}` : ""}${e.location_name ? ` \u00B7 ${this.esc(e.location_name)}` : ""}${e.is_virtual ? " \u00B7 Virtual" : ""}</div>
					</div>
				</div>
			`).join("")}
		</div>`;
	}

	// ── 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>`;
	}

	// ── 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();
			});
		});

		// 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(); });

		// 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; }

			/* ── Day Detail Panel ── */
			.day-detail { grid-column: 1 / -1; background: #1a1a2e; border: 1px solid #334155; border-radius: 8px; padding: 12px; }
			.dd-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
			.dd-date { font-size: 14px; font-weight: 600; color: #e2e8f0; }
			.dd-close { background: none; border: none; color: #64748b; font-size: 18px; cursor: pointer; padding: 4px 8px; }
			.dd-event { display: flex; gap: 8px; align-items: flex-start; padding: 8px; border-radius: 6px; margin-bottom: 4px; cursor: pointer; -webkit-tap-highlight-color: transparent; }
			.dd-event:hover { background: rgba(255,255,255,0.05); }
			.dd-color { width: 4px; border-radius: 2px; align-self: stretch; flex-shrink: 0; }
			.dd-info { flex: 1; min-width: 0; }
			.dd-title { font-size: 13px; font-weight: 500; color: #e2e8f0; }
			.dd-meta { font-size: 11px; color: #94a3b8; margin-top: 2px; }
			.dd-empty { font-size: 12px; color: #64748b; padding: 8px 0; }

			/* ── Event Modal ── */
			.modal-bg { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; z-index: 1000; }
			.modal { background: #1e1e2e; border: 1px solid #333; border-radius: 12px; padding: 20px; max-width: 400px; width: 90%; }
			.modal-title { font-size: 16px; font-weight: 600; margin-bottom: 12px; }
			.modal-field { font-size: 13px; color: #aaa; margin-bottom: 6px; }
			.modal-close { float: right; background: none; border: none; color: #888; font-size: 18px; cursor: pointer; }

			/* ── Day View ── */
			.day-view { position: relative; }
			.day-view-header { font-size: 13px; color: #94a3b8; margin-bottom: 8px; font-weight: 500; }
			.day-allday { background: #16161e; border: 1px solid #222; border-radius: 8px; padding: 8px; margin-bottom: 8px; }
			.day-allday-label { font-size: 10px; color: #64748b; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 4px; }
			.timeline { position: relative; border-left: 1px solid #222; margin-left: 44px; }
			.hour-row { display: flex; min-height: 48px; border-bottom: 1px solid rgba(255,255,255,0.04); position: relative; }
			.hour-label { position: absolute; left: -48px; top: -7px; width: 40px; text-align: right; font-size: 10px; color: #4a5568; font-variant-numeric: tabular-nums; }
			.hour-content { flex: 1; position: relative; padding-left: 8px; }
			.tl-event { position: absolute; left: 8px; right: 8px; border-radius: 6px; padding: 4px 8px; font-size: 11px; overflow: hidden; cursor: pointer; border-left: 3px solid; z-index: 1; transition: opacity 0.15s; }
			.tl-event:hover { opacity: 0.85; }
			.tl-event-title { font-weight: 600; color: #e2e8f0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
			.tl-event-time { font-size: 10px; color: #94a3b8; }
			.tl-event-loc { font-size: 10px; color: #64748b; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
			.now-line { position: absolute; left: 0; right: 0; height: 2px; background: #ef4444; z-index: 5; }
			.now-dot { position: absolute; left: -5px; top: -3px; width: 8px; height: 8px; border-radius: 50%; background: #ef4444; }

			/* ── Week View ── */
			.week-view { overflow-x: auto; }
			.week-header { display: grid; grid-template-columns: 44px repeat(7, 1fr); gap: 0; margin-bottom: 0; }
			.week-day-header { text-align: center; padding: 8px 4px; font-size: 11px; color: #64748b; font-weight: 600; border-bottom: 1px solid #222; cursor: pointer; }
			.week-day-header:hover { color: #e2e8f0; }
			.week-day-header.today { color: #6366f1; border-bottom-color: #6366f1; }
			.week-day-num { font-size: 16px; font-weight: 700; display: block; }
			.week-day-name { font-size: 10px; text-transform: uppercase; letter-spacing: 0.05em; }
			.week-grid { display: grid; grid-template-columns: 44px repeat(7, 1fr); }
			.week-time-label { font-size: 10px; color: #4a5568; text-align: right; padding-right: 6px; 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; }

			/* ── 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; }

			/* ── 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-dot { position: absolute; bottom: 1px; left: 50%; transform: translateX(-50%); width: 3px; height: 3px; border-radius: 50%; }
			.mini-hint { text-align: center; font-size: 11px; color: #4a5568; margin-top: 8px; }

			/* ── 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; }
				.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; }
			}
			@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); }
				.mini-day { font-size: 8px; }
				.mini-month-title { font-size: 10px; }
			}
		`;
	}

	// ── Escape HTML ──

	private esc(s: string): string {
		const d = document.createElement("div");
		d.textContent = s || "";
		return d.innerHTML;
	}
}

customElements.define("folk-calendar-view", FolkCalendarView);
