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

2820 lines
153 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* <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";
// ── 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;
// 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;
// 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;
// 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.render(); }
if (!localStorage.getItem("rcal_tour_done")) {
setTimeout(() => this._tour.start(), 1200);
}
}
disconnectedCallback() {
this._offlineUnsub?.();
this._offlineUnsub = null;
if (this.boundKeyHandler) {
document.removeEventListener("keydown", this.boundKeyHandler);
this.boundKeyHandler = null;
}
if (this._wheelTimer) { clearTimeout(this._wheelTimer); this._wheelTimer = null; }
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 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.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,
}));
}
// 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_lat: e.locationLat, location_lng: e.locationLng,
}));
// Only use doc events if REST hasn't loaded yet
if (this.events.length === 0 && docEvents.length > 0) {
this.events = docEvents;
}
}
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;
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];
}
// ── 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;
}[] = [
// ── 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" },
{ start: rel(0, 12, 14, 30), durationMin: 30, title: "Hotel Check-in", source: 1, desc: "Hotel V Nesplein, Amsterdam", location: "Hotel V Nesplein", virtual: false, lat: 52.3667, lng: 4.8945, breadcrumb: "Earth > Europe > Netherlands > Amsterdam" },
{ start: rel(0, 13, 10, 0), durationMin: 180, title: "Partner Meeting", source: 0, desc: "On-site with Amsterdam design team", location: "WeWork Weteringschans", virtual: false, lat: 52.3603, lng: 4.8880, breadcrumb: "Earth > Europe > Netherlands > Amsterdam" },
{ start: rel(0, 13, 15, 0), durationMin: 120, title: "Canal District Walk", source: 2, desc: "Afternoon along Prinsengracht and Jordaan", location: "Prinsengracht, Amsterdam", virtual: false, lat: 52.3738, lng: 4.8820, breadcrumb: "Earth > Europe > Netherlands > Amsterdam" },
{ start: rel(0, 14, 9, 30), durationMin: 390, title: "Return Train Amsterdam \u2192 Berlin", source: 1, desc: "ICE 148 Amsterdam \u2192 Berlin", location: "Amsterdam Centraal", virtual: false, lat: 52.3791, lng: 4.9003, breadcrumb: "Earth > Europe > Netherlands > Amsterdam" },
{ start: rel(0, 15, 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" },
{ start: rel(0, 23, 9, 0), durationMin: 540, title: "Web Summit Day 1", source: 3, desc: "Opening keynotes, startup pavilion", location: "Altice Arena, Lisbon", virtual: false, lat: 38.7685, lng: -9.0943, breadcrumb: "Earth > Europe > Portugal > Lisbon" },
{ start: rel(0, 24, 9, 0), durationMin: 540, title: "Web Summit Day 2", source: 3, desc: "Panel: Local-First Software", location: "Altice Arena, Lisbon", virtual: false, lat: 38.7685, lng: -9.0943, breadcrumb: "Earth > Europe > Portugal > Lisbon" },
{ start: rel(0, 25, 10, 0), durationMin: 360, title: "Lisbon City Tour", source: 2, desc: "Alfama, Tram 28, Past\u00e9is de Bel\u00e9m", location: "Alfama, Lisbon", virtual: false, lat: 38.7118, lng: -9.1300, breadcrumb: "Earth > Europe > Portugal > Lisbon" },
{ start: rel(0, 25, 19, 30), durationMin: 195, title: "Flight \u2192 Berlin", source: 1, desc: "TAP TP 572 LIS \u2192 BER", location: "Lisbon Airport", virtual: false, lat: 38.7756, lng: -9.1354, breadcrumb: "Earth > Europe > Portugal > Lisbon" },
{ start: rel(0, 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" },
{ start: rel(1, 3, 14, 0), durationMin: 240, title: "Team Retreat Day 1", source: 0, desc: "Strategy offsite at Barcel\u00f3 Sants", location: "Barcel\u00f3 Sants, Barcelona", virtual: false, lat: 41.3795, lng: 2.1405, breadcrumb: "Earth > Europe > Spain > Barcelona" },
{ start: rel(1, 4, 9, 0), durationMin: 480, title: "Team Retreat Day 2", source: 0, desc: "Workshops + Sagrada Familia visit", location: "Barcelona", virtual: false, lat: 41.4036, lng: 2.1744, breadcrumb: "Earth > Europe > Spain > Barcelona" },
{ start: rel(1, 5, 15, 0), durationMin: 180, title: "Flight \u2192 Berlin", source: 1, desc: "VY 1863 BCN \u2192 BER", location: "BCN Airport", virtual: false, lat: 41.2974, lng: 2.0833, breadcrumb: "Earth > Europe > Spain > Barcelona" },
{ start: rel(1, 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,
};
});
this.sources = sources;
// 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 || []; }
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.render();
}
// ── Navigation ──
private navigate(delta: number) {
const calPane = this.shadow.getElementById('calendar-pane');
this._ghostHtml = calPane?.innerHTML || null;
this._pendingTransition = delta > 0 ? 'nav-left' : 'nav-right';
switch (this.viewMode) {
case "day":
this.currentDate = new Date(this.currentDate.getFullYear(), this.currentDate.getMonth(), this.currentDate.getDate() + delta);
break;
case "week":
this.currentDate = new Date(this.currentDate.getFullYear(), this.currentDate.getMonth(), this.currentDate.getDate() + delta * 7);
break;
case "month":
this.currentDate = new Date(this.currentDate.getFullYear(), this.currentDate.getMonth() + delta, 1);
break;
case "season":
this.currentDate = new Date(this.currentDate.getFullYear(), this.currentDate.getMonth() + delta * 3, 1);
break;
case "year":
this.currentDate = new Date(this.currentDate.getFullYear() + delta, this.currentDate.getMonth(), 1);
break;
case "multi-year":
this.currentDate = new Date(this.currentDate.getFullYear() + delta * 9, this.currentDate.getMonth(), 1);
break;
}
this.expandedDay = "";
if (this.space !== "demo") { this.loadMonth(); } else { this.render(); }
}
// ── Helpers ──
private getMoonEmoji(phase: string): string { return MOON_EMOJI[phase] || ""; }
private formatTime(iso: string): string {
const d = new Date(iso);
return `${d.getHours()}:${String(d.getMinutes()).padStart(2, "0")}`;
}
private dateStr(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
private getEventsForDate(dateStr: string): any[] {
return this.events.filter(e => e.start_time && e.start_time.startsWith(dateStr)
&& !this.filteredSources.has(e.source_name));
}
private 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);
}
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>
${this.renderSources()}
<div class="main-layout ${isDocked ? "main-layout--docked" : ""}">
<div class="calendar-pane" id="calendar-pane">
${this.renderCalendarContent()}
</div>
${isDocked ? `<div class="zoom-bar--middle">${this.renderZoomController()}</div>` : ""}
${this.renderMapPanel()}
</div>
<div class="bottom-bar">
${!isDocked ? this.renderZoomController() : ""}
${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.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 {
if (this.sources.length === 0) return "";
return `<div class="sources">
${this.sources.map(s => `<span class="src-badge ${this.filteredSources.has(s.name) ? "filtered" : ""}"
data-source="${this.esc(s.name)}"
style="border-color:${s.color || "#666"};color:${s.color || "#aaa"}">${this.esc(s.name)}</span>`).join("")}
</div>`;
}
// ── Map Panel (floating / docked / minimized) ──
private renderMapPanel(): string {
if (this.mapPanelState === "minimized") {
return `<button class="map-fab" id="map-fab" title="Show map (m)">\u{1F5FA}</button>`;
}
const 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();
return `
<div class="weekdays">
${["S", "M", "T", "W", "T", "F", "S"].map(d => `<div class="wd">${d}</div>`).join("")}
</div>
<div class="grid">
${this.renderDays(year, month)}
</div>`;
}
private renderDays(year: number, month: number): string {
const firstDay = new Date(year, month, 1).getDay();
const daysInMonth = new Date(year, month + 1, 0).getDate();
const today = new Date();
const todayStr = this.dateStr(today);
let html = "";
const prevDays = new Date(year, month, 0).getDate();
for (let i = firstDay - 1; i >= 0; i--) {
html += `<div class="day other"><div class="day-num">${prevDays - i}</div></div>`;
}
for (let d = 1; d <= daysInMonth; d++) {
const ds = `${year}-${String(month + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
const isToday = ds === todayStr;
const isExpanded = ds === this.expandedDay;
const dayEvents = this.getEventsForDate(ds);
const 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>
${dayEvents.length > 0 || dayReminders.length > 0 ? `
<div class="dots">
${dayEvents.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("")}
${dayEvents.length > 5 ? `<span style="font-size:8px;color:var(--rs-text-muted)">+${dayEvents.length - 5}</span>` : ""}
</div>
${dayEvents.slice(0, 2).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>` : "";
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>' : ""}${virtualHtml}<span class="ev-time">${this.formatTime(e.start_time)}</span>${this.esc(e.title)}${likelihoodHtml}${cityHtml}</div>`;
}).join("")}
` : ""}
</div>`;
if (isExpanded) {
const cellIndex = firstDay + d - 1;
const posInRow = cellIndex % 7;
if (posInRow === 6 || d === daysInMonth) {
html += this.renderDayDetail(ds, dayEvents);
}
}
}
const totalCells = firstDay + daysInMonth;
const remaining = (7 - (totalCells % 7)) % 7;
for (let i = 1; i <= remaining; i++) {
html += `<div class="day other"><div class="day-num">${i}</div></div>`;
}
if (this.expandedDay) {
const dayEvents = this.getEventsForDate(this.expandedDay);
html += this.renderDayDetail(this.expandedDay, dayEvents);
}
return html;
}
// ── Season View (3 mini-months) ──
private renderSeason(): string {
const year = this.currentDate.getFullYear();
const quarter = Math.floor(this.currentDate.getMonth() / 3);
const months = [quarter * 3, quarter * 3 + 1, quarter * 3 + 2];
const seasonName = ["Winter", "Spring", "Summer", "Autumn"][quarter];
const monthsHtml = months.map(m => {
const dim = new Date(year, m + 1, 0).getDate();
const monthEvents: any[] = [];
for (let d = 1; d <= dim; d++) {
const ds = `${year}-${String(m + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
monthEvents.push(...this.getEventsForDate(ds));
}
const cities = this.getUniqueSpatialLabels(monthEvents, 5, 4);
const citiesHtml = cities.length > 0
? `<div class="season-cities">${cities.map(c => `<span class="season-city-chip">${this.esc(c)}</span>`).join("")}</div>`
: "";
return `<div class="season-month-wrap">${this.renderMiniMonth(year, m)}${citiesHtml}</div>`;
}).join("");
return `
<div class="season-header">${seasonName} ${year} <span class="season-q">Q${quarter + 1}</span></div>
<div class="season-grid">
${monthsHtml}
</div>
<div class="mini-hint">Click any day to zoom in</div>`;
}
// ── Year View (12 mini-months) ──
private renderYear(): string {
const year = this.currentDate.getFullYear();
return `
<div class="year-grid">
${Array.from({length: 12}, (_, i) => this.renderMiniMonth(year, i)).join("")}
</div>`;
}
// ── Day Horizontal View (variant 1) ──
private renderDayHorizontal(): string {
const ds = this.dateStr(this.currentDate);
const dayEvents = this.getEventsForDate(ds);
const lunar = this.lunarData[ds];
const now = new Date();
const isToday = this.dateStr(now) === ds;
const HOUR_WIDTH = 80;
const START_HOUR = 6;
const END_HOUR = 23;
const totalWidth = (END_HOUR - START_HOUR + 1) * HOUR_WIDTH;
const timed = dayEvents.filter(e => {
const s = new Date(e.start_time), en = new Date(e.end_time);
return (en.getTime() - s.getTime()) < 86400000;
}).sort((a, b) => a.start_time.localeCompare(b.start_time));
let hoursHtml = "";
for (let h = START_HOUR; h <= END_HOUR; h++) {
const label = h === 0 ? "12a" : h < 12 ? `${h}a` : h === 12 ? "12p" : `${h - 12}p`;
hoursHtml += `<div class="dh-hour" style="left:${(h - START_HOUR) * HOUR_WIDTH}px;width:${HOUR_WIDTH}px">${label}</div>`;
}
let eventsHtml = "";
for (const ev of timed) {
const start = new Date(ev.start_time), end = new Date(ev.end_time);
const startMin = start.getHours() * 60 + start.getMinutes();
const endMin = end.getHours() * 60 + end.getMinutes();
const duration = Math.max(endMin - startMin, 30);
const leftPx = ((startMin - START_HOUR * 60) / 60) * HOUR_WIDTH;
const widthPx = Math.max((duration / 60) * HOUR_WIDTH, 60);
const 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;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 countries = this.getUniqueSpatialLabels(monthEvents, 3, 2);
const countryHtml = countries.length > 0 ? `<span class="yv-country">${countries.join(", ")}</span>` : "";
html += `<div class="yv-month" data-mini-month="${m}">
<div class="yv-label">${monthName}${countryHtml}</div>
<div class="yv-days">${daysHtml}</div>
</div>`;
}
html += `</div>`;
return html;
}
// ── Multi-Year View (3x3 grid) ──
private renderMultiYear(): string {
const centerYear = this.currentDate.getFullYear();
const startYear = centerYear - 4;
let html = `<div class="multi-year-grid">`;
for (let i = 0; i < 9; i++) {
const y = startYear + i;
const isCurrent = y === new Date().getFullYear();
let monthsHtml = "";
for (let m = 0; m < 12; m++) {
monthsHtml += this.renderMicroMonth(y, m);
}
html += `<div class="my-year ${isCurrent ? "current" : ""}" data-my-year="${y}">
<div class="my-year-label">${y}</div>
<div class="my-months">${monthsHtml}</div>
</div>`;
}
html += `</div>`;
return html;
}
private renderMicroMonth(year: number, month: number): string {
const monthInitials = ["J","F","M","A","M","J","J","A","S","O","N","D"];
const dim = new Date(year, month + 1, 0).getDate();
const allEvents: any[] = [];
for (let d = 1; d <= dim; d++) {
const ds = `${year}-${String(month + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
allEvents.push(...this.getEventsForDate(ds));
}
const eventCount = allEvents.length;
const maxBar = 8;
const barWidth = Math.min(eventCount, maxBar);
const barPct = (barWidth / maxBar) * 100;
// Group by source_color for stacked segments
const colorCounts: Record<string, number> = {};
for (const e of allEvents) {
const c = e.source_color || "#6366f1";
colorCounts[c] = (colorCounts[c] || 0) + 1;
}
const countries = this.getUniqueSpatialLabels(allEvents, 3, 3);
const tooltipExtra = countries.length > 0 ? ` \u00B7 ${countries.join(", ")}` : "";
let barHtml = "";
if (eventCount > 0) {
const segments = Object.entries(colorCounts).map(([color, count]) => {
const segPct = (count / eventCount) * 100;
return `<span class="micro-seg" style="background:${color};width:${segPct}%"></span>`;
}).join("");
barHtml = `<span class="micro-bar-stack" style="width:${barPct}%">${segments}</span>`;
}
return `<div class="micro-month" data-micro-click="${year}-${month}" title="${new Date(year, month, 1).toLocaleDateString("default", { month: "long", year: "numeric" })}${eventCount ? ` (${eventCount} events)${tooltipExtra}` : ""}">
<span class="micro-label">${monthInitials[month]}</span>
${barHtml}
${eventCount > 0 ? `<span class="micro-count">${eventCount}</span>` : ""}
</div>`;
}
// ── Mini-Month (shared by Season & Year views) ──
private renderMiniMonth(year: number, month: number): string {
const monthName = new Date(year, month, 1).toLocaleDateString("default", { month: "short" });
const firstDay = new Date(year, month, 1).getDay();
const daysInMonth = new Date(year, month + 1, 0).getDate();
const today = new Date();
const todayStr = this.dateStr(today);
const isCurrentMonth = year === today.getFullYear() && month === today.getMonth();
let daysHtml = "";
for (let i = 0; i < firstDay; i++) { daysHtml += `<div class="mini-day empty"></div>`; }
for (let d = 1; d <= daysInMonth; d++) {
const ds = `${year}-${String(month + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
const isToday = ds === todayStr;
const dayEvents = this.getEventsForDate(ds);
const hasEvents = dayEvents.length > 0;
const isBusy = dayEvents.length >= 3;
const titleParts = dayEvents.slice(0, 3).map(e => e.title);
const tooltipText = hasEvents ? `${dayEvents.length} event${dayEvents.length > 1 ? 's' : ''}: ${titleParts.join(', ')}` : '';
const busyStyle = isBusy ? `background:rgba(99,102,241,0.08);` : '';
// Up to 3 stacked dots with individual colors
let dotsHtml = "";
if (hasEvents) {
const uniqueColors = dayEvents.slice(0, 3).map(e => e.source_color || '#6366f1');
dotsHtml = `<span class="mini-dots">${uniqueColors.map(c => `<span class="mini-dot" style="background:${c}"></span>`).join("")}</span>`;
}
daysHtml += `<div class="mini-day ${isToday ? "today" : ""}" data-mini-date="${ds}"
style="${busyStyle}" title="${tooltipText}">${d}${dotsHtml}</div>`;
}
return `<div class="mini-month ${isCurrentMonth ? "current" : ""}" data-mini-month="${month}">
<div class="mini-month-title">${monthName}</div>
<div class="mini-wd-row">${["S","M","T","W","T","F","S"].map(d => `<div class="mini-wd">${d}</div>`).join("")}</div>
<div class="mini-grid">${daysHtml}</div>
</div>`;
}
// ── Day View ──
private renderDay(): string {
const ds = this.dateStr(this.currentDate);
const dayEvents = this.getEventsForDate(ds);
const lunar = this.lunarData[ds];
const now = new Date();
const isToday = this.dateStr(now) === ds;
const HOUR_HEIGHT = 48;
const START_HOUR = 6;
const END_HOUR = 23;
const allDay = dayEvents.filter(e => {
const s = new Date(e.start_time), en = new Date(e.end_time);
return (en.getTime() - s.getTime()) >= 86400000;
});
const timed = dayEvents.filter(e => {
const s = new Date(e.start_time), en = new Date(e.end_time);
return (en.getTime() - s.getTime()) < 86400000;
}).sort((a, b) => a.start_time.localeCompare(b.start_time));
let hoursHtml = "";
for (let h = START_HOUR; h <= END_HOUR; h++) {
const label = h === 0 ? "12 AM" : h < 12 ? `${h} AM` : h === 12 ? "12 PM" : `${h - 12} PM`;
hoursHtml += `<div class="hour-row"><span class="hour-label">${label}</span><div class="hour-content"></div></div>`;
}
let eventsHtml = "";
for (const ev of timed) {
const start = new Date(ev.start_time), end = new Date(ev.end_time);
const startMin = start.getHours() * 60 + start.getMinutes();
const endMin = end.getHours() * 60 + end.getMinutes();
const duration = Math.max(endMin - startMin, 30);
const topPx = ((startMin - START_HOUR * 60) / 60) * HOUR_HEIGHT;
const heightPx = Math.max((duration / 60) * HOUR_HEIGHT, 24);
const 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;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>`;
}
}
let eventsOverlay = "";
for (let i = 0; i < 7; i++) {
const ds = this.dateStr(days[i]);
for (const ev of this.getEventsForDate(ds)) {
const start = new Date(ev.start_time), end = new Date(ev.end_time);
if ((end.getTime() - start.getTime()) >= 86400000) continue;
const startMin = start.getHours() * 60 + start.getMinutes();
const endMin = end.getHours() * 60 + end.getMinutes();
const duration = Math.max(endMin - startMin, 20);
const topPx = ((startMin - START_HOUR * 60) / 60) * HOUR_HEIGHT;
const heightPx = Math.max((duration / 60) * HOUR_HEIGHT, 18);
const es = this.getEventStyles(ev);
const colLeft = `calc(44px + ${i} * ((100% - 44px) / 7) + 2px)`;
const colWidth = `calc((100% - 44px) / 7 - 4px)`;
const showMeta = heightPx >= 36;
const timeStr = `${this.formatTime(ev.start_time)}\u2013${this.formatTime(ev.end_time)}`;
const locName = ev.location_name ? this.esc(ev.location_name) : "";
const virtualBadge = ev.is_virtual ? `<span class="wk-virtual" title="Virtual">\u{1F4BB}</span>` : "";
eventsOverlay += `<div class="week-event" data-event-id="${ev.id}" style="
top:${topPx}px;height:${heightPx}px;left:${colLeft};width:${colWidth};
background:${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>`;
}
}
let nowHtml = "";
const nowDay = days.findIndex(day => this.dateStr(day) === todayStr);
if (nowDay >= 0) {
const nowMin = today.getHours() * 60 + today.getMinutes();
const nowPx = ((nowMin - START_HOUR * 60) / 60) * HOUR_HEIGHT;
if (nowPx >= 0 && nowPx <= totalHeight) {
nowHtml = `<div class="now-line" style="top:${nowPx}px;left:44px;right:0"><div class="now-dot"></div></div>`;
}
}
return `
<div class="week-view">
<div class="week-header">${headerHtml}</div>
<div style="position:relative;overflow-y:auto;max-height:600px;">
<div class="week-grid" style="position:relative;height:${totalHeight}px">${gridHtml}</div>
${eventsOverlay}${nowHtml}
</div>
</div>`;
}
// ── Day Detail Panel ──
private renderDayDetail(dateStr: string, dayEvents: any[]): string {
const d = new Date(dateStr + "T00:00:00");
const label = d.toLocaleDateString("default", { weekday: "long", month: "long", day: "numeric" });
return `<div class="day-detail">
<div class="dd-header">
<span class="dd-date">${label}</span>
<button class="dd-close" id="dd-close">\u2715</button>
</div>
${dayEvents.length === 0 ? `<div class="dd-empty">No events</div>` :
dayEvents.sort((a, b) => a.start_time.localeCompare(b.start_time)).map(e => {
const ddDesc = e.description ? (e.description.length > 80 ? e.description.slice(0, 77) + "..." : e.description) : "";
const srcTag = e.source_name ? `<span class="dd-source" style="border-color:${e.source_color || '#666'};color:${e.source_color || '#aaa'}">${this.esc(e.source_name)}</span>` : "";
const es = this.getEventStyles(e);
const likelihoodBadge = es.isTentative ? `<span class="dd-likelihood">${es.likelihoodLabel}</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">${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>` : ""}
</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);
}
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(); });
// 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();
});
// Modal close
$("modal-overlay")?.addEventListener("click", (e) => {
if ((e.target as HTMLElement).id === "modal-overlay") { this.selectedEvent = null; this.render(); }
});
$("modal-close")?.addEventListener("click", () => { this.selectedEvent = null; this.render(); });
// Multi-year: click year tile → zoom to year
$$("[data-my-year]").forEach(el => {
el.addEventListener("click", (e) => {
e.stopPropagation();
const y = parseInt((el as HTMLElement).dataset.myYear!);
this.currentDate = new Date(y, this.currentDate.getMonth(), 1);
this.setTemporalGranularity(6);
});
});
// Multi-year: click micro-month → zoom to month
$$("[data-micro-click]").forEach(el => {
el.addEventListener("click", (e) => {
e.stopPropagation();
const parts = (el as HTMLElement).dataset.microClick!.split("-");
this.currentDate = new Date(parseInt(parts[0]), parseInt(parts[1]), 1);
this.setTemporalGranularity(4);
});
});
// Transposed month cell click → zoom to day
$$(".mt-cell:not(.empty)").forEach(el => {
el.addEventListener("click", () => {
const date = (el as HTMLElement).dataset.date;
if (!date) return;
const parts = date.split("-");
this.currentDate = new Date(parseInt(parts[0]), parseInt(parts[1]) - 1, parseInt(parts[2]));
this.setTemporalGranularity(2);
});
});
// Scroll/trackpad zoom on calendar pane
const calPane = $("calendar-pane");
if (calPane) {
calPane.addEventListener("wheel", (e: WheelEvent) => {
// Skip if target is inside the map panel (Leaflet handles its own zoom)
const mapPanel = this.shadow.getElementById("map-panel");
if (mapPanel && mapPanel.contains(e.target as Node)) return;
e.preventDefault();
// Debounce: 120ms
if (this._wheelTimer) clearTimeout(this._wheelTimer);
this._wheelTimer = setTimeout(() => {
if (e.deltaY > 0) {
this.zoomOut(); // scroll down → zoom out
} else if (e.deltaY < 0) {
this.zoomIn(); // scroll up → zoom in
}
this._wheelTimer = null;
}, 120);
}, { passive: false });
// 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);
}
}
// ── 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,
});
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;
const isTravel = curr.source_name === "Travel" || next.source_name === "Travel";
const line = L.polyline(
[[curr.latitude, curr.longitude], [next.latitude, next.longitude]],
{ color: isTravel ? "#f97316" : "#94a3b8", weight: isTravel ? 3 : 2,
opacity: isTravel ? 0.8 : 0.4, dashArray: isTravel ? "8, 8" : "4, 8" }
);
line.bindTooltip(
`${this.esc(curr.location_name || curr.title)} \u2192 ${this.esc(next.location_name || next.title)}`,
{ sticky: true }
);
this.transitLineLayer.addLayer(line);
}
}
// ── Styles ──
private getStyles(): string {
return `
:host { display: block; font-family: system-ui, -apple-system, sans-serif; color: 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 ── */
.sources { display: flex; gap: 6px; margin-bottom: 10px; flex-wrap: wrap; }
.src-badge { font-size: 10px; padding: 3px 8px; border-radius: 10px; border: 1px solid 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; }
/* ── Main Layout ── */
.main-layout { position: relative; min-height: 400px; }
.main-layout--docked { display: grid; grid-template-columns: 1fr auto 400px; gap: 0; min-height: 500px; }
.calendar-pane { overflow: auto; min-width: 0; touch-action: pan-y; user-select: none; }
.zoom-bar--middle { display: flex; flex-direction: column; justify-content: center; padding: 8px 2px; border-left: 1px solid var(--rs-border-subtle); border-right: 1px solid var(--rs-border-subtle); }
.zoom-bar--middle .zoom-bar { padding: 0; }
.zoom-bar--middle .zoom-bar__label-end { display: none; }
.zoom-bar--middle .zoom-bar__track { min-width: 100px; }
.zoom-bar--middle .zoom-bar__tick-label { font-size: 7px; }
.zoom-bar--middle .coupling-btn { padding: 2px 6px; font-size: 10px; }
.zoom-bar--middle .variant-indicator { display: none; }
/* ── 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, 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, 1fr); gap: 2px; }
.day { background: var(--rs-bg-surface); border: 1px solid var(--rs-border); border-radius: 6px; min-height: 80px; 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-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; }
/* ── 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-close { background: none; border: none; color: var(--rs-text-muted); font-size: 18px; cursor: pointer; padding: 4px 8px; }
.dd-event { display: flex; gap: 8px; align-items: flex-start; padding: 8px; border-radius: 6px; margin-bottom: 4px; cursor: pointer; -webkit-tap-highlight-color: transparent; }
.dd-event:hover { background: 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; }
.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; }
.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; left: 8px; right: 8px; border-radius: 6px; padding: 4px 8px; font-size: 11px; overflow: hidden; cursor: pointer; border-left: 3px solid; z-index: 1; transition: opacity 0.15s; }
.tl-event:hover { opacity: 0.85; }
.tl-event-title { font-weight: 600; color: 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; }
.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); }
.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; left: 2px; right: 2px; border-radius: 4px; padding: 2px 4px; font-size: 10px; overflow: hidden; cursor: pointer; border-left: 2px solid; z-index: 1; }
.week-event:hover { opacity: 0.85; }
.week-event-title { font-weight: 600; color: 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; }
.season-month-wrap { display: flex; flex-direction: column; gap: 4px; }
.season-cities { display: flex; flex-wrap: wrap; gap: 3px; padding: 2px 4px; }
.season-city-chip { font-size: 9px; color: 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; }
/* ── 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; }
.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; }
.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; top: 32px; height: auto; min-height: 48px; border-radius: 6px; padding: 4px 8px; font-size: 11px; overflow: hidden; cursor: pointer; border-top: 3px solid; z-index: 1; }
.dh-event:hover { opacity: 0.85; }
.dh-now { position: absolute; top: 0; bottom: 0; width: 2px; background: 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; }
.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 auto 320px; }
}
@media (max-width: 900px) and (min-width: 769px) {
.main-layout--docked { grid-template-columns: 1fr; }
.zoom-bar--middle { display: none; }
.ev-label { display: none; }
.day { min-height: 64px; }
}
/* ── Mobile ── */
@media (max-width: 768px) {
:host { padding: 0.25rem; }
.main-layout--docked { grid-template-columns: 1fr; }
.zoom-bar--middle { display: none; }
.year-grid { grid-template-columns: repeat(3, 1fr); }
.season-grid { grid-template-columns: 1fr; }
.day { min-height: 52px; padding: 4px; }
.day-num { font-size: 11px; }
.ev-label { display: none; }
.dot { width: 5px; height: 5px; }
.moon { font-size: 8px; }
.nav-title { font-size: 13px; }
.nav { gap: 4px; }
.sources { gap: 4px; }
.src-badge { font-size: 9px; padding: 2px 6px; }
.wd { font-size: 10px; padding: 3px; }
.season-cities, .week-event-meta, .tl-event-desc, .yv-country { display: none; }
.week-view { font-size: 10px; }
.zoom-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) {
.day { min-height: 44px; padding: 3px; }
.day-num { font-size: 10px; }
.wd { font-size: 9px; padding: 2px; }
.nav { flex-wrap: wrap; justify-content: center; }
.nav-title { width: 100%; order: -1; margin-bottom: 4px; }
.year-grid { grid-template-columns: repeat(2, 1fr); }
.multi-year-grid { grid-template-columns: repeat(2, 1fr); }
.mini-day { font-size: 8px; }
.mini-month-title { font-size: 10px; }
.ev-loc, .dd-desc { display: none; }
}
`;
}
// ── Escape HTML ──
private esc(s: string): string {
const d = document.createElement("div");
d.textContent = s || "";
return d.innerHTML;
}
}
customElements.define("folk-calendar-view", FolkCalendarView);