339 lines
15 KiB
JavaScript
339 lines
15 KiB
JavaScript
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)
|