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

307
calendar/calendarUtil.js Normal file
View File

@@ -0,0 +1,307 @@
export default class calendarUtil {
static addDays(date, days) {
const d = new Date(date);
d.setDate(d.getDate() + days);
return d;
}
static addMonths(date, months) {
const d = new Date(date);
const day = d.getDate();
d.setMonth(d.getMonth() + months);
// setMonth overflows short months (e.g. Jan 31 + 1 → Mar 3); clamp to last day of target month
if (d.getDate() !== day) d.setDate(0);
return d;
}
static rangesOverlap(startA, endA, startB, endB) {
return startA < endB && endA > startB;
}
static getCalendarColor(calendars, calendarId) {
return calendars.find(c => c.id === calendarId)?.color ?? "#888888";
}
static getWeekNumber(date) {
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
const dayNum = d.getUTCDay() || 7;
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
return Math.ceil((((d - yearStart) / 86400000) + 1) / 7);
}
static generateTimeSlots({ startHour = 0, endHour = 24, stepMinutes = 60, use12Hour = true } = {}) {
const slots = [];
for (let minutes = startHour * 60; minutes <= endHour * 60; minutes += stepMinutes) { // inclusive endHour
const hour24 = Math.floor(minutes / 60);
const minute = minutes % 60;
let label;
if (use12Hour) {
const suffix = (hour24 % 24) < 12 ? "am" : "pm";
const hour12 = hour24 % 12 || 12;
label = minute === 0
? `${hour12}${suffix}`
: `${hour12}:${String(minute).padStart(2, "0")}${suffix}`;
} else {
label = `${String(hour24).padStart(2, "0")}:${String(minute).padStart(2, "0")}`;
}
slots.push({
hour24,
minute,
totalMinutes: minutes,
label
});
}
return slots;
}
static isToday(date) {
const now = new Date();
return (
now.getFullYear() === date.getFullYear() &&
now.getMonth() === date.getMonth() &&
now.getDate() === date.getDate()
);
}
static isSameDay(a, b) {
return (
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate()
);
}
static isSameWeek(a, b) {
return calendarUtil.isSameDay(this.startOfWeek(a), this.startOfWeek(b));
}
static isSameMonth(a, b) {
return (
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth()
);
}
static startOfWeek(date, weekStartsOn = 0) {
const d = new Date(date);
d.setHours(0, 0, 0, 0);
const day = d.getDay(); // 0=Sun, 1=Mon ...
const diff = (day - weekStartsOn + 7) % 7;
d.setDate(d.getDate() - diff);
return d;
}
static startOfDay(date) {
const d = new Date(date);
d.setHours(0, 0, 0, 0);
return d;
}
static endOfDay(date) {
const d = new Date(date);
d.setHours(24, 0, 0, 0);
return d;
}
static toDateInput(date) {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
}
static toTimeInput(date) {
return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;
}
// Returns the effective end time for overlap checks, ensuring zero-duration
// timed events (start === end) still register as occupying their start moment.
static timedEnd(event) {
return event.time_end > event.time_start
? event.time_end
: new Date(event.time_start.getTime() + 1);
}
// Converts a date string + all_day flag to a UTC ISO string.
// When all_day is true and isEnd is true, sets time to 23:59:59.999 so
// rangesOverlap (strict endA > startB) catches same-day events.
static toISO(str, allDay, isEnd = false) {
if (!allDay) return new Date(str).toISOString()
const d = new Date(str + "T00:00:00")
if (isEnd) d.setHours(23, 59, 59, 999)
return d.toISOString()
}
// Formats an event's time range as a human-readable string.
static formatEventTime(event) {
const start = new Date(event.time_start)
const end = new Date(event.time_end)
const dateFmt = { weekday: "short", month: "short", day: "numeric" }
const timeFmt = { hour: "numeric", minute: "2-digit" }
if (event.all_day) {
const startStr = start.toLocaleDateString("en-US", dateFmt)
if (calendarUtil.isSameDay(start, end)) return `All day · ${startStr}`
return `All day · ${startStr} ${end.toLocaleDateString("en-US", dateFmt)}`
}
return `${start.toLocaleDateString("en-US", dateFmt)} · ${start.toLocaleTimeString("en-US", timeFmt)} ${end.toLocaleTimeString("en-US", timeFmt)}`
}
// Formats a Date to a short 12-hour time string, e.g. "3pm" or "3:30pm".
static formatTimeShort(date) {
const h = date.getHours()
const m = date.getMinutes()
const suffix = h < 12 ? "am" : "pm"
const h12 = h % 12 || 12
return m === 0 ? `${h12}${suffix}` : `${h12}:${String(m).padStart(2, "0")}${suffix}`
}
// Returns a relative time string, e.g. "3h ago", "2d ago", "just now".
static timeAgo(dateStr) {
const seconds = Math.floor((Date.now() - new Date(dateStr)) / 1000)
if (seconds < 60) return "just now"
const minutes = Math.floor(seconds / 60)
if (minutes < 60) return `${minutes}m ago`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours}h ago`
const days = Math.floor(hours / 24)
if (days < 7) return `${days}d ago`
const weeks = Math.floor(days / 7)
if (weeks < 5) return `${weeks}w ago`
const months = Math.floor(days / 30)
if (months < 12) return `${months}mo ago`
return `${Math.floor(days / 365)}y ago`
}
// Returns a flat array of 42 Date objects covering the 6-week grid for the
// given month, aligned to weekStartsOn (0 = Sun, 1 = Mon).
static buildMonthGrid(date, weekStartsOn = 0) {
const firstOfMonth = new Date(date.getFullYear(), date.getMonth(), 1)
const gridStart = calendarUtil.startOfWeek(firstOfMonth, weekStartsOn)
return Array.from({ length: 42 }, (_, i) => calendarUtil.addDays(gridStart, i))
}
// Generates every occurrence Date of a recurring template within [rangeStart, rangeEnd].
static generateOccurrenceDates(template, rangeStart, rangeEnd) {
const rule = template.recurrence;
if (!rule) return [];
const duration = template.time_end.getTime() - template.time_start.getTime();
const seriesStart = new Date(template.time_start);
const endDate = rule.end_date ? new Date(rule.end_date) : null;
const maxCount = rule.count ?? Infinity;
const interval = rule.interval ?? 1;
const dates = [];
let count = 0;
// Generous overlap: use at least 1 day of coverage so zero-duration all-day events aren't missed.
const inRange = (occ) => {
const occEnd = new Date(occ.getTime() + Math.max(duration, 86400000));
return occ <= rangeEnd && occEnd >= rangeStart;
};
if (rule.frequency === 'weekly' && rule.days_of_week?.length > 0) {
const sortedDays = [...rule.days_of_week].sort((a, b) => a - b);
// Anchor on the Sunday of the week containing seriesStart
const sun = new Date(seriesStart);
sun.setDate(sun.getDate() - sun.getDay());
sun.setHours(seriesStart.getHours(), seriesStart.getMinutes(), seriesStart.getSeconds(), 0);
weekLoop: for (let weekOffset = 0; ; weekOffset += interval) {
const weekSun = new Date(sun);
weekSun.setDate(sun.getDate() + weekOffset * 7);
if (weekSun > rangeEnd) break;
for (const dayIdx of sortedDays) {
const occ = new Date(weekSun);
occ.setDate(weekSun.getDate() + dayIdx);
if (occ < seriesStart) continue;
if (endDate && occ >= endDate) break weekLoop;
if (count >= maxCount) break weekLoop;
count++;
if (occ > rangeEnd) break weekLoop;
if (inRange(occ)) dates.push(occ);
}
}
} else {
const advance = (d) => {
const next = new Date(d);
const day = next.getDate();
switch (rule.frequency) {
case 'daily': next.setDate(next.getDate() + interval); break;
case 'weekly': next.setDate(next.getDate() + interval * 7); break;
case 'monthly': next.setMonth(next.getMonth() + interval); break;
case 'yearly': next.setFullYear(next.getFullYear() + interval); break;
}
// Clamp month/year overflow (e.g. Jan 31 + 1 month → Mar 3, or Feb 29 + 1 year → Mar 1)
if ((rule.frequency === 'monthly' || rule.frequency === 'yearly') && next.getDate() !== day) {
next.setDate(0);
}
return next;
};
let current = new Date(seriesStart);
while (current <= rangeEnd) {
if (endDate && current >= endDate) break;
if (count >= maxCount) break;
count++;
if (inRange(current)) dates.push(new Date(current));
current = advance(current);
}
}
return dates;
}
// Expands recurring template events into concrete occurrences within [rangeStart, rangeEnd].
// Override rows replace their corresponding virtual occurrence; cancelled ones are skipped.
// Non-recurring events pass through unchanged.
static expandRecurringEvents(events, rangeStart, rangeEnd) {
const templates = events.filter(e => e.recurrence_id && !e.recurrence_parent_id);
const overrides = events.filter(e => !!e.recurrence_parent_id);
const regular = events.filter(e => !e.recurrence_id && !e.recurrence_parent_id);
const overrideMap = {};
overrides.forEach(ov => {
const pid = ov.recurrence_parent_id;
if (!overrideMap[pid]) overrideMap[pid] = {};
const key = new Date(ov.recurrence_exception_date).toDateString();
overrideMap[pid][key] = ov;
});
const result = [...regular];
templates.forEach(template => {
const templateOverrides = overrideMap[template.id] || {};
const duration = template.time_end.getTime() - template.time_start.getTime();
calendarUtil.generateOccurrenceDates(template, rangeStart, rangeEnd).forEach(occDate => {
const override = templateOverrides[occDate.toDateString()];
if (override) {
if (!override.is_cancelled) result.push(override);
} else {
result.push({
...template,
time_start: occDate,
time_end: new Date(occDate.getTime() + duration),
_isOccurrence: true,
_occurrenceDate: occDate,
_templateStart: new Date(template.time_start),
_templateEnd: new Date(template.time_end),
});
}
});
});
return result;
}
}