feat: add colorful, spatially-enriched calendar event rendering

Progressive spatial detail across all zoom levels: stacked source-color
bars in multi-year, multi-dot indicators in year views, city chips in
season, colored bg tints with location labels in month/week/day views,
description previews and virtual platform badges in day detail.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-03 18:20:14 -08:00
parent 7e5499d087
commit e838cb9a7e
1 changed files with 168 additions and 31 deletions

View File

@ -456,6 +456,31 @@ class FolkCalendarView extends HTMLElement {
&& !this.filteredSources.has(e.source_name)); && !this.filteredSources.has(e.source_name));
} }
private getSpatialLabel(breadcrumb: string | null | undefined, level: number): string {
if (!breadcrumb) return "";
const parts = breadcrumb.split(" > ").map(s => s.trim());
// SPATIAL_LABELS: Planet(0), Continent(1), Bioregion(2), Country(3), Region(4), City(5), Neighborhood(6), Address(7), Coordinates(8)
// Breadcrumb: "Earth > Europe > Germany > Berlin > Kreuzberg" → indices 0,1,2,3,4
// Map: Country(3)→idx 2, Region(4)→idx 3, City(5)→idx 3-4, Neighborhood(6)→idx 4
const indexMap: Record<number, number> = { 0: 0, 1: 1, 2: 1, 3: 2, 4: 3, 5: 3, 6: 4, 7: 4, 8: 4 };
const partIdx = indexMap[level] ?? 2;
return parts[partIdx] || parts[parts.length - 1] || "";
}
private getCurrentSpatialLabel(): string {
return SPATIAL_LABELS[this.getEffectiveSpatialIndex()];
}
private getUniqueSpatialLabels(events: any[], level: number, max: number): string[] {
const labels = new Set<string>();
for (const e of events) {
if (labels.size >= max) break;
const lbl = this.getSpatialLabel(e.location_breadcrumb, level);
if (lbl) labels.add(lbl);
}
return Array.from(labels);
}
private toggleSource(name: string) { private toggleSource(name: string) {
if (this.filteredSources.has(name)) { this.filteredSources.delete(name); } if (this.filteredSources.has(name)) { this.filteredSources.delete(name); }
else { this.filteredSources.add(name); } else { this.filteredSources.add(name); }
@ -784,12 +809,16 @@ class FolkCalendarView extends HTMLElement {
</div> </div>
${dayEvents.length > 0 ? ` ${dayEvents.length > 0 ? `
<div class="dots"> <div class="dots">
${dayEvents.slice(0, 4).map(e => `<span class="dot" style="background:${e.source_color || "#6366f1"}"></span>`).join("")} ${dayEvents.slice(0, 5).map(e => `<span class="dot" style="background:${e.source_color || "#6366f1"}"></span>`).join("")}
${dayEvents.length > 4 ? `<span style="font-size:8px;color:#888">+${dayEvents.length - 4}</span>` : ""} ${dayEvents.length > 5 ? `<span style="font-size:8px;color:#888">+${dayEvents.length - 5}</span>` : ""}
</div> </div>
${dayEvents.slice(0, 2).map(e => ${dayEvents.slice(0, 2).map(e => {
`<div class="ev-label" style="border-left:2px solid ${e.source_color || "#6366f1"}" data-event-id="${e.id}">${e.rToolSource === "rSchedule" ? '<span class="ev-bell">&#128276;</span>' : ""}<span class="ev-time">${this.formatTime(e.start_time)}</span>${this.esc(e.title)}</div>` const evColor = e.source_color || "#6366f1";
).join("")} const city = this.getSpatialLabel(e.location_breadcrumb, 5);
const cityHtml = city ? `<span class="ev-loc">${this.esc(city)}</span>` : "";
const virtualHtml = e.is_virtual ? `<span class="ev-virtual" title="Virtual">\u{1F4BB}</span>` : "";
return `<div class="ev-label" style="border-left:2px solid ${evColor};background:${evColor}10" data-event-id="${e.id}">${e.rToolSource === "rSchedule" ? '<span class="ev-bell">&#128276;</span>' : ""}${virtualHtml}<span class="ev-time">${this.formatTime(e.start_time)}</span>${this.esc(e.title)}${cityHtml}</div>`;
}).join("")}
` : ""} ` : ""}
</div>`; </div>`;
@ -824,10 +853,24 @@ class FolkCalendarView extends HTMLElement {
const months = [quarter * 3, quarter * 3 + 1, quarter * 3 + 2]; const months = [quarter * 3, quarter * 3 + 1, quarter * 3 + 2];
const seasonName = ["Winter", "Spring", "Summer", "Autumn"][quarter]; 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 ` return `
<div class="season-header">${seasonName} ${year} <span class="season-q">Q${quarter + 1}</span></div> <div class="season-header">${seasonName} ${year} <span class="season-q">Q${quarter + 1}</span></div>
<div class="season-grid"> <div class="season-grid">
${months.map(m => this.renderMiniMonth(year, m)).join("")} ${monthsHtml}
</div> </div>
<div class="mini-hint">Click any day to zoom in</div>`; <div class="mini-hint">Click any day to zoom in</div>`;
} }
@ -875,12 +918,14 @@ class FolkCalendarView extends HTMLElement {
const duration = Math.max(endMin - startMin, 30); const duration = Math.max(endMin - startMin, 30);
const leftPx = ((startMin - START_HOUR * 60) / 60) * HOUR_WIDTH; const leftPx = ((startMin - START_HOUR * 60) / 60) * HOUR_WIDTH;
const widthPx = Math.max((duration / 60) * HOUR_WIDTH, 60); const widthPx = Math.max((duration / 60) * HOUR_WIDTH, 60);
const bgColor = ev.source_color ? `${ev.source_color}18` : "#6366f118"; const bgColor = ev.source_color ? `${ev.source_color}24` : "#6366f124";
const virtualBadge = ev.is_virtual ? `<span class="dh-virtual" title="Virtual">\u{1F4BB}</span>` : "";
eventsHtml += `<div class="dh-event" data-event-id="${ev.id}" style=" eventsHtml += `<div class="dh-event" data-event-id="${ev.id}" style="
left:${leftPx}px;width:${widthPx}px;background:${bgColor};border-top:3px solid ${ev.source_color || "#6366f1"}"> left:${leftPx}px;width:${widthPx}px;background:${bgColor};border-top:3px solid ${ev.source_color || "#6366f1"}">
<div class="tl-event-title">${this.esc(ev.title)}</div> <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> <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>`; </div>`;
} }
@ -945,9 +990,20 @@ class FolkCalendarView extends HTMLElement {
const isToday = ds === todayStr; const isToday = ds === todayStr;
const dayEvents = this.getEventsForDate(ds); const dayEvents = this.getEventsForDate(ds);
const isWeekend = dow === 0 || dow === 6; const isWeekend = dow === 0 || dow === 6;
rowsHtml += `<div class="mt-cell ${isToday ? "today" : ""} ${isWeekend ? "weekend" : ""}" data-date="${ds}"> 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> <span class="mt-num">${dayOfMonth}</span>
${dayEvents.length > 0 ? `<span class="mt-count" style="background:${dayEvents[0].source_color || "#6366f1"}">${dayEvents.length}</span>` : ""} ${mtBarHtml}
${dayEvents.length > 0 ? `<span class="mt-count-num">${dayEvents.length}</span>` : ""}
</div>`; </div>`;
} }
rowsHtml += `</div>`; rowsHtml += `</div>`;
@ -971,22 +1027,35 @@ class FolkCalendarView extends HTMLElement {
for (let m = 0; m < 12; m++) { for (let m = 0; m < 12; m++) {
const monthName = new Date(year, m, 1).toLocaleDateString("default", { month: "short" }); const monthName = new Date(year, m, 1).toLocaleDateString("default", { month: "short" });
const dim = new Date(year, m + 1, 0).getDate(); const dim = new Date(year, m + 1, 0).getDate();
const monthEvents: any[] = [];
let daysHtml = ""; let daysHtml = "";
for (let d = 1; d <= dim; d++) { for (let d = 1; d <= dim; d++) {
const ds = `${year}-${String(m + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}`; const ds = `${year}-${String(m + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
const isToday = ds === todayStr; const isToday = ds === todayStr;
const dayEvents = this.getEventsForDate(ds); const dayEvents = this.getEventsForDate(ds);
monthEvents.push(...dayEvents);
const dow = new Date(year, m, d).getDay(); const dow = new Date(year, m, d).getDay();
const isWeekend = dow === 0 || dow === 6; 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)` : ""}"> daysHtml += `<div class="yv-day ${isToday ? "today" : ""} ${isWeekend ? "weekend" : ""}" data-mini-date="${ds}" title="${d} ${monthName}${dayEvents.length ? ` (${dayEvents.length} events)` : ""}">
${d} ${d}
${dayEvents.length > 0 ? `<span class="yv-dot" style="background:${dayEvents[0].source_color || "#6366f1"}"></span>` : ""} ${dotsHtml}
</div>`; </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}"> html += `<div class="yv-month" data-mini-month="${m}">
<div class="yv-label">${monthName}</div> <div class="yv-label">${monthName}${countryHtml}</div>
<div class="yv-days">${daysHtml}</div> <div class="yv-days">${daysHtml}</div>
</div>`; </div>`;
} }
@ -1022,18 +1091,37 @@ class FolkCalendarView extends HTMLElement {
private renderMicroMonth(year: number, month: number): string { private renderMicroMonth(year: number, month: number): string {
const monthInitials = ["J","F","M","A","M","J","J","A","S","O","N","D"]; const monthInitials = ["J","F","M","A","M","J","J","A","S","O","N","D"];
const dim = new Date(year, month + 1, 0).getDate(); const dim = new Date(year, month + 1, 0).getDate();
let eventCount = 0; const allEvents: any[] = [];
for (let d = 1; d <= dim; d++) { for (let d = 1; d <= dim; d++) {
const ds = `${year}-${String(month + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}`; const ds = `${year}-${String(month + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
eventCount += this.getEventsForDate(ds).length; allEvents.push(...this.getEventsForDate(ds));
} }
const maxBar = 8; // normalize bar width const eventCount = allEvents.length;
const maxBar = 8;
const barWidth = Math.min(eventCount, maxBar); const barWidth = Math.min(eventCount, maxBar);
const barPct = (barWidth / maxBar) * 100; const barPct = (barWidth / maxBar) * 100;
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)` : ""}"> // 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> <span class="micro-label">${monthInitials[month]}</span>
${eventCount > 0 ? `<span class="micro-bar" style="width:${barPct}%"></span>` : ""} ${barHtml}
${eventCount > 0 ? `<span class="micro-count">${eventCount}</span>` : ""} ${eventCount > 0 ? `<span class="micro-count">${eventCount}</span>` : ""}
</div>`; </div>`;
} }
@ -1056,9 +1144,20 @@ class FolkCalendarView extends HTMLElement {
const isToday = ds === todayStr; const isToday = ds === todayStr;
const dayEvents = this.getEventsForDate(ds); const dayEvents = this.getEventsForDate(ds);
const hasEvents = dayEvents.length > 0; 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}" daysHtml += `<div class="mini-day ${isToday ? "today" : ""}" data-mini-date="${ds}"
title="${hasEvents ? dayEvents.length + ' event' + (dayEvents.length > 1 ? 's' : '') : ''}">${d}${hasEvents ? `<span class="mini-dot" style="background:${dayEvents[0].source_color || '#6366f1'}"></span>` : ""}</div>`; style="${busyStyle}" title="${tooltipText}">${d}${dotsHtml}</div>`;
} }
return `<div class="mini-month ${isCurrentMonth ? "current" : ""}" data-mini-month="${month}"> return `<div class="mini-month ${isCurrentMonth ? "current" : ""}" data-mini-month="${month}">
@ -1104,13 +1203,19 @@ class FolkCalendarView extends HTMLElement {
const duration = Math.max(endMin - startMin, 30); const duration = Math.max(endMin - startMin, 30);
const topPx = ((startMin - START_HOUR * 60) / 60) * HOUR_HEIGHT; const topPx = ((startMin - START_HOUR * 60) / 60) * HOUR_HEIGHT;
const heightPx = Math.max((duration / 60) * HOUR_HEIGHT, 24); const heightPx = Math.max((duration / 60) * HOUR_HEIGHT, 24);
const bgColor = ev.source_color ? `${ev.source_color}18` : "#6366f118"; const bgColor = ev.source_color ? `${ev.source_color}20` : "#6366f120";
const showDesc = heightPx >= 60 && ev.description;
const descPreview = showDesc ? (ev.description.length > 60 ? ev.description.slice(0, 57) + "..." : ev.description) : "";
const neighborhood = this.getSpatialLabel(ev.location_breadcrumb, 6);
const virtualBadge = ev.is_virtual ? `<span class="tl-virtual" title="${this.esc(ev.virtual_platform || 'Virtual')}">\u{1F4BB} ${this.esc(ev.virtual_platform || 'Virtual')}</span>` : "";
eventsHtml += `<div class="tl-event" data-event-id="${ev.id}" style=" eventsHtml += `<div class="tl-event" data-event-id="${ev.id}" style="
top:${topPx}px;height:${heightPx}px;background:${bgColor};border-left-color:${ev.source_color || "#6366f1"}"> top:${topPx}px;height:${heightPx}px;background:${bgColor};border-left-color:${ev.source_color || "#6366f1"}">
<div class="tl-event-title">${this.esc(ev.title)}</div> <div class="tl-event-title">${this.esc(ev.title)}</div>
<div class="tl-event-time">${this.formatTime(ev.start_time)} \u2013 ${this.formatTime(ev.end_time)}</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>` : ""} ${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>`; </div>`;
} }
@ -1187,13 +1292,18 @@ class FolkCalendarView extends HTMLElement {
const duration = Math.max(endMin - startMin, 20); const duration = Math.max(endMin - startMin, 20);
const topPx = ((startMin - START_HOUR * 60) / 60) * HOUR_HEIGHT; const topPx = ((startMin - START_HOUR * 60) / 60) * HOUR_HEIGHT;
const heightPx = Math.max((duration / 60) * HOUR_HEIGHT, 18); const heightPx = Math.max((duration / 60) * HOUR_HEIGHT, 18);
const bgColor = ev.source_color ? `${ev.source_color}20` : "#6366f120"; const bgColor = ev.source_color ? `${ev.source_color}28` : "#6366f128";
const colLeft = `calc(44px + ${i} * ((100% - 44px) / 7) + 2px)`; const colLeft = `calc(44px + ${i} * ((100% - 44px) / 7) + 2px)`;
const colWidth = `calc((100% - 44px) / 7 - 4px)`; 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=" eventsOverlay += `<div class="week-event" data-event-id="${ev.id}" style="
top:${topPx}px;height:${heightPx}px;left:${colLeft};width:${colWidth}; top:${topPx}px;height:${heightPx}px;left:${colLeft};width:${colWidth};
background:${bgColor};border-left-color:${ev.source_color || "#6366f1"}"> background:${bgColor};border-left-color:${ev.source_color || "#6366f1"}">
<div class="week-event-title">${this.esc(ev.title)}</div> <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>`; </div>`;
} }
} }
@ -1229,15 +1339,18 @@ class FolkCalendarView extends HTMLElement {
<button class="dd-close" id="dd-close">\u2715</button> <button class="dd-close" id="dd-close">\u2715</button>
</div> </div>
${dayEvents.length === 0 ? `<div class="dd-empty">No events</div>` : ${dayEvents.length === 0 ? `<div class="dd-empty">No events</div>` :
dayEvents.sort((a, b) => a.start_time.localeCompare(b.start_time)).map(e => ` dayEvents.sort((a, b) => a.start_time.localeCompare(b.start_time)).map(e => {
<div class="dd-event" data-event-id="${e.id}"> const ddDesc = e.description ? (e.description.length > 80 ? e.description.slice(0, 77) + "..." : e.description) : "";
const srcTag = e.source_name ? `<span class="dd-source" style="border-color:${e.source_color || '#666'};color:${e.source_color || '#aaa'}">${this.esc(e.source_name)}</span>` : "";
return `<div class="dd-event" data-event-id="${e.id}">
<div class="dd-color" style="background:${e.source_color || "#6366f1"}"></div> <div class="dd-color" style="background:${e.source_color || "#6366f1"}"></div>
<div class="dd-info"> <div class="dd-info">
<div class="dd-title">${this.esc(e.title)}</div> <div class="dd-title">${this.esc(e.title)}${srcTag}</div>
<div class="dd-meta">${this.formatTime(e.start_time)}${e.end_time ? ` \u2013 ${this.formatTime(e.end_time)}` : ""}${e.location_name ? ` \u00B7 ${this.esc(e.location_name)}` : ""}${e.is_virtual ? " \u00B7 Virtual" : ""}</div> <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>
</div> </div>`;
`).join("")} }).join("")}
</div>`; </div>`;
} }
@ -1717,6 +1830,8 @@ class FolkCalendarView extends HTMLElement {
.ev-label:hover { background: rgba(255,255,255,0.08); } .ev-label:hover { background: rgba(255,255,255,0.08); }
.ev-time { color: #666; font-size: 8px; margin-right: 2px; } .ev-time { color: #666; font-size: 8px; margin-right: 2px; }
.ev-bell { margin-right: 2px; font-size: 8px; } .ev-bell { margin-right: 2px; font-size: 8px; }
.ev-loc { color: #64748b; font-size: 7px; margin-left: 3px; }
.ev-virtual { font-size: 8px; margin-right: 2px; vertical-align: middle; }
/* ── Drop Target ── */ /* ── Drop Target ── */
.day.drop-target { background: rgba(245,158,11,0.15); border: 2px dashed #f59e0b; } .day.drop-target { background: rgba(245,158,11,0.15); border: 2px dashed #f59e0b; }
@ -1733,6 +1848,8 @@ class FolkCalendarView extends HTMLElement {
.dd-title { font-size: 13px; font-weight: 500; color: #e2e8f0; } .dd-title { font-size: 13px; font-weight: 500; color: #e2e8f0; }
.dd-meta { font-size: 11px; color: #94a3b8; margin-top: 2px; } .dd-meta { font-size: 11px; color: #94a3b8; margin-top: 2px; }
.dd-empty { font-size: 12px; color: #64748b; padding: 8px 0; } .dd-empty { font-size: 12px; color: #64748b; padding: 8px 0; }
.dd-desc { font-size: 11px; color: #64748b; margin-top: 3px; line-height: 1.4; }
.dd-source { font-size: 9px; padding: 1px 6px; border-radius: 8px; border: 1px solid; margin-left: 6px; vertical-align: middle; }
/* ── Event Modal ── */ /* ── 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-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; }
@ -1755,6 +1872,9 @@ class FolkCalendarView extends HTMLElement {
.tl-event-title { font-weight: 600; color: #e2e8f0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .tl-event-title { font-weight: 600; color: #e2e8f0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.tl-event-time { font-size: 10px; color: #94a3b8; } .tl-event-time { font-size: 10px; color: #94a3b8; }
.tl-event-loc { font-size: 10px; color: #64748b; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .tl-event-loc { font-size: 10px; color: #64748b; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.tl-event-desc { font-size: 9px; color: #64748b; margin-top: 2px; line-height: 1.3; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tl-breadcrumb { font-size: 8px; color: #4a5568; background: rgba(255,255,255,0.04); padding: 1px 5px; border-radius: 3px; margin-top: 2px; display: inline-block; }
.tl-virtual { font-size: 9px; color: #818cf8; margin-left: 6px; }
.now-line { position: absolute; left: 0; right: 0; height: 2px; background: #ef4444; z-index: 5; } .now-line { position: absolute; left: 0; right: 0; height: 2px; background: #ef4444; z-index: 5; }
.now-dot { position: absolute; left: -5px; top: -3px; width: 8px; height: 8px; border-radius: 50%; background: #ef4444; } .now-dot { position: absolute; left: -5px; top: -3px; width: 8px; height: 8px; border-radius: 50%; background: #ef4444; }
@ -1773,11 +1893,18 @@ class FolkCalendarView extends HTMLElement {
.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 { 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:hover { opacity: 0.85; }
.week-event-title { font-weight: 600; color: #e2e8f0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .week-event-title { font-weight: 600; color: #e2e8f0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.week-event-meta { font-size: 9px; color: #94a3b8; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.week-event-time { color: #64748b; }
.week-event-loc { margin-left: 3px; color: #4a5568; }
.wk-virtual { font-size: 9px; margin-right: 2px; vertical-align: middle; }
/* ── Season View ── */ /* ── Season View ── */
.season-header { text-align: center; margin-bottom: 12px; font-size: 16px; font-weight: 600; color: #e2e8f0; } .season-header { text-align: center; margin-bottom: 12px; font-size: 16px; font-weight: 600; color: #e2e8f0; }
.season-q { font-size: 12px; color: #64748b; font-weight: 400; margin-left: 4px; } .season-q { font-size: 12px; color: #64748b; font-weight: 400; margin-left: 4px; }
.season-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; } .season-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
.season-month-wrap { display: flex; flex-direction: column; gap: 4px; }
.season-cities { display: flex; flex-wrap: wrap; gap: 3px; padding: 2px 4px; }
.season-city-chip { font-size: 9px; color: #94a3b8; background: rgba(255,255,255,0.04); border: 1px solid #333; border-radius: 8px; padding: 1px 6px; white-space: nowrap; }
/* ── Year View ── */ /* ── Year View ── */
.year-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; } .year-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; }
@ -1794,7 +1921,8 @@ class FolkCalendarView extends HTMLElement {
.mini-day:hover { background: rgba(255,255,255,0.08); } .mini-day:hover { background: rgba(255,255,255,0.08); }
.mini-day.today { background: #4f46e5; color: #fff; font-weight: 700; } .mini-day.today { background: #4f46e5; color: #fff; font-weight: 700; }
.mini-day.empty { cursor: default; } .mini-day.empty { cursor: default; }
.mini-dot { position: absolute; bottom: 1px; left: 50%; transform: translateX(-50%); width: 3px; height: 3px; border-radius: 50%; } .mini-dots { position: absolute; bottom: 1px; left: 50%; transform: translateX(-50%); display: flex; gap: 1px; }
.mini-dot { width: 3px; height: 3px; border-radius: 50%; flex-shrink: 0; }
.mini-hint { text-align: center; font-size: 11px; color: #4a5568; margin-top: 8px; } .mini-hint { text-align: center; font-size: 11px; color: #4a5568; margin-top: 8px; }
/* ── Transition Animations ── */ /* ── Transition Animations ── */
@ -1842,6 +1970,8 @@ class FolkCalendarView extends HTMLElement {
.dh-event:hover { opacity: 0.85; } .dh-event:hover { opacity: 0.85; }
.dh-now { position: absolute; top: 0; bottom: 0; width: 2px; background: #ef4444; z-index: 5; } .dh-now { position: absolute; top: 0; bottom: 0; width: 2px; background: #ef4444; z-index: 5; }
.dh-now::before { content: ""; position: absolute; top: -3px; left: -3px; width: 8px; height: 8px; border-radius: 50%; background: #ef4444; } .dh-now::before { content: ""; position: absolute; top: -3px; left: -3px; width: 8px; height: 8px; border-radius: 50%; background: #ef4444; }
.dh-event-loc { font-size: 9px; color: #64748b; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.dh-virtual { font-size: 9px; margin-right: 2px; vertical-align: middle; }
/* ── Month Transposed View ── */ /* ── Month Transposed View ── */
.month-transposed { overflow-x: auto; } .month-transposed { overflow-x: auto; }
@ -1855,7 +1985,9 @@ class FolkCalendarView extends HTMLElement {
.mt-cell.weekend { background: rgba(255,255,255,0.02); } .mt-cell.weekend { background: rgba(255,255,255,0.02); }
.mt-cell.empty { background: transparent; border-color: transparent; cursor: default; } .mt-cell.empty { background: transparent; border-color: transparent; cursor: default; }
.mt-num { font-size: 12px; color: #94a3b8; font-weight: 500; } .mt-num { font-size: 12px; color: #94a3b8; font-weight: 500; }
.mt-count { font-size: 9px; color: #fff; padding: 1px 5px; border-radius: 8px; font-weight: 600; } .mt-color-bar { display: flex; height: 3px; border-radius: 2px; overflow: hidden; width: 100%; position: absolute; bottom: 2px; left: 0; }
.mt-seg { height: 100%; min-width: 2px; }
.mt-count-num { font-size: 9px; color: #94a3b8; font-weight: 600; }
/* ── Year Vertical View ── */ /* ── Year Vertical View ── */
.year-vertical { max-height: 600px; overflow-y: auto; } .year-vertical { max-height: 600px; overflow-y: auto; }
@ -1867,7 +1999,9 @@ class FolkCalendarView extends HTMLElement {
.yv-day:hover { background: rgba(255,255,255,0.08); color: #e2e8f0; } .yv-day:hover { background: rgba(255,255,255,0.08); color: #e2e8f0; }
.yv-day.today { background: #4f46e5; color: #fff; font-weight: 700; } .yv-day.today { background: #4f46e5; color: #fff; font-weight: 700; }
.yv-day.weekend { opacity: 0.5; } .yv-day.weekend { opacity: 0.5; }
.yv-dot { position: absolute; bottom: 1px; left: 50%; transform: translateX(-50%); width: 3px; height: 3px; border-radius: 50%; } .yv-dots { position: absolute; bottom: 1px; left: 50%; transform: translateX(-50%); display: flex; gap: 1px; }
.yv-dot { width: 3px; height: 3px; border-radius: 50%; flex-shrink: 0; }
.yv-country { font-size: 9px; color: #4a5568; margin-left: 6px; font-weight: 400; }
/* ── Multi-Year View ── */ /* ── Multi-Year View ── */
.multi-year-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; } .multi-year-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
@ -1879,7 +2013,8 @@ class FolkCalendarView extends HTMLElement {
.micro-month { display: flex; align-items: center; gap: 2px; padding: 2px 3px; border-radius: 3px; cursor: pointer; overflow: hidden; } .micro-month { display: flex; align-items: center; gap: 2px; padding: 2px 3px; border-radius: 3px; cursor: pointer; overflow: hidden; }
.micro-month:hover { background: rgba(255,255,255,0.08); } .micro-month:hover { background: rgba(255,255,255,0.08); }
.micro-label { font-size: 8px; color: #4a5568; font-weight: 600; width: 8px; flex-shrink: 0; } .micro-label { font-size: 8px; color: #4a5568; font-weight: 600; width: 8px; flex-shrink: 0; }
.micro-bar { height: 3px; background: #6366f1; border-radius: 2px; flex-shrink: 0; } .micro-bar-stack { height: 3px; border-radius: 2px; flex-shrink: 0; display: flex; overflow: hidden; }
.micro-seg { height: 100%; min-width: 1px; }
.micro-count { font-size: 7px; color: #64748b; flex-shrink: 0; } .micro-count { font-size: 7px; color: #64748b; flex-shrink: 0; }
/* ── Keyboard Hint ── */ /* ── Keyboard Hint ── */
@ -1903,6 +2038,7 @@ class FolkCalendarView extends HTMLElement {
.sources { gap: 4px; } .sources { gap: 4px; }
.src-badge { font-size: 9px; padding: 2px 6px; } .src-badge { font-size: 9px; padding: 2px 6px; }
.wd { font-size: 10px; padding: 3px; } .wd { font-size: 10px; padding: 3px; }
.season-cities, .week-event-meta, .tl-event-desc, .yv-country { display: none; }
.week-view { font-size: 10px; } .week-view { font-size: 10px; }
.zoom-track { min-width: 150px; } .zoom-track { min-width: 150px; }
.zoom-ctrl { gap: 4px; padding: 6px 8px; } .zoom-ctrl { gap: 4px; padding: 6px 8px; }
@ -1924,6 +2060,7 @@ class FolkCalendarView extends HTMLElement {
.multi-year-grid { grid-template-columns: repeat(2, 1fr); } .multi-year-grid { grid-template-columns: repeat(2, 1fr); }
.mini-day { font-size: 8px; } .mini-day { font-size: 8px; }
.mini-month-title { font-size: 10px; } .mini-month-title { font-size: 10px; }
.ev-loc, .dd-desc { display: none; }
} }
`; `;
} }