307 lines
12 KiB
JavaScript
307 lines
12 KiB
JavaScript
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;
|
||
}
|
||
} |