From 15ca1868b06ad4e91fb3ed61ac55a07047ddd621 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Sun, 5 Apr 2026 15:24:30 -0400 Subject: [PATCH] =?UTF-8?q?fix(rcal):=20fix=20calendar=20view=20layout=20b?= =?UTF-8?q?ugs=20=E2=80=94=20uneven=20day=20widths,=20event=20overlap,=20d?= =?UTF-8?q?uplicate=20detail?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Change grid-template-columns from repeat(7, 1fr) to repeat(7, minmax(0, 1fr)) to prevent content from stretching day cells unevenly - Add computeEventColumns() helper using greedy interval-coloring algorithm with per-cluster column counts for tiling overlapping events side-by-side - Apply sub-column layout to day view, week view, and horizontal day view so concurrent events no longer render on top of each other - Guard duplicate day detail rendering when expanded day is at end-of-row or last day of month Co-Authored-By: Claude Opus 4.6 --- modules/rcal/components/folk-calendar-view.ts | 150 ++++++++++++++---- 1 file changed, 123 insertions(+), 27 deletions(-) diff --git a/modules/rcal/components/folk-calendar-view.ts b/modules/rcal/components/folk-calendar-view.ts index 4fbdbd0..3a2725d 100644 --- a/modules/rcal/components/folk-calendar-view.ts +++ b/modules/rcal/components/folk-calendar-view.ts @@ -1149,6 +1149,7 @@ class FolkCalendarView extends HTMLElement { const todayStr = this.dateStr(today); let html = ""; + let detailRenderedInline = false; const prevDays = new Date(year, month, 0).getDate(); for (let i = firstDay - 1; i >= 0; i--) { html += `
${prevDays - i}
`; @@ -1199,6 +1200,7 @@ class FolkCalendarView extends HTMLElement { const posInRow = cellIndex % 7; if (posInRow === 6 || d === daysInMonth) { html += this.renderDayDetail(ds, dayEvents); + detailRenderedInline = true; } } } @@ -1209,7 +1211,7 @@ class FolkCalendarView extends HTMLElement { html += `
${i}
`; } - if (this.expandedDay) { + if (this.expandedDay && !detailRenderedInline) { const dayEvents = this.getEventsForDate(this.expandedDay); html += this.renderDayDetail(this.expandedDay, dayEvents); } @@ -1383,19 +1385,30 @@ class FolkCalendarView extends HTMLElement { hoursHtml += `
${label}
`; } + // Compute row assignments for overlapping horizontal events + const dhIntervals = timed.map(ev => { + const s = new Date(ev.start_time), e = new Date(ev.end_time); + const startMin = s.getHours() * 60 + s.getMinutes(); + const endMin = e.getHours() * 60 + e.getMinutes(); + return { startMin, endMin: Math.max(endMin, startMin + 30) }; + }); + const dhLayout = this.computeEventColumns(dhIntervals); + const DH_ROW_HEIGHT = 56; // min-height 48 + 8px gap + 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); + for (let idx = 0; idx < timed.length; idx++) { + const ev = timed[idx]; + const { startMin, endMin } = dhIntervals[idx]; + const { col: row } = dhLayout[idx]; + const duration = endMin - startMin; const leftPx = ((startMin - START_HOUR * 60) / 60) * HOUR_WIDTH; const widthPx = Math.max((duration / 60) * HOUR_WIDTH, 60); + const topPx = 8 + row * DH_ROW_HEIGHT; const es = this.getEventStyles(ev); const virtualBadge = ev.is_virtual ? `\u{1F4BB}` : ""; eventsHtml += `
+ left:${leftPx}px;width:${widthPx}px;top:${topPx}px;background:${es.bgColor};border-top:3px ${es.borderStyle} ${ev.source_color || "#6366f1"};opacity:${es.opacity}">
${virtualBadge}${this.esc(ev.title)}
${this.formatTime(ev.start_time)} \u2013 ${this.formatTime(ev.end_time)}
${ev.location_name ? `
${this.esc(ev.location_name)}
` : ""} @@ -1640,6 +1653,67 @@ class FolkCalendarView extends HTMLElement {
`; } + // ── Event Layout Helper (overlap columns) ── + + /** + * Assign columns to overlapping events so they tile side-by-side. + * Returns an array parallel to `events` with {col, totalCols} per event. + */ + private computeEventColumns(events: Array<{startMin: number, endMin: number}>): Array<{col: number, totalCols: number}> { + const n = events.length; + if (n === 0) return []; + + // Sort indices by start, then by longest duration first + const indices = Array.from({length: n}, (_, i) => i); + indices.sort((a, b) => events[a].startMin - events[b].startMin + || (events[b].endMin - events[b].startMin) - (events[a].endMin - events[a].startMin)); + + const cols = new Array(n).fill(0); + const colEnds: number[] = []; // end-time of last event in each column + + // Track overlap clusters to set per-cluster totalCols + const clusterIndices: number[][] = [[]]; // indices in current cluster + let clusterMaxEnd = -Infinity; + + for (const i of indices) { + const ev = events[i]; + + // New cluster? (no overlap with any previous event) + if (colEnds.length > 0 && ev.startMin >= clusterMaxEnd) { + clusterIndices.push([]); + colEnds.length = 0; + } + + // Find first free column + let placed = -1; + for (let c = 0; c < colEnds.length; c++) { + if (colEnds[c] <= ev.startMin) { + colEnds[c] = ev.endMin; + placed = c; + break; + } + } + if (placed === -1) { + placed = colEnds.length; + colEnds.push(ev.endMin); + } + + cols[i] = placed; + clusterMaxEnd = Math.max(clusterMaxEnd, ev.endMin); + clusterIndices[clusterIndices.length - 1].push(i); + } + + // Assign totalCols per cluster + const result = new Array<{col: number, totalCols: number}>(n); + for (const cluster of clusterIndices) { + const maxCol = cluster.reduce((m, i) => Math.max(m, cols[i]), 0) + 1; + for (const i of cluster) { + result[i] = { col: cols[i], totalCols: maxCol }; + } + } + return result; + } + // ── Day View ── private renderDay(): string { @@ -1668,12 +1742,21 @@ class FolkCalendarView extends HTMLElement { hoursHtml += `
${label}
`; } + // Compute overlap columns for timed events + const timedIntervals = timed.map(ev => { + const s = new Date(ev.start_time), e = new Date(ev.end_time); + const startMin = s.getHours() * 60 + s.getMinutes(); + const endMin = e.getHours() * 60 + e.getMinutes(); + return { startMin, endMin: Math.max(endMin, startMin + 30) }; + }); + const layout = this.computeEventColumns(timedIntervals); + let eventsHtml = ""; - for (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); + for (let idx = 0; idx < timed.length; idx++) { + const ev = timed[idx]; + const { startMin, endMin } = timedIntervals[idx]; + const { col, totalCols } = layout[idx]; + const duration = endMin - startMin; const topPx = ((startMin - START_HOUR * 60) / 60) * HOUR_HEIGHT; const heightPx = Math.max((duration / 60) * HOUR_HEIGHT, 24); const es = this.getEventStyles(ev); @@ -1684,7 +1767,7 @@ class FolkCalendarView extends HTMLElement { const likelihoodBadge = es.isTentative ? `${es.likelihoodLabel}` : ""; eventsHtml += `
+ top:${topPx}px;height:${heightPx}px;left:calc(8px + ${col} * (100% - 16px) / ${totalCols});width:calc((100% - 16px) / ${totalCols} - 2px);background:${es.bgColor};border-left-color:${ev.source_color || "#6366f1"};border-left-style:${es.borderStyle};opacity:${es.opacity}">
${this.esc(ev.title)}${likelihoodBadge}
${this.formatTime(ev.start_time)} \u2013 ${this.formatTime(ev.end_time)}${virtualBadge}
${ev.location_name ? `
${this.esc(ev.location_name)}
` : ""} @@ -1758,17 +1841,30 @@ class FolkCalendarView extends HTMLElement { 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 dayTimed = this.getEventsForDate(ds).filter(ev => { + const s = new Date(ev.start_time), en = new Date(ev.end_time); + return (en.getTime() - s.getTime()) < 86400000; + }).sort((a, b) => a.start_time.localeCompare(b.start_time)); + + // Compute sub-columns for overlapping events within this day + const intervals = dayTimed.map(ev => { + const s = new Date(ev.start_time), e = new Date(ev.end_time); + const startMin = s.getHours() * 60 + s.getMinutes(); + const endMin = e.getHours() * 60 + e.getMinutes(); + return { startMin, endMin: Math.max(endMin, startMin + 20) }; + }); + const dayLayout = this.computeEventColumns(intervals); + + for (let j = 0; j < dayTimed.length; j++) { + const ev = dayTimed[j]; + const { startMin, endMin } = intervals[j]; + const { col, totalCols } = dayLayout[j]; + const duration = endMin - startMin; const topPx = ((startMin - START_HOUR * 60) / 60) * HOUR_HEIGHT; const heightPx = Math.max((duration / 60) * HOUR_HEIGHT, 18); const es = this.getEventStyles(ev); - const colLeft = `calc(44px + ${i} * ((100% - 44px) / 7) + 2px)`; - const colWidth = `calc((100% - 44px) / 7 - 4px)`; + const colLeft = `calc(44px + ${i} * ((100% - 44px) / 7) + 2px + ${col} * ((100% - 44px) / 7 - 4px) / ${totalCols})`; + const colWidth = `calc(((100% - 44px) / 7 - 4px) / ${totalCols} - 1px)`; 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) : ""; @@ -2785,10 +2881,10 @@ class FolkCalendarView extends HTMLElement { .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; } + .weekdays { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 2px; margin-bottom: 4px; } .wd { text-align: center; font-size: 11px; color: var(--rs-text-muted); padding: 4px; font-weight: 600; } - .grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 2px; position: relative; flex: 1; } - .day { background: var(--rs-bg-surface); border: 1px solid var(--rs-border); border-radius: 6px; min-height: 0; padding: 6px; cursor: pointer; position: relative; -webkit-tap-highlight-color: transparent; } + .grid { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 2px; position: relative; flex: 1; } + .day { background: var(--rs-bg-surface); border: 1px solid var(--rs-border); border-radius: 6px; min-height: 0; min-width: 0; overflow: hidden; padding: 6px; cursor: pointer; position: relative; -webkit-tap-highlight-color: transparent; } .day:hover { border-color: var(--rs-border-strong); } .day.today { border-color: var(--rs-primary-hover); background: var(--rs-bg-active); } .day.expanded { border-color: var(--rs-primary-hover); background: rgba(99,102,241,0.1); } @@ -2916,7 +3012,7 @@ class FolkCalendarView extends HTMLElement { .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 { position: absolute; border-radius: 6px; padding: 4px 8px; font-size: 11px; overflow: hidden; cursor: pointer; border-left: 3px solid; z-index: 1; transition: opacity 0.15s; box-sizing: border-box; } .tl-event:hover { opacity: 0.85; } .tl-event-title { font-weight: 600; color: var(--rs-text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .tl-event-time { font-size: 10px; color: var(--rs-text-secondary); } @@ -2939,7 +3035,7 @@ class FolkCalendarView extends HTMLElement { .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 { position: absolute; border-radius: 4px; padding: 2px 4px; font-size: 10px; overflow: hidden; cursor: pointer; border-left: 2px solid; z-index: 1; box-sizing: border-box; } .week-event:hover { opacity: 0.85; } .week-event-title { font-weight: 600; color: var(--rs-text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .week-event-meta { font-size: 9px; color: var(--rs-text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } @@ -3015,7 +3111,7 @@ class FolkCalendarView extends HTMLElement { .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 { position: absolute; height: auto; min-height: 48px; border-radius: 6px; padding: 4px 8px; font-size: 11px; overflow: hidden; cursor: pointer; border-top: 3px solid; z-index: 1; box-sizing: border-box; } .dh-event:hover { opacity: 0.85; } .dh-now { position: absolute; top: 0; bottom: 0; width: 2px; background: var(--rs-error); z-index: 5; } .dh-now::before { content: ""; position: absolute; top: -3px; left: -3px; width: 8px; height: 8px; border-radius: 50%; background: var(--rs-error); }