This commit is contained in:
metacryst
2026-04-28 20:05:00 -05:00
commit 0d6c7683ff
123 changed files with 20922 additions and 0 deletions

View File

@@ -0,0 +1,519 @@
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)