init
This commit is contained in:
29
calendar/Week/SpacerCell.js
Normal file
29
calendar/Week/SpacerCell.js
Normal 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)
|
||||
37
calendar/Week/TimedLabelsColumn.js
Normal file
37
calendar/Week/TimedLabelsColumn.js
Normal 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)
|
||||
338
calendar/Week/TimedWeekGrid.js
Normal file
338
calendar/Week/TimedWeekGrid.js
Normal 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)
|
||||
160
calendar/Week/WeekHeaderRow.js
Normal file
160
calendar/Week/WeekHeaderRow.js
Normal 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
142
calendar/Week/WeekView.js
Normal 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)
|
||||
Reference in New Issue
Block a user