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

219
calendar/Month/MonthGrid.js Normal file
View 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)

View 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
View 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)