Files
RustBox/src/web/js/71-calendar.js
T
Mateusz Gruszczyński fff8521a17
RustBox CI / rustbox (push) Waiting to run
v0.0.2
2026-10-29 19:28:47 +01:00

270 lines
13 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.
/* 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"); }
}