From 17a5922a440346b51d558f4eef7fcd9ea33bf965 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Thu, 9 Apr 2026 12:44:01 -0400 Subject: [PATCH] feat(rcal): semantic zoom on overlapping day cell events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When 3+ events land on the same day in month view, individual event labels collapse into spatial summary chips showing where the user needs to be (e.g. "Berlin 3", "Amsterdam 2"). Chip granularity auto-adapts: city → country → continent as location diversity grows. Virtual events shown separately. ≤2 events still show full labels. Co-Authored-By: Claude Opus 4.6 --- modules/rcal/components/folk-calendar-view.ts | 85 +++++++++++++++++-- 1 file changed, 76 insertions(+), 9 deletions(-) diff --git a/modules/rcal/components/folk-calendar-view.ts b/modules/rcal/components/folk-calendar-view.ts index 472768b2..dba80e62 100644 --- a/modules/rcal/components/folk-calendar-view.ts +++ b/modules/rcal/components/folk-calendar-view.ts @@ -841,6 +841,75 @@ class FolkCalendarView extends HTMLElement { return Array.from(labels); } + /** + * Render day cell content — individual event labels when few events, + * spatial summary chips when events start to overlap. + */ + private renderDayCellContent(cellEvents: any[]): string { + // Few events: show individual labels (original behavior) + if (cellEvents.length <= 2) { + return cellEvents.map(e => { + const evColor = e.source_color || "#6366f1"; + const es = this.getEventStyles(e); + const city = this.getSpatialLabel(e.location_breadcrumb, 5); + const cityHtml = city ? `${this.esc(city)}` : ""; + const virtualHtml = e.is_virtual ? `\u{1F4BB}` : ""; + const likelihoodHtml = es.isTentative ? `${es.likelihoodLabel}` : ""; + return `
${e.rToolSource === "rSchedule" ? '🔔' : ""}${virtualHtml}${this.formatTime(e.start_time)}${this.esc(e.title)}${likelihoodHtml}${cityHtml}
`; + }).join(""); + } + + // Many events: semantic zoom — show spatial summary chips instead + // Group by location: count events per city, then decide label granularity + const located = cellEvents.filter(e => e.location_breadcrumb); + const virtualCount = cellEvents.filter(e => e.is_virtual).length; + + // Try city-level grouping first + const cityGroups = new Map(); + for (const e of located) { + const city = this.getSpatialLabel(e.location_breadcrumb, 5); + if (city) cityGroups.set(city, (cityGroups.get(city) || 0) + 1); + } + + // If too many cities (5+), zoom out to country level + let groups: Map; + let levelLabel: string; + if (cityGroups.size > 4) { + groups = new Map(); + for (const e of located) { + const country = this.getSpatialLabel(e.location_breadcrumb, 3); + if (country) groups.set(country, (groups.get(country) || 0) + 1); + } + levelLabel = "country"; + // If still too many countries, zoom to continent + if (groups.size > 4) { + groups = new Map(); + for (const e of located) { + const continent = this.getSpatialLabel(e.location_breadcrumb, 1); + if (continent) groups.set(continent, (groups.get(continent) || 0) + 1); + } + levelLabel = "continent"; + } + } else { + groups = cityGroups; + levelLabel = "city"; + } + + // Render spatial chips + let html = ""; + const sorted = Array.from(groups.entries()).sort((a, b) => b[1] - a[1]); + for (const [label, count] of sorted.slice(0, 3)) { + html += `
${this.esc(label)}${count}
`; + } + if (sorted.length > 3) { + html += `
+${sorted.length - 3}
`; + } + if (virtualCount > 0) { + html += `
\u{1F4BB} ${virtualCount}
`; + } + return html; + } + private toggleSource(name: string) { if (this.filteredSources.has(name)) { this.filteredSources.delete(name); } else { this.filteredSources.add(name); } @@ -1222,15 +1291,7 @@ class FolkCalendarView extends HTMLElement { ${dayReminders.slice(0, 3).map(r => ``).join("")} ${cellEvents.length > 5 ? `+${cellEvents.length - 5}` : ""} - ${cellEvents.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 ? `${this.esc(city)}` : ""; - const virtualHtml = e.is_virtual ? `\u{1F4BB}` : ""; - const likelihoodHtml = es.isTentative ? `${es.likelihoodLabel}` : ""; - return `
${e.rToolSource === "rSchedule" ? '🔔' : ""}${virtualHtml}${this.formatTime(e.start_time)}${this.esc(e.title)}${likelihoodHtml}${cityHtml}
`; - }).join("")} + ${this.renderDayCellContent(cellEvents)} ` : ""} `; @@ -2960,6 +3021,11 @@ class FolkCalendarView extends HTMLElement { .ev-time { color: var(--rs-text-muted); font-size: 8px; margin-right: 2px; } .ev-bell { margin-right: 2px; font-size: 8px; } .ev-loc { color: var(--rs-text-muted); font-size: 7px; margin-left: 3px; } + .ev-spatial-chip { display: flex; align-items: center; gap: 2px; font-size: 8px; color: var(--rs-text-secondary); background: var(--rs-bg-hover); border: 1px solid var(--rs-border); border-radius: 6px; padding: 1px 5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; line-height: 1.4; } + .ev-spatial-name { overflow: hidden; text-overflow: ellipsis; } + .ev-spatial-count { font-weight: 700; color: var(--rs-text-muted); font-size: 7px; flex-shrink: 0; } + .ev-spatial-more { color: var(--rs-text-muted); font-size: 7px; justify-content: center; } + .ev-spatial-virtual { color: var(--rs-text-muted); font-size: 7px; } .ev-virtual { font-size: 8px; margin-right: 2px; vertical-align: middle; } .ev-likelihood { font-size: 8px; color: var(--rs-warning); margin-left: 3px; } .dd-likelihood { font-size: 9px; color: var(--rs-warning); margin-left: 6px; } @@ -3250,6 +3316,7 @@ class FolkCalendarView extends HTMLElement { .day { min-height: 0; padding: 4px; } .day-num { font-size: 11px; } .ev-label { display: none; } + .ev-spatial-chip { display: none; } /* Dots → thin colored bars on mobile */ .dots { flex-direction: column; gap: 1px; margin-top: 2px; } .dot { width: 100%; height: 2px; border-radius: 1px; margin: 0; }