/* 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 = `${escapeHtml(time)}${escapeHtml(event.title)}`;
} else {
button.innerHTML = `
${escapeHtml(event.title)}${escapeHtml(event.location || "")}
${escapeHtml(event.status || "")}`;
}
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"); }
}