/**
 * <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, 4, 1, 0, 0, 0]; // temporal → spatial coupling
const S_TO_T = [7, 6, 6, 4, 5, 3, 2, 2, 2]; // spatial → temporal (reverse lookup)
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;
}

// ── Offline-first imports ──
import { calendarSchema, calendarDocId, type CalendarDoc } from "../schemas";
import type { DocumentId } from "../../../shared/local-first/document";
import { TourEngine } from "../../../shared/tour-engine";
import { startPresenceHeartbeat } from '../../../shared/collab-presence';
import { generateBreadcrumb } from "../geo-hierarchy";

// ── 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 reminders: any[] = [];
	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;
	private _offlineUnsub: (() => void) | null = null;
	private _subscribedDocIds: string[] = [];
	private _stopPresence: (() => void) | null = null;
	// Membrane — caller's role for visibility filtering
	private _myRole: string = 'viewer';

	// Spatio-temporal state
	private temporalGranularity = 4; // MONTH
	private spatialGranularity: number | null = 3; // Country (independent default)
	private zoomCoupled = false;

	// Map panel state (replaces old tab system)
	private mapPanelState: "minimized" | "docked" = "docked";
	private currentTileLayer: any = null;
	private lunarOverlayExpanded = false;
	private _wheelTimer: ReturnType<typeof setTimeout> | null = null;

	// Map resize state
	private mapPanelWidth = 400;
	private _isResizing = false;
	private _resizeStartX = 0;
	private _resizeStartWidth = 0;

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

	// Gesture state (pan/swipe + pinch-to-zoom)
	private _pointerCache: PointerEvent[] = [];
	private _panStartX: number | null = null;
	private _panStartY: number | null = null;
	private _initialPinchDist: number | null = null;
	private _gestureMode: 'none' | 'pan' | 'pinch' = 'none';
	private _gestureFired = false;

	// Leaflet map (preserved across re-renders)
	private leafletMap: any = null;
	private mapContainer: HTMLDivElement | null = null;
	private mapMarkerLayer: any = null;
	private transitLineLayer: any = null;

	// Google Calendar integration
	private showGcalModal = false;
	private gcalCalendars: Array<{ id: string; name: string; color: string | null; primary: boolean; subscribed: boolean }> = [];
	private gcalLoading = false;
	private gcalError = "";
	private gcalConnected = false;

	// Guided tour
	private _tour!: TourEngine;
	private static readonly TOUR_STEPS = [
		{ target: '#prev', title: "Navigate", message: "Use the arrow buttons to move between time periods. Keyboard shortcuts: left/right arrows.", advanceOnClick: false },
		{ target: '#calendar-pane', title: "Calendar View", message: "Click any day cell to add an event. Drag events to reschedule. Use keyboard 1-5 to switch views.", advanceOnClick: false },
		{ target: '#toggle-lunar', title: "Lunar Phases", message: "Toggle lunar phase display to see moon cycles alongside your events. Press 'L' as a shortcut.", advanceOnClick: true },
		{ target: '#map-fab, .map-panel', title: "Map View", message: "Events with locations appear on the map. Expand to see spatial context alongside your calendar.", advanceOnClick: false },
	];

	constructor() {
		super();
		this.shadow = this.attachShadow({ mode: "open" });
		this._tour = new TourEngine(
			this.shadow,
			FolkCalendarView.TOUR_STEPS,
			"rcal_tour_done",
			() => this.shadow.host as HTMLElement,
		);
	}

	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(); }
		else { this.subscribeOffline(); this.loadMonth(); this.loadMyRole(); this.render(); }
		if (!localStorage.getItem("rcal_tour_done")) {
			setTimeout(() => this._tour.start(), 1200);
		}
		this._stopPresence = startPresenceHeartbeat(() => ({ module: 'rcal', context: this.selectedEvent?.title || 'Calendar' }));

		// If redirected back from Google OAuth, open the calendar picker
		const params = new URLSearchParams(window.location.search);
		if (params.get("connected") === "google") {
			history.replaceState(null, "", window.location.pathname);
			this.gcalConnected = true;
			setTimeout(() => this.openGcalModal(), 500);
		}
		// Check Google connection status
		this.checkGcalStatus();
	}

	disconnectedCallback() {
		this._offlineUnsub?.();
		this._offlineUnsub = null;
		this._stopPresence?.();
		const runtime = (window as any).__rspaceOfflineRuntime;
		if (runtime) {
			for (const id of this._subscribedDocIds) runtime.unsubscribe(id);
		}
		this._subscribedDocIds = [];
		if (this.boundKeyHandler) {
			document.removeEventListener("keydown", this.boundKeyHandler);
			this.boundKeyHandler = null;
		}
		if (this._wheelTimer) { clearTimeout(this._wheelTimer); this._wheelTimer = null; }
		if (this._isResizing) {
			this._isResizing = false;
			document.body.style.cursor = "";
			document.body.style.userSelect = "";
		}
		this._pointerCache = [];
		this._panStartX = this._panStartY = this._initialPinchDist = null;
		this._gestureMode = 'none';
		this._gestureFired = false;
		if (this.leafletMap) {
			this.leafletMap.remove();
			this.leafletMap = null;
			this.mapContainer = null;
		}
	}

	private _isVisibleTo(vis: string | undefined | null, role: string): boolean {
		const levels: Record<string, number> = { viewer: 0, member: 1, moderator: 2, admin: 3 };
		return (levels[role] ?? 0) >= (levels[vis ?? 'viewer'] ?? 0);
	}

	private async loadMyRole() {
		if (this.space === 'demo') return;
		const token = localStorage.getItem('encryptid-token');
		if (!token) return;
		try {
			const res = await fetch(`/api/space-access/${encodeURIComponent(this.space)}`, {
				headers: { Authorization: `Bearer ${token}` },
			});
			if (res.ok) {
				const data = await res.json();
				this._myRole = data.role || 'viewer';
			}
		} catch {}
	}

	private async subscribeOffline() {
		const runtime = (window as any).__rspaceOfflineRuntime;
		if (!runtime?.isInitialized) return;

		try {
			const docId = calendarDocId(this.space) as DocumentId;
			const doc = await runtime.subscribe(docId, calendarSchema);
			this._subscribedDocIds.push(docId);
			this.renderFromCalDoc(doc);

			this._offlineUnsub = runtime.onChange(docId, (updated: any) => {
				this.renderFromCalDoc(updated);
			});
		} catch {
			// Runtime unavailable — REST fallback handles data loading
		}
	}

	private renderFromCalDoc(doc: CalendarDoc) {
		if (!doc) return;

		// Populate sources from Automerge doc
		if (doc.sources && Object.keys(doc.sources).length > 0) {
			this.sources = Object.values(doc.sources).map(s => ({
				id: s.id, name: s.name, source_type: s.sourceType,
				url: s.url, color: s.color, is_active: s.isActive,
				is_visible: s.isVisible, owner_id: s.ownerId,
				external_id: s.externalId || null,
				last_synced_at: s.lastSyncedAt || 0,
				last_sync_status: s.lastSyncStatus || null,
			}));
		}

		// Populate events from Automerge doc (supplement REST data)
		if (doc.events && Object.keys(doc.events).length > 0) {
			const docEvents = Object.values(doc.events).map(e => ({
				id: e.id, title: e.title, description: e.description,
				start_time: new Date(e.startTime).toISOString(),
				end_time: new Date(e.endTime).toISOString(),
				all_day: e.allDay, source_id: e.sourceId,
				source_name: e.sourceName, source_color: e.sourceColor,
				location_name: e.locationName,
				location_breadcrumb: e.locationBreadcrumb ?? null,
				booking_status: e.bookingStatus ?? null,
				location_lat: e.locationLat, location_lng: e.locationLng,
				latitude: e.locationLat ?? null,
				longitude: e.locationLng ?? null,
			}));
			// Only use doc events if REST hasn't loaded yet
			if (this.events.length === 0 && docEvents.length > 0) {
				this.events = docEvents;
			}
		}

		this.fillMissingBreadcrumbs();
		this.render();
	}

	// ── 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":
				// Toggle: docked ↔ minimized
				this.mapPanelState = this.mapPanelState === "docked" ? "minimized" : "docked";
				this.render();
				break;
			case "c": case "C":
				if (!this.zoomCoupled) {
					// Coupling: let spatial follow temporal
					this.zoomCoupled = true;
					this.spatialGranularity = null;
					this.syncMapToSpatial();
				} else {
					// Decoupling: preserve current effective spatial index
					this.spatialGranularity = this.getEffectiveSpatialIndex();
					this.zoomCoupled = false;
				}
				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 setSpatialGranularity(n: number) {
		n = Math.max(0, Math.min(8, n));
		this.spatialGranularity = n;
		if (this.zoomCoupled) {
			const mapped = S_TO_T[n];
			if (mapped !== this.temporalGranularity) {
				this.temporalGranularity = mapped;
				const view = T_TO_VIEW[mapped];
				if (view) this.viewMode = view;
				this.viewVariant = 0;
				this.expandedDay = "";
			}
		}
		this.syncMapToSpatial();
		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()];
	}

	/** Returns [start, end] epoch-ms bounds for the currently visible calendar period. */
	private getVisibleDateRange(): [number, number] {
		const d = this.currentDate;
		const y = d.getFullYear(), m = d.getMonth(), dt = d.getDate();
		switch (this.viewMode) {
			case "day":
				return [new Date(y, m, dt).getTime(), new Date(y, m, dt + 1).getTime()];
			case "week": {
				const ws = new Date(y, m, dt - d.getDay());
				return [ws.getTime(), new Date(ws.getFullYear(), ws.getMonth(), ws.getDate() + 7).getTime()];
			}
			case "month":
				return [new Date(y, m, 1).getTime(), new Date(y, m + 1, 1).getTime()];
			case "season": {
				const q = Math.floor(m / 3);
				return [new Date(y, q * 3, 1).getTime(), new Date(y, q * 3 + 3, 1).getTime()];
			}
			case "year":
				return [new Date(y, 0, 1).getTime(), new Date(y + 1, 0, 1).getTime()];
			case "multi-year": {
				const sy = y - 4;
				return [new Date(sy, 0, 1).getTime(), new Date(sy + 9, 0, 1).getTime()];
			}
			default:
				return [new Date(y, m, 1).getTime(), new Date(y, m + 1, 1).getTime()];
		}
	}

	/** Events with coordinates that fall within the visible calendar period. */
	private getVisibleLocatedEvents(): any[] {
		const [start, end] = this.getVisibleDateRange();
		return this.events.filter(e => {
			if (e.latitude == null || e.longitude == null) return false;
			if (this.filteredSources.has(e.source_name)) return false;
			// Client-side membrane filter
			if (!this._isVisibleTo(e.accessVisibility, this._myRole)) return false;
			const t = new Date(e.start_time).getTime();
			return t >= start && t < end;
		});
	}

	private syncMapToSpatial() {
		if (!this.leafletMap) return;
		const L = (window as any).L;
		if (!L) return;
		const located = this.getVisibleLocatedEvents();
		if (located.length === 0) {
			// No events in view — fly to coupled zoom at default center
			this.leafletMap.flyTo([52.52, 13.405], this.getEffectiveLeafletZoom(), { duration: 0.8 });
			return;
		}
		if (located.length === 1) {
			this.leafletMap.flyTo([located[0].latitude, located[0].longitude], this.getEffectiveLeafletZoom(), { duration: 0.8 });
			return;
		}
		const bounds = L.latLngBounds(located.map((e: any) => [e.latitude, e.longitude]));
		this.leafletMap.flyToBounds(bounds, { padding: [40, 40], maxZoom: 16, duration: 0.8 });
	}

	private computeMapCenter(): [number, number] {
		const located = this.getVisibleLocatedEvents();
		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];
	}

	/** Fill missing location_breadcrumb fields from lat/lng using static geo lookup. */
	private fillMissingBreadcrumbs() {
		for (const ev of this.events) {
			if (!ev.location_breadcrumb && ev.latitude != null && ev.longitude != null) {
				ev.location_breadcrumb = generateBreadcrumb(ev.latitude, ev.longitude, ev.location_name);
			}
		}
	}

	/** Get events for a specific month (used by year/season/multi-year spatial labels). */
	private getMonthEvents(year: number, month: number): any[] {
		const start = new Date(year, month, 1).getTime();
		const end = new Date(year, month + 1, 1).getTime();
		return this.events.filter(e => {
			const t = new Date(e.start_time).getTime();
			// Client-side membrane filter
			if (!this._isVisibleTo(e.accessVisibility, this._myRole)) return false;
			return t >= start && t < end && !this.filteredSources.has(e.source_name);
		});
	}

	// ── 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" },
			{ name: "Community", color: "#ec4899" },
			{ name: "Health", color: "#06b6d4" },
		];

		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; likelihood?: number; booked?: boolean;
		}[] = [
			// ── 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, 23, 7, 30), durationMin: 75, title: "Morning Yoga", source: 5, desc: "Vinyasa flow", location: "Yoga Studio Kreuzberg", virtual: false, lat: 52.4960, lng: 13.4088, breadcrumb: "Earth > Europe > Germany > Berlin > Kreuzberg" },
			{ start: rel(-1, 25, 7, 30), durationMin: 75, title: "Morning Yoga", source: 5, 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, 27, 19, 0), durationMin: 90, title: "Climate Action Meetup", source: 4, desc: "Monthly community meetup on local climate projects", location: "Regenbogenfabrik", virtual: false, lat: 52.4945, lng: 13.4290, breadcrumb: "Earth > Europe > Germany > Berlin > Kreuzberg" },
			{ 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 — Periodic: MWF standups ──
			{ 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, 4, 7, 30), durationMin: 75, title: "Morning Yoga", source: 5, desc: "Vinyasa flow", location: "Yoga Studio Kreuzberg", virtual: false, breadcrumb: "Earth > Europe > Germany > Berlin > Kreuzberg" },
			{ 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, 6, 7, 30), durationMin: 75, title: "Morning Yoga", source: 5, desc: "Vinyasa flow", location: "Yoga Studio Kreuzberg", virtual: false, breadcrumb: "Earth > Europe > Germany > Berlin > Kreuzberg" },
			{ 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, 9, 0), durationMin: 30, title: "Team Standup", source: 0, desc: "Weekly sync", location: "Factory Berlin", virtual: false, breadcrumb: "Earth > Europe > Germany > Berlin" },
			{ 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: 5, desc: "Vinyasa flow", location: "Yoga Studio Kreuzberg", virtual: false, breadcrumb: "Earth > Europe > Germany > Berlin > Kreuzberg" },
			{ start: rel(0, 10, 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, 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, 11, 7, 30), durationMin: 75, title: "Morning Yoga", source: 5, desc: "Vinyasa flow", location: "Yoga Studio Kreuzberg", virtual: false, breadcrumb: "Earth > Europe > Germany > Berlin > Kreuzberg" },
			{ start: rel(0, 11, 18, 0), durationMin: 120, title: "Workshop: Intro to CRDTs (1/4)", source: 4, desc: "Community workshop series on conflict-free replicated data types", location: "c-base, Berlin", virtual: false, lat: 52.5130, lng: 13.4200, breadcrumb: "Earth > Europe > Germany > Berlin > Mitte" },
			{ 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", booked: true },
			{ 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", booked: true },
			{ 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", booked: true },
			{ 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", booked: true },
			{ start: rel(0, 15, 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, 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: 5, desc: "Vinyasa flow", location: "Yoga Studio Kreuzberg", virtual: false, breadcrumb: "Earth > Europe > Germany > Berlin > Kreuzberg" },
			{ start: rel(0, 16, 14, 0), durationMin: 45, title: "Physiotherapy", source: 5, desc: "Shoulder rehab session", location: "Praxis Neuk\u00f6lln", virtual: false, lat: 52.4812, lng: 13.4350, breadcrumb: "Earth > Europe > Germany > Berlin > Neuk\u00f6lln" },
			{ 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, 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, 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, 18, 18, 0), durationMin: 120, title: "Workshop: CRDTs (2/4)", source: 4, desc: "Hands-on: Counters, sets, and registers", location: "c-base, Berlin", virtual: false, lat: 52.5130, lng: 13.4200, breadcrumb: "Earth > Europe > Germany > Berlin > Mitte" },
			{ start: rel(0, 19, 9, 0), durationMin: 45, title: "Dentist", source: 5, 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: 4, 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: 4, 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", booked: true },
			{ 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", booked: true },
			{ 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", booked: true },
			{ 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", booked: true },
			{ start: rel(0, 25, 18, 0), durationMin: 120, title: "Workshop: CRDTs (3/4)", source: 4, desc: "Merging trees and documents", location: "c-base, Berlin", virtual: false, lat: 52.5130, lng: 13.4200, breadcrumb: "Earth > Europe > Germany > Berlin > Mitte" },
			{ start: rel(0, 26, 18, 0), durationMin: 180, title: "Hackathon \u2014 c-base", source: 4, 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, 27, 19, 0), durationMin: 90, title: "Climate Action Meetup", source: 4, desc: "Monthly: urban food systems", location: "Regenbogenfabrik", virtual: false, lat: 52.4945, lng: 13.4290, breadcrumb: "Earth > Europe > Germany > Berlin > Kreuzberg" },
			{ 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 (+1) ──
			{ start: rel(1, 1, 18, 0), durationMin: 120, title: "Workshop: CRDTs (4/4)", source: 4, desc: "Final session: production patterns", location: "c-base, Berlin", virtual: false, lat: 52.5130, lng: 13.4200, breadcrumb: "Earth > Europe > Germany > Berlin > Mitte" },
			{ 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", booked: true },
			{ 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", booked: true },
			{ 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", booked: true },
			{ 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", booked: true },
			{ start: rel(1, 6, 7, 30), durationMin: 75, title: "Morning Yoga", source: 5, desc: "Vinyasa flow", location: "Yoga Studio Kreuzberg", virtual: false, breadcrumb: "Earth > Europe > Germany > Berlin > Kreuzberg" },
			{ start: rel(1, 8, 7, 30), durationMin: 75, title: "Morning Yoga", source: 5, desc: "Vinyasa flow", location: "Yoga Studio Kreuzberg", virtual: false, breadcrumb: "Earth > Europe > Germany > Berlin > Kreuzberg" },
			{ 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, 10, 19, 0), durationMin: 90, title: "Book Club", source: 4, desc: '"Braiding Sweetgrass"', location: "Shakespeare & Sons, Berlin", virtual: false, lat: 52.4925, lng: 13.4310, breadcrumb: "Earth > Europe > Germany > Berlin > Kreuzberg" },
			{ 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" },
			{ start: rel(1, 15, 9, 0), durationMin: 30, title: "Team Standup", source: 0, desc: "Weekly sync", location: "Factory Berlin", virtual: false, breadcrumb: "Earth > Europe > Germany > Berlin" },
			{ start: rel(1, 20, 7, 30), durationMin: 75, title: "Morning Yoga", source: 5, desc: "Vinyasa flow", location: "Yoga Studio Kreuzberg", virtual: false, breadcrumb: "Earth > Europe > Germany > Berlin > Kreuzberg" },
			{ start: rel(1, 22, 14, 0), durationMin: 45, title: "Physiotherapy", source: 5, desc: "Shoulder rehab follow-up", location: "Praxis Neuk\u00f6lln", virtual: false, lat: 52.4812, lng: 13.4350, breadcrumb: "Earth > Europe > Germany > Berlin > Neuk\u00f6lln" },
			{ start: rel(1, 25, 19, 0), durationMin: 90, title: "Climate Action Meetup", source: 4, desc: "Monthly: renewable energy co-ops", location: "Regenbogenfabrik", virtual: false, lat: 52.4945, lng: 13.4290, breadcrumb: "Earth > Europe > Germany > Berlin > Kreuzberg" },

			// ── MONTH +2 ──
			{ start: rel(2, 3, 9, 0), durationMin: 30, title: "Team Standup", source: 0, desc: "Weekly sync", location: "Factory Berlin", virtual: false, breadcrumb: "Earth > Europe > Germany > Berlin" },
			{ start: rel(2, 5, 10, 0), durationMin: 120, title: "Sprint Planning", source: 0, desc: "Sprint 26", location: "Factory Berlin", virtual: false, breadcrumb: "Earth > Europe > Germany > Berlin" },
			{ start: rel(2, 8, 7, 30), durationMin: 75, title: "Morning Yoga", source: 5, desc: "Vinyasa flow", location: "Yoga Studio Kreuzberg", virtual: false, breadcrumb: "Earth > Europe > Germany > Berlin > Kreuzberg" },
			{ start: rel(2, 10, 19, 0), durationMin: 90, title: "Book Club", source: 4, desc: '"Ministry for the Future"', location: "Shakespeare & Sons, Berlin", virtual: false, lat: 52.4925, lng: 13.4310, breadcrumb: "Earth > Europe > Germany > Berlin > Kreuzberg" },
			{ start: rel(2, 14, 9, 0), durationMin: 480, title: "DWeb Camp Day 1", source: 3, desc: "Decentralized web camp", location: "Camp Navarro, CA", virtual: false, lat: 39.1766, lng: -123.6335, breadcrumb: "Earth > Americas > USA > California" },
			{ start: rel(2, 15, 9, 0), durationMin: 480, title: "DWeb Camp Day 2", source: 3, desc: "Workshops and lightning talks", location: "Camp Navarro, CA", virtual: false, lat: 39.1766, lng: -123.6335, breadcrumb: "Earth > Americas > USA > California" },
			{ start: rel(2, 20, 7, 30), durationMin: 75, title: "Morning Yoga", source: 5, desc: "Vinyasa flow", location: "Yoga Studio Kreuzberg", virtual: false, breadcrumb: "Earth > Europe > Germany > Berlin > Kreuzberg" },
			{ start: rel(2, 25, 19, 0), durationMin: 90, title: "Climate Action Meetup", source: 4, desc: "Monthly: water commons", location: "Regenbogenfabrik", virtual: false, lat: 52.4945, lng: 13.4290, breadcrumb: "Earth > Europe > Germany > Berlin > Kreuzberg" },

			// ── MONTH +3 — tentative events begin ──
			{ start: rel(3, 5, 10, 0), durationMin: 120, title: "Sprint Planning", source: 0, desc: "Sprint 27", location: "Factory Berlin", virtual: false, breadcrumb: "Earth > Europe > Germany > Berlin", likelihood: 80 },
			{ start: rel(3, 8, 9, 0), durationMin: 540, title: "IPFS Camp", source: 3, desc: "Potential IPFS community gathering", location: "Lisbon", virtual: false, lat: 38.7223, lng: -9.1393, breadcrumb: "Earth > Europe > Portugal > Lisbon", likelihood: 50 },
			{ start: rel(3, 9, 9, 0), durationMin: 540, title: "IPFS Camp Day 2", source: 3, desc: "Workshops track", location: "Lisbon", virtual: false, lat: 38.7223, lng: -9.1393, breadcrumb: "Earth > Europe > Portugal > Lisbon", likelihood: 50 },
			{ start: rel(3, 12, 19, 0), durationMin: 90, title: "Book Club", source: 4, desc: "TBD", location: "Shakespeare & Sons", virtual: false, lat: 52.4925, lng: 13.4310, breadcrumb: "Earth > Europe > Germany > Berlin > Kreuzberg", likelihood: 70 },
			{ start: rel(3, 15, 14, 0), durationMin: 45, title: "Physiotherapy", source: 5, desc: "Check-in session", location: "Praxis Neuk\u00f6lln", virtual: false, lat: 52.4812, lng: 13.4350, breadcrumb: "Earth > Europe > Germany > Berlin > Neuk\u00f6lln", likelihood: 75 },
			{ start: rel(3, 20, 10, 0), durationMin: 480, title: "Summer Retreat \u2014 Alps", source: 2, desc: "Hiking & writing retreat in Bavarian Alps", location: "Garmisch-Partenkirchen", virtual: false, lat: 47.4921, lng: 11.0958, breadcrumb: "Earth > Europe > Germany > Bavaria", likelihood: 60 },
			{ start: rel(3, 21, 10, 0), durationMin: 480, title: "Summer Retreat Day 2", source: 2, desc: "Zugspitze summit attempt", location: "Garmisch-Partenkirchen", virtual: false, lat: 47.4921, lng: 11.0958, breadcrumb: "Earth > Europe > Germany > Bavaria", likelihood: 60 },

			// ── MONTH +4 ──
			{ start: rel(4, 2, 10, 0), durationMin: 120, title: "Sprint Planning", source: 0, desc: "Sprint 28", location: "Factory Berlin", virtual: false, breadcrumb: "Earth > Europe > Germany > Berlin", likelihood: 75 },
			{ start: rel(4, 6, 9, 0), durationMin: 480, title: "EthCC Paris", source: 3, desc: "Ethereum community conference", location: "Palais Brongniart, Paris", virtual: false, lat: 48.8696, lng: 2.3413, breadcrumb: "Earth > Europe > France > Paris", likelihood: 65 },
			{ start: rel(4, 7, 9, 0), durationMin: 480, title: "EthCC Day 2", source: 3, desc: "Panel on decentralized identity", location: "Palais Brongniart, Paris", virtual: false, lat: 48.8696, lng: 2.3413, breadcrumb: "Earth > Europe > France > Paris", likelihood: 65 },
			{ start: rel(4, 15, 19, 0), durationMin: 90, title: "Climate Action Meetup", source: 4, desc: "Monthly: transport decarbonization", location: "Regenbogenfabrik", virtual: false, lat: 52.4945, lng: 13.4290, breadcrumb: "Earth > Europe > Germany > Berlin > Kreuzberg", likelihood: 70 },
			{ start: rel(4, 20, 19, 0), durationMin: 90, title: "Book Club", source: 4, desc: "TBD", location: "Shakespeare & Sons", virtual: false, lat: 52.4925, lng: 13.4310, breadcrumb: "Earth > Europe > Germany > Berlin > Kreuzberg", likelihood: 60 },

			// ── MONTH +5 ──
			{ start: rel(5, 5, 10, 0), durationMin: 120, title: "Sprint Planning", source: 0, desc: "Sprint 29", location: "Factory Berlin", virtual: false, breadcrumb: "Earth > Europe > Germany > Berlin", likelihood: 60 },
			{ start: rel(5, 10, 10, 0), durationMin: 480, title: "Team Offsite \u2014 Tenerife", source: 0, desc: "Winter offsite planning", location: "Tenerife", virtual: false, lat: 28.2916, lng: -16.6291, breadcrumb: "Earth > Europe > Spain > Canary Islands", likelihood: 40 },
			{ start: rel(5, 11, 10, 0), durationMin: 480, title: "Team Offsite Day 2", source: 0, desc: "Workshops + hike", location: "Tenerife", virtual: false, lat: 28.2916, lng: -16.6291, breadcrumb: "Earth > Europe > Spain > Canary Islands", likelihood: 40 },
			{ start: rel(5, 18, 19, 0), durationMin: 90, title: "Book Club", source: 4, desc: "TBD", location: "Shakespeare & Sons", virtual: false, lat: 52.4925, lng: 13.4310, breadcrumb: "Earth > Europe > Germany > Berlin > Kreuzberg", likelihood: 55 },

			// ── MONTH +6 ──
			{ start: rel(6, 3, 9, 0), durationMin: 480, title: "RheinMain Salon", source: 3, desc: "Regional tech meetup", location: "Frankfurt", virtual: false, lat: 50.1109, lng: 8.6821, breadcrumb: "Earth > Europe > Germany > Hessen > Frankfurt", likelihood: 45 },
			{ start: rel(6, 12, 10, 0), durationMin: 480, title: "DevConnect Istanbul", source: 3, desc: "Ethereum developer conference", location: "Istanbul", virtual: false, lat: 41.0082, lng: 28.9784, breadcrumb: "Earth > Europe > Turkey > Istanbul", likelihood: 35 },
			{ start: rel(6, 13, 10, 0), durationMin: 480, title: "DevConnect Day 2", source: 3, desc: "Identity & privacy track", location: "Istanbul", virtual: false, lat: 41.0082, lng: 28.9784, breadcrumb: "Earth > Europe > Turkey > Istanbul", likelihood: 35 },
			{ start: rel(6, 20, 19, 0), durationMin: 90, title: "Climate Action Meetup", source: 4, desc: "Monthly TBD", location: "Regenbogenfabrik", virtual: false, lat: 52.4945, lng: 13.4290, breadcrumb: "Earth > Europe > Germany > Berlin > Kreuzberg", likelihood: 50 },

			// ── MONTH +7 ──
			{ start: rel(7, 5, 10, 0), durationMin: 120, title: "Sprint Planning", source: 0, desc: "Sprint 30", location: "Factory Berlin", virtual: false, breadcrumb: "Earth > Europe > Germany > Berlin", likelihood: 40 },
			{ start: rel(7, 15, 10, 0), durationMin: 480, title: "36C3 Day 1", source: 3, desc: "Chaos Communication Congress", location: "CCH, Hamburg", virtual: false, lat: 53.5631, lng: 9.9864, breadcrumb: "Earth > Europe > Germany > Hamburg", likelihood: 30 },
			{ start: rel(7, 16, 10, 0), durationMin: 480, title: "36C3 Day 2", source: 3, desc: "Assembly: local-first computing", location: "CCH, Hamburg", virtual: false, lat: 53.5631, lng: 9.9864, breadcrumb: "Earth > Europe > Germany > Hamburg", likelihood: 30 },

			// ── MONTH +8 ──
			{ start: rel(8, 8, 10, 0), durationMin: 480, title: "FOSDEM", source: 3, desc: "Free & Open Source Developers European Meeting", location: "ULB, Brussels", virtual: false, lat: 50.8120, lng: 4.3817, breadcrumb: "Earth > Europe > Belgium > Brussels", likelihood: 25 },
			{ start: rel(8, 9, 10, 0), durationMin: 480, title: "FOSDEM Day 2", source: 3, desc: "Decentralization devroom", location: "ULB, Brussels", virtual: false, lat: 50.8120, lng: 4.3817, breadcrumb: "Earth > Europe > Belgium > Brussels", likelihood: 25 },

			// ── PLANETARY / COSMIC SCALE — visible when zoomed all the way out ──
			{ start: new Date(year, 2, 20, 15, 6), durationMin: 1, title: "March Equinox", source: 2, desc: "Vernal equinox \u2014 day and night equal length. Marks astronomical spring in Northern Hemisphere.", location: "Earth", virtual: false, breadcrumb: "Earth" },
			{ start: new Date(year, 5, 20, 22, 51), durationMin: 1, title: "June Solstice", source: 2, desc: "Summer solstice \u2014 longest day in Northern Hemisphere, shortest in Southern.", location: "Earth", virtual: false, breadcrumb: "Earth" },
			{ start: new Date(year, 8, 22, 18, 19), durationMin: 1, title: "September Equinox", source: 2, desc: "Autumnal equinox \u2014 equal day and night. Marks astronomical fall.", location: "Earth", virtual: false, breadcrumb: "Earth" },
			{ start: new Date(year, 11, 21, 15, 3), durationMin: 1, title: "December Solstice", source: 2, desc: "Winter solstice \u2014 shortest day in Northern Hemisphere. Yule, Dongzhi.", location: "Earth", virtual: false, breadcrumb: "Earth" },
			{ start: new Date(year, 3, 22, 0, 0), durationMin: 1440, title: "Earth Day", source: 4, desc: "Global day of environmental action \u2014 1 billion+ participants across 192 countries.", location: "Earth", virtual: false, breadcrumb: "Earth" },
			{ start: new Date(year, 7, 12, 0, 0), durationMin: 360, title: "Perseid Meteor Shower Peak", source: 2, desc: "Up to 100 meteors/hour visible. Best viewing after midnight in dark skies.", location: "Northern Hemisphere", virtual: false, breadcrumb: "Earth" },
			{ start: new Date(year, 9, 14, 0, 0), durationMin: 1440, title: "International Repair Day", source: 4, desc: "Global movement for right to repair \u2014 events in 50+ countries.", location: "Earth", virtual: false, breadcrumb: "Earth" },

			// ── CONTINENTAL SCALE — visible at continent zoom ──
			{ start: new Date(year, 4, 9, 0, 0), durationMin: 1440, title: "Europe Day", source: 4, desc: "Celebrating peace and unity across the European continent. Open-door events at EU institutions.", location: "Europe", virtual: false, lat: 50.0, lng: 10.0, breadcrumb: "Earth > Europe" },
			{ start: new Date(year, 8, 16, 0, 0), durationMin: 10080, title: "European Mobility Week", source: 4, desc: "Week-long car-free zones, bike lanes, and public transport events across 3,000+ European cities.", location: "Europe", virtual: false, lat: 48.5, lng: 9.0, breadcrumb: "Earth > Europe", likelihood: 80 },
			{ start: new Date(year, 10, 1, 0, 0), durationMin: 1440, title: "Pan-European Climate Strike", source: 4, desc: "Coordinated Fridays for Future actions across European capitals \u2014 Berlin, Paris, Brussels, Madrid, Stockholm.", location: "Europe", virtual: false, lat: 50.0, lng: 10.0, breadcrumb: "Earth > Europe", likelihood: 60 },

			// ── COUNTRY SCALE — visible at country zoom ──
			{ start: new Date(year, 9, 3, 0, 0), durationMin: 1440, title: "German Unity Day", source: 2, desc: "National holiday celebrating German reunification (1990). Public celebrations in all 16 federal states.", location: "Germany", virtual: false, lat: 51.1657, lng: 10.4515, breadcrumb: "Earth > Europe > Germany" },
			{ start: new Date(year, 5, 21, 0, 0), durationMin: 1440, title: "F\u00eate de la Musique", source: 2, desc: "Free live music in every town and city across France \u2014 streets, parks, and courtyards.", location: "France", virtual: false, lat: 46.6034, lng: 1.8883, breadcrumb: "Earth > Europe > France" },
			{ start: new Date(year, 0, 1, 0, 0), durationMin: 1440, title: "Neujahrstag", source: 2, desc: "New Year's Day \u2014 national holiday across Germany.", location: "Germany", virtual: false, lat: 51.1657, lng: 10.4515, breadcrumb: "Earth > Europe > Germany" },

			// ── BIOREGION / REGION SCALE ──
			{ start: new Date(year, 5, 1, 0, 0), durationMin: 129600, title: "Brandenburg Summer Festival Season", source: 4, desc: "Three months of open-air concerts, garden fests, and lake events across the Brandenburg lakes region.", location: "Brandenburg", virtual: false, lat: 52.4085, lng: 12.5316, breadcrumb: "Earth > Europe > Germany > Brandenburg", likelihood: 70 },
			{ start: new Date(year, 8, 1, 10, 0), durationMin: 2880, title: "Rh\u00f6n Biosphere Reserve Hiking Week", source: 2, desc: "Guided hikes through Germany\u2019s Land of Open Distances \u2014 wetlands, beech forests, stargazing.", location: "Rh\u00f6n Biosphere Reserve", virtual: false, lat: 50.4900, lng: 9.9700, breadcrumb: "Earth > Europe > Germany > Hessen > Rh\u00f6n", likelihood: 45 },
			{ start: new Date(year, 4, 15, 0, 0), durationMin: 4320, title: "Amsterdam Maker Festival", source: 3, desc: "3-day festival of open hardware, creative tech, and community fabrication across Amsterdam Noord.", location: "NDSM Wharf, Amsterdam", virtual: false, lat: 52.4012, lng: 4.8921, breadcrumb: "Earth > Europe > Netherlands > Amsterdam > Noord", likelihood: 55 },

			// ── CITY SCALE — visible at city zoom (already well covered, adding a few unique ones) ──
			{ start: new Date(year, 6, 1, 0, 0), durationMin: 43200, title: "48h Neuk\u00f6lln Art Festival", source: 4, desc: "Annual open-studio art festival across 500+ venues in Neuk\u00f6lln. Galleries, squats, caf\u00e9s.", location: "Neuk\u00f6lln, Berlin", virtual: false, lat: 52.4812, lng: 13.4350, breadcrumb: "Earth > Europe > Germany > Berlin > Neuk\u00f6lln" },
			{ start: new Date(year, 7, 1, 0, 0), durationMin: 20160, title: "Kreuzberg Community Garden Season", source: 4, desc: "Prinzessinneng\u00e4rten open hours \u2014 weekly workshops on composting, seed saving, urban agriculture.", location: "Prinzessinneng\u00e4rten, Kreuzberg", virtual: false, lat: 52.4939, lng: 13.4115, breadcrumb: "Earth > Europe > Germany > Berlin > Kreuzberg" },

			// ── MULTI-CONTINENT — showing global reach ──
			{ start: rel(2, 1, 0, 0), durationMin: 7200, title: "Open Source Summit \u2014 Tokyo", source: 3, desc: "5-day Linux Foundation summit. Keynotes, kernel track, supply chain security.", location: "Tokyo Big Sight", virtual: false, lat: 35.6320, lng: 139.7970, breadcrumb: "Earth > Asia > Japan > Tokyo", likelihood: 35 },
			{ start: rel(3, 10, 0, 0), durationMin: 5760, title: "RightsCon \u2014 Nairobi", source: 3, desc: "Global summit on human rights in the digital age. 4 days, 3000+ participants.", location: "KICC, Nairobi", virtual: false, lat: -1.2864, lng: 36.8172, breadcrumb: "Earth > Africa > Kenya > Nairobi", likelihood: 30 },
			{ start: rel(4, 20, 0, 0), durationMin: 4320, title: "Cumbre de los Pueblos \u2014 Bogot\u00e1", source: 4, desc: "People\u2019s summit on commons-based governance in Latin America.", location: "Universidad Nacional, Bogot\u00e1", virtual: false, lat: 4.6371, lng: -74.0836, breadcrumb: "Earth > Americas > Colombia > Bogot\u00e1", likelihood: 25 },
			{ start: rel(5, 5, 0, 0), durationMin: 2880, title: "IndiaFOSS 4.0", source: 3, desc: "India\u2019s largest free software conference. Bangalore campus.", location: "NIMHANS, Bangalore", virtual: false, lat: 12.9437, lng: 77.5960, breadcrumb: "Earth > Asia > India > Karnataka > Bangalore", likelihood: 20 },

			// ── SEASON-SCALE events — visible when temporal zoom = Season ──
			{ start: new Date(year, 3, 1, 0, 0), durationMin: 131040, title: "Q2 \u2014 Local-First Sync Engine", source: 0, desc: "Quarter goal: ship Automerge-based sync with offline conflict resolution. Milestones: Week 2 prototype, Week 8 beta, Week 12 production.", location: "Factory Berlin", virtual: false, breadcrumb: "Earth > Europe > Germany > Berlin", likelihood: 80 },
			{ start: new Date(year, 6, 1, 0, 0), durationMin: 131040, title: "Q3 \u2014 Federation & Multi-Space", source: 0, desc: "Quarter goal: cross-space document sharing, ActivityPub integration, provider mesh network.", location: "Factory Berlin", virtual: false, breadcrumb: "Earth > Europe > Germany > Berlin", likelihood: 60 },

			// ── YEAR-SCALE events — visible when temporal zoom = Year ──
			{ start: new Date(year, 0, 1, 0, 0), durationMin: 525600, title: "2026 Vision: Cosmolocal Infrastructure", source: 0, desc: "Annual objective: build production-grade cosmolocal infrastructure stack \u2014 identity, commerce, governance, and media tools for community self-organization.", location: null, virtual: true, breadcrumb: "Earth" },
			{ start: new Date(year, 0, 1, 0, 0), durationMin: 525600, title: "Permacomputing Transition Year", source: 4, desc: "Year-long community commitment to reduce digital infrastructure footprint by 30%. Monthly audits, quarterly reports.", location: null, virtual: true, breadcrumb: "Earth > Europe" },
		];

		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,
				likelihood: e.likelihood ?? null,
				booking_status: e.booked ? "booked" : null,
			};
		});

		this.sources = sources;
		this.fillMissingBreadcrumbs();

		// Compute lunar phases for visible range (month-6 through month+9)
		const lunar: Record<string, { phase: string; illumination: number }> = {};
		for (let m = month - 6; m <= month + 9; 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();
		const schedBase = this.getScheduleApiBase();
		try {
			const [eventsRes, sourcesRes, lunarRes, remindersRes] = await Promise.all([
				fetch(`${base}/api/events?start=${start}&end=${end}`),
				fetch(`${base}/api/sources`),
				fetch(`${base}/api/lunar?start=${start}&end=${end}`),
				fetch(`${schedBase}/api/reminders?upcoming=true`).catch(() => null),
			]);
			if (eventsRes.ok) {
				const data = await eventsRes.json();
				this.events = (data.results || []).map((e: any) => ({
					...e,
					latitude: e.latitude ?? e.location_lat ?? null,
					longitude: e.longitude ?? e.location_lng ?? null,
				}));
			}
			if (sourcesRes.ok) { const data = await sourcesRes.json(); this.sources = data.results || []; }
			if (lunarRes.ok) { this.lunarData = await lunarRes.json(); }
			if (remindersRes?.ok) { const data = await remindersRes.json(); this.reminders = data.reminders || []; }
		} catch { /* offline fallback */ }
		this.fillMissingBreadcrumbs();
		this.render();
	}

	// ── Google Calendar integration ──

	private async checkGcalStatus() {
		try {
			const res = await fetch(`/api/oauth/status?space=${this.space}`);
			if (res.ok) {
				const data = await res.json();
				this.gcalConnected = !!data.google?.connected;
			}
		} catch { /* ignore */ }
	}

	private async openGcalModal() {
		this.showGcalModal = true;
		this.gcalLoading = true;
		this.gcalError = "";
		this.render();

		const base = this.getApiBase();
		const token = (window as any).__rspaceToken;
		try {
			const res = await fetch(`${base}/api/google/calendars`, {
				headers: token ? { Authorization: `Bearer ${token}` } : {},
			});
			if (!res.ok) {
				const err = await res.json().catch(() => ({ error: res.statusText }));
				this.gcalError = err.error || "Failed to load calendars";
				if (res.status === 400 && err.error?.includes("not connected")) {
					this.gcalConnected = false;
				}
			} else {
				this.gcalCalendars = await res.json();
			}
		} catch (err: any) {
			this.gcalError = err.message || "Network error";
		}
		this.gcalLoading = false;
		this.render();
	}

	private async toggleGcalSubscription(calId: string, calName: string, calColor: string | null, subscribe: boolean) {
		const base = this.getApiBase();
		const token = (window as any).__rspaceToken;
		const headers: Record<string, string> = { "Content-Type": "application/json" };
		if (token) headers.Authorization = `Bearer ${token}`;

		try {
			if (subscribe) {
				await fetch(`${base}/api/google/subscribe`, {
					method: "POST", headers,
					body: JSON.stringify({ calendarId: calId, name: calName, color: calColor || "#4285f4" }),
				});
			} else {
				// Find the source ID for this calendar
				const source = this.sources.find((s: any) => s.source_type === "GOOGLE" && s.external_id === calId);
				if (source) {
					await fetch(`${base}/api/google/${source.id}`, { method: "DELETE", headers });
				}
			}
			// Refresh calendars and events
			await this.openGcalModal();
			this.loadMonth();
		} catch (err: any) {
			this.gcalError = err.message || "Failed to update subscription";
			this.render();
		}
	}

	private async syncAllGcal() {
		const base = this.getApiBase();
		const token = (window as any).__rspaceToken;
		const headers: Record<string, string> = {};
		if (token) headers.Authorization = `Bearer ${token}`;

		try {
			await fetch(`${base}/api/google/sync-all`, { method: "POST", headers });
			this.loadMonth();
		} catch { /* ignore */ }
	}

	private async pushEventToGoogle(eventId: string) {
		const base = this.getApiBase();
		const token = (window as any).__rspaceToken;
		const headers: Record<string, string> = { "Content-Type": "application/json" };
		if (token) headers.Authorization = `Bearer ${token}`;

		try {
			const res = await fetch(`${base}/api/google/push/${eventId}`, { method: "POST", headers });
			if (res.ok) {
				const data = await res.json();
				alert(`Event exported to Google Calendar`);
			} else {
				const err = await res.json().catch(() => ({ error: "Failed" }));
				alert(`Error: ${err.error}`);
			}
		} catch (err: any) {
			alert(`Error: ${err.message}`);
		}
	}

	// ── 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 => {
			if (!e.start_time || this.filteredSources.has(e.source_name)) return false;
			// Client-side membrane filter (defense-in-depth)
			if (!this._isVisibleTo(e.accessVisibility, this._myRole)) return false;
			const startDay = e.start_time.slice(0, 10);
			const endDay = e.end_time ? e.end_time.slice(0, 10) : startDay;
			return dateStr >= startDay && dateStr <= endDay;
		});
	}

	private getRemindersForDate(dateStr: string): any[] {
		return this.reminders.filter(r => {
			if (!r.remindAt) return false;
			const d = new Date(r.remindAt);
			const rs = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
			return rs === dateStr;
		});
	}

	/** Returns visual style overrides for tentative (likelihood < 100) events. */
	private getEventStyles(ev: any): { bgColor: string; borderStyle: string; opacity: number; isTentative: boolean; likelihoodLabel: string } {
		const baseColor = ev.source_color || "#6366f1";
		const likelihood: number | null = ev.likelihood ?? null;
		if (likelihood === null || likelihood >= 100) {
			return { bgColor: `${baseColor}20`, borderStyle: "solid", opacity: 1, isTentative: false, likelihoodLabel: "" };
		}
		// Scale opacity from 0.5 (0%) to 1.0 (100%)
		const alpha = Math.round(0.5 + (likelihood / 100) * 0.5);
		const hexAlpha = Math.round((0.08 + (likelihood / 100) * 0.12) * 255).toString(16).padStart(2, "0");
		return {
			bgColor: `${baseColor}${hexAlpha}`,
			borderStyle: "dashed",
			opacity: 0.5 + (likelihood / 100) * 0.5,
			isTentative: true,
			likelihoodLabel: `${likelihood}%`,
		};
	}

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

	/**
	 * Render day cell content — individual event labels when few events,
	 * spatial summary chips when events start to overlap.
	 */
	private renderDayCellContent(cellEvents: any[]): string {
		// Few events: show individual labels (original behavior)
		if (cellEvents.length <= 2) {
			return cellEvents.map(e => {
				const evColor = e.source_color || "#6366f1";
				const es = this.getEventStyles(e);
				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>` : "";
				const likelihoodHtml = es.isTentative ? `<span class="ev-likelihood">${es.likelihoodLabel}</span>` : "";
				const lockHtml = e.accessVisibility && e.accessVisibility !== 'viewer' ? `<span class="membrane-lock" title="Restricted: ${e.accessVisibility} only">&#128274;</span> ` : "";
				return `<div class="ev-label" style="border-left:2px ${es.borderStyle} ${evColor};background:${es.bgColor};opacity:${es.opacity}" data-event-id="${e.id}">${e.rToolSource === "rSchedule" ? '<span class="ev-bell">&#128276;</span>' : ""}${lockHtml}${virtualHtml}<span class="ev-time">${this.formatTime(e.start_time)}</span>${this.esc(e.title)}${likelihoodHtml}${cityHtml}</div>`;
			}).join("");
		}

		// Many events: semantic zoom — show spatial summary chips instead
		// Group by location: count events per city, then decide label granularity
		const located = cellEvents.filter(e => e.location_breadcrumb);
		const virtualCount = cellEvents.filter(e => e.is_virtual).length;

		// Try city-level grouping first
		const cityGroups = new Map<string, number>();
		for (const e of located) {
			const city = this.getSpatialLabel(e.location_breadcrumb, 5);
			if (city) cityGroups.set(city, (cityGroups.get(city) || 0) + 1);
		}

		// If too many cities (5+), zoom out to country level
		let groups: Map<string, number>;
		let levelLabel: string;
		if (cityGroups.size > 4) {
			groups = new Map<string, number>();
			for (const e of located) {
				const country = this.getSpatialLabel(e.location_breadcrumb, 3);
				if (country) groups.set(country, (groups.get(country) || 0) + 1);
			}
			levelLabel = "country";
			// If still too many countries, zoom to continent
			if (groups.size > 4) {
				groups = new Map<string, number>();
				for (const e of located) {
					const continent = this.getSpatialLabel(e.location_breadcrumb, 1);
					if (continent) groups.set(continent, (groups.get(continent) || 0) + 1);
				}
				levelLabel = "continent";
			}
		} else {
			groups = cityGroups;
			levelLabel = "city";
		}

		// Render spatial chips
		let html = "";
		const sorted = Array.from(groups.entries()).sort((a, b) => b[1] - a[1]);
		for (const [label, count] of sorted.slice(0, 3)) {
			html += `<div class="ev-spatial-chip" title="${count} event${count > 1 ? "s" : ""} in ${label}"><span class="ev-spatial-name">${this.esc(label)}</span><span class="ev-spatial-count">${count}</span></div>`;
		}
		if (sorted.length > 3) {
			html += `<div class="ev-spatial-chip ev-spatial-more">+${sorted.length - 3}</div>`;
		}
		if (virtualCount > 0) {
			html += `<div class="ev-spatial-chip ev-spatial-virtual" title="${virtualCount} virtual">\u{1F4BB} ${virtualCount}</div>`;
		}
		return html;
	}

	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" id="next">\u2192</button>
			<button class="nav-btn" id="btn-tour" style="margin-left:auto;font-size:0.78rem">Tour</button>
		</div>

		<div class="main-layout ${isDocked ? "main-layout--docked" : ""}" id="main-layout"${isDocked ? ` style="grid-template-columns: 1fr 6px ${this.mapPanelWidth}px"` : ""}>
			<div class="calendar-pane" id="calendar-pane">
				${this.renderCalendarContent()}
			</div>
			${isDocked ? `<div class="resize-handle" id="resize-handle"></div>` : ""}
			${this.renderMapPanel()}
		</div>

		${this.renderSources()}

		<div class="zoom-bar--top">${this.renderZoomController()}</div>

		<div class="bottom-bar">
			${this.renderLunarOverlay()}
			<button class="bottom-bar__lunar-toggle ${this.showLunar ? "active" : ""}" id="toggle-lunar" title="Toggle lunar phases (l)">\u{1F319}</button>
		</div>

		${this.selectedEvent ? this.renderEventModal() : ""}
		${this.renderGcalModal()}
		`;

		this.attachListeners();
		this._tour.renderOverlay();

		// 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 temporalLevels = [
			{ 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 spatialLevels = [
			{ idx: 0, label: "Planet" }, { idx: 1, label: "Cont." },
			{ idx: 2, label: "Bioreg." }, { idx: 3, label: "Country" },
			{ idx: 4, label: "Region" }, { idx: 5, label: "City" },
			{ idx: 6, label: "Nbhd" }, { idx: 7, label: "Addr" },
			{ idx: 8, label: "Coords" },
		];
		const maxVariant = VIEW_VARIANTS[this.viewMode] || 1;
		const tPct = ((this.temporalGranularity - 2) / 5) * 100;
		const effSpatial = this.getEffectiveSpatialIndex();
		const sPct = (effSpatial / 8) * 100;

		return `<div class="zoom-bar">
			<div class="zoom-bar__section">
				<div class="zoom-bar__row">
					<span class="zoom-bar__label-end">Day</span>
					<div class="zoom-bar__track" id="zoom-track">
						<div class="zoom-bar__gradient"></div>
						<div class="zoom-bar__thumb" id="zoom-thumb" style="left:${tPct}%"></div>
						${temporalLevels.map(l => {
							const tickPct = ((l.idx - 2) / 5) * 100;
							return `<div class="zoom-bar__tick" data-zoom="${l.idx}" style="left:${tickPct}%">
								<div class="zoom-bar__tick-mark ${this.temporalGranularity === l.idx ? "active" : ""}"></div>
								<div class="zoom-bar__tick-label ${this.temporalGranularity === l.idx ? "active" : ""}">${l.label}</div>
							</div>`;
						}).join("")}
					</div>
					<span class="zoom-bar__label-end">Years</span>
					${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>` : ""}
				</div>
			</div>
			<div class="zoom-bar__coupling-row">
				<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}"}
				</button>
			</div>
			<div class="zoom-bar__section ${this.zoomCoupled ? "zoom-bar__section--coupled" : ""}">
				<div class="zoom-bar__row">
					<span class="zoom-bar__label-end">Coords</span>
					<div class="zoom-bar__track zoom-bar__spatial-track" id="spatial-track">
						<div class="zoom-bar__gradient zoom-bar__spatial-gradient"></div>
						<div class="zoom-bar__thumb zoom-bar__spatial-thumb" id="spatial-thumb" style="left:${sPct}%"></div>
						${spatialLevels.map(l => {
							const tickPct = (l.idx / 8) * 100;
							return `<div class="zoom-bar__tick" data-spatial-zoom="${l.idx}" style="left:${tickPct}%">
								<div class="zoom-bar__tick-mark ${effSpatial === l.idx ? "active" : ""}"></div>
								<div class="zoom-bar__tick-label ${effSpatial === l.idx ? "active" : ""}">${l.label}</div>
							</div>`;
						}).join("")}
					</div>
					<span class="zoom-bar__label-end">Planet</span>
				</div>
			</div>
		</div>`;
	}

	// ── Sources ──

	private renderSources(): string {
		const hasGoogleSources = this.sources.some((s: any) => s.source_type === "GOOGLE");
		return `<div class="sources">
			<div class="sources-heading">Calendar Legend</div>
			${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)}${s.source_type === "GOOGLE" ? ' <span class="gcal-dot" title="Google Calendar"></span>' : ''}</span>`).join("")}
			<button class="src-badge gcal-btn" id="gcal-manage" title="${this.gcalConnected ? 'Manage Google Calendars' : 'Connect Google Calendar'}">${this.gcalConnected ? '\u{1F504} Google' : '\u{1F517} Google Cal'}</button>
			${hasGoogleSources ? `<button class="src-badge gcal-sync-btn" id="gcal-sync-all" title="Sync all Google Calendars now">\u{1F504} Sync</button>` : ''}
		</div>`;
	}

	private renderGcalModal(): string {
		if (!this.showGcalModal) return "";
		return `<div class="gcal-modal-overlay" id="gcal-overlay">
			<div class="gcal-modal">
				<div class="gcal-modal-header">
					<span>Google Calendar</span>
					<button class="gcal-modal-close" id="gcal-close">\u2715</button>
				</div>
				<div class="gcal-modal-body">
					${!this.gcalConnected ? `
						<p style="color:var(--rs-text-secondary);margin:12px 0">Connect your Google account to sync calendars.</p>
						<a class="gcal-connect-btn" href="/api/oauth/google/authorize?space=${this.space}&returnTo=rcal">Connect Google Account</a>
					` : this.gcalLoading ? `
						<p style="color:var(--rs-text-secondary);text-align:center;padding:24px 0">Loading calendars...</p>
					` : this.gcalError ? `
						<p style="color:var(--rs-error);margin:12px 0">${this.esc(this.gcalError)}</p>
						${!this.gcalConnected ? `<a class="gcal-connect-btn" href="/api/oauth/google/authorize?space=${this.space}&returnTo=rcal">Connect Google Account</a>` : ''}
					` : `
						<p style="color:var(--rs-text-secondary);margin:0 0 12px;font-size:12px">Select calendars to sync into rCal. Events will auto-update every 10 minutes.</p>
						<div class="gcal-list">
							${this.gcalCalendars.map(cal => `
								<label class="gcal-item" data-gcal-id="${this.esc(cal.id)}" data-gcal-name="${this.esc(cal.name)}" data-gcal-color="${cal.color || '#4285f4'}">
									<span class="gcal-color" style="background:${cal.color || '#4285f4'}"></span>
									<span class="gcal-name">${this.esc(cal.name)}${cal.primary ? ' <span style="opacity:0.5;font-size:10px">(primary)</span>' : ''}</span>
									<input type="checkbox" class="gcal-check" ${cal.subscribed ? 'checked' : ''}>
								</label>
							`).join("")}
						</div>
					`}
				</div>
			</div>
		</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 spatialLabel = SPATIAL_LABELS[this.getEffectiveSpatialIndex()];

		return `<div class="map-panel map-panel--docked" id="map-panel">
			<div class="map-panel-header">
				<span class="map-panel-title">\u{1F5FA} ${spatialLabel}</span>
				<div class="map-panel-controls">
					<button class="map-ctrl-btn" id="map-minimize" title="Minimize (m)">\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();
		const spatialLevel = this.getEffectiveSpatialIndex();

		// Spatial summary bar for zoomed-out views (country level or broader)
		let spatialSummary = "";
		if (spatialLevel <= 3) {
			const monthEvents = this.getMonthEvents(year, month);
			const countries = this.getUniqueSpatialLabels(monthEvents, 3, 5);
			if (countries.length > 0) {
				spatialSummary = `<div class="season-cities" style="margin-bottom:6px">${countries.map(c => `<span class="season-city-chip">${this.esc(c)}</span>`).join("")}</div>`;
			}
		}

		return `
		${spatialSummary}
		<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 = "";
		let detailRenderedInline = false;
		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);
			// For dots/labels in day cells, only show events that START on this day.
			// Multi-day events on intermediate days are shown via span bars instead.
			const cellEvents = dayEvents.filter(e => e.start_time.slice(0, 10) === ds);
			const dayReminders = this.getRemindersForDate(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>` : ""}
					${dayReminders.length > 0 ? `<span class="reminder-badge" title="${dayReminders.length} reminder${dayReminders.length > 1 ? "s" : ""}">🔔${dayReminders.length > 1 ? dayReminders.length : ""}</span>` : ""}
				</div>
				${cellEvents.length > 0 || dayReminders.length > 0 ? `
					<div class="dots">
						${cellEvents.slice(0, 5).map(e => {
							const es = this.getEventStyles(e);
							return es.isTentative
								? `<span class="dot dot--tentative" style="border-color:${e.source_color || "#6366f1"}"></span>`
								: `<span class="dot" style="background:${e.source_color || "#6366f1"}"></span>`;
						}).join("")}
						${dayReminders.slice(0, 3).map(r => `<span class="dot dot--reminder" style="background:${r.sourceColor || "#f59e0b"}" title="${this.esc(r.title)}"></span>`).join("")}
						${cellEvents.length > 5 ? `<span style="font-size:8px;color:var(--rs-text-muted)">+${cellEvents.length - 5}</span>` : ""}
					</div>
					${this.renderDayCellContent(cellEvents)}
				` : ""}
			</div>`;

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

		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 && !detailRenderedInline) {
			const dayEvents = this.getEventsForDate(this.expandedDay);
			html += this.renderDayDetail(this.expandedDay, dayEvents);
		}

		// Multi-day event span bars (position:absolute so they don't
		// displace auto-placed day cells in the CSS grid)
		html += this.renderMultiDaySpans(year, month, firstDay, daysInMonth);

		return html;
	}

	private renderMultiDaySpans(year: number, month: number, firstDay: number, daysInMonth: number): string {
		const mm = String(month + 1).padStart(2, "0");
		const monthStart = `${year}-${mm}-01`;
		const monthEnd = `${year}-${mm}-${String(daysInMonth).padStart(2, "0")}`;

		const multiDay = this.events.filter(e => {
			if (!e.start_time || !e.end_time || this.filteredSources.has(e.source_name)) return false;
			const sd = e.start_time.slice(0, 10);
			const ed = e.end_time.slice(0, 10);
			return ed > sd && ed >= monthStart && sd <= monthEnd;
		});
		if (multiDay.length === 0) return "";

		const totalCells = firstDay + daysInMonth + ((7 - ((firstDay + daysInMonth) % 7)) % 7);
		const totalRows = totalCells / 7;
		let html = "";

		// If a day detail is expanded, figure out which grid row it inserted
		// so we can offset span bars below it by +1
		let detailAfterGridRow = -1;
		if (this.expandedDay) {
			const ed = new Date(this.expandedDay + "T00:00:00");
			if (ed.getFullYear() === year && ed.getMonth() === month) {
				const cellIdx = firstDay + ed.getDate() - 1;
				const posInRow = cellIdx % 7;
				// Detail is emitted when posInRow===6 or last day of month
				if (posInRow === 6 || ed.getDate() === daysInMonth) {
					detailAfterGridRow = Math.floor(cellIdx / 7) + 1;
				} else {
					// Detail is emitted after all days via the outer render
					detailAfterGridRow = totalRows + 1;
				}
			}
		}

		// Helper: date string → cell index (clamped to grid)
		const dateToCellIdx = (ds: string): number => {
			const d = new Date(ds);
			if (d.getFullYear() === year && d.getMonth() === month) return firstDay + d.getDate() - 1;
			if (d < new Date(year, month, 1)) return 0;
			return totalCells - 1;
		};

		for (let row = 0; row < totalRows; row++) {
			const rowStart = row * 7;
			const rowEnd = rowStart + 6;

			const rowEvents: { ev: any; c0: number; c1: number }[] = [];
			for (const e of multiDay) {
				const sc = Math.max(dateToCellIdx(e.start_time.slice(0, 10)), rowStart);
				const ec = Math.min(dateToCellIdx(e.end_time.slice(0, 10)), rowEnd);
				if (sc > rowEnd || ec < rowStart) continue;
				rowEvents.push({ ev: e, c0: sc - rowStart, c1: ec - rowStart });
			}
			if (rowEvents.length === 0) continue;

			// Assign lanes (max 2 visible)
			const MAX_LANES = 2;
			const lanes: typeof rowEvents[] = [];
			let overflow = 0;
			for (const re of rowEvents) {
				let placed = false;
				for (let l = 0; l < MAX_LANES; l++) {
					if (!lanes[l]) lanes[l] = [];
					if (!lanes[l].some(x => re.c0 <= x.c1 && re.c1 >= x.c0)) {
						lanes[l].push(re); placed = true; break;
					}
				}
				if (!placed) overflow++;
			}

			// Offset grid row if a detail panel was inserted above this row
			let gridRow = row + 1;
			if (detailAfterGridRow > 0 && gridRow > detailAfterGridRow) gridRow++;

			const mob = window.innerWidth <= 768;
			const spanTop = mob ? 16 : 22;
			const spanGap = mob ? 12 : 18;
			for (let l = 0; l < lanes.length; l++) {
				for (const re of lanes[l]) {
					const color = re.ev.source_color || "#6366f1";
					const realStart = dateToCellIdx(re.ev.start_time.slice(0, 10));
					const realEnd = dateToCellIdx(re.ev.end_time.slice(0, 10));
					const contL = realStart < rowStart;
					const contR = realEnd > rowEnd;
					const rL = contL ? "0" : "4px";
					const rR = contR ? "0" : "4px";
					html += `<div class="ev-span" style="grid-row:${gridRow};grid-column:${re.c0 + 1}/${re.c1 + 2};left:0;right:0;margin-top:${spanTop + l * spanGap}px;background:${color}25;border-left:2px solid ${color};border-radius:${rL} ${rR} ${rR} ${rL}" data-event-id="${re.ev.id}" title="${this.esc(re.ev.title)}"><span class="ev-span-text" style="color:${color}">${this.esc(re.ev.title)}</span></div>`;
				}
			}
			if (overflow > 0) {
				html += `<div class="ev-span-more" style="grid-row:${gridRow};grid-column:1;left:0;margin-top:${spanTop + MAX_LANES * spanGap}px">+${overflow} more</div>`;
			}
		}
		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 spatialLevel = this.getEffectiveSpatialIndex();
		const labelLevel = spatialLevel <= 3 ? 3 : 5; // country or city based on zoom

		const monthsHtml = months.map(m => {
			const monthEvents = this.getMonthEvents(year, m);
			const labels = this.getUniqueSpatialLabels(monthEvents, labelLevel, 4);
			const labelsHtml = labels.length > 0
				? `<div class="season-cities">${labels.map(c => `<span class="season-city-chip">${this.esc(c)}</span>`).join("")}</div>`
				: "";
			return `<div class="season-month-wrap">${this.renderMiniMonth(year, m)}${labelsHtml}</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();
		const spatialLevel = this.getEffectiveSpatialIndex();
		const labelLevel = spatialLevel <= 3 ? 3 : 5; // country or city based on zoom

		const monthsHtml = Array.from({length: 12}, (_, i) => {
			const monthEvents = this.getMonthEvents(year, i);
			const labels = this.getUniqueSpatialLabels(monthEvents, labelLevel, 3);
			const chipsHtml = labels.length > 0
				? `<div class="season-cities">${labels.map(c => `<span class="season-city-chip">${this.esc(c)}</span>`).join("")}</div>`
				: "";
			return `<div class="season-month-wrap">${this.renderMiniMonth(year, i)}${chipsHtml}</div>`;
		}).join("");

		return `
		<div class="year-grid">
			${monthsHtml}
		</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>`;
		}

		// Compute row assignments for overlapping horizontal events
		const MIN_DH_INTERVAL = Math.ceil(60 / HOUR_WIDTH * 60); // min display width (60px) → min interval
		const dhIntervals = timed.map(ev => {
			const s = new Date(ev.start_time), e = new Date(ev.end_time);
			const startMin = s.getHours() * 60 + s.getMinutes();
			const endMin = e.getHours() * 60 + e.getMinutes();
			return { startMin, endMin: Math.max(endMin, startMin + MIN_DH_INTERVAL) };
		});
		const dhLayout = this.computeEventColumns(dhIntervals);
		const DH_ROW_HEIGHT = 56; // min-height 48 + 8px gap

		let eventsHtml = "";
		for (let idx = 0; idx < timed.length; idx++) {
			const ev = timed[idx];
			const { startMin, endMin } = dhIntervals[idx];
			const { col: row } = dhLayout[idx];
			const duration = endMin - startMin;
			const leftPx = ((startMin - START_HOUR * 60) / 60) * HOUR_WIDTH;
			const widthPx = Math.max((duration / 60) * HOUR_WIDTH, 60);
			const topPx = 8 + row * DH_ROW_HEIGHT;
			const es = this.getEventStyles(ev);
			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;top:${topPx}px;background:${es.bgColor};border-top:3px ${es.borderStyle} ${ev.source_color || "#6366f1"};opacity:${es.opacity}">
				<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 spatialLevel = this.getEffectiveSpatialIndex();
			const labelLevel = spatialLevel <= 1 ? 1 : spatialLevel <= 3 ? 3 : 5;
			const spatialLabels = this.getUniqueSpatialLabels(monthEvents, labelLevel, 2);
			const countryHtml = spatialLabels.length > 0 ? `<span class="yv-country">${spatialLabels.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 = "";
			const yearEvents: any[] = [];
			for (let m = 0; m < 12; m++) {
				monthsHtml += this.renderMicroMonth(y, m);
				yearEvents.push(...this.getMonthEvents(y, m));
			}

			// Continent-level labels for the year
			const continents = this.getUniqueSpatialLabels(yearEvents, 1, 3);
			const continentHtml = continents.length > 0
				? `<div class="season-cities" style="margin-top:4px">${continents.map(c => `<span class="season-city-chip">${this.esc(c)}</span>`).join("")}</div>`
				: "";

			html += `<div class="my-year ${isCurrent ? "current" : ""}" data-my-year="${y}">
				<div class="my-year-label">${y}</div>
				<div class="my-months">${monthsHtml}</div>
				${continentHtml}
			</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>`;
	}

	// ── Event Layout Helper (overlap columns) ──

	/**
	 * Assign columns to overlapping events so they tile side-by-side.
	 * Returns an array parallel to `events` with {col, totalCols} per event.
	 */
	private computeEventColumns(events: Array<{startMin: number, endMin: number}>): Array<{col: number, totalCols: number}> {
		const n = events.length;
		if (n === 0) return [];

		// Sort indices by start, then by longest duration first
		const indices = Array.from({length: n}, (_, i) => i);
		indices.sort((a, b) => events[a].startMin - events[b].startMin
			|| (events[b].endMin - events[b].startMin) - (events[a].endMin - events[a].startMin));

		const cols = new Array<number>(n).fill(0);
		const colEnds: number[] = []; // end-time of last event in each column

		// Track overlap clusters to set per-cluster totalCols
		const clusterIndices: number[][] = [[]]; // indices in current cluster
		let clusterMaxEnd = -Infinity;

		for (const i of indices) {
			const ev = events[i];

			// New cluster? (no overlap with any previous event)
			if (colEnds.length > 0 && ev.startMin >= clusterMaxEnd) {
				clusterIndices.push([]);
				colEnds.length = 0;
			}

			// Find first free column
			let placed = -1;
			for (let c = 0; c < colEnds.length; c++) {
				if (colEnds[c] <= ev.startMin) {
					colEnds[c] = ev.endMin;
					placed = c;
					break;
				}
			}
			if (placed === -1) {
				placed = colEnds.length;
				colEnds.push(ev.endMin);
			}

			cols[i] = placed;
			clusterMaxEnd = Math.max(clusterMaxEnd, ev.endMin);
			clusterIndices[clusterIndices.length - 1].push(i);
		}

		// Assign totalCols per cluster
		const result = new Array<{col: number, totalCols: number}>(n);
		for (const cluster of clusterIndices) {
			const maxCol = cluster.reduce((m, i) => Math.max(m, cols[i]), 0) + 1;
			for (const i of cluster) {
				result[i] = { col: cols[i], totalCols: maxCol };
			}
		}
		return result;
	}

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

		// Compute overlap columns for timed events
		const timedIntervals = timed.map(ev => {
			const s = new Date(ev.start_time), e = new Date(ev.end_time);
			const startMin = s.getHours() * 60 + s.getMinutes();
			const endMin = e.getHours() * 60 + e.getMinutes();
			return { startMin, endMin: Math.max(endMin, startMin + 30) };
		});
		const layout = this.computeEventColumns(timedIntervals);

		let eventsHtml = "";
		for (let idx = 0; idx < timed.length; idx++) {
			const ev = timed[idx];
			const { startMin, endMin } = timedIntervals[idx];
			const { col, totalCols } = layout[idx];
			const duration = endMin - startMin;
			const topPx = ((startMin - START_HOUR * 60) / 60) * HOUR_HEIGHT;
			const heightPx = Math.max((duration / 60) * HOUR_HEIGHT, 24);
			const es = this.getEventStyles(ev);
			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>` : "";
			const likelihoodBadge = es.isTentative ? `<span class="ev-likelihood">${es.likelihoodLabel}</span>` : "";

			eventsHtml += `<div class="tl-event" data-event-id="${ev.id}" style="
				top:${topPx}px;height:${heightPx}px;left:calc(8px + ${col} * (100% - 16px) / ${totalCols});width:calc((100% - 16px) / ${totalCols} - 2px);background:${es.bgColor};border-left-color:${ev.source_color || "#6366f1"};border-left-style:${es.borderStyle};opacity:${es.opacity}">
				<div class="tl-event-title">${this.esc(ev.title)}${likelihoodBadge}</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}" data-collab-id="event:${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>`;
			}
		}

		// Build per-day event overlays (grid-aligned to avoid calc() rounding vs 1fr drift)
		const MIN_INTERVAL_WEEK = Math.ceil(18 / HOUR_HEIGHT * 60); // min display height → min interval
		const dayOverlays: string[] = [];
		for (let i = 0; i < 7; i++) {
			const ds = this.dateStr(days[i]);
			const dayTimed = this.getEventsForDate(ds).filter(ev => {
				const s = new Date(ev.start_time), en = new Date(ev.end_time);
				return (en.getTime() - s.getTime()) < 86400000;
			}).sort((a, b) => a.start_time.localeCompare(b.start_time));

			const intervals = dayTimed.map(ev => {
				const s = new Date(ev.start_time), e = new Date(ev.end_time);
				const startMin = s.getHours() * 60 + s.getMinutes();
				const endMin = e.getHours() * 60 + e.getMinutes();
				return { startMin, endMin: Math.max(endMin, startMin + MIN_INTERVAL_WEEK) };
			});
			const dayLayout = this.computeEventColumns(intervals);

			let dayHtml = "";
			for (let j = 0; j < dayTimed.length; j++) {
				const ev = dayTimed[j];
				const { startMin, endMin } = intervals[j];
				const { col, totalCols } = dayLayout[j];
				const duration = endMin - startMin;
				const topPx = ((startMin - START_HOUR * 60) / 60) * HOUR_HEIGHT;
				const heightPx = Math.max((duration / 60) * HOUR_HEIGHT, 18);
				const es = this.getEventStyles(ev);
				const evLeft = totalCols > 1 ? `calc(${(col / totalCols * 100).toFixed(2)}% + 1px)` : "1px";
				const evWidth = totalCols > 1 ? `calc(${(100 / totalCols).toFixed(2)}% - 2px)` : "calc(100% - 2px)";
				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>` : "";
				dayHtml += `<div class="week-event" data-event-id="${ev.id}" style="
					top:${topPx}px;height:${heightPx}px;left:${evLeft};width:${evWidth};
					background:${es.bgColor};border-left-color:${ev.source_color || "#6366f1"};border-left-style:${es.borderStyle};opacity:${es.opacity}">
					<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>`;
			}
			dayOverlays.push(dayHtml);
		}

		// Grid-aligned overlay mirrors week-grid columns exactly (no calc drift)
		const overlayDivs = dayOverlays.map(h =>
			`<div style="position:relative;pointer-events:auto;">${h}</div>`
		).join("");
		const eventsOverlay = `<div style="position:absolute;inset:0;display:grid;grid-template-columns:44px repeat(7,1fr);pointer-events:none;"><div></div>${overlayDivs}</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="overflow-y:auto;max-height:600px;">
				<div style="position:relative;height:${totalHeight}px;">
					<div class="week-grid" style="height:100%">${gridHtml}</div>
					${eventsOverlay}${nowHtml}
				</div>
			</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>
				<div class="dd-header-actions">
					<button class="dd-add" id="dd-add" data-add-date="${dateStr}" title="Add reminder">+</button>
					<button class="dd-close" id="dd-close">\u2715</button>
				</div>
			</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>` : "";
					const es = this.getEventStyles(e);
					const likelihoodBadge = es.isTentative ? `<span class="dd-likelihood">${es.likelihoodLabel}</span>` : "";
					const ddLockBadge = e.accessVisibility && e.accessVisibility !== 'viewer' ? `<span class="membrane-lock" title="Restricted: ${e.accessVisibility} only">&#128274;</span> ` : "";
					return `<div class="dd-event" data-event-id="${e.id}" data-collab-id="event:${e.id}" style="opacity:${es.opacity}">
					<div class="dd-color" style="background:${e.source_color || "#6366f1"};${es.isTentative ? "border:1px dashed " + (e.source_color || "#6366f1") + ";background:transparent" : ""}"></div>
					<div class="dd-info">
						<div class="dd-title">${ddLockBadge}${this.esc(e.title)}${likelihoodBadge}${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;
		const es = this.getEventStyles(e);
		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>
				${es.isTentative ? `<div class="modal-field" style="color:var(--rs-warning);font-weight:500">Penciled in (${es.likelihoodLabel})</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:var(--rs-text-muted)">${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:var(--rs-primary-hover)">Join</a>` : ""}</div>` : ""}
				${e.latitude != null ? `<div class="modal-field" style="font-size:11px;color:var(--rs-text-muted)">\u{1F4CD} ${e.latitude.toFixed(4)}, ${e.longitude.toFixed(4)}</div>` : ""}
				${this.gcalConnected && e.r_tool_source !== 'GOOGLE_CAL' ? `<div class="modal-field" style="margin-top:12px"><button class="gcal-push-btn" data-push-event="${e.id}">\u{1F4E4} Export to Google Calendar</button></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;

		// Show time-picker popover instead of confirm()
		this.showReminderPopover(e, dropDate, {
			title: title.trim(), sourceModule, sourceEntityId, sourceLabel, sourceColor,
		});
	}

	private showReminderPopover(
		e: DragEvent,
		dropDate: string,
		item: { title: string; sourceModule: string | null; sourceEntityId: string | null; sourceLabel: string | null; sourceColor: string | null },
	) {
		// Remove any existing popover
		this.shadow.querySelector(".reminder-popover")?.remove();

		const accentColor = item.sourceColor || "#818cf8";
		const labelText = item.sourceLabel || "Item";
		const friendlyDate = new Date(dropDate + "T12:00:00").toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric" });

		const popover = document.createElement("div");
		popover.className = "reminder-popover";
		popover.innerHTML = `
			<div class="reminder-popover__header">
				<span class="reminder-popover__badge" style="background:${accentColor}">${labelText}</span>
				<span class="reminder-popover__title">${item.title}</span>
			</div>
			<div class="reminder-popover__date">${friendlyDate}</div>
			<div class="reminder-popover__times">
				<button class="reminder-popover__time" data-hour="9">🌅 9:00 AM</button>
				<button class="reminder-popover__time" data-hour="12">☀️ 12:00 PM</button>
				<button class="reminder-popover__time" data-hour="17">🌇 5:00 PM</button>
				<button class="reminder-popover__time" data-hour="21">🌙 9:00 PM</button>
			</div>
			<div class="reminder-popover__custom">
				<input type="time" class="reminder-popover__time-input" value="09:00">
				<button class="reminder-popover__time" data-custom="true">Set Custom</button>
			</div>
			<button class="reminder-popover__cancel">Cancel</button>
		`;

		this.shadow.appendChild(popover);

		// Position near drop target
		const rect = (e.target as HTMLElement)?.closest?.("[data-drop-date]")?.getBoundingClientRect();
		if (rect) {
			const hostRect = this.getBoundingClientRect();
			popover.style.position = "absolute";
			popover.style.left = `${rect.left - hostRect.left}px`;
			popover.style.top = `${rect.bottom - hostRect.top + 4}px`;
			popover.style.zIndex = "1000";
		}

		const createReminder = async (hour: number, minute = 0) => {
			popover.remove();
			const remindAt = new Date(`${dropDate}T${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}:00`).getTime();
			const base = this.getScheduleApiBase();
			try {
				await fetch(`${base}/api/reminders`, {
					method: "POST",
					headers: { "Content-Type": "application/json" },
					body: JSON.stringify({
						title: item.title,
						remindAt,
						allDay: false,
						syncToCalendar: true,
						sourceModule: item.sourceModule,
						sourceEntityId: item.sourceEntityId,
						sourceLabel: item.sourceLabel,
						sourceColor: item.sourceColor,
					}),
				});
				if (this.space === "demo") { this.loadDemoData(); } else { this.loadMonth(); }
			} catch (err) {
				console.error("[rCal] Failed to create reminder:", err);
			}
		};

		// Quick-pick time buttons
		popover.querySelectorAll<HTMLButtonElement>(".reminder-popover__time[data-hour]").forEach((btn) => {
			btn.addEventListener("click", () => createReminder(parseInt(btn.dataset.hour!)));
		});

		// Custom time
		popover.querySelector(".reminder-popover__time[data-custom]")?.addEventListener("click", () => {
			const input = popover.querySelector(".reminder-popover__time-input") as HTMLInputElement;
			const [h, m] = (input.value || "09:00").split(":").map(Number);
			createReminder(h, m);
		});

		// Cancel
		popover.querySelector(".reminder-popover__cancel")?.addEventListener("click", () => popover.remove());

		// Close on outside click
		const closeHandler = (ev: Event) => {
			if (!popover.contains(ev.target as Node)) {
				popover.remove();
				document.removeEventListener("click", closeHandler);
			}
		};
		setTimeout(() => document.addEventListener("click", closeHandler), 100);
	}

	private showDayAddForm(dateStr: string) {
		// Remove any existing add-form
		this.shadow.querySelector(".dd-add-form")?.remove();

		const friendlyDate = new Date(dateStr + "T12:00:00").toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric" });
		const form = document.createElement("div");
		form.className = "dd-add-form";
		form.innerHTML = `
			<input type="text" class="dd-add-title" placeholder="Reminder title..." autofocus>
			<div class="dd-add-times">
				<button class="dd-add-time" data-hour="9">\u{1F305} 9 AM</button>
				<button class="dd-add-time" data-hour="12">\u2600\uFE0F Noon</button>
				<button class="dd-add-time" data-hour="17">\u{1F307} 5 PM</button>
				<button class="dd-add-time" data-hour="21">\u{1F319} 9 PM</button>
			</div>
			<div class="dd-add-custom">
				<input type="time" class="dd-add-time-input" value="09:00">
				<button class="dd-add-submit">Add</button>
			</div>
		`;

		const detail = this.shadow.querySelector(".day-detail");
		if (!detail) return;
		detail.appendChild(form);

		const titleInput = form.querySelector(".dd-add-title") as HTMLInputElement;
		titleInput.focus();

		const createReminder = async (hour: number, minute = 0) => {
			const title = titleInput.value.trim();
			if (!title) { titleInput.focus(); titleInput.style.borderColor = "#ef4444"; return; }
			form.remove();
			const remindAt = new Date(`${dateStr}T${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}:00`).getTime();
			const base = this.getScheduleApiBase();
			try {
				await fetch(`${base}/api/reminders`, {
					method: "POST",
					headers: { "Content-Type": "application/json" },
					body: JSON.stringify({ title, remindAt, allDay: false, syncToCalendar: true }),
				});
				if (this.space === "demo") { this.loadDemoData(); } else { this.loadMonth(); }
			} catch (err) {
				console.error("[rCal] Failed to create reminder:", err);
			}
		};

		// Quick-pick time buttons
		form.querySelectorAll<HTMLButtonElement>(".dd-add-time[data-hour]").forEach((btn) => {
			btn.addEventListener("click", () => createReminder(parseInt(btn.dataset.hour!)));
		});

		// Custom time + submit
		form.querySelector(".dd-add-submit")?.addEventListener("click", () => {
			const input = form.querySelector(".dd-add-time-input") as HTMLInputElement;
			const [h, m] = (input.value || "09:00").split(":").map(Number);
			createReminder(h, m);
		});

		// Enter key in title → use 9 AM default
		titleInput.addEventListener("keydown", (e) => {
			if (e.key === "Enter") createReminder(9);
		});
	}

	startTour() { this._tour.start(); }

	// ── Attach Listeners ──

	private attachListeners() {
		const $ = (id: string) => this.shadow.getElementById(id);
		const $$ = (sel: string) => this.shadow.querySelectorAll(sel);

		$("btn-tour")?.addEventListener("click", () => this.startTour());

		// 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 = "docked"; this.render(); });
		$("map-minimize")?.addEventListener("click", () => { this.mapPanelState = "minimized"; this.render(); });

		// Resize handle drag
		const resizeHandle = $("resize-handle");
		const mainLayout = $("main-layout");
		if (resizeHandle && mainLayout) {
			const startResize = (startX: number) => {
				this._isResizing = true;
				this._resizeStartX = startX;
				this._resizeStartWidth = this.mapPanelWidth;
				resizeHandle.classList.add("active");
				document.body.style.cursor = "col-resize";
				document.body.style.userSelect = "none";
			};
			const doResize = (clientX: number) => {
				if (!this._isResizing) return;
				const delta = this._resizeStartX - clientX; // drag left = bigger map
				const newWidth = Math.max(200, Math.min(800, this._resizeStartWidth + delta));
				this.mapPanelWidth = newWidth;
				(mainLayout as HTMLElement).style.gridTemplateColumns = `1fr 6px ${newWidth}px`;
			};
			const endResize = () => {
				if (!this._isResizing) return;
				this._isResizing = false;
				resizeHandle.classList.remove("active");
				document.body.style.cursor = "";
				document.body.style.userSelect = "";
				if (this.leafletMap) this.leafletMap.invalidateSize();
			};

			resizeHandle.addEventListener("mousedown", (e: Event) => {
				(e as MouseEvent).preventDefault();
				startResize((e as MouseEvent).clientX);
				const onMouseMove = (em: MouseEvent) => doResize(em.clientX);
				const onMouseUp = () => {
					document.removeEventListener("mousemove", onMouseMove);
					document.removeEventListener("mouseup", onMouseUp);
					endResize();
				};
				document.addEventListener("mousemove", onMouseMove);
				document.addEventListener("mouseup", onMouseUp);
			});

			resizeHandle.addEventListener("touchstart", (e: Event) => {
				const te = e as TouchEvent;
				te.preventDefault();
				startResize(te.touches[0].clientX);
				const onTouchMove = (em: Event) => doResize((em as TouchEvent).touches[0].clientX);
				const onTouchEnd = () => {
					document.removeEventListener("touchmove", onTouchMove);
					document.removeEventListener("touchend", onTouchEnd);
					endResize();
				};
				document.addEventListener("touchmove", onTouchMove);
				document.addEventListener("touchend", onTouchEnd);
			}, { passive: false });
		}

		// Zoom spectrum slider — click on track or tick labels
		const zoomTrack = $("zoom-track");
		if (zoomTrack) {
			const pctToGranularity = (pct: number) => Math.round(pct * 5 / 100) + 2;
			const trackClick = (e: MouseEvent) => {
				const rect = zoomTrack.getBoundingClientRect();
				const pct = Math.max(0, Math.min(100, ((e.clientX - rect.left) / rect.width) * 100));
				this.setTemporalGranularity(pctToGranularity(pct));
			};
			zoomTrack.addEventListener("click", trackClick);

			// Drag on thumb
			const thumb = $("zoom-thumb");
			if (thumb) {
				const startDrag = (eDown: MouseEvent) => {
					eDown.preventDefault();
					eDown.stopPropagation();
					const onMove = (eMove: MouseEvent) => {
						const rect = zoomTrack.getBoundingClientRect();
						const pct = Math.max(0, Math.min(100, ((eMove.clientX - rect.left) / rect.width) * 100));
						(thumb as HTMLElement).style.left = `${pct}%`;
					};
					const onUp = (eUp: MouseEvent) => {
						document.removeEventListener("mousemove", onMove);
						document.removeEventListener("mouseup", onUp);
						const rect = zoomTrack.getBoundingClientRect();
						const pct = Math.max(0, Math.min(100, ((eUp.clientX - rect.left) / rect.width) * 100));
						this.setTemporalGranularity(pctToGranularity(pct));
					};
					document.addEventListener("mousemove", onMove);
					document.addEventListener("mouseup", onUp);
				};
				thumb.addEventListener("mousedown", startDrag);

				// Touch support
				thumb.addEventListener("touchstart", (e: Event) => {
					const te = e as TouchEvent;
					te.preventDefault();
					const onTouchMove = (em: Event) => {
						const tm = em as TouchEvent;
						const rect = zoomTrack.getBoundingClientRect();
						const pct = Math.max(0, Math.min(100, ((tm.touches[0].clientX - rect.left) / rect.width) * 100));
						(thumb as HTMLElement).style.left = `${pct}%`;
					};
					const onTouchEnd = (eu: Event) => {
						document.removeEventListener("touchmove", onTouchMove);
						document.removeEventListener("touchend", onTouchEnd);
						const tu = eu as TouchEvent;
						const rect = zoomTrack.getBoundingClientRect();
						const touch = tu.changedTouches[0];
						const pct = Math.max(0, Math.min(100, ((touch.clientX - rect.left) / rect.width) * 100));
						this.setTemporalGranularity(pctToGranularity(pct));
					};
					document.addEventListener("touchmove", onTouchMove);
					document.addEventListener("touchend", onTouchEnd);
				}, { passive: false });
			}
		}
		$$("[data-zoom]").forEach(el => {
			el.addEventListener("click", (e) => {
				e.stopPropagation();
				this.setTemporalGranularity(parseInt((el as HTMLElement).dataset.zoom!));
			});
		});
		$("toggle-coupling")?.addEventListener("click", () => {
			if (!this.zoomCoupled) {
				this.zoomCoupled = true;
				this.spatialGranularity = null;
				this.syncMapToSpatial();
			} else {
				this.spatialGranularity = this.getEffectiveSpatialIndex();
				this.zoomCoupled = false;
			}
			this.render();
		});

		// Spatial zoom slider
		const spatialTrack = $("spatial-track");
		if (spatialTrack) {
			const pctToSpatial = (pct: number) => Math.round(pct * 8 / 100);
			const spatialClick = (e: MouseEvent) => {
				if (this.zoomCoupled) return;
				const rect = spatialTrack.getBoundingClientRect();
				const pct = Math.max(0, Math.min(100, ((e.clientX - rect.left) / rect.width) * 100));
				this.setSpatialGranularity(pctToSpatial(pct));
			};
			spatialTrack.addEventListener("click", spatialClick);

			const sThumb = $("spatial-thumb");
			if (sThumb) {
				const startDrag = (eDown: MouseEvent) => {
					if (this.zoomCoupled) return;
					eDown.preventDefault();
					eDown.stopPropagation();
					const onMove = (eMove: MouseEvent) => {
						const rect = spatialTrack.getBoundingClientRect();
						const pct = Math.max(0, Math.min(100, ((eMove.clientX - rect.left) / rect.width) * 100));
						(sThumb as HTMLElement).style.left = `${pct}%`;
					};
					const onUp = (eUp: MouseEvent) => {
						document.removeEventListener("mousemove", onMove);
						document.removeEventListener("mouseup", onUp);
						const rect = spatialTrack.getBoundingClientRect();
						const pct = Math.max(0, Math.min(100, ((eUp.clientX - rect.left) / rect.width) * 100));
						this.setSpatialGranularity(pctToSpatial(pct));
					};
					document.addEventListener("mousemove", onMove);
					document.addEventListener("mouseup", onUp);
				};
				sThumb.addEventListener("mousedown", startDrag);

				sThumb.addEventListener("touchstart", (e: Event) => {
					if (this.zoomCoupled) return;
					const te = e as TouchEvent;
					te.preventDefault();
					const onTouchMove = (em: Event) => {
						const tm = em as TouchEvent;
						const rect = spatialTrack.getBoundingClientRect();
						const pct = Math.max(0, Math.min(100, ((tm.touches[0].clientX - rect.left) / rect.width) * 100));
						(sThumb as HTMLElement).style.left = `${pct}%`;
					};
					const onTouchEnd = (eu: Event) => {
						document.removeEventListener("touchmove", onTouchMove);
						document.removeEventListener("touchend", onTouchEnd);
						const tu = eu as TouchEvent;
						const rect = spatialTrack.getBoundingClientRect();
						const touch = tu.changedTouches[0];
						const pct = Math.max(0, Math.min(100, ((touch.clientX - rect.left) / rect.width) * 100));
						this.setSpatialGranularity(pctToSpatial(pct));
					};
					document.addEventListener("touchmove", onTouchMove);
					document.addEventListener("touchend", onTouchEnd);
				}, { passive: false });
			}
		}
		$$("[data-spatial-zoom]").forEach(el => {
			el.addEventListener("click", (e) => {
				if (this.zoomCoupled) return;
				e.stopPropagation();
				this.setSpatialGranularity(parseInt((el as HTMLElement).dataset.spatialZoom!));
			});
		});

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

		// Add reminder from day detail
		$("dd-add")?.addEventListener("click", (e) => {
			e.stopPropagation();
			const dateStr = (e.target as HTMLElement).dataset.addDate;
			if (!dateStr) return;
			this.showDayAddForm(dateStr);
		});

		// 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 gestures on calendar pane
		// - Pinch-to-zoom (ctrlKey wheel) → zoom in/out temporal granularity
		// - Two-finger horizontal pan (deltaX) → navigate dates
		// - Two-finger vertical scroll (deltaY) → pass through to page scroll
		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;

				if (e.ctrlKey) {
					// Pinch-to-zoom on trackpad (or Ctrl+scroll with mouse)
					e.preventDefault();
					if (this._wheelTimer) clearTimeout(this._wheelTimer);
					this._wheelTimer = setTimeout(() => {
						if (e.deltaY > 0) {
							this.zoomOut();
						} else if (e.deltaY < 0) {
							this.zoomIn();
						}
						this._wheelTimer = null;
					}, 120);
				} else if (Math.abs(e.deltaX) > Math.abs(e.deltaY) && Math.abs(e.deltaX) > 10) {
					// Two-finger horizontal pan → navigate dates
					e.preventDefault();
					if (this._wheelTimer) clearTimeout(this._wheelTimer);
					this._wheelTimer = setTimeout(() => {
						if (e.deltaX > 0) {
							this.navigate(1);  // pan right → next period
						} else if (e.deltaX < 0) {
							this.navigate(-1); // pan left → previous period
						}
						this._wheelTimer = null;
					}, 120);
				}
				// Vertical scroll (deltaY without ctrlKey) → no preventDefault, page scrolls naturally
			}, { passive: false });

			// Pan/swipe + pinch-to-zoom on calendar pane
			calPane.addEventListener("pointerdown", (e: PointerEvent) => {
				const mapPanel = this.shadow.getElementById("map-panel");
				if (mapPanel && mapPanel.contains(e.target as Node)) return;
				this._pointerCache.push(e);
				if (this._pointerCache.length === 1) {
					this._panStartX = e.clientX;
					this._panStartY = e.clientY;
					this._gestureMode = 'none';
					this._gestureFired = false;
				} else if (this._pointerCache.length === 2) {
					this._gestureMode = 'pinch';
					this._gestureFired = false;
					const dx = this._pointerCache[0].clientX - this._pointerCache[1].clientX;
					const dy = this._pointerCache[0].clientY - this._pointerCache[1].clientY;
					this._initialPinchDist = Math.hypot(dx, dy);
				}
			});

			calPane.addEventListener("pointermove", (e: PointerEvent) => {
				// Update pointer in cache
				const idx = this._pointerCache.findIndex(p => p.pointerId === e.pointerId);
				if (idx < 0) return;
				this._pointerCache[idx] = e;

				if (this._gestureMode === 'pinch' && this._pointerCache.length === 2 && this._initialPinchDist && !this._gestureFired) {
					const dx = this._pointerCache[0].clientX - this._pointerCache[1].clientX;
					const dy = this._pointerCache[0].clientY - this._pointerCache[1].clientY;
					const dist = Math.hypot(dx, dy);
					const ratio = dist / this._initialPinchDist;
					if (ratio > 1.3) { this._gestureFired = true; this.zoomIn(); }
					else if (ratio < 0.7) { this._gestureFired = true; this.zoomOut(); }
					return;
				}

				// Single-pointer pan
				if (this._pointerCache.length === 1 && this._panStartX !== null && this._panStartY !== null && !this._gestureFired) {
					const dx = e.clientX - this._panStartX;
					const dy = e.clientY - this._panStartY;
					// Only trigger if horizontal movement dominates
					if (Math.abs(dx) > 50 && Math.abs(dx) > Math.abs(dy) * 1.5) {
						this._gestureFired = true;
						this._gestureMode = 'pan';
						this.navigate(dx < 0 ? 1 : -1);
					}
				}
			});

			const pointerEnd = (e: PointerEvent) => {
				this._pointerCache = this._pointerCache.filter(p => p.pointerId !== e.pointerId);
				if (this._pointerCache.length === 0) {
					this._panStartX = this._panStartY = this._initialPinchDist = null;
					this._gestureMode = 'none';
					this._gestureFired = false;
				}
			};
			calPane.addEventListener("pointerup", pointerEnd);
			calPane.addEventListener("pointercancel", pointerEnd);
		}

		// Google Calendar integration listeners
		$("gcal-manage")?.addEventListener("click", () => {
			if (this.gcalConnected) {
				this.openGcalModal();
			} else {
				window.location.href = `/api/oauth/google/authorize?space=${this.space}&returnTo=rcal`;
			}
		});
		$("gcal-sync-all")?.addEventListener("click", () => this.syncAllGcal());
		$("gcal-overlay")?.addEventListener("click", (e) => {
			if ((e.target as HTMLElement).id === "gcal-overlay") { this.showGcalModal = false; this.render(); }
		});
		$("gcal-close")?.addEventListener("click", () => { this.showGcalModal = false; this.render(); });
		$$(".gcal-check").forEach(el => {
			el.addEventListener("change", (e) => {
				const label = (el as HTMLElement).closest(".gcal-item") as HTMLElement;
				const calId = label?.dataset.gcalId || "";
				const calName = label?.dataset.gcalName || "";
				const calColor = label?.dataset.gcalColor || "#4285f4";
				const checked = (el as HTMLInputElement).checked;
				this.toggleGcalSubscription(calId, calName, calColor, checked);
			});
		});
		$$("[data-push-event]").forEach(el => {
			el.addEventListener("click", (e) => {
				e.stopPropagation();
				const eventId = (el as HTMLElement).dataset.pushEvent!;
				this.pushEventToGoogle(eventId);
			});
		});
	}

	// ── 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,
				scrollWheelZoom: false,  // Disable: plain scroll should pass to page
			});

			// Only zoom map on Ctrl+scroll (pinch-to-zoom on trackpad)
			this.mapContainer.addEventListener("wheel", (ev: WheelEvent) => {
				if (ev.ctrlKey) {
					ev.preventDefault();
					if (ev.deltaY < 0) this.leafletMap.zoomIn();
					else if (ev.deltaY > 0) this.leafletMap.zoomOut();
				}
				// Plain scroll passes through to page
			}, { passive: false });

			this.applyMapTileLayer();

			// Watch for theme changes
			const observer = new MutationObserver(() => this.applyMapTileLayer());
			observer.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] });

			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];
					// Update spatial thumb position without full re-render
					const sThumb = this.shadow.getElementById("spatial-thumb") as HTMLElement | null;
					if (sThumb) sThumb.style.left = `${(this.spatialGranularity / 8) * 100}%`;
				}
			});
		} else {
			setTimeout(() => this.leafletMap?.invalidateSize(), 50);
		}

		this.updateMapMarkers();
		this.updateTransitLines();
	}

	private isDarkTheme(): boolean {
		const theme = document.documentElement.getAttribute("data-theme");
		if (theme) return theme === "dark";
		return window.matchMedia("(prefers-color-scheme: dark)").matches;
	}

	private applyMapTileLayer() {
		const L = (window as any).L;
		if (!L || !this.leafletMap) return;

		if (this.currentTileLayer) {
			this.leafletMap.removeLayer(this.currentTileLayer);
		}

		const dark = this.isDarkTheme();
		const tileUrl = dark
			? "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
			: "https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png";

		this.currentTileLayer = L.tileLayer(tileUrl, {
			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);

		// Apply brightness filter for dark mode to improve contrast
		const container = this.leafletMap.getContainer();
		const tilePane = container?.querySelector(".leaflet-tile-pane") as HTMLElement;
		if (tilePane) {
			tilePane.style.filter = dark ? "brightness(1.4) contrast(1.15)" : "none";
		}
	}

	private updateMapMarkers() {
		const L = (window as any).L;
		if (!L || !this.mapMarkerLayer) return;
		this.mapMarkerLayer.clearLayers();

		const located = this.getVisibleLocatedEvents();

		for (const ev of located) {
			const es = this.getEventStyles(ev);
			const marker = L.circleMarker([ev.latitude, ev.longitude], {
				radius: 6, color: ev.source_color || "#6366f1",
				fillColor: ev.source_color || "#6366f1",
				fillOpacity: es.isTentative ? 0.3 : 0.7,
				weight: 2,
				dashArray: es.isTentative ? "4 3" : undefined,
			});
			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);
		}

		// Auto-fit map to visible event bounds
		if (this.zoomCoupled && located.length > 0) {
			if (located.length === 1) {
				this.leafletMap?.flyTo([located[0].latitude, located[0].longitude], this.getEffectiveLeafletZoom(), { duration: 0.8 });
			} else {
				const bounds = L.latLngBounds(located.map((e: any) => [e.latitude, e.longitude]));
				this.leafletMap?.flyToBounds(bounds, { padding: [40, 40], maxZoom: 16, duration: 0.8 });
			}
		}
	}

	private updateTransitLines() {
		const L = (window as any).L;
		if (!L || !this.transitLineLayer) return;
		this.transitLineLayer.clearLayers();

		const sorted = this.getVisibleLocatedEvents()
			.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;

			// Line color based on booking status: green = booked, red = unbooked/unknown
			const isBooked = next.booking_status === "booked";
			const color = isBooked ? "#22c55e" : "#ef4444";
			const label = isBooked ? "Booked" : "Not booked";
			const line = L.polyline(
				[[curr.latitude, curr.longitude], [next.latitude, next.longitude]],
				{ color, weight: 3, opacity: 0.7, dashArray: isBooked ? undefined : "8, 6" }
			);
			line.bindTooltip(
				`${this.esc(curr.location_name || curr.title)} \u2192 ${this.esc(next.location_name || next.title)} <span style="color:${color};font-weight:600">[${label}]</span>`,
				{ sticky: true }
			);
			this.transitLineLayer.addLayer(line);
		}
	}

	// ── Styles ──

	private getStyles(): string {
		return `
			:host { display: block; font-family: system-ui, -apple-system, sans-serif; color: var(--rs-text-primary); padding: 0.5rem; -webkit-tap-highlight-color: transparent; }
			* { box-sizing: border-box; }
			button, a, input, select, textarea, [role="button"] { touch-action: manipulation; }

			.error { color: var(--rs-error); 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 var(--rs-border); background: transparent; color: var(--rs-text-secondary); cursor: pointer; font-size: 14px; -webkit-tap-highlight-color: transparent; }
			.nav-btn:hover { color: var(--rs-text-primary); border-color: var(--rs-border-strong); }
			.nav-btn.active { border-color: var(--rs-primary-hover); color: var(--rs-primary-hover); }
			.nav-title { font-size: 15px; font-weight: 600; flex: 1; text-align: center; color: var(--rs-text-primary); }
			.nav-primary { padding: 6px 14px; border-radius: 6px; border: none; background: var(--rs-primary); color: #fff; font-weight: 600; cursor: pointer; font-size: 12px; }

			/* ── Bottom Bar ── */
			.bottom-bar { margin-top: 12px; padding-top: 8px; border-top: 1px solid var(--rs-border-subtle); display: flex; flex-direction: column; gap: 6px; }
			.bottom-bar__lunar-toggle { align-self: flex-start; padding: 4px 10px; border-radius: 12px; border: 1px solid var(--rs-border); background: transparent; color: var(--rs-text-muted); cursor: pointer; font-size: 14px; transition: all 0.15s; }
			.bottom-bar__lunar-toggle:hover { border-color: var(--rs-border-strong); color: var(--rs-text-primary); }
			.bottom-bar__lunar-toggle.active { border-color: var(--rs-primary-hover); color: var(--rs-primary-hover); }

			/* ── Lunar Overlay (bottom) ── */
			.lunar-overlay { background: var(--rs-bg-surface); border: 1px solid var(--rs-border); border-radius: 8px; padding: 0; 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: var(--rs-bg-hover); }
			.lunar-summary-phase { font-size: 13px; font-weight: 600; color: var(--rs-text-primary); text-transform: capitalize; white-space: nowrap; }
			.lunar-summary-stats { font-size: 11px; color: var(--rs-text-secondary); white-space: nowrap; }
			.lunar-summary-chevron { margin-left: auto; font-size: 10px; color: var(--rs-text-muted); }
			.lunar-expanded { border-top: 1px solid var(--rs-border); 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 var(--rs-border-strong); font-size: 11px; color: var(--rs-text-secondary); white-space: nowrap; flex-shrink: 0; transition: all 0.15s; }
			.phase-chip.current { border-color: var(--rs-primary-hover); color: #818cf8; background: var(--rs-bg-active); }
			.phase-chip.past { opacity: 0.4; }
			.phase-chip-label { text-transform: capitalize; }

			/* ── Zoom Spectrum Bar ── */
			.zoom-bar { padding: 6px 0; }
			.zoom-bar__section { padding: 2px 0; }
			.zoom-bar__row { display: flex; align-items: center; gap: 8px; }
			.zoom-bar__label-end { font-size: 10px; color: var(--rs-text-muted); font-weight: 500; white-space: nowrap; user-select: none; flex-shrink: 0; }
			.zoom-bar__track { position: relative; flex: 1; height: 28px; cursor: pointer; min-width: 180px; }
			.zoom-bar__gradient { position: absolute; top: 10px; left: 0; right: 0; height: 8px; border-radius: 4px; background: linear-gradient(to right, #818cf8, #6366f1, #4f46e5, #4338ca, #3730a3, #312e81); pointer-events: none; }
			.zoom-bar__thumb { position: absolute; top: 4px; width: 20px; height: 20px; border-radius: 50%; background: #818cf8; border: 2px solid #fff; box-shadow: 0 1px 4px rgba(0,0,0,0.3); transform: translateX(-50%); cursor: grab; transition: left 0.15s ease; z-index: 2; }
			.zoom-bar__thumb:active { cursor: grabbing; transform: translateX(-50%) scale(1.15); transition: left 0s, transform 0.1s; }
			.zoom-bar__tick { position: absolute; top: 0; transform: translateX(-50%); text-align: center; pointer-events: auto; cursor: pointer; z-index: 1; }
			.zoom-bar__tick-mark { width: 2px; height: 28px; margin: 0 auto; background: var(--rs-border, rgba(255,255,255,0.15)); opacity: 0.4; border-radius: 1px; transition: opacity 0.15s; }
			.zoom-bar__tick-mark.active { opacity: 0.8; }
			.zoom-bar__tick-label { font-size: 9px; color: var(--rs-text-muted); margin-top: 2px; transition: color 0.15s; white-space: nowrap; }
			.zoom-bar__tick-label.active { color: #818cf8; font-weight: 600; }
			.zoom-bar__coupling-row { display: flex; justify-content: center; padding: 1px 0; }
			.coupling-btn { padding: 4px 10px; border-radius: 12px; border: 1px solid var(--rs-border-strong); background: transparent; color: var(--rs-text-muted); cursor: pointer; font-size: 11px; transition: all 0.15s; white-space: nowrap; flex-shrink: 0; }
			.coupling-btn:hover { border-color: var(--rs-border-strong); color: var(--rs-text-secondary); }
			.coupling-btn.coupled { border-color: var(--rs-primary-hover); color: #818cf8; background: var(--rs-bg-active); }
			.zoom-bar__spatial-gradient { background: linear-gradient(to right, #2dd4bf, #14b8a6, #0d9488, #0f766e, #115e59, #134e4a); }
			.zoom-bar__spatial-thumb { background: #2dd4bf; }
			.zoom-bar__spatial-thumb:active { background: #14b8a6; }
			.zoom-bar__spatial-track .zoom-bar__tick-label.active { color: #2dd4bf; }
			.zoom-bar__spatial-track .zoom-bar__tick-mark.active { background: #2dd4bf; }
			.zoom-bar__section--coupled .zoom-bar__spatial-track { opacity: 0.5; pointer-events: none; }
			.zoom-bar__section--coupled .zoom-bar__spatial-thumb { cursor: default; }

			/* ── Sources / Calendar Legend ── */
			.sources { display: flex; gap: 6px; margin-top: 10px; flex-wrap: wrap; align-items: center; }
			.sources-heading { font-size: 9px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: var(--rs-text-muted); margin-right: 4px; }
			.src-badge { font-size: 10px; padding: 3px 8px; border-radius: 10px; border: 1px solid var(--rs-border-strong); 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; }
			.gcal-btn { background: transparent; color: var(--rs-text-secondary); border-color: #4285f4 !important; }
			.gcal-btn:hover { color: #4285f4; }
			.gcal-sync-btn { background: transparent; color: var(--rs-text-muted); font-size: 9px !important; }
			.gcal-dot { display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: #4285f4; margin-left: 3px; vertical-align: middle; }

			/* ── Google Calendar Modal ── */
			.gcal-modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.6); z-index: 1000; display: flex; align-items: center; justify-content: center; }
			.gcal-modal { background: var(--rs-bg-surface); border: 1px solid var(--rs-border-strong); border-radius: 12px; width: 380px; max-width: 90vw; max-height: 80vh; overflow: hidden; display: flex; flex-direction: column; }
			.gcal-modal-header { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; border-bottom: 1px solid var(--rs-border); font-weight: 600; font-size: 14px; }
			.gcal-modal-close { background: none; border: none; color: var(--rs-text-muted); cursor: pointer; font-size: 16px; padding: 4px; }
			.gcal-modal-close:hover { color: var(--rs-text-primary); }
			.gcal-modal-body { padding: 12px 16px; overflow-y: auto; }
			.gcal-list { display: flex; flex-direction: column; gap: 4px; }
			.gcal-item { display: flex; align-items: center; gap: 8px; padding: 8px 10px; border-radius: 8px; cursor: pointer; transition: background 0.1s; }
			.gcal-item:hover { background: var(--rs-bg-surface-sunken); }
			.gcal-color { width: 12px; height: 12px; border-radius: 3px; flex-shrink: 0; }
			.gcal-name { flex: 1; font-size: 13px; color: var(--rs-text-primary); }
			.gcal-check { accent-color: #4285f4; width: 16px; height: 16px; cursor: pointer; }
			.gcal-connect-btn { display: inline-block; padding: 8px 20px; background: #4285f4; color: white; border-radius: 6px; text-decoration: none; font-size: 13px; font-weight: 500; }
			.gcal-connect-btn:hover { background: #3367d6; }
			.gcal-push-btn { background: none; border: 1px solid var(--rs-border-strong); color: var(--rs-text-secondary); padding: 4px 12px; border-radius: 6px; cursor: pointer; font-size: 11px; }
			.gcal-push-btn:hover { color: #4285f4; border-color: #4285f4; }

			/* ── Zoom Bar Top ── */
			.zoom-bar--top { margin-top: 10px; padding: 8px 12px; background: var(--rs-bg-surface); border: 1px solid var(--rs-border-subtle); border-radius: 8px; }
			.zoom-bar--top .zoom-bar__track { min-width: 200px; }
			.zoom-bar--top .zoom-bar__row { gap: 12px; }
			.zoom-bar--top .zoom-bar__tick-label { font-size: 10px; }
			.zoom-bar--top .zoom-bar__label-end { font-size: 11px; }

			/* ── Main Layout ── */
			.main-layout { position: relative; min-height: 400px; }
			.main-layout--docked { display: grid; grid-template-columns: 1fr 6px 400px; gap: 0; min-height: 500px; }
			.calendar-pane { overflow: auto; min-width: 0; touch-action: pan-y; user-select: none; min-height: 60vh; display: flex; flex-direction: column; }

			/* ── Resize Handle ── */
			.resize-handle { width: 6px; cursor: col-resize; background: transparent; position: relative; z-index: 10; transition: background 0.15s; }
			.resize-handle:hover, .resize-handle.active { background: var(--rs-primary-hover); opacity: 0.5; }
			.resize-handle::before { content: ""; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 2px; height: 32px; border-radius: 1px; background: var(--rs-border-strong); opacity: 0.4; transition: opacity 0.15s; }
			.resize-handle:hover::before, .resize-handle.active::before { opacity: 0.8; }

			/* ── Map Panel ── */
			.map-panel { background: var(--rs-bg-surface-sunken); border: 1px solid var(--rs-border-strong); border-radius: 12px; overflow: hidden; display: flex; flex-direction: column; }
			.map-panel--docked { min-height: 400px; }
			.map-panel-header { display: flex; align-items: center; justify-content: space-between; padding: 6px 10px; background: var(--rs-bg-surface); border-bottom: 1px solid var(--rs-border); cursor: default; flex-shrink: 0; }
			.map-panel-title { font-size: 12px; font-weight: 500; color: var(--rs-text-secondary); }
			.map-panel-controls { display: flex; gap: 4px; }
			.map-ctrl-btn { width: 24px; height: 24px; border-radius: 4px; border: 1px solid var(--rs-border-strong); background: transparent; color: var(--rs-text-secondary); cursor: pointer; font-size: 12px; display: flex; align-items: center; justify-content: center; }
			.map-ctrl-btn:hover { border-color: var(--rs-primary-hover); color: var(--rs-text-primary); }
			.map-body { flex: 1; position: relative; min-height: 200px; }
			.map-overlay-label { position: absolute; top: 8px; right: 8px; z-index: 500; background: var(--rs-bg-overlay); border: 1px solid var(--rs-border-strong); border-radius: 6px; padding: 4px 10px; font-size: 11px; color: var(--rs-text-secondary); pointer-events: none; }
			.map-fab { position: absolute; bottom: 16px; right: 16px; width: 44px; height: 44px; border-radius: 50%; border: 1px solid var(--rs-border-strong); background: var(--rs-bg-surface); color: var(--rs-text-primary); cursor: pointer; font-size: 20px; display: flex; align-items: center; justify-content: center; z-index: 100; box-shadow: var(--rs-shadow-md); transition: all 0.15s; }
			.map-fab:hover { border-color: var(--rs-primary-hover); background: var(--rs-bg-surface-raised); 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: var(--rs-text-muted); margin-bottom: 6px; }
			.synodic-bar { height: 14px; background: var(--rs-border); border-radius: 7px; overflow: visible; position: relative; }
			.synodic-fill { height: 100%; background: linear-gradient(to right, var(--rs-bg-surface), var(--rs-text-primary), var(--rs-bg-surface)); 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, minmax(0, 1fr)); gap: 2px; margin-bottom: 4px; }
			.wd { text-align: center; font-size: 11px; color: var(--rs-text-muted); padding: 4px; font-weight: 600; }
			.grid { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 2px; position: relative; flex: 1; }
			.day { background: var(--rs-bg-surface); border: 1px solid var(--rs-border); border-radius: 6px; min-height: 0; min-width: 0; overflow: hidden; padding: 6px; cursor: pointer; position: relative; -webkit-tap-highlight-color: transparent; }
			.day:hover { border-color: var(--rs-border-strong); }
			.day.today { border-color: var(--rs-primary-hover); background: var(--rs-bg-active); }
			.day.expanded { border-color: var(--rs-primary-hover); 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: var(--rs-text-secondary); line-height: 1.4; padding: 1px 3px; border-radius: 3px; cursor: pointer; }
			.ev-label:hover { background: var(--rs-bg-hover); }
			.ev-time { color: var(--rs-text-muted); font-size: 8px; margin-right: 2px; }
			.ev-bell { margin-right: 2px; font-size: 8px; }
			.ev-loc { color: var(--rs-text-muted); font-size: 7px; margin-left: 3px; }
			.ev-spatial-chip { display: flex; align-items: center; gap: 2px; font-size: 8px; color: var(--rs-text-secondary); background: var(--rs-bg-hover); border: 1px solid var(--rs-border); border-radius: 6px; padding: 1px 5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; line-height: 1.4; }
			.ev-spatial-name { overflow: hidden; text-overflow: ellipsis; }
			.ev-spatial-count { font-weight: 700; color: var(--rs-text-muted); font-size: 7px; flex-shrink: 0; }
			.ev-spatial-more { color: var(--rs-text-muted); font-size: 7px; justify-content: center; }
			.ev-spatial-virtual { color: var(--rs-text-muted); font-size: 7px; }
			.ev-virtual { font-size: 8px; margin-right: 2px; vertical-align: middle; }
			.ev-likelihood { font-size: 8px; color: var(--rs-warning); margin-left: 3px; }
			.dd-likelihood { font-size: 9px; color: var(--rs-warning); margin-left: 6px; }
			.dot--tentative { border: 1px dashed; background: transparent !important; width: 6px; height: 6px; border-radius: 50%; display: inline-block; margin: 1px; }

			/* ── Multi-day Span Bars ── */
			.ev-span { position: absolute; height: 16px; font-size: 10px; line-height: 16px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 0 4px; cursor: pointer; z-index: 2; pointer-events: auto; }
			.ev-span:hover { filter: brightness(1.15); }
			.ev-span-text { font-weight: 500; }
			.ev-span-more { position: absolute; height: 14px; font-size: 9px; line-height: 14px; color: var(--rs-text-muted); z-index: 2; pointer-events: none; padding: 0 4px; }

			/* ── Drop Target ── */
			.day.drop-target { background: rgba(245,158,11,0.15); border: 2px dashed var(--rs-warning); }

			/* Reminder popover */
			/* Reminder indicators on calendar days */
			.reminder-badge {
				font-size: 10px; margin-left: 2px; opacity: 0.85;
			}
			.dot--reminder {
				animation: pulse-reminder 2s ease-in-out infinite;
			}
			@keyframes pulse-reminder {
				0%, 100% { opacity: 1; }
				50% { opacity: 0.5; }
			}

			.reminder-popover {
				background: #1e1e2e; border: 1px solid #444; border-radius: 12px;
				padding: 14px; min-width: 220px; max-width: 280px;
				box-shadow: 0 8px 32px rgba(0,0,0,0.5); font-size: 13px;
			}
			.reminder-popover__header {
				display: flex; align-items: center; gap: 8px; margin-bottom: 6px;
			}
			.reminder-popover__badge {
				font-size: 10px; padding: 2px 8px; border-radius: 4px; color: #fff;
				font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px;
			}
			.reminder-popover__title {
				font-weight: 600; color: #e0e0e0; overflow: hidden;
				text-overflow: ellipsis; white-space: nowrap; flex: 1;
			}
			.reminder-popover__date {
				color: #94a3b8; font-size: 12px; margin-bottom: 12px;
			}
			.reminder-popover__times {
				display: grid; grid-template-columns: 1fr 1fr; gap: 6px; margin-bottom: 8px;
			}
			.reminder-popover__time {
				padding: 8px; border-radius: 6px; border: 1px solid #444;
				background: #2a2a3e; color: #e0e0e0; cursor: pointer;
				font-size: 12px; text-align: center; transition: all 0.15s;
			}
			.reminder-popover__time:hover {
				background: #3a3a5e; border-color: #818cf8;
			}
			.reminder-popover__custom {
				display: flex; gap: 6px; margin-bottom: 8px;
			}
			.reminder-popover__time-input {
				flex: 1; padding: 6px 8px; border-radius: 6px; border: 1px solid #444;
				background: #2a2a3e; color: #e0e0e0; font-size: 12px;
			}
			.reminder-popover__cancel {
				width: 100%; padding: 6px; border-radius: 6px; border: 1px solid #333;
				background: transparent; color: #64748b; cursor: pointer; font-size: 11px;
			}
			.reminder-popover__cancel:hover { color: #94a3b8; }

			/* ── Day Detail Panel ── */
			.day-detail { grid-column: 1 / -1; background: var(--rs-bg-surface); border: 1px solid var(--rs-bg-surface-raised); 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: var(--rs-text-primary); }
			.dd-header-actions { display: flex; gap: 4px; align-items: center; }
			.dd-add { background: none; border: 1px solid var(--rs-border-strong, #444); color: var(--rs-primary-hover, #818cf8); font-size: 18px; cursor: pointer; padding: 2px 8px; border-radius: 6px; line-height: 1; }
			.dd-add:hover { background: var(--rs-bg-hover); }
			.dd-close { background: none; border: none; color: var(--rs-text-muted); font-size: 18px; cursor: pointer; padding: 4px 8px; }
			.dd-add-form { margin-top: 8px; padding: 10px; background: var(--rs-bg-surface-raised, #2a2a3e); border-radius: 8px; border: 1px solid var(--rs-border-strong, #444); }
			.dd-add-title { width: 100%; padding: 8px; border-radius: 6px; border: 1px solid var(--rs-border-strong, #444); background: var(--rs-bg-surface, #1e1e2e); color: var(--rs-text-primary, #e0e0e0); font-size: 13px; margin-bottom: 8px; box-sizing: border-box; }
			.dd-add-title:focus { outline: none; border-color: var(--rs-primary-hover, #818cf8); }
			.dd-add-times { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; margin-bottom: 8px; }
			.dd-add-time { padding: 6px; border-radius: 6px; border: 1px solid var(--rs-border-strong, #444); background: var(--rs-bg-surface, #1e1e2e); color: var(--rs-text-primary, #e0e0e0); cursor: pointer; font-size: 12px; text-align: center; }
			.dd-add-time:hover { border-color: var(--rs-primary-hover, #818cf8); background: var(--rs-bg-hover); }
			.dd-add-custom { display: flex; gap: 6px; }
			.dd-add-time-input { flex: 1; padding: 6px 8px; border-radius: 6px; border: 1px solid var(--rs-border-strong, #444); background: var(--rs-bg-surface, #1e1e2e); color: var(--rs-text-primary, #e0e0e0); font-size: 12px; }
			.dd-add-submit { padding: 6px 12px; border-radius: 6px; border: 1px solid var(--rs-primary-hover, #818cf8); background: var(--rs-primary-hover, #818cf8); color: #fff; cursor: pointer; font-size: 12px; font-weight: 500; }
			.dd-add-submit:hover { opacity: 0.9; }
			.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: var(--rs-bg-hover); }
			.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: var(--rs-text-primary); }
			.dd-meta { font-size: 11px; color: var(--rs-text-secondary); margin-top: 2px; }
			.dd-empty { font-size: 12px; color: var(--rs-text-muted); padding: 8px 0; }
			.dd-desc { font-size: 11px; color: var(--rs-text-muted); 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: var(--rs-bg-surface); border: 1px solid var(--rs-border-strong); 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: var(--rs-text-secondary); margin-bottom: 6px; }
			.modal-close { float: right; background: none; border: none; color: var(--rs-text-muted); font-size: 18px; cursor: pointer; }

			/* ── Day View ── */
			.day-view { position: relative; flex: 1; display: flex; flex-direction: column; }
			.day-view-header { font-size: 13px; color: var(--rs-text-secondary); margin-bottom: 8px; font-weight: 500; }
			.day-allday { background: var(--rs-bg-surface); border: 1px solid var(--rs-border); border-radius: 8px; padding: 8px; margin-bottom: 8px; }
			.day-allday-label { font-size: 10px; color: var(--rs-text-muted); text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 4px; }
			.timeline { position: relative; border-left: 1px solid var(--rs-border); margin-left: 44px; flex: 1; }
			.hour-row { display: flex; min-height: 48px; border-bottom: 1px solid var(--rs-border-subtle); position: relative; }
			.hour-label { position: absolute; left: -48px; top: -7px; width: 40px; text-align: right; font-size: 10px; color: var(--rs-text-muted); font-variant-numeric: tabular-nums; }
			.hour-content { flex: 1; position: relative; padding-left: 8px; }
			.tl-event { position: absolute; border-radius: 6px; padding: 4px 8px; font-size: 11px; overflow: hidden; cursor: pointer; border-left: 3px solid; z-index: 1; transition: opacity 0.15s; box-sizing: border-box; }
			.tl-event:hover { opacity: 0.85; }
			.tl-event-title { font-weight: 600; color: var(--rs-text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
			.tl-event-time { font-size: 10px; color: var(--rs-text-secondary); }
			.tl-event-loc { font-size: 10px; color: var(--rs-text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
			.tl-event-desc { font-size: 9px; color: var(--rs-text-muted); margin-top: 2px; line-height: 1.3; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
			.tl-breadcrumb { font-size: 8px; color: var(--rs-text-muted); background: var(--rs-bg-hover); 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: var(--rs-error); z-index: 5; }
			.now-dot { position: absolute; left: -5px; top: -3px; width: 8px; height: 8px; border-radius: 50%; background: var(--rs-error); }

			/* ── Week View ── */
			.week-view { overflow-x: auto; flex: 1; display: flex; flex-direction: column; }
			.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: var(--rs-text-muted); font-weight: 600; border-bottom: 1px solid var(--rs-border); cursor: pointer; }
			.week-day-header:hover { color: var(--rs-text-primary); }
			.week-day-header.today { color: var(--rs-primary-hover); border-bottom-color: var(--rs-primary-hover); }
			.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); flex: 1; }
			.week-time-label { font-size: 10px; color: var(--rs-text-muted); text-align: right; padding-right: 6px; font-variant-numeric: tabular-nums; height: 48px; }
			.week-cell { border-left: 1px solid var(--rs-border-subtle); border-bottom: 1px solid var(--rs-border-subtle); min-height: 48px; position: relative; }
			.week-cell.today { background: rgba(99,102,241,0.04); }
			.week-event { position: absolute; border-radius: 4px; padding: 2px 4px; font-size: 10px; overflow: hidden; cursor: pointer; border-left: 2px solid; z-index: 1; box-sizing: border-box; }
			.week-event:hover { opacity: 0.85; }
			.week-event-title { font-weight: 600; color: var(--rs-text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
			.week-event-meta { font-size: 9px; color: var(--rs-text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
			.week-event-time { color: var(--rs-text-muted); }
			.week-event-loc { margin-left: 3px; color: var(--rs-text-muted); }
			.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: var(--rs-text-primary); }
			.season-q { font-size: 12px; color: var(--rs-text-muted); font-weight: 400; margin-left: 4px; }
			.season-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; flex: 1; align-content: stretch; }
			.season-month-wrap { display: flex; flex-direction: column; gap: 4px; flex: 1; }
			.season-cities { display: flex; flex-wrap: wrap; gap: 3px; padding: 2px 4px; }
			.season-city-chip { font-size: 9px; color: var(--rs-text-secondary); background: var(--rs-bg-hover); border: 1px solid var(--rs-border-strong); border-radius: 8px; padding: 1px 6px; white-space: nowrap; }

			/* ── Year View ── */
			.year-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; flex: 1; align-content: stretch; }

			/* ── Mini-Month (shared) ── */
			.mini-month { background: var(--rs-bg-surface); border: 1px solid var(--rs-border); border-radius: 8px; padding: 8px; cursor: pointer; transition: border-color 0.15s; flex: 1; display: flex; flex-direction: column; }
			.mini-month:hover { border-color: var(--rs-border-strong); }
			.mini-month.current { border-color: var(--rs-primary-hover); background: var(--rs-bg-active); }
			.mini-month-title { font-size: 12px; font-weight: 600; text-align: center; color: var(--rs-text-primary); 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: var(--rs-text-muted); font-weight: 600; }
			.mini-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 1px; flex: 1; align-content: start; }
			.mini-day { position: relative; display: flex; align-items: center; justify-content: center; font-size: 10px; color: var(--rs-text-secondary); border-radius: 3px; aspect-ratio: 1; cursor: pointer; }
			.mini-day:hover { background: var(--rs-bg-hover); }
			.mini-day.today { background: var(--rs-primary); 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: var(--rs-text-muted); 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 var(--rs-border-strong); background: transparent; transition: all 0.15s; }
			.variant-dot.active { background: var(--rs-primary-hover); border-color: var(--rs-primary-hover); }

			/* ── 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 var(--rs-border); }
			.dh-hour { position: absolute; top: 0; height: 24px; font-size: 10px; color: var(--rs-text-muted); text-align: center; border-left: 1px solid var(--rs-border-subtle); line-height: 24px; }
			.dh-events { position: relative; min-height: 140px; padding-top: 8px; }
			.dh-event { position: absolute; 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; box-sizing: border-box; }
			.dh-event:hover { opacity: 0.85; }
			.dh-now { position: absolute; top: 0; bottom: 0; width: 2px; background: var(--rs-error); z-index: 5; }
			.dh-now::before { content: ""; position: absolute; top: -3px; left: -3px; width: 8px; height: 8px; border-radius: 50%; background: var(--rs-error); }
			.dh-event-loc { font-size: 9px; color: var(--rs-text-muted); 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: var(--rs-text-muted); 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: var(--rs-text-muted); 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: var(--rs-bg-surface); border: 1px solid var(--rs-border); border-radius: 4px; display: flex; align-items: center; justify-content: center; gap: 4px; cursor: pointer; position: relative; }
			.mt-cell:hover { border-color: var(--rs-border-strong); }
			.mt-cell.today { border-color: var(--rs-primary-hover); background: var(--rs-bg-active); }
			.mt-cell.weekend { background: var(--rs-bg-hover); }
			.mt-cell.empty { background: transparent; border-color: transparent; cursor: default; }
			.mt-num { font-size: 12px; color: var(--rs-text-secondary); 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: var(--rs-text-secondary); 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 var(--rs-border-subtle); cursor: pointer; }
			.yv-month:hover { background: var(--rs-bg-hover); }
			.yv-label { width: 36px; flex-shrink: 0; font-size: 11px; font-weight: 600; color: var(--rs-text-secondary); 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: var(--rs-text-muted); border-radius: 3px; cursor: pointer; }
			.yv-day:hover { background: var(--rs-bg-hover); color: var(--rs-text-primary); }
			.yv-day.today { background: var(--rs-primary); 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: var(--rs-text-muted); margin-left: 6px; font-weight: 400; }

			/* ── Multi-Year View ── */
			.multi-year-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; flex: 1; align-content: stretch; }
			.my-year { background: var(--rs-bg-surface); border: 1px solid var(--rs-border); border-radius: 8px; padding: 8px; cursor: pointer; transition: border-color 0.15s; }
			.my-year:hover { border-color: var(--rs-border-strong); }
			.my-year.current { border-color: var(--rs-primary-hover); background: var(--rs-bg-active); }
			.my-year-label { font-size: 14px; font-weight: 700; text-align: center; color: var(--rs-text-primary); 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: var(--rs-bg-hover); }
			.micro-label { font-size: 8px; color: var(--rs-text-muted); 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: var(--rs-text-muted); flex-shrink: 0; }

			/* ── Keyboard Hint ── */
			.kbd-hint { text-align: center; font-size: 10px; color: var(--rs-text-muted); margin-top: 12px; padding-top: 8px; border-top: 1px solid var(--rs-border-subtle); }
			.kbd-hint kbd { padding: 1px 4px; background: var(--rs-bg-surface); border: 1px solid var(--rs-border); border-radius: 3px; font-family: inherit; font-size: 9px; }

			/* ── Tablet/Adaptive ── */
			@media (max-width: 1024px) {
				.main-layout--docked { grid-template-columns: 1fr 6px 320px; }
			}
			@media (max-width: 900px) and (min-width: 769px) {
				.main-layout--docked { grid-template-columns: 1fr !important; }
				.resize-handle { display: none; }
				.ev-label { display: none; }
				.day { min-height: 0; }
				.zoom-bar--top .zoom-bar__track { min-width: 140px; }
			}
			/* ── Mobile ── */
			@media (max-width: 768px) {
				:host { padding: 0.25rem; }
				.calendar-pane { min-height: 50vh; }
				.main-layout--docked { grid-template-columns: 1fr !important; }
				.resize-handle { display: none; }
				.year-grid { grid-template-columns: repeat(3, 1fr); }
				.season-grid { grid-template-columns: 1fr; }
				.day { min-height: 0; padding: 4px; }
				.day-num { font-size: 11px; }
				.ev-label { display: none; }
				.ev-spatial-chip { display: none; }
				/* Dots → thin colored bars on mobile */
				.dots { flex-direction: column; gap: 1px; margin-top: 2px; }
				.dot { width: 100%; height: 2px; border-radius: 1px; margin: 0; }
				.dot--tentative { width: 100%; height: 0; border-radius: 0; margin: 0; border: none; border-top: 2px dashed; }
				.dot--reminder { width: 100%; height: 2px; border-radius: 1px; margin: 0; }
				/* Thinner multi-day spans */
				.ev-span { height: 10px; line-height: 10px; font-size: 0; }
				.ev-span-text { display: none; }
				.ev-span-more { font-size: 8px; height: 10px; line-height: 10px; }
				.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-bar--top { padding: 6px 8px; margin-top: 6px; }
				.zoom-bar--top .zoom-bar__track { min-width: 120px; }
				.zoom-bar__track { min-width: 120px; }
				.zoom-bar__label-end { font-size: 9px; }
				.zoom-bar__tick-label { font-size: 8px; }
				.coupling-btn { font-size: 10px; padding: 3px 8px; }
				.zoom-bar__coupling-row { padding: 0; }
				.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) {
				.calendar-pane { min-height: 45vh; }
				.day { min-height: 0; padding: 3px; }
				.day-num { font-size: 10px; }
				.dot { height: 1.5px; }
				.dot--tentative { border-top-width: 1.5px; }
				.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);
