Files
apps/calendar/calendarUtil.js
metacryst 0d6c7683ff init
2026-04-28 20:05:00 -05:00

307 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
}
}