init
This commit is contained in:
219
calendar/Month/MonthGrid.js
Normal file
219
calendar/Month/MonthGrid.js
Normal file
@@ -0,0 +1,219 @@
|
||||
import calendarUtil from "../calendarUtil.js";
|
||||
|
||||
css(`
|
||||
monthgrid- {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
monthgrid-::-webkit-scrollbar {
|
||||
display: none;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
`)
|
||||
|
||||
class MonthGrid extends Shadow {
|
||||
constructor(weeks, calendars, onDayTap = null) {
|
||||
super()
|
||||
this.weeks = weeks;
|
||||
this.calendars = calendars;
|
||||
this.onDayTap = onDayTap;
|
||||
this.maxVisible = 3; // caps both rendering and row height calculation
|
||||
|
||||
// em
|
||||
this.dateFontSize = 1.2;
|
||||
this.dateLineHeight = 1;
|
||||
this.paddingTop = 1.5;
|
||||
this.paddingBottom = 0.55;
|
||||
|
||||
// em
|
||||
this.pillHeight = 1.15;
|
||||
this.pillGap = 0.2;
|
||||
this.rowBottomPadding = 0.2;
|
||||
|
||||
this.headerHeight = this.paddingTop + (this.dateFontSize * this.dateLineHeight) + this.paddingBottom;
|
||||
this.rowHeight = this.headerHeight + this.maxVisible * (this.pillHeight + this.pillGap) + this.rowBottomPadding;
|
||||
}
|
||||
|
||||
render() {
|
||||
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)
|
||||
.flex(1)
|
||||
.overflowY("scroll")
|
||||
}
|
||||
|
||||
renderWeekRow(week, isLastWeek) {
|
||||
ZStack(() => {
|
||||
this.renderCellLayer(week, isLastWeek)
|
||||
this.renderPillLayer(week)
|
||||
})
|
||||
.position("relative")
|
||||
.width(100, pct)
|
||||
.height(this.rowHeight + 0.5, 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, isLast, 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")
|
||||
.paddingHorizontal(0.2, em)
|
||||
.paddingVertical(0.125, em)
|
||||
.borderRadius(25, pct)
|
||||
.textAlign("center")
|
||||
.opacity(isCurrentMonth ? 1 : 0)
|
||||
.lineHeight(`${this.dateLineHeight}`)
|
||||
|
||||
})
|
||||
.position("relative")
|
||||
.justifyContent("center")
|
||||
.paddingTop(this.paddingTop, em)
|
||||
.paddingHorizontal(0.4, em)
|
||||
.paddingBottom(this.paddingBottom, em)
|
||||
})
|
||||
.flex(1)
|
||||
.width(0, px)
|
||||
.minWidth(0)
|
||||
.height(100, pct)
|
||||
.borderBottom(isLastWeek ? "1px solid transparent" : "0.5px solid var(--divider)")
|
||||
.boxSizing("border-box")
|
||||
.overflow("hidden")
|
||||
.alignItems("stretch")
|
||||
.cursor("pointer")
|
||||
.onTap(() => { this.onDayTap(day) })
|
||||
}
|
||||
|
||||
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) => {
|
||||
if (!dayData.isCurrentMonth) return;
|
||||
const overflow = Math.max(0, dayData.events.length - this.maxVisible);
|
||||
if (overflow === 0) return;
|
||||
|
||||
const leftPct = (col / 7) * 100;
|
||||
|
||||
p(`+${overflow} more`)
|
||||
.margin(0)
|
||||
.fontSize(0.62, em)
|
||||
.fontWeight("600")
|
||||
.color("var(--headertext)")
|
||||
.opacity(0.5)
|
||||
.position("absolute")
|
||||
.bottom(this.rowBottomPadding, em)
|
||||
.left(leftPct, pct)
|
||||
.width(100 / 7, pct)
|
||||
.paddingHorizontal(0.4, em)
|
||||
.zIndex(2)
|
||||
})
|
||||
})
|
||||
.position("absolute")
|
||||
.top(0).left(0).right(0).bottom(0)
|
||||
.pointerEvents("none")
|
||||
}
|
||||
|
||||
renderPill({ startCol, endCol, event }, week, row) {
|
||||
const color = 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 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 colWidthPct = 100 / 7;
|
||||
const leftInsetPct = clippedLeft ? 0 : 0.025 * colWidthPct;
|
||||
const rightInsetPct = clippedRight ? 0 : 0.025 * colWidthPct;
|
||||
|
||||
const brLeft = clippedLeft ? 0 : 5;
|
||||
const brRight = clippedRight ? 0 : 5;
|
||||
|
||||
HStack(() => {
|
||||
p(event.title || "Untitled")
|
||||
.margin(0)
|
||||
.fontSize(0.9, em)
|
||||
.fontWeight("600")
|
||||
.color("white")
|
||||
.whiteSpace("nowrap")
|
||||
.overflow("hidden")
|
||||
})
|
||||
.position("absolute")
|
||||
.top(topEm, em)
|
||||
.left(leftPct + leftInsetPct, pct)
|
||||
.width(widthPct - leftInsetPct - rightInsetPct, pct)
|
||||
.height(this.pillHeight, em)
|
||||
.paddingHorizontal(0.4, 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("auto")
|
||||
.cursor("pointer")
|
||||
.onTap(() => $("bottomsheet-").showEvent(event))
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
// Only render spans that touch at least one current-month day
|
||||
return spans.filter(span => {
|
||||
for (let col = span.startCol; col <= span.endCol; col++) {
|
||||
if (week.days[col]?.isCurrentMonth) return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
register(MonthGrid)
|
||||
31
calendar/Month/MonthHeaderRow.js
Normal file
31
calendar/Month/MonthHeaderRow.js
Normal file
@@ -0,0 +1,31 @@
|
||||
class MonthHeaderRow extends Shadow {
|
||||
constructor(weekStartsOn) {
|
||||
super()
|
||||
this.weekStartsOn = weekStartsOn;
|
||||
}
|
||||
|
||||
render() {
|
||||
const dayNames = ["S", "M", "T", "W", "T", "F", "S"];
|
||||
const ordered = Array.from({ length: 7}, (_, i) => dayNames[(this.weekStartsOn + i) % 7]);
|
||||
|
||||
HStack(() => {
|
||||
ordered.forEach(name => {
|
||||
p(name)
|
||||
.margin(0)
|
||||
.fontSize(.8, em)
|
||||
.fontWeight("500")
|
||||
.letterSpacing(0.04, em)
|
||||
.color("var(--headertext)")
|
||||
.opacity(0.5)
|
||||
.flex(1)
|
||||
.textAlign("center")
|
||||
.paddingVertical(0.6, em)
|
||||
.boxSizing("border-box")
|
||||
})
|
||||
})
|
||||
.width(100, pct)
|
||||
.borderBottom("1px solid var(--divider)")
|
||||
}
|
||||
}
|
||||
|
||||
register(MonthHeaderRow)
|
||||
137
calendar/Month/MonthView.js
Normal file
137
calendar/Month/MonthView.js
Normal file
@@ -0,0 +1,137 @@
|
||||
import calendarUtil from "../calendarUtil.js"
|
||||
import "./MonthHeaderRow.js"
|
||||
import "./MonthGrid.js"
|
||||
|
||||
css(`
|
||||
monthview- {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
`)
|
||||
|
||||
class MonthView extends Shadow {
|
||||
constructor(calendars, events, currentDate, weekStartsOn = 0, onDayTap = null) {
|
||||
super()
|
||||
this.calendars = calendars;
|
||||
this.events = events;
|
||||
this.currentDate = currentDate;
|
||||
this.weekStartsOn = weekStartsOn;
|
||||
this.onDayTap = onDayTap;
|
||||
}
|
||||
|
||||
render() {
|
||||
const weeks = this.buildMonthWeeks();
|
||||
|
||||
VStack(() => {
|
||||
MonthHeaderRow(this.weekStartsOn)
|
||||
MonthGrid(weeks, this.calendars, this.onDayTap)
|
||||
})
|
||||
.width(100, pct)
|
||||
.height(100, pct)
|
||||
.boxSizing("border-box")
|
||||
.fontSize(0.9, em)
|
||||
}
|
||||
|
||||
buildMonthWeeks() {
|
||||
const month = this.currentDate.getMonth();
|
||||
|
||||
const allDays = calendarUtil.buildMonthGrid(this.currentDate, this.weekStartsOn);
|
||||
const gridStart = allDays[0];
|
||||
|
||||
// Split into weeks
|
||||
const weeks = [];
|
||||
for (let w = 0; w < 6; w++) {
|
||||
weeks.push(allDays.slice(w * 7, w * 7 + 7));
|
||||
}
|
||||
|
||||
const gridEnd = calendarUtil.endOfDay(weeks[weeks.length - 1][6]);
|
||||
const expanded = calendarUtil.expandRecurringEvents(this.events, gridStart, gridEnd);
|
||||
const relevantEvents = expanded.filter(event =>
|
||||
calendarUtil.rangesOverlap(event.time_start, event.time_end, gridStart, gridEnd)
|
||||
&& this.calendars.some(c => event.calendars?.some(id => id === c.id))
|
||||
);
|
||||
|
||||
// Build week data with slot maps (for spanning event row alignment)
|
||||
return weeks.map(week => this.buildWeekData(week, month, relevantEvents));
|
||||
}
|
||||
|
||||
buildWeekData(week, currentMonth, events) {
|
||||
const weekStart = calendarUtil.startOfDay(week[0]);
|
||||
const weekEnd = calendarUtil.endOfDay(week[6]);
|
||||
|
||||
// Events that appear in this week
|
||||
const weekEvents = events.filter(event =>
|
||||
calendarUtil.rangesOverlap(event.time_start, event.time_end, weekStart, weekEnd)
|
||||
);
|
||||
|
||||
// Sort: all-day / multi-day first, then timed; then by start
|
||||
weekEvents.sort((a, b) => {
|
||||
const aSpan = a.all_day || this.isMultiDay(a);
|
||||
const bSpan = b.all_day || this.isMultiDay(b);
|
||||
if (aSpan !== bSpan) return aSpan ? -1 : 1;
|
||||
return a.time_start - b.time_start;
|
||||
});
|
||||
|
||||
// slotRows[row] = array of 7 entries (null or event)
|
||||
const slotRows = [];
|
||||
|
||||
weekEvents.forEach(event => {
|
||||
const startCol = Math.max(0, this.dayIndex(event.time_start, week));
|
||||
const endCol = Math.min(6, this.dayIndex(event.time_end, week));
|
||||
|
||||
// Find first slot row where all cols [startCol..endCol] are free
|
||||
let row = 0;
|
||||
while (true) {
|
||||
if (!slotRows[row]) slotRows[row] = new Array(7).fill(null);
|
||||
if (slotRows[row].slice(startCol, endCol + 1).every(v => v === null)) { break; }
|
||||
row++;
|
||||
}
|
||||
|
||||
for (let c = startCol; c <= endCol; c++) {
|
||||
slotRows[row][c] = {
|
||||
event,
|
||||
isStart: c === startCol,
|
||||
isEnd: c === endCol,
|
||||
isSingleDay: startCol === endCol
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Transpose: slotMap[colIndex] = ordered list of slot entries (or null gaps)
|
||||
const slotMap = Array.from({ length: 7 }, (_, col) =>
|
||||
slotRows.map(row => row[col] ?? null)
|
||||
);
|
||||
|
||||
return {
|
||||
days: week.map(day => ({
|
||||
day,
|
||||
isCurrentMonth: day.getMonth() === currentMonth,
|
||||
events: weekEvents.filter(event => {
|
||||
const effectiveEnd = event.all_day ? calendarUtil.endOfDay(event.time_end) : event.time_end;
|
||||
return calendarUtil.rangesOverlap(
|
||||
event.time_start, effectiveEnd,
|
||||
calendarUtil.startOfDay(day), calendarUtil.endOfDay(day)
|
||||
);
|
||||
})
|
||||
})),
|
||||
slotMap
|
||||
};
|
||||
}
|
||||
|
||||
isMultiDay(event) {
|
||||
return calendarUtil.startOfDay(event.time_start).getTime() !==
|
||||
calendarUtil.startOfDay(event.time_end).getTime();
|
||||
}
|
||||
|
||||
dayIndex(date, week) {
|
||||
const dayStart = calendarUtil.startOfDay(date);
|
||||
for (let i = 0; i < 7; i++) {
|
||||
if (calendarUtil.startOfDay(week[i]).getTime() === dayStart.getTime()) return i;
|
||||
}
|
||||
// Clamp to week boundaries for events that start/end outside the week
|
||||
return date.getTime() < week[0].getTime() ? 0 : 6;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
register(MonthView)
|
||||
Reference in New Issue
Block a user