/* 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"); } }