From e838cb9a7e35adb7201c5130c17841a5a9a16e47 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Tue, 3 Mar 2026 18:20:14 -0800 Subject: [PATCH] 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 --- modules/rcal/components/folk-calendar-view.ts | 199 +++++++++++++++--- 1 file changed, 168 insertions(+), 31 deletions(-) diff --git a/modules/rcal/components/folk-calendar-view.ts b/modules/rcal/components/folk-calendar-view.ts index 139c14c..5cac8c3 100644 --- a/modules/rcal/components/folk-calendar-view.ts +++ b/modules/rcal/components/folk-calendar-view.ts @@ -456,6 +456,31 @@ class FolkCalendarView extends HTMLElement { && !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 = { 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(); + 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); } @@ -784,12 +809,16 @@ class FolkCalendarView extends HTMLElement { ${dayEvents.length > 0 ? `
- ${dayEvents.slice(0, 4).map(e => ``).join("")} - ${dayEvents.length > 4 ? `+${dayEvents.length - 4}` : ""} + ${dayEvents.slice(0, 5).map(e => ``).join("")} + ${dayEvents.length > 5 ? `+${dayEvents.length - 5}` : ""}
- ${dayEvents.slice(0, 2).map(e => - `
${e.rToolSource === "rSchedule" ? '🔔' : ""}${this.formatTime(e.start_time)}${this.esc(e.title)}
` - ).join("")} + ${dayEvents.slice(0, 2).map(e => { + const evColor = e.source_color || "#6366f1"; + const city = this.getSpatialLabel(e.location_breadcrumb, 5); + const cityHtml = city ? `${this.esc(city)}` : ""; + const virtualHtml = e.is_virtual ? `\u{1F4BB}` : ""; + return `
${e.rToolSource === "rSchedule" ? '🔔' : ""}${virtualHtml}${this.formatTime(e.start_time)}${this.esc(e.title)}${cityHtml}
`; + }).join("")} ` : ""} `; @@ -824,10 +853,24 @@ class FolkCalendarView extends HTMLElement { 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 + ? `
${cities.map(c => `${this.esc(c)}`).join("")}
` + : ""; + return `
${this.renderMiniMonth(year, m)}${citiesHtml}
`; + }).join(""); + return `
${seasonName} ${year} Q${quarter + 1}
- ${months.map(m => this.renderMiniMonth(year, m)).join("")} + ${monthsHtml}
Click any day to zoom in
`; } @@ -875,12 +918,14 @@ class FolkCalendarView extends HTMLElement { const duration = Math.max(endMin - startMin, 30); const leftPx = ((startMin - START_HOUR * 60) / 60) * HOUR_WIDTH; const widthPx = Math.max((duration / 60) * HOUR_WIDTH, 60); - const bgColor = ev.source_color ? `${ev.source_color}18` : "#6366f118"; + const bgColor = ev.source_color ? `${ev.source_color}24` : "#6366f124"; + const virtualBadge = ev.is_virtual ? `\u{1F4BB}` : ""; eventsHtml += `
-
${this.esc(ev.title)}
+
${virtualBadge}${this.esc(ev.title)}
${this.formatTime(ev.start_time)} \u2013 ${this.formatTime(ev.end_time)}
+ ${ev.location_name ? `
${this.esc(ev.location_name)}
` : ""}
`; } @@ -945,9 +990,20 @@ class FolkCalendarView extends HTMLElement { const isToday = ds === todayStr; const dayEvents = this.getEventsForDate(ds); const isWeekend = dow === 0 || dow === 6; - rowsHtml += `
+ 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 = {}; + 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]) => ``).join(""); + mtBarHtml = `${segs}`; + } + rowsHtml += `
${dayOfMonth} - ${dayEvents.length > 0 ? `${dayEvents.length}` : ""} + ${mtBarHtml} + ${dayEvents.length > 0 ? `${dayEvents.length}` : ""}
`; } rowsHtml += `
`; @@ -971,22 +1027,35 @@ class FolkCalendarView extends HTMLElement { 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 = `${colors.map(c => ``).join("")}`; + } + daysHtml += `
${d} - ${dayEvents.length > 0 ? `` : ""} + ${dotsHtml}
`; } + const countries = this.getUniqueSpatialLabels(monthEvents, 3, 2); + const countryHtml = countries.length > 0 ? `${countries.join(", ")}` : ""; + html += `
-
${monthName}
+
${monthName}${countryHtml}
${daysHtml}
`; } @@ -1022,18 +1091,37 @@ class FolkCalendarView extends HTMLElement { 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(); - let eventCount = 0; + const allEvents: any[] = []; for (let d = 1; d <= dim; d++) { 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 barPct = (barWidth / maxBar) * 100; - return `
+ // Group by source_color for stacked segments + const colorCounts: Record = {}; + 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 ``; + }).join(""); + barHtml = `${segments}`; + } + + return `
${monthInitials[month]} - ${eventCount > 0 ? `` : ""} + ${barHtml} ${eventCount > 0 ? `${eventCount}` : ""}
`; } @@ -1056,9 +1144,20 @@ class FolkCalendarView extends HTMLElement { 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 = `${uniqueColors.map(c => ``).join("")}`; + } daysHtml += `
${d}${hasEvents ? `` : ""}
`; + style="${busyStyle}" title="${tooltipText}">${d}${dotsHtml}
`; } return `
@@ -1104,13 +1203,19 @@ class FolkCalendarView extends HTMLElement { const duration = Math.max(endMin - startMin, 30); const topPx = ((startMin - START_HOUR * 60) / 60) * HOUR_HEIGHT; const heightPx = Math.max((duration / 60) * HOUR_HEIGHT, 24); - const bgColor = ev.source_color ? `${ev.source_color}18` : "#6366f118"; + 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 ? `\u{1F4BB} ${this.esc(ev.virtual_platform || 'Virtual')}` : ""; eventsHtml += `
${this.esc(ev.title)}
-
${this.formatTime(ev.start_time)} \u2013 ${this.formatTime(ev.end_time)}
+
${this.formatTime(ev.start_time)} \u2013 ${this.formatTime(ev.end_time)}${virtualBadge}
${ev.location_name ? `
${this.esc(ev.location_name)}
` : ""} + ${neighborhood ? `${this.esc(neighborhood)}` : ""} + ${showDesc ? `
${this.esc(descPreview)}
` : ""}
`; } @@ -1187,13 +1292,18 @@ class FolkCalendarView extends HTMLElement { const duration = Math.max(endMin - startMin, 20); const topPx = ((startMin - START_HOUR * 60) / 60) * HOUR_HEIGHT; const heightPx = Math.max((duration / 60) * HOUR_HEIGHT, 18); - const bgColor = ev.source_color ? `${ev.source_color}20` : "#6366f120"; + const bgColor = ev.source_color ? `${ev.source_color}28` : "#6366f128"; const colLeft = `calc(44px + ${i} * ((100% - 44px) / 7) + 2px)`; const colWidth = `calc((100% - 44px) / 7 - 4px)`; + const showMeta = heightPx >= 36; + const timeStr = `${this.formatTime(ev.start_time)}\u2013${this.formatTime(ev.end_time)}`; + const locName = ev.location_name ? this.esc(ev.location_name) : ""; + const virtualBadge = ev.is_virtual ? `\u{1F4BB}` : ""; eventsOverlay += `
-
${this.esc(ev.title)}
+
${virtualBadge}${this.esc(ev.title)}
+ ${showMeta ? `
${timeStr}${locName ? `${locName}` : ""}
` : ""}
`; } } @@ -1229,15 +1339,18 @@ class FolkCalendarView extends HTMLElement {
${dayEvents.length === 0 ? `
No events
` : - 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 => { + const ddDesc = e.description ? (e.description.length > 80 ? e.description.slice(0, 77) + "..." : e.description) : ""; + const srcTag = e.source_name ? `${this.esc(e.source_name)}` : ""; + return `
-
${this.esc(e.title)}
+
${this.esc(e.title)}${srcTag}
${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" : ""}
+ ${ddDesc ? `
${this.esc(ddDesc)}
` : ""}
-
- `).join("")} +
`; + }).join("")} `; } @@ -1717,6 +1830,8 @@ class FolkCalendarView extends HTMLElement { .ev-label:hover { background: rgba(255,255,255,0.08); } .ev-time { color: #666; font-size: 8px; margin-right: 2px; } .ev-bell { margin-right: 2px; font-size: 8px; } + .ev-loc { color: #64748b; font-size: 7px; margin-left: 3px; } + .ev-virtual { font-size: 8px; margin-right: 2px; vertical-align: middle; } /* ── Drop Target ── */ .day.drop-target { background: rgba(245,158,11,0.15); border: 2px dashed #f59e0b; } @@ -1733,6 +1848,8 @@ class FolkCalendarView extends HTMLElement { .dd-title { font-size: 13px; font-weight: 500; color: #e2e8f0; } .dd-meta { font-size: 11px; color: #94a3b8; margin-top: 2px; } .dd-empty { font-size: 12px; color: #64748b; padding: 8px 0; } + .dd-desc { font-size: 11px; color: #64748b; margin-top: 3px; line-height: 1.4; } + .dd-source { font-size: 9px; padding: 1px 6px; border-radius: 8px; border: 1px solid; margin-left: 6px; vertical-align: middle; } /* ── Event Modal ── */ .modal-bg { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; z-index: 1000; } @@ -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-time { font-size: 10px; color: #94a3b8; } .tl-event-loc { font-size: 10px; color: #64748b; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + .tl-event-desc { font-size: 9px; color: #64748b; margin-top: 2px; line-height: 1.3; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .tl-breadcrumb { font-size: 8px; color: #4a5568; background: rgba(255,255,255,0.04); padding: 1px 5px; border-radius: 3px; margin-top: 2px; display: inline-block; } + .tl-virtual { font-size: 9px; color: #818cf8; margin-left: 6px; } .now-line { position: absolute; left: 0; right: 0; height: 2px; background: #ef4444; z-index: 5; } .now-dot { position: absolute; left: -5px; top: -3px; width: 8px; height: 8px; border-radius: 50%; background: #ef4444; } @@ -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:hover { opacity: 0.85; } .week-event-title { font-weight: 600; color: #e2e8f0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + .week-event-meta { font-size: 9px; color: #94a3b8; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + .week-event-time { color: #64748b; } + .week-event-loc { margin-left: 3px; color: #4a5568; } + .wk-virtual { font-size: 9px; margin-right: 2px; vertical-align: middle; } /* ── Season View ── */ .season-header { text-align: center; margin-bottom: 12px; font-size: 16px; font-weight: 600; color: #e2e8f0; } .season-q { font-size: 12px; color: #64748b; font-weight: 400; margin-left: 4px; } .season-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; } + .season-month-wrap { display: flex; flex-direction: column; gap: 4px; } + .season-cities { display: flex; flex-wrap: wrap; gap: 3px; padding: 2px 4px; } + .season-city-chip { font-size: 9px; color: #94a3b8; background: rgba(255,255,255,0.04); border: 1px solid #333; border-radius: 8px; padding: 1px 6px; white-space: nowrap; } /* ── Year View ── */ .year-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; } @@ -1794,7 +1921,8 @@ class FolkCalendarView extends HTMLElement { .mini-day:hover { background: rgba(255,255,255,0.08); } .mini-day.today { background: #4f46e5; color: #fff; font-weight: 700; } .mini-day.empty { cursor: default; } - .mini-dot { position: absolute; bottom: 1px; left: 50%; transform: translateX(-50%); width: 3px; height: 3px; border-radius: 50%; } + .mini-dots { position: absolute; bottom: 1px; left: 50%; transform: translateX(-50%); display: flex; gap: 1px; } + .mini-dot { width: 3px; height: 3px; border-radius: 50%; flex-shrink: 0; } .mini-hint { text-align: center; font-size: 11px; color: #4a5568; margin-top: 8px; } /* ── Transition Animations ── */ @@ -1842,6 +1970,8 @@ class FolkCalendarView extends HTMLElement { .dh-event:hover { opacity: 0.85; } .dh-now { position: absolute; top: 0; bottom: 0; width: 2px; background: #ef4444; z-index: 5; } .dh-now::before { content: ""; position: absolute; top: -3px; left: -3px; width: 8px; height: 8px; border-radius: 50%; background: #ef4444; } + .dh-event-loc { font-size: 9px; color: #64748b; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + .dh-virtual { font-size: 9px; margin-right: 2px; vertical-align: middle; } /* ── Month Transposed View ── */ .month-transposed { overflow-x: auto; } @@ -1855,7 +1985,9 @@ class FolkCalendarView extends HTMLElement { .mt-cell.weekend { background: rgba(255,255,255,0.02); } .mt-cell.empty { background: transparent; border-color: transparent; cursor: default; } .mt-num { font-size: 12px; color: #94a3b8; font-weight: 500; } - .mt-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 { 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.today { background: #4f46e5; color: #fff; font-weight: 700; } .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-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:hover { background: rgba(255,255,255,0.08); } .micro-label { font-size: 8px; color: #4a5568; font-weight: 600; width: 8px; flex-shrink: 0; } - .micro-bar { 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; } /* ── Keyboard Hint ── */ @@ -1903,6 +2038,7 @@ class FolkCalendarView extends HTMLElement { .sources { gap: 4px; } .src-badge { font-size: 9px; padding: 2px 6px; } .wd { font-size: 10px; padding: 3px; } + .season-cities, .week-event-meta, .tl-event-desc, .yv-country { display: none; } .week-view { font-size: 10px; } .zoom-track { min-width: 150px; } .zoom-ctrl { gap: 4px; padding: 6px 8px; } @@ -1924,6 +2060,7 @@ class FolkCalendarView extends HTMLElement { .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; } } `; }