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,29 @@
class SpacerCell extends Shadow {
constructor(weekNumber, sidebarWidth) {
super()
this.weekNumber = weekNumber;
this.sidebarWidth = sidebarWidth
}
render() {
VStack(() => {
p(`W${this.weekNumber}`)
.fontSize(0.9, em)
.fontWeight("600")
.color("var(--headertext)")
})
.width(this.sidebarWidth, em)
.paddingHorizontal(0.5, em)
.flexShrink(0)
.flexGrow(0)
.justifyContent("center")
.alignItems("center")
.borderRight("1px solid var(--divider)")
.borderBottom("1px solid var(--divider)")
.borderTop("1px solid var(--sidebottombars)")
.background("var(--sidebottombars)")
.boxSizing("border-box")
}
}
register(SpacerCell)

View File

@@ -0,0 +1,37 @@
class TimedLabelsColumn extends Shadow {
constructor(slots, slotHeight, sidebarWidth) {
super()
this.slots = slots;
this.slotHeight = slotHeight;
this.sidebarWidth = sidebarWidth
}
render() {
VStack(() => {
this.slots.forEach(slot => {
const isHour = slot.minute === 0;
VStack(() => {
p(isHour ? slot.label : "")
.margin(0)
.fontSize(0.9, em)
.color("var(--headertext)")
.transform("translateY(-0.55em)")
})
.height(this.slotHeight, em)
.justifyContent("flex-start")
.alignItems("center")
.paddingHorizontal(0.5, em)
.width(3, em)
.boxSizing("border-box")
})
})
.paddingTop(this.slotHeight, em)
.width(this.sidebarWidth, em)
.background("var(--sidebottombars)")
.boxSizing("border-box")
.borderRight("1px solid var(--divider)")
}
}
register(TimedLabelsColumn)

View File

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

View File

@@ -0,0 +1,160 @@
import calendarUtil from "../calendarUtil.js";
class WeekHeaderRow extends Shadow {
constructor(groupedDays, calendars, onDayTap = null) {
super()
this.groupedDays = groupedDays;
this.calendars = calendars;
this.onDayTap = onDayTap;
}
render() {
const allDayEvents = this.collectAllDayEvents();
const maxEventsPerDay = Math.max(0, ...this.groupedDays.map(g => g.allDay.length))
VStack(() => {
HStack(() => {
this.groupedDays.forEach((group, index) => {
const day = group.day;
const today = calendarUtil.isToday(day);
const isLast = index === this.groupedDays.length - 1;
VStack(() => {
h3(day.getDate())
.margin(0)
.fontSize(1.35, em)
.fontWeight("700")
.lineHeight("1")
.textAlign("center")
p(day.toLocaleDateString("en-US", { weekday: "short" }).toUpperCase())
.margin(0)
.fontSize(0.72, em)
.fontWeight("600")
.letterSpacing(0.04, em)
.opacity(today ? 1 : 0.5)
.textAlign("center")
})
.color(today ? "var(--quillred)" : "var(--headertext)")
.flex(1)
.width(0, px)
.minWidth(0)
.justifyContent("center")
.alignItems("center")
.gap(0.5, em)
.paddingTop(0.85, em)
.background(today ? "var(--desktop-item-background)" : "transparent")
.borderRight(isLast ? "1px solid transparent" : "1px solid var(--divider)")
.boxSizing("border-box")
.paddingBottom(maxEventsPerDay > 0 ? (maxEventsPerDay * 2.0) + 0.7 : 0.35, em)
.cursor("pointer")
.onTap(() => { this.onDayTap(day) })
})
})
.width(100, pct)
.alignItems("stretch")
this.allDayRow(allDayEvents, maxEventsPerDay);
})
.width(100, pct)
.position("relative")
.background("var(--sidebottombars)")
.borderBottom("1px solid var(--divider)")
}
allDayRow(allDayEvents, maxEventsPerDay) {
if (allDayEvents.length === 0) return;
const rowHeight = 1.75;
const gap = 0.25;
const totalHeight = maxEventsPerDay * rowHeight + (maxEventsPerDay - 1) * gap;
ZStack(() => {
allDayEvents.forEach(({ event, startIndex, endIndex, clippedLeft, clippedRight }) => {
this.spanningEvent(event, startIndex, endIndex, rowHeight, gap, clippedLeft, clippedRight);
})
})
.position("absolute")
.bottom(0.25, em)
.left(0, px)
.width(100, pct)
.height(totalHeight, em)
.boxSizing("border-box")
.pointerEvents("none")
}
spanningEvent(event, startIndex, endIndex, rowHeight, gap, clippedLeft, clippedRight) {
const totalDays = this.groupedDays.length;
const spanCount = endIndex - startIndex + 1;
const leftPct = (startIndex / totalDays) * 100;
const widthPct = (spanCount / totalDays) * 100;
const color = calendarUtil.getCalendarColor(this.calendars, event.calendars.find(id => this.calendars.some(c => c.id === id)) ?? event.calendars[0]);
const id = event.id ?? event.title;
const slot = this.groupedDays[startIndex].allDay.findIndex(e => (e.id ?? e.title) === id);
const topEm = slot * (rowHeight + gap);
const borderLeft = clippedLeft ? 0 : 0.25;
const borderRight = clippedRight ? 0 : 0.25;
const leftPad = clippedLeft ? 0 : 1.25;
const rightPad = clippedRight ? 0 : 1.25;
HStack(() => {
p(event.title)
.margin(0)
.fontSize(0.72, em)
.fontWeight("600")
.color("white")
.whiteSpace("nowrap")
.overflow("hidden")
})
.position("absolute")
.top(topEm, em)
.left(leftPct + leftPad, pct)
.width(widthPct - leftPad - rightPad, pct)
.height(rowHeight, em)
.padding(0.35, em)
.background(color)
.borderTopLeftRadius(`${borderLeft}em`)
.borderBottomLeftRadius(`${borderLeft}em`)
.borderTopRightRadius(`${borderRight}em`)
.borderBottomRightRadius(`${borderRight}em`)
.alignItems("center")
.boxSizing("border-box")
.overflow("hidden")
.pointerEvents("auto")
.cursor("pointer")
.onTap(() => $("bottomsheet-").showEvent(event))
}
collectAllDayEvents() {
const seen = new Map();
// Key by id + time_start date: events spanning multiple days share the same time_start
// so they merge into one bar; different occurrences of the same recurring template
// have different time_start dates and render as separate bars.
const eventKey = (event) => {
const d = event.time_start instanceof Date ? event.time_start : new Date(event.time_start);
return `${event.id ?? event.title}_${calendarUtil.toDateInput(d)}`;
};
this.groupedDays.forEach((group, index) => {
group.allDay.forEach(event => {
const key = eventKey(event);
if (!seen.has(key)) {
seen.set(key, { event, startIndex: index, endIndex: index });
} else {
seen.get(key).endIndex = index;
}
});
});
const lastIndex = this.groupedDays.length - 1;
return Array.from(seen.values()).map(entry => ({
...entry,
clippedLeft: entry.startIndex === 0 && calendarUtil.startOfDay(entry.event.time_start) < calendarUtil.startOfDay(this.groupedDays[0].day),
clippedRight: entry.endIndex === lastIndex && calendarUtil.startOfDay(entry.event.time_end) > calendarUtil.startOfDay(this.groupedDays[lastIndex].day)
}));
}
}
register(WeekHeaderRow)

142
calendar/Week/WeekView.js Normal file
View File

@@ -0,0 +1,142 @@
import calendarUtil from "../calendarUtil.js"
import "./SpacerCell.js"
import "./TimedLabelsColumn.js"
import "./TimedWeekGrid.js"
import "./WeekHeaderRow.js"
css(`
weekview- {
scrollbar-width: none;
-ms-overflow-style: none;
}
weekview- .VStack::-webkit-scrollbar {
display: none;
width: 0px;
height: 0px;
}
weekview- .VStack::-webkit-scrollbar-thumb {
background: transparent;
}
weekview- .VStack::-webkit-scrollbar-track {
background: transparent;
}
`)
let _saved = null;
class WeekView extends Shadow {
constructor(calendars, events, currentDate, weekStartsOn = 0, onSlotTap = null, onDayTap = null, isCenter = false) {
super()
this.calendars = calendars;
this.events = events;
this.currentDate = currentDate;
this.weekStartsOn = weekStartsOn;
this.onSlotTap = onSlotTap;
this.onDayTap = onDayTap;
this.isCenter = isCenter;
this.slots = calendarUtil.generateTimeSlots({ stepMinutes: 30 });
this.slotHeight = 2;
this.sidebarWidth = 3;
}
render() {
ZStack(() => {
const visibleWeekStart = calendarUtil.startOfWeek(this.currentDate, this.weekStartsOn);
const weekDays = this.getWeekDays(visibleWeekStart);
const groupedDays = this.groupEventsByWeekDay(
this.filterEventsForWeek(this.events, weekDays),
weekDays
);
const weekNumber = calendarUtil.getWeekNumber(visibleWeekStart);
VStack(() => {
HStack(() => {
SpacerCell(weekNumber, this.sidebarWidth)
WeekHeaderRow(groupedDays, this.calendars, this.onDayTap)
})
.boxSizing("border-box")
.position("sticky")
.top(0)
.width(100, pct)
.zIndex(2)
HStack(() => {
TimedLabelsColumn(this.slots, this.slotHeight, this.sidebarWidth)
TimedWeekGrid(weekDays, this.slots, groupedDays, this.calendars, this.slotHeight, "week", this.onSlotTap)
})
.boxSizing("border-box")
.onAppear(() => {
// console.log("groupedDays:", groupedDays)
this.scrollToEight();
})
})
})
.position("relative")
.gap(1, em)
.boxSizing("border-box")
.width(100, pct)
.height(100, pct)
.fontSize(0.9, em)
.overscrollBehavior("none")
.overflowY("scroll")
.display("block")
}
getWeekDays(weekStart) {
return Array.from({ length: 7 }, (_, i) => calendarUtil.addDays(weekStart, i));
}
filterEventsForWeek(events, weekDays) {
const rangeStart = calendarUtil.startOfDay(weekDays[0]);
const rangeEnd = calendarUtil.endOfDay(weekDays[6]);
const expanded = calendarUtil.expandRecurringEvents(events, rangeStart, rangeEnd);
return expanded.filter(event => {
const end = event.all_day ? calendarUtil.endOfDay(event.time_end) : calendarUtil.timedEnd(event);
return weekDays.some(day => calendarUtil.rangesOverlap(
event.time_start,
end,
calendarUtil.startOfDay(day),
calendarUtil.endOfDay(day)
) && this.calendars.some(c => event.calendars?.some(id => id === c.id)));
});
}
scrollToEight() {
requestAnimationFrame(() => {
const fs = parseFloat(getComputedStyle(this).fontSize);
const slotsBeforeEight = this.slots.findIndex(s => s.hour24 === 8 && s.minute === 0);
const defaultTarget = slotsBeforeEight * this.slotHeight * fs;
if (this.isCenter) {
const dateKey = calendarUtil.toDateInput(calendarUtil.startOfWeek(this.currentDate, this.weekStartsOn));
this.scrollTop = (_saved?.dateKey === dateKey) ? _saved.scrollTop : defaultTarget;
this.addEventListener('scroll', () => { _saved = { dateKey, scrollTop: this.scrollTop }; }, { passive: true });
} else {
this.scrollTop = defaultTarget;
}
})
}
groupEventsByWeekDay(events, weekDays) {
return weekDays.map(day => {
const dayStart = calendarUtil.startOfDay(day);
const dayEnd = calendarUtil.endOfDay(day)
return {
day,
allDay: events.filter(event => {
if (!event.all_day) return false;
const end = calendarUtil.endOfDay(event.time_end);
return calendarUtil.rangesOverlap(event.time_start, end, dayStart, dayEnd);
}),
timed: events.filter(event => {
return !event.all_day && calendarUtil.rangesOverlap(event.time_start, calendarUtil.timedEnd(event), dayStart, dayEnd);
})
};
});
}
}
register(WeekView)