import calendarUtil from "../calendarUtil.js"; let _saved = null; css(` desktopmonthgrid- { scrollbar-width: none; -ms-overflow-style: none; } `) class DesktopMonthGrid extends Shadow { constructor(weeks, calendars, weekStartsOn = 0, onEventClick = null, onDayDoubleClick = null) { super() this.weeks = weeks; this.calendars = calendars; this.weekStartsOn = weekStartsOn; this.onEventClick = onEventClick; this.onDayDoubleClick = onDayDoubleClick; this.ghostDay = null; this.maxVisible = 4; // Layout in em this.dateFontSize = 0.82; this.dateLineHeight = 1.7; this.paddingTop = 0.35; this.paddingBottom = 0.25; this.pillHeight = 1.15; this.pillGap = 0.15; this.overflowLabelHeight = 0.7; this.rowBottomPadding = this.overflowLabelHeight + 0.5; this.headerHeight = this.paddingTop + (this.dateFontSize * this.dateLineHeight) + this.paddingBottom; this.rowHeight = this.headerHeight + this.maxVisible * (this.pillHeight + this.pillGap) + this.rowBottomPadding; } render() { const DAY_NAMES = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; const ordered = Array.from({ length: 7 }, (_, i) => DAY_NAMES[(this.weekStartsOn + i) % 7]); VStack(() => { // Day-name header HStack(() => { ordered.forEach(name => { p(name) .margin(0) .fontSize(0.72, em) .fontWeight("500") .letterSpacing("0.015em") .color("var(--headertext)") .opacity(0.5) .flex(1) .textAlign("center") .paddingVertical(0.55, em) .boxSizing("border-box") }) }) .width(100, pct) .borderBottom("1px solid var(--divider)") .flexShrink(0) // Week rows VStack(() => { this.weeks.forEach((week, wi) => { this.renderWeekRow(week, wi === this.weeks.length - 1) }) }) .width(100, pct) .height(this.rowHeight * this.weeks.length, em) .flexShrink(0) }) .width(100, pct) .height(100, pct) .overflowY("auto") .boxSizing("border-box") .onAppear(() => { requestAnimationFrame(() => { const monthKey = calendarUtil.toDateInput(this.weeks[2].days[3].day).substring(0, 7); this.scrollTop = (_saved?.monthKey === monthKey) ? _saved.scrollTop : 0; this.addEventListener('scroll', () => { _saved = { monthKey, scrollTop: this.scrollTop }; }, { passive: true }); }); }) } renderWeekRow(week, isLastWeek) { const displayWeek = this.ghostDay ? this.injectGhostIntoWeek(week) : week; ZStack(() => { this.renderCellLayer(week, isLastWeek) this.renderPillLayer(displayWeek) }) .position("relative") .width(100, pct) .height(this.rowHeight, em) .flexShrink(0) .alignItems("stretch") } renderCellLayer(week, isLastWeek) { HStack(() => { week.days.forEach((dayData, di) => { this.renderDayCell(dayData, di === 6, isLastWeek) }) }) .width(100, pct) .alignItems("stretch") .height(100, pct) } renderDayCell(dayData, isLastCol, isLastWeek) { const { day, isCurrentMonth } = dayData; const today = calendarUtil.isToday(day); VStack(() => { HStack(() => { p(day.getDate()) .margin(0) .fontSize(this.dateFontSize, em) .fontWeight(today ? "700" : "500") .color(today ? "white" : "var(--headertext)") .background(today ? "var(--quillred)" : "transparent") .width(1.65, em) .height(1.65, em) .borderRadius(5, px) .textAlign("center") .lineHeight("1.65em") .flexShrink(0) .opacity(isCurrentMonth ? 1 : 0.3) }) .paddingTop(this.paddingTop, em) .paddingHorizontal(0.4, em) .paddingBottom(this.paddingBottom, em) }) .flex(1) .width(0, px) .minWidth(0) .height(100, pct) .background(isCurrentMonth ? "var(--darkaccent)" : "") .borderBottom(isLastWeek ? "1px solid transparent" : "1px solid var(--divider)") .borderRight(isLastCol ? "none" : "1px solid var(--divider)") .boxSizing("border-box") .overflow("hidden") .alignItems("stretch") .onClick((done, e) => { if (e.detail !== 2) return if (!done) { this.ghostDay = day this.rerender() } else { this.onDayDoubleClick(day, () => { this.ghostDay = null this.rerender() }) } }) } injectGhostIntoWeek(week) { const col = week.days.findIndex(d => calendarUtil.startOfDay(d.day).getTime() === calendarUtil.startOfDay(this.ghostDay).getTime() ); if (col === -1) return week; const ghostEvent = { id: '__ghost__', title: 'New event', all_day: true, time_start: this.ghostDay, time_end: this.ghostDay, calendars: ['__ghost__'] }; const slotMap = week.slotMap.map(colSlots => [...colSlots]); let row = 0; while (row < slotMap[col].length && slotMap[col][row] !== null) row++; for (let c = 0; c < 7; c++) { while (slotMap[c].length <= row) slotMap[c].push(null); } slotMap[col][row] = { event: ghostEvent, isStart: true, isEnd: true, isSingleDay: true }; const days = week.days.map((d, i) => i === col ? { ...d, events: [...d.events, ghostEvent] } : d ); return { ...week, slotMap, days }; } renderPillLayer(week) { ZStack(() => { const maxSlots = Math.max(0, ...week.slotMap.map(s => s.length)); for (let row = 0; row < Math.min(maxSlots, this.maxVisible); row++) { this.collectSpans(week, row).forEach(span => this.renderPill(span, week, row)) } // Overflow labels week.days.forEach((dayData, col) => { const overflow = Math.max(0, dayData.events.length - this.maxVisible); if (overflow === 0) return; p(`+${overflow} more`) .margin(0) .fontSize(0.63, em) .fontWeight("600") .color("var(--headertext)") .opacity(0.5) .position("absolute") .bottom(this.overflowLabelHeight, em) .left((col / 7) * 100, pct) .width(100 / 7, pct) .paddingHorizontal(0.55, em) .zIndex(2) }) }) .position("absolute") .top(0).left(0).right(0).bottom(0) .pointerEvents("none") } renderPill({ startCol, endCol, event }, week, row) { const isGhost = event.id === '__ghost__'; const color = isGhost ? 'var(--quillred)' : calendarUtil.getCalendarColor(this.calendars, event.calendars.find(id => this.calendars.some(c => c.id === id)) ?? event.calendars[0]); const leftPct = (startCol / 7) * 100; const widthPct = ((endCol - startCol + 1) / 7) * 100; const topEm = this.headerHeight + row * (this.pillHeight + this.pillGap); // const isSingleDay = startCol === endCol && // calendarUtil.startOfDay(event.time_start).getTime() === calendarUtil.startOfDay(event.time_end).getTime(); // const isTimedSingle = isSingleDay && !event.all_day; const isTimedSingle = !event.all_day; // wasn't rendering circle on multi-day timed events const clippedLeft = startCol === 0 && calendarUtil.startOfDay(event.time_start) < calendarUtil.startOfDay(week.days[0].day); const clippedRight = endCol === 6 && calendarUtil.endOfDay(event.time_end) > calendarUtil.endOfDay(week.days[6].day); const colW = 100 / 7; const leftInset = clippedLeft ? 0 : 0.02 * colW; const rightInset = clippedRight ? 0 : 0.02 * colW; const brLeft = clippedLeft ? 0 : 4; const brRight = clippedRight ? 0 : 4; HStack(() => { if (isTimedSingle) { // Dot + time + title VStack(() => {}) .width(0.42, em) .height(0.42, em) .borderRadius(50, pct) .background("white") .flexShrink(0) .marginRight(0.3, em) p(calendarUtil.formatTimeShort(event.time_start) + " " + (event.title || "Untitled")) .margin(0) .fontSize(0.68, em) .fontWeight("500") .color("white") .whiteSpace("nowrap") .overflow("hidden") } else { p(event.title || "Untitled") .margin(0) .fontSize(0.68, em) .fontWeight("600") .color("white") .whiteSpace("nowrap") .overflow("hidden") } }) .position("absolute") .top(topEm, em) .left(leftPct + leftInset, pct) .width(widthPct - leftInset - rightInset, pct) .height(this.pillHeight, em) .paddingHorizontal(0.5, em) .background(color) .borderTopLeftRadius(`${brLeft}px`) .borderBottomLeftRadius(`${brLeft}px`) .borderTopRightRadius(`${brRight}px`) .borderBottomRightRadius(`${brRight}px`) .alignItems("center") .overflow("hidden") .boxSizing("border-box") .zIndex(1) .pointerEvents(isGhost ? "none" : "auto") .cursor(isGhost ? "default" : "pointer") .opacity(isGhost ? 0.75 : 1) .onClick((done) => { if (!isGhost && done && this.onEventClick) this.onEventClick(event) }) .onHover(function(hovering) { if (!isGhost) this.opacity(hovering ? 0.82 : 1) }) } collectSpans(week, row) { const spans = []; let current = null; for (let col = 0; col < 7; col++) { const slot = (week.slotMap[col] || [])[row] ?? null; if (slot && current && slot.event === current.event) { current.endCol = col; } else { if (current) spans.push(current); current = slot ? { startCol: col, endCol: col, event: slot.event } : null; } } if (current) spans.push(current); return spans; } } register(DesktopMonthGrid)