270 lines
13 KiB
JavaScript
270 lines
13 KiB
JavaScript
/* RustBox UI module: 71-calendar.js */
|
||
function startOfCalendarDay(date) {
|
||
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
||
}
|
||
|
||
function addCalendarDays(date, count) {
|
||
const next = new Date(date);
|
||
next.setDate(next.getDate() + count);
|
||
return next;
|
||
}
|
||
|
||
function startOfCalendarWeek(date) {
|
||
const start = startOfCalendarDay(date);
|
||
const day = start.getDay();
|
||
const mondayOffset = day === 0 ? -6 : 1 - day;
|
||
return addCalendarDays(start, mondayOffset);
|
||
}
|
||
|
||
function calendarRange(date, view = state.calendarView) {
|
||
if (view === "week") {
|
||
const start = startOfCalendarWeek(date);
|
||
return { start, end: addCalendarDays(start, 7) };
|
||
}
|
||
if (view === "day") {
|
||
const start = startOfCalendarDay(date);
|
||
return { start, end: addCalendarDays(start, 1) };
|
||
}
|
||
if (view === "agenda") {
|
||
const start = startOfCalendarDay(date);
|
||
return { start, end: addCalendarDays(start, 30) };
|
||
}
|
||
const first = new Date(date.getFullYear(), date.getMonth(), 1);
|
||
const start = startOfCalendarWeek(first);
|
||
return { start, end: addCalendarDays(start, 42) };
|
||
}
|
||
|
||
function calendarDayKey(value) {
|
||
const date = value instanceof Date ? value : new Date(value);
|
||
if (Number.isNaN(date.getTime())) return "";
|
||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
|
||
}
|
||
|
||
function calendarRangeLabel() {
|
||
const cursor = state.calendarCursor;
|
||
if (state.calendarView === "month") return cursor.toLocaleDateString([], { month: "long", year: "numeric" });
|
||
if (state.calendarView === "day") return cursor.toLocaleDateString([], { weekday: "long", year: "numeric", month: "long", day: "numeric" });
|
||
const { start, end } = calendarRange(cursor);
|
||
const last = addCalendarDays(end, -1);
|
||
if (state.calendarView === "agenda") return `${start.toLocaleDateString()} – ${last.toLocaleDateString()}`;
|
||
return `${start.toLocaleDateString([], { month: "short", day: "numeric" })} – ${last.toLocaleDateString([], { month: "short", day: "numeric", year: "numeric" })}`;
|
||
}
|
||
|
||
function shiftCalendarCursor(direction) {
|
||
const cursor = new Date(state.calendarCursor);
|
||
if (state.calendarView === "month") cursor.setMonth(cursor.getMonth() + direction);
|
||
else if (state.calendarView === "week") cursor.setDate(cursor.getDate() + (7 * direction));
|
||
else if (state.calendarView === "agenda") cursor.setDate(cursor.getDate() + (30 * direction));
|
||
else cursor.setDate(cursor.getDate() + direction);
|
||
state.calendarCursor = cursor;
|
||
}
|
||
|
||
function toLocalInput(value) {
|
||
const date = new Date(value);
|
||
if (Number.isNaN(date.getTime())) return "";
|
||
const local = new Date(date.getTime() - date.getTimezoneOffset() * 60000);
|
||
return local.toISOString().slice(0, 16);
|
||
}
|
||
|
||
async function importCalendarIcs(files) {
|
||
const file = files?.[0];
|
||
if (!file) return;
|
||
if (file.size > 10 * 1024 * 1024) return toast("ICS file is too large", "error");
|
||
try {
|
||
const text = await file.text();
|
||
const headers = new Headers({ "Content-Type": "text/calendar; charset=utf-8", "X-Requested-With": "XMLHttpRequest" });
|
||
if (state.accountId) headers.set("X-Active-Account", state.accountId);
|
||
const response = await fetch(apiPath("/calendar/events/import-ics"), { method: "POST", credentials: "include", headers, body: text });
|
||
const data = await response.json().catch(() => ({}));
|
||
if (!response.ok) throw new Error(data?.error?.message || `ICS import failed (${response.status})`);
|
||
toast(`ICS import: ${data.created || 0} created, ${data.skipped || 0} skipped`);
|
||
await loadCalendar();
|
||
} catch (error) { toast(error.message, "error"); }
|
||
finally { $("#calendar-import-file").value = ""; }
|
||
}
|
||
|
||
async function exportCalendarIcs() {
|
||
try {
|
||
const headers = new Headers();
|
||
if (state.accountId) headers.set("X-Active-Account", state.accountId);
|
||
const response = await fetch(apiPath("/calendar/events/export-ics"), { credentials: "include", headers });
|
||
if (!response.ok) throw new Error(`Calendar export failed (${response.status})`);
|
||
const blob = await response.blob();
|
||
const url = URL.createObjectURL(blob);
|
||
const link = document.createElement("a");
|
||
link.href = url;
|
||
link.download = "rustbox-calendar.ics";
|
||
document.body.append(link);
|
||
link.click();
|
||
link.remove();
|
||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||
} catch (error) { toast(error.message, "error"); }
|
||
}
|
||
|
||
async function openCalendar() {
|
||
$("#calendar-dialog").showModal();
|
||
await loadCalendar();
|
||
}
|
||
|
||
async function loadCalendar() {
|
||
const { start, end } = calendarRange(state.calendarCursor);
|
||
$("#calendar-view").value = state.calendarView;
|
||
$("#calendar-range").textContent = calendarRangeLabel();
|
||
try {
|
||
const data = await request(`/calendar/events?start=${encodeURIComponent(start.toISOString())}&end=${encodeURIComponent(end.toISOString())}`, { method: "GET" });
|
||
state.calendarEvents = data.events || [];
|
||
renderCalendarEvents();
|
||
} catch (error) { toast(error.message, "error"); }
|
||
}
|
||
|
||
function createCalendarEventButton(event, compact = false) {
|
||
const button = el("button", compact ? "calendar-event-chip" : "calendar-event");
|
||
button.type = "button";
|
||
const color = /^#[0-9a-f]{6}$/i.test(String(event.color || "")) ? event.color : "var(--accent)";
|
||
button.style.setProperty("--event-color", color);
|
||
if (compact) {
|
||
const time = event.all_day ? "All day" : new Date(event.start_time).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||
button.innerHTML = `<span>${escapeHtml(time)}</span><strong>${escapeHtml(event.title)}</strong>`;
|
||
} else {
|
||
button.innerHTML = `<time>${escapeHtml(formatDate(event.start_time, true))}</time><div><strong>${escapeHtml(event.title)}</strong><small>${escapeHtml(event.location || "")}</small></div><span>${escapeHtml(event.status || "")}</span>`;
|
||
}
|
||
button.addEventListener("click", () => editCalendarEvent(event));
|
||
return button;
|
||
}
|
||
|
||
function renderCalendarMonthOrWeek(view) {
|
||
const host = $("#calendar-events");
|
||
const { start, end } = calendarRange(state.calendarCursor, view);
|
||
const days = view === "week" ? 7 : 42;
|
||
const grid = el("div", `calendar-grid calendar-${view}-grid`);
|
||
for (const label of ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]) grid.append(el("div", "calendar-weekday", label));
|
||
const byDay = new Map();
|
||
for (const event of state.calendarEvents) {
|
||
const key = calendarDayKey(event.start_time);
|
||
if (!byDay.has(key)) byDay.set(key, []);
|
||
byDay.get(key).push(event);
|
||
}
|
||
for (let i = 0; i < days; i++) {
|
||
const date = addCalendarDays(start, i);
|
||
const cell = el("section", "calendar-day-cell");
|
||
if (view === "month" && date.getMonth() !== state.calendarCursor.getMonth()) cell.classList.add("outside-month");
|
||
if (calendarDayKey(date) === calendarDayKey(new Date())) cell.classList.add("today");
|
||
const heading = el("button", "calendar-day-number", String(date.getDate()));
|
||
heading.type = "button";
|
||
heading.title = "Create event on this day";
|
||
heading.addEventListener("click", () => {
|
||
const startDate = new Date(date); startDate.setHours(9, 0, 0, 0);
|
||
const endDate = new Date(startDate.getTime() + 3600000);
|
||
editCalendarEvent(null);
|
||
$("#calendar-start").value = toLocalInput(startDate);
|
||
$("#calendar-end").value = toLocalInput(endDate);
|
||
});
|
||
cell.append(heading);
|
||
const dayEvents = (byDay.get(calendarDayKey(date)) || []).sort((a, b) => new Date(a.start_time) - new Date(b.start_time));
|
||
for (const event of dayEvents.slice(0, view === "month" ? 4 : 12)) cell.append(createCalendarEventButton(event, true));
|
||
if (view === "month" && dayEvents.length > 4) cell.append(el("span", "calendar-more", `+${dayEvents.length - 4} more`));
|
||
grid.append(cell);
|
||
}
|
||
host.append(grid);
|
||
}
|
||
|
||
function renderCalendarAgenda() {
|
||
const host = $("#calendar-events");
|
||
const groups = new Map();
|
||
for (const event of state.calendarEvents) {
|
||
const key = calendarDayKey(event.start_time);
|
||
if (!groups.has(key)) groups.set(key, []);
|
||
groups.get(key).push(event);
|
||
}
|
||
if (!groups.size) { host.append(el("div", "empty-list", "No events in this range")); return; }
|
||
for (const [key, events] of [...groups.entries()].sort(([a], [b]) => a.localeCompare(b))) {
|
||
const date = new Date(`${key}T12:00:00`);
|
||
const section = el("section", "calendar-agenda-day");
|
||
section.append(el("h4", "", date.toLocaleDateString([], { weekday: "long", month: "long", day: "numeric", year: "numeric" })));
|
||
for (const event of events.sort((a, b) => new Date(a.start_time) - new Date(b.start_time))) section.append(createCalendarEventButton(event));
|
||
host.append(section);
|
||
}
|
||
}
|
||
|
||
function renderCalendarEvents() {
|
||
const list = $("#calendar-events");
|
||
list.replaceChildren();
|
||
if (state.calendarView === "month" || state.calendarView === "week") {
|
||
renderCalendarMonthOrWeek(state.calendarView);
|
||
return;
|
||
}
|
||
if (!state.calendarEvents.length) {
|
||
list.append(el("div", "empty-list", state.calendarView === "day" ? "No events today" : "No events in this range"));
|
||
return;
|
||
}
|
||
renderCalendarAgenda();
|
||
}
|
||
|
||
function updateCalendarRecurrenceUi() {
|
||
$("#calendar-rrule-wrap").classList.toggle("hidden", $("#calendar-recurrence").value !== "custom");
|
||
}
|
||
|
||
function editCalendarEvent(event = null) {
|
||
$("#calendar-form").classList.remove("hidden");
|
||
$("#calendar-event-id").value = event?.id || "";
|
||
$("#calendar-title").value = event?.title || "";
|
||
$("#calendar-start").value = event ? toLocalInput(event.start_time) : toLocalInput(new Date());
|
||
$("#calendar-end").value = event ? toLocalInput(event.end_time) : toLocalInput(new Date(Date.now() + 3600000));
|
||
$("#calendar-all-day").checked = Boolean(event?.all_day);
|
||
const rule = event?.recurrence_rule || "";
|
||
const commonRules = new Set(["", "FREQ=DAILY", "FREQ=WEEKLY", "FREQ=MONTHLY", "FREQ=YEARLY"]);
|
||
$("#calendar-recurrence").value = commonRules.has(rule) ? rule : "custom";
|
||
$("#calendar-rrule").value = commonRules.has(rule) ? "" : rule;
|
||
updateCalendarRecurrenceUi();
|
||
let attendees = [];
|
||
try { attendees = JSON.parse(event?.attendees || "[]"); } catch (_) { }
|
||
$("#calendar-attendees").value = Array.isArray(attendees) ? attendees.join(", ") : "";
|
||
$("#calendar-organizer").value = event?.organizer || state.accounts.find((item) => item.id === state.accountId)?.email || "";
|
||
$("#calendar-status").value = String(event?.status || "confirmed").toLowerCase();
|
||
$("#calendar-location").value = event?.location || "";
|
||
$("#calendar-meeting-url").value = event?.meeting_url || "";
|
||
$("#calendar-description").value = event?.description || "";
|
||
const color = String(event?.color || "");
|
||
$("#calendar-color").value = /^#[0-9a-f]{6}$/i.test(color) ? color : "#6b7280";
|
||
const reminder = event?.reminder_minutes == null ? "" : String(event.reminder_minutes);
|
||
$("#calendar-reminder").value = Array.from($("#calendar-reminder").options).some((option) => option.value === reminder) ? reminder : "";
|
||
$("#calendar-delete").disabled = !event;
|
||
}
|
||
|
||
async function saveCalendarEvent(event) {
|
||
event.preventDefault();
|
||
const id = $("#calendar-event-id").value;
|
||
const recurrenceChoice = $("#calendar-recurrence").value;
|
||
const recurrenceRule = recurrenceChoice === "custom" ? $("#calendar-rrule").value.trim() : recurrenceChoice;
|
||
const attendees = $("#calendar-attendees").value.split(",").map((value) => value.trim()).filter(Boolean);
|
||
const startValue = $("#calendar-start").value;
|
||
const endValue = $("#calendar-end").value;
|
||
if (!startValue || !endValue) return toast("Start and end are required", "error");
|
||
const base = {
|
||
title: $("#calendar-title").value.trim(),
|
||
description: $("#calendar-description").value,
|
||
location: $("#calendar-location").value.trim(),
|
||
start_time: new Date(startValue).toISOString(),
|
||
end_time: new Date(endValue).toISOString(),
|
||
all_day: $("#calendar-all-day").checked,
|
||
recurrence_rule: recurrenceRule || null,
|
||
attendees: JSON.stringify(attendees),
|
||
organizer: $("#calendar-organizer").value.trim(),
|
||
status: $("#calendar-status").value || "confirmed",
|
||
meeting_url: $("#calendar-meeting-url").value.trim() || null,
|
||
color: $("#calendar-color").value || null,
|
||
reminder_minutes: $("#calendar-reminder").value === "" ? null : Number($("#calendar-reminder").value),
|
||
};
|
||
const payload = id ? base : { ...base, source: "manual", source_uid: null, source_message_uid: null, source_message_folder: null };
|
||
try { await request(id ? `/calendar/events/${encodeURIComponent(id)}` : "/calendar/events", { method: id ? "PUT" : "POST", body: JSON.stringify(payload) }); $("#calendar-form").classList.add("hidden"); await loadCalendar(); toast("Event saved"); }
|
||
catch (error) { toast(error.message, "error"); }
|
||
}
|
||
|
||
async function deleteCalendarEvent() {
|
||
const id = $("#calendar-event-id").value;
|
||
if (!id || !await appConfirm("Delete this event?", { title: "Delete event", confirmLabel: "Delete", danger: true })) return;
|
||
try { await request(`/calendar/events/${encodeURIComponent(id)}`, { method: "DELETE" }); $("#calendar-form").classList.add("hidden"); await loadCalendar(); toast("Event deleted"); }
|
||
catch (error) { toast(error.message, "error"); }
|
||
}
|
||
|