import calendarUtil from "../calendarUtil.js"; class ToolbarPopout extends Shadow { swipeTranslate = 0; isSwiping = false; swipeDragStartX = null; swipeDragStartY = null; swipeDragStartTime = null; swipeAxisLocked = false; swipeIsHorizontal = false; SWIPE_COMMIT_DISTANCE = window.outerWidth * 0.25; SWIPE_VELOCITY_THRESHOLD = 0.4; static DAY_NAMES = ["S", "M", "T", "W", "T", "F", "S"]; constructor(currentDate, weekStartsOn, calendars, events, showPopout, goToDate, onMonthChange) { super() this.currentDate = currentDate; this.weekStartsOn = weekStartsOn; this.calendars = calendars; this.events = events; this.selectedDate = this.currentDate; this.showPopout = showPopout; this.goToDate = goToDate; this.onMonthChange = onMonthChange; this.swipeDidMove = false; if (this.miniCurrentDate === undefined) { this.miniCurrentDate = this.currentDate; } this.layout = this.buildLayout(); // Caches this.monthCache = new Map(); this._miniPanels = null; this._monthEventsVersion = 0; this.buildDerivedData(); } buildLayout() { const fs = 0.75, hp = 0.4, dch = 1.55, dh = 0.28, dmt = 0.15, cp = 0.4; return { fontSize: fs, headerPadding: hp, headerRowHeight: fs + hp * 2, dateCircleHeight: dch, dotHeight: dh, dotMarginTop: dmt, cellPadding: cp, rowHeight: cp + dch + dh + dmt, get lastRowHeight() { return cp + dch; }, get gridHeight() { return this.rowHeight * 5 + this.lastRowHeight; }, get openHeight() { return this.headerRowHeight * fs + this.gridHeight; }, }; } buildDerivedData() { this.colorByCalendarId = new Map( (this.calendars || []).map(calendar => [calendar.id, calendar.color]) ); // Version string for invalidating month cache when events update this.eventsVersion = JSON.stringify( (this.events || []).map(ev => [ ev.id, ev.calendars, ev.recurrence_id, +new Date(ev.time_start), +new Date(ev.time_end) ]) ); } getMiniPanels() { if (!this._miniPanels) { this._miniPanels = this.$$("[data-mini-panel]"); } return this._miniPanels; } getMonthKey(date) { return [ date.getFullYear(), date.getMonth(), this.weekStartsOn, this.eventsVersion ].join("|"); } render() { const l = this.layout; const ordered = Array.from( { length: 7 }, (_, i) => ToolbarPopout.DAY_NAMES[(this.weekStartsOn + i) % 7] ); const today = new Date(); // Remove stale cache this._miniPanels = null; // Only render the current month panel // Previous/Next injected programmatically whenever user begins // swiping - touch event never interrupted by rerenders const panelOffsets = [0]; VStack(() => { HStack(() => { ordered.forEach(name => { p(name) .margin(0) .fontSize(l.fontSize, em) .fontWeight("600") .color("var(--headertext)") .opacity(0.45) .flex(1) .textAlign("center") .paddingVertical(l.headerPadding, em) .boxSizing("border-box") }) }) .width(100, pct) ZStack(() => { panelOffsets.forEach(offset => { const viewDate = calendarUtil.addMonths(this.miniCurrentDate, offset); const { weeks } = this.getMonthData(viewDate); VStack(() => { weeks.forEach((week, wi) => { const isLast = wi === weeks.length - 1; HStack(() => { week.forEach(({ day, isCurrentMonth, calendarColors }) => { const isToday = calendarUtil.isSameDay(day, today); const isSelected = calendarUtil.isSameDay(day, this.selectedDate); VStack(() => { p(day.getDate()) .margin(0) .fontSize(l.fontSize, em) .fontWeight(isSelected ? "700" : "400") .color(isSelected || isToday ? "white" : "var(--headertext)") .background( isToday ? "var(--quillred)" : isSelected ? "var(--lightaccent)" : "transparent" ) .width(l.dateCircleHeight, em) .height(l.dateCircleHeight, em) .borderRadius(25, pct) .textAlign("center") .lineHeight(`${l.dateCircleHeight}em`) .opacity(isCurrentMonth ? 1 : 0.28) .boxSizing("border-box") // Only show event dots if they exist if (calendarColors.length > 0) { HStack(() => { calendarColors.slice(0, 3).forEach(color => { VStack(() => {}) .width(l.dotHeight, em) .height(l.dotHeight, em) .borderRadius(50, pct) .background(color) .flexShrink(0) }) }) .gap(0.18, em) .justifyContent("center") .alignItems("center") .marginTop(l.dotMarginTop, em) .height(l.dotHeight, em) } else { // Spacer for dot-less cells VStack(() => {}) .marginTop(l.dotMarginTop, em) .height(l.dotHeight, em) } }) .flex(1) .alignItems("center") .paddingTop(l.cellPadding, em) .paddingBottom(isLast ? 0 : l.cellPadding, em) .boxSizing("border-box") .onTap(() => { if (!this.swipeDidMove) { this.selectedDate = day; if (!this.goToDate(day)) { this.rerender(); } } }) }) }) .width(100, pct) .background("var(--minicalendarbackground)") }) }) .position("absolute") .width(100, pct) .height(100, pct) .transform(`translateX(${offset * 100}%)`) .willChange("transform") .attr({ "data-mini-panel": offset }) }) }) .position("relative") .overflow("hidden") .width(100, pct) .height(l.gridHeight, em) .onTouch((start, e) => this.handleSwipeTouch(start, e)) }) .width(100, pct) .boxSizing("border-box") .overflow("hidden") .maxHeight(this.showPopout ? l.openHeight : 0, em) .transition("max-height 0.3s ease") } // Build previous/next calendars and insert them without triggering a rerender injectSidePanels() { const currentPanel = this.$("[data-mini-panel='0']"); if (!currentPanel) return; const container = currentPanel.parentElement; if (!container) return; // Already injected (shouldn't happen, but guard anyway). if (this.$("[data-mini-panel='-1']")) return; const l = this.layout; const today = new Date(); [-1, 1].forEach(offset => { const viewDate = calendarUtil.addMonths(this.miniCurrentDate, offset); const { weeks } = this.getMonthData(viewDate); // Outer panel div — mirrors the VStack styling applied in render(). const panel = document.createElement("div"); panel.classList.add("VStack"); panel.setAttribute("data-mini-panel", offset); panel.style.cssText = ` position: absolute; top: 0; left: 0; width: 100%; height: 100%; display: flex; flex-direction: column; transform: translateX(${offset * 100}%); will-change: transform; box-sizing: border-box; `; weeks.forEach((week, wi) => { const isLast = wi === weeks.length - 1; const row = document.createElement("div"); row.classList.add("HStack"); row.style.cssText = ` display: flex; flex-direction: row; width: 100%; background: var(--minicalendarbackground); `; week.forEach(({ day, isCurrentMonth, calendarColors }) => { const isToday = calendarUtil.isSameDay(day, today); const isSelected = calendarUtil.isSameDay(day, this.selectedDate); const cell = document.createElement("div"); cell.classList.add("VStack"); cell.style.cssText = ` flex: 1; display: flex; flex-direction: column; align-items: center; padding-top: ${l.cellPadding}em; padding-bottom: ${isLast ? 0 : l.cellPadding}em; box-sizing: border-box; `; const circle = document.createElement("p"); circle.textContent = day.getDate(); circle.style.cssText = ` margin: 0; font-size: ${l.fontSize}em; font-weight: ${isSelected ? "700" : "400"}; color: ${isSelected || isToday ? "white" : "var(--headertext)"}; background: ${isToday ? "var(--quillred)" : isSelected ? "var(--lightaccent)" : "transparent"}; width: ${l.dateCircleHeight}em; height: ${l.dateCircleHeight}em; border-radius: 25%; text-align: center; line-height: ${l.dateCircleHeight}em; opacity: ${isCurrentMonth ? 1 : 0.28}; box-sizing: border-box; `; cell.appendChild(circle); const spacer = document.createElement("div"); spacer.classList.add("HStack"); spacer.style.cssText = ` margin-top: ${l.dotMarginTop}em; height: ${l.dotHeight}em; display: flex; flex-direction: row; justify-content: center; align-items: center; gap: 0.18em; `; if (calendarColors.length > 0) { calendarColors.slice(0, 3).forEach(color => { const dot = document.createElement("div"); dot.classList.add("VStack"); dot.style.cssText = ` width: ${l.dotHeight}em; height: ${l.dotHeight}em; border-radius: 50%; background: ${color}; flex-shrink: 0; `; spacer.appendChild(dot); }); } cell.appendChild(spacer); row.appendChild(cell); }); panel.appendChild(row); }); container.appendChild(panel); }); // Invalidate the panel cache so getMiniPanels() picks up the new nodes. this._miniPanels = null; } handleSwipeTouch(start, e) { if (start) { this.swipeDragStartX = e.touches[0].clientX; this.swipeDragStartY = e.touches[0].clientY; this.swipeDragStartTime = Date.now(); this.isSwiping = true; this.swipeDidMove = false; this.swipeAxisLocked = false; this.swipeIsHorizontal = false; // Inject previous/next calendar this.injectSidePanels(); document.addEventListener("touchmove", this.onSwipeMove, { passive: true }); } else { if (!this.isSwiping) return; document.removeEventListener("touchmove", this.onSwipeMove); if (!this.swipeIsHorizontal) { this.isSwiping = false; this.swipeDragStartX = null; this.swipeDragStartY = null; return; } const endX = e.changedTouches[0].clientX; const delta = endX - this.swipeDragStartX; const elapsed = Date.now() - this.swipeDragStartTime; const velocity = Math.abs(delta) / elapsed; const shouldCommit = Math.abs(delta) > this.SWIPE_COMMIT_DISTANCE || velocity > this.SWIPE_VELOCITY_THRESHOLD; if (shouldCommit && delta < 0) this.commitSwipe("next"); else if (shouldCommit && delta > 0) this.commitSwipe("previous"); else this.snapBack(); this.isSwiping = false; this.swipeDragStartX = null; this.swipeDragStartY = null; } } onSwipeMove = (e) => { if (!this.isSwiping) return; const dx = e.touches[0].clientX - this.swipeDragStartX; const dy = e.touches[0].clientY - this.swipeDragStartY; if (!this.swipeAxisLocked) { if (Math.abs(dx) < 5 && Math.abs(dy) < 5) return; this.swipeAxisLocked = true; this.swipeIsHorizontal = Math.abs(dx) > Math.abs(dy); } if (!this.swipeIsHorizontal) return; this.swipeDidMove = true; const delta = e.touches[0].clientX - this.swipeDragStartX; this.swipeTranslate = delta; this.applySwipeTransform(delta, false); } applySwipeTransform(delta, animated) { const panels = this.getMiniPanels(); panels.forEach(panel => { const offset = parseInt(panel.getAttribute("data-mini-panel")); panel.style.transition = animated ? "transform 300ms ease" : ""; panel.style.transform = `translateX(calc(${offset * 100}% + ${delta}px))`; }); } commitSwipe(direction) { const sign = direction === "next" ? 1 : -1; const panels = this.getMiniPanels(); const container = panels[0]?.parentElement; // Snap panels to their current drag position panels.forEach(panel => { const offset = parseInt(panel.getAttribute("data-mini-panel")); panel.style.transition = "none"; panel.style.transform = `translateX(calc(${offset * 100}% + ${this.swipeTranslate}px))`; }); // Force reflow so the browser registers the snap as a committed state panels[0]?.getBoundingClientRect(); panels.forEach(panel => { const offset = parseInt(panel.getAttribute("data-mini-panel")); panel.style.transition = "transform 300ms ease"; panel.style.transform = `translateX(calc(${(offset - sign) * 100}%))`; }); setTimeout(() => { this.miniCurrentDate = direction === "next" ? calendarUtil.addMonths(this.miniCurrentDate, 1) : calendarUtil.addMonths(this.miniCurrentDate, -1); this.onMonthChange(this.miniCurrentDate) this.swipeTranslate = 0; this.rerender(); }, 300); } snapBack() { const panels = this.getMiniPanels(); panels.forEach(panel => { const offset = parseInt(panel.getAttribute("data-mini-panel")); panel.style.transition = "transform 300ms ease"; panel.style.transform = `translateX(${offset * 100}%)`; }); this.swipeTranslate = 0; } getMonthData(date) { const monthKey = this.getMonthKey(date); const cached = this.monthCache.get(monthKey); if (cached) return cached; const year = date.getFullYear(); const month = date.getMonth(); const firstOfMonth = new Date(year, month, 1); const gridStart = calendarUtil.startOfWeek(firstOfMonth, this.weekStartsOn); const allDays = Array.from({ length: 42 }, (_, i) => calendarUtil.addDays(gridStart, i)); const weeks = []; for (let w = 0; w < 6; w++) { weeks.push(allDays.slice(w * 7, w * 7 + 7)); } const gridEnd = calendarUtil.endOfDay(allDays[41]); const colorsByDay = new Map(); allDays.forEach(day => { colorsByDay.set(calendarUtil.toDateInput(day), []); }); const expanded = calendarUtil.expandRecurringEvents(this.events || [], gridStart, gridEnd); const relevantEvents = expanded.filter(ev => { const end = ev.all_day ? calendarUtil.endOfDay(ev.time_end) : ev.time_end; return calendarUtil.rangesOverlap(ev.time_start, end, gridStart, gridEnd); }); relevantEvents.forEach(ev => { const colors = (ev.calendars || []) .map(id => this.colorByCalendarId.get(id)) .filter(Boolean); if (colors.length === 0) return; const effectiveEnd = ev.all_day ? calendarUtil.endOfDay(ev.time_end) : ev.time_end; let cursor = calendarUtil.startOfDay( ev.time_start > gridStart ? ev.time_start : gridStart ); const eventEnd = effectiveEnd < gridEnd ? effectiveEnd : gridEnd; while (cursor < eventEnd) { const key = calendarUtil.toDateInput(cursor); const arr = colorsByDay.get(key); if (arr) { colors.forEach(color => { if (!arr.includes(color) && arr.length < 3) arr.push(color); }); } cursor = calendarUtil.addDays(cursor, 1); } }); const result = { weeks: weeks.map(week => week.map(day => ({ day, isCurrentMonth: day.getMonth() === month, calendarColors: colorsByDay.get(calendarUtil.toDateInput(day)) || [] })) ) }; this.monthCache.set(monthKey, result); return result; } } register(ToolbarPopout)