import calendarUtil from "../calendarUtil.js"; class TimedWeekGrid extends Shadow { constructor(weekDays, slots, groupedDays, calendars, slotHeight, viewMode = "week", onSlotTap = null) { super() this.weekDays = weekDays; this.slots = slots; this.groupedDays = groupedDays; this.calendars = calendars; this.slotHeight = slotHeight; this.viewMode = viewMode; this.onSlotTap = onSlotTap; this.ghostSlot = null; this._layoutCache = new WeakMap(); } render() { const totalGridHeight = this.slots.length * this.slotHeight; const minutesPerSlot = this.minutesPerSlot(); const gridStartMinutes = this.gridStartMinutes(); const slotGridBackground = [ `repeating-linear-gradient(to bottom,`, ` transparent,`, ` transparent calc(${this.slotHeight}em - 1px),`, ` var(--divider) calc(${this.slotHeight}em - 1px),`, ` var(--divider) ${this.slotHeight}em,`, ` transparent ${this.slotHeight}em,`, ` transparent calc(${this.slotHeight * 2}em - 1px),`, ` var(--lightDivider) calc(${this.slotHeight * 2}em - 1px),`, ` var(--lightDivider) ${this.slotHeight * 2}em`, `)` ].join("\n"); HStack(() => { this.groupedDays.forEach((group, index) => { const today = calendarUtil.isToday(group.day); const isLast = index === this.groupedDays.length - 1; const dayStart = calendarUtil.startOfDay(group.day); const dayEnd = calendarUtil.endOfDay(group.day); // Build ghost event for this day if applicable const ghostForThisDay = this.ghostSlot && calendarUtil.startOfDay(this.ghostSlot.day).getTime() === dayStart.getTime(); const ghostEvent = ghostForThisDay ? { _isGhost: true, id: '__ghost__', time_start: new Date(dayStart.getTime() + this.ghostSlot.startMinutes * 60000), time_end: new Date(dayStart.getTime() + (this.ghostSlot.startMinutes + 30) * 60000), calendars: [], all_day: false, title: '+' } : null; // Extend the ghost by 1ms on each side for layout only — this makes // touching time boundaries (ghost ends exactly when event starts, or vice versa) // register as overlapping so directly adjacent slots trigger a split. const ghostForLayout = ghostEvent ? { ...ghostEvent, time_start: new Date(ghostEvent.time_start.getTime() - 1), time_end: new Date(ghostEvent.time_end.getTime() + 1), } : null; const layoutEvents = ghostForLayout ? [...group.timed, ghostForLayout] : group.timed; const layout = this.computeLayout(layoutEvents, group.day); const layoutEntries = [...layout.entries()]; ZStack(() => { // Slot grid background VStack(() => { }) .width(100, pct) .height(totalGridHeight, em) .backgroundImage(slotGridBackground) .backgroundSize(`100% ${this.slotHeight * 2}em`) .pointerEvents("none") // Tap overlay — catches taps on empty slots VStack(() => {}) .position("absolute").top(0).left(0).right(0) .height(totalGridHeight, em) .zIndex(0) .pointerEvents("auto") .onTap((e) => this.handleOverlayTap(e, group.day)) // Event pills + ghost pill ZStack(() => { group.timed.forEach(event => { const color = calendarUtil.getCalendarColor(this.calendars, event.calendars.find(id => this.calendars.some(c => c.id === id)) ?? event.calendars[0]); const top = this.eventTopEm(event, minutesPerSlot, gridStartMinutes, dayStart); const height = this.eventHeightEm(event, minutesPerSlot, dayStart, dayEnd); const clippedTop = event.time_start < dayStart; const clippedBottom = event.time_end > dayEnd; const borderTop = clippedTop ? 0 : 7.5; const borderBottom = clippedBottom ? 0 : 7.5; const { col, total, startMs, endMs } = layout.get(event); const isSplit = total > 1; const gapPct = isSplit ? 1 : 0; let span = 1; for (let nextCol = col + 1; nextCol < total; nextCol++) { const blocked = layoutEntries.some(([other, o]) => other !== event && o.col === nextCol && calendarUtil.rangesOverlap(startMs, endMs, o.startMs, o.endMs) ); if (blocked) break; span++; } const widthPct = (span * 100 / total) - gapPct; const leftPct = col * (100 / total) + (col > 0 ? gapPct : 0); VStack(() => { p(event.title || "Untitled") .margin(0) .fontSize(this.viewMode === "day" ? 1 : 0.5, em) .fontWeight("600") .color("white") .lineHeight(this.viewMode === "day" ? "1.6" : "1.2") .overflow("hidden") }) .position("absolute") .top(top + 2, em) .left(leftPct, pct) .width(widthPct, pct) .height(height, em) .minHeight(0.25, em) .paddingVertical(isSplit ? 0.2 : 0.35, em) .paddingHorizontal(0.35, em) .background(color) .borderTopLeftRadius(`${borderTop}px`) .borderTopRightRadius(`${borderTop}px`) .borderBottomLeftRadius(`${borderBottom}px`) .borderBottomRightRadius(`${borderBottom}px`) .boxSizing("border-box") .zIndex(1) .textAlign("left") .pointerEvents("auto") .cursor("pointer") .onTap(() => $("bottomsheet-").showEvent(event)) }) // Ghost pill if (ghostEvent) { const { col, total, startMs, endMs } = layout.get(ghostForLayout); const isSplit = total > 1; const gapPct = isSplit ? 1 : 0; let span = 1; for (let nextCol = col + 1; nextCol < total; nextCol++) { const blocked = layoutEntries.some(([other, o]) => other !== ghostForLayout && o.col === nextCol && calendarUtil.rangesOverlap(startMs, endMs, o.startMs, o.endMs) ); if (blocked) break; span++; } const widthPct = (span * 100 / total) - gapPct; const leftPct = col * (100 / total) + (col > 0 ? gapPct : 0); const top = this.eventTopEm(ghostEvent, minutesPerSlot, gridStartMinutes, dayStart); const height = this.eventHeightEm(ghostEvent, minutesPerSlot, dayStart, dayEnd); const ghostDateTime = new Date(dayStart.getTime() + this.ghostSlot.startMinutes * 60000); VStack(() => { p("+") .margin(0) .fontSize(this.viewMode === "day" ? 1.5 : 0.9, em) .fontWeight("700") .color("white") }) .position("absolute") .top(top + this.slotHeight, em) .left(leftPct, pct) .width(widthPct, pct) .height(height, em) .minHeight(0.25, em) .background("var(--quillred)") .borderRadius("7.5px") .boxSizing("border-box") .zIndex(1) .alignItems("center") .justifyContent("center") .pointerEvents("auto") .cursor("pointer") .onTap(() => { this.ghostSlot = null; this.rerender(); if (this.onSlotTap) this.onSlotTap(ghostDateTime); }) } }) .position("absolute") .top(0) .left(0) .right(0) .bottom(0) .pointerEvents("none") .boxSizing("border-box") }) .flex(1) .width(0, px) .minWidth(0) .height(totalGridHeight, em) .position("relative") .background(today && this.viewMode !== "day" ? "var(--desktop-item-background)" : "transparent") .borderRight(isLast ? "1px solid transparent" : "1px solid var(--divider)") .boxSizing("border-box") .overflow("hidden") }) }) .position("relative") .width(100, pct) .minHeight(totalGridHeight, em) } handleOverlayTap(e, day) { const rect = e.currentTarget.getBoundingClientRect(); const clientY = e.clientY !== undefined ? e.clientY : (e.changedTouches?.[0]?.clientY ?? e.touches?.[0]?.clientY ?? 0); const fs = parseFloat(getComputedStyle(this).fontSize); const relY = clientY - rect.top - (this.slotHeight * fs); const raw = (relY / (this.slotHeight * fs)) * this.minutesPerSlot() + this.gridStartMinutes(); const snapped = Math.floor(raw / 30) * 30; const clamped = Math.max(0, Math.min(23 * 60 + 30, snapped)); this.ghostSlot = { day, startMinutes: clamped }; this.rerender(); } // 3 passes, O(n^2) worst case. // - Adjacency list is built once after Pass 1 and shared by Passes 2 and 3 // - The inner loop also breaks early once it reaches an event that starts // after the current one ends, computeLayout(events, day) { if (this._layoutCache.has(events)) return this._layoutCache.get(events); const MIN_DURATION_MS = 30 * 60 * 1000; const dayStart = calendarUtil.startOfDay(day); const dayEnd = calendarUtil.endOfDay(day); const result = new Map(); const columns = []; const sorted = [...events].sort((a, b) => a.time_start - b.time_start); // Pass 1: assign each event to a column — O(n log n) sorted.forEach(event => { const startMs = Math.max(event.time_start.getTime(), dayStart.getTime()); const rawEndMs = Math.min(event.time_end.getTime(), dayEnd.getTime()); const endMs = Math.max(rawEndMs, startMs + MIN_DURATION_MS); let col = columns.findIndex(colEnd => colEnd <= startMs); if (col === -1) { col = columns.length; columns.push(endMs); } else { columns[col] = endMs; } result.set(event, { col, total: 0, startMs, endMs }); }); // Build adjacency list const neighbors = new Map(sorted.map(e => [e, []])); for (let i = 0; i < sorted.length; i++) { const { startMs: as, endMs: ae } = result.get(sorted[i]); for (let j = i + 1; j < sorted.length; j++) { const { startMs: bs, endMs: be } = result.get(sorted[j]); if (bs >= ae) break; if (calendarUtil.rangesOverlap(as, ae, bs, be)) { neighbors.get(sorted[i]).push(sorted[j]); neighbors.get(sorted[j]).push(sorted[i]); } } } // Pass 2: each event's total = highest col among its direct neighbors + 1 sorted.forEach(event => { let maxCol = result.get(event).col; neighbors.get(event).forEach(other => { maxCol = Math.max(maxCol, result.get(other).col); }); result.get(event).total = maxCol + 1; }); // Pass 3: BFS — propagate max total across connected clusters so events // linked only via a bridge event still share the same column width. const visited = new Set(); sorted.forEach(event => { if (visited.has(event)) return; const cluster = []; const queue = [event]; while (queue.length) { const cur = queue.pop(); if (visited.has(cur)) continue; visited.add(cur); cluster.push(cur); neighbors.get(cur).forEach(other => { if (!visited.has(other)) queue.push(other); }); } const clusterMax = Math.max(...cluster.map(e => result.get(e).total)); cluster.forEach(e => { result.get(e).total = clusterMax; }); }); this._layoutCache.set(events, result); return result; } eventTopEm(event, minutesPerSlot, gridStartMinutes, dayStart) { const startMs = Math.max(event.time_start.getTime(), dayStart.getTime()); const start = new Date(startMs); const minutesFromGridStart = (start.getHours() * 60 + start.getMinutes()) - gridStartMinutes; return (minutesFromGridStart / minutesPerSlot) * this.slotHeight; } eventHeightEm(event, minutesPerSlot, dayStart, dayEnd) { const startMs = Math.max(event.time_start.getTime(), dayStart.getTime()); const endMs = Math.min(event.time_end.getTime(), dayEnd.getTime()); const durationMinutes = Math.max(30, (endMs - startMs) / 60000); return (durationMinutes / minutesPerSlot) * this.slotHeight; } minutesPerSlot() { if (this.slots.length < 2) return 30; return this.slots[1].totalMinutes - this.slots[0].totalMinutes; } gridStartMinutes() { return this.slots.length ? this.slots[0].totalMinutes : 0; } } register(TimedWeekGrid)