/**
* Modul: Kalender (Calendar)
* Zweck: Monats-/Wochen-/Tages-/Agenda-Ansicht mit vollem Termin-CRUD
* Abhängigkeiten: /api.js, /router.js (window.oikos)
*/
import { api } from '/api.js';
import { renderRRuleFields, bindRRuleEvents, getRRuleValues } from '/rrule-ui.js';
import { openModal as openSharedModal, closeModal } from '/components/modal.js';
import { stagger } from '/utils/ux.js';
import { t, formatDate as formatPreferredDate, formatTime, dateInputPlaceholder, formatDateInput, parseDateInput, isDateInputValid, formatTimeInput, parseTimeInput, timeInputPlaceholder } from '/i18n.js';
import { esc, fmtLocation } from '/utils/html.js';
import { refresh as refreshReminders } from '/reminders.js';
// --------------------------------------------------------
// Konstanten
// --------------------------------------------------------
const VIEWS = ['month', 'week', 'day', 'agenda'];
const VIEW_LABELS = () => ({
month: t('calendar.viewMonth'),
week: t('calendar.viewWeek'),
day: t('calendar.viewDay'),
agenda: t('calendar.viewAgenda'),
});
const DAY_NAMES_SHORT = () => [
t('calendar.dayShortSunday'), t('calendar.dayShortMonday'), t('calendar.dayShortTuesday'),
t('calendar.dayShortWednesday'), t('calendar.dayShortThursday'), t('calendar.dayShortFriday'),
t('calendar.dayShortSaturday'),
];
const DAY_NAMES_LONG = () => [
t('calendar.dayLongSunday'), t('calendar.dayLongMonday'), t('calendar.dayLongTuesday'),
t('calendar.dayLongWednesday'), t('calendar.dayLongThursday'), t('calendar.dayLongFriday'),
t('calendar.dayLongSaturday'),
];
const MONTH_NAMES = () => [
t('calendar.monthJanuary'), t('calendar.monthFebruary'), t('calendar.monthMarch'),
t('calendar.monthApril'), t('calendar.monthMay'), t('calendar.monthJune'),
t('calendar.monthJuly'), t('calendar.monthAugust'), t('calendar.monthSeptember'),
t('calendar.monthOctober'), t('calendar.monthNovember'), t('calendar.monthDecember'),
];
const EVENT_COLORS = [
'#007AFF', '#34C759', '#FF9500', '#FF3B30',
'#AF52DE', '#FF6B35', '#5AC8FA', '#FFCC00',
'#8E8E93', '#30B0C7',
];
const EVENT_COLOR_NAMES = () => ({
'#007AFF': t('calendar.colorBlue'),
'#34C759': t('calendar.colorGreen'),
'#FF9500': t('calendar.colorOrange'),
'#FF3B30': t('calendar.colorRed'),
'#AF52DE': t('calendar.colorPurple'),
'#FF6B35': t('calendar.colorCoral'),
'#5AC8FA': t('calendar.colorSkyBlue'),
'#FFCC00': t('calendar.colorYellow'),
'#8E8E93': t('calendar.colorGray'),
'#30B0C7': t('calendar.colorCyan'),
});
const EVENT_ICON_ALIASES = {
drill: 'tooth',
};
const EVENT_ICON_CATEGORIES = () => [
{
key: 'general',
label: t('calendar.iconCategoryGeneral'),
icons: [
{ value: 'calendar', label: t('calendar.iconCalendar') },
{ value: 'alarm-clock', label: t('calendar.iconAlarm') },
{ value: 'clock', label: t('calendar.iconClock') },
{ value: 'bell', label: t('calendar.iconBell') },
{ value: 'map-pin', label: t('calendar.iconLocation') },
{ value: 'star', label: t('calendar.iconStar') },
{ value: 'flag', label: t('calendar.iconFlag') },
{ value: 'target', label: t('calendar.iconTarget') },
{ value: 'flame', label: t('calendar.iconFlame') },
],
},
{
key: 'health',
label: t('calendar.iconCategoryHealth'),
icons: [
{ value: 'tooth', label: t('calendar.iconTooth') },
{ value: 'hospital', label: t('calendar.iconHospital') },
{ value: 'stethoscope', label: t('calendar.iconDoctor') },
{ value: 'syringe', label: t('calendar.iconVaccine') },
{ value: 'pill', label: t('calendar.iconMedicine') },
{ value: 'bandage', label: t('calendar.iconBandage') },
{ value: 'heart-pulse', label: t('calendar.iconHealth') },
{ value: 'activity', label: t('calendar.iconActivity') },
{ value: 'scissors', label: t('calendar.iconHaircut') },
{ value: 'dumbbell', label: t('calendar.iconSports') },
{ value: 'trophy', label: t('calendar.iconTrophy') },
],
},
{
key: 'transport',
label: t('calendar.iconCategoryTransport'),
icons: [
{ value: 'car', label: t('calendar.iconCar') },
{ value: 'bus', label: t('calendar.iconBus') },
{ value: 'train', label: t('calendar.iconTrain') },
{ value: 'plane', label: t('calendar.iconPlane') },
{ value: 'plane-takeoff', label: t('calendar.iconFlight') },
{ value: 'fuel', label: t('calendar.iconFuel') },
{ value: 'navigation', label: t('calendar.iconNavigation') },
],
},
{
key: 'work',
label: t('calendar.iconCategoryWork'),
icons: [
{ value: 'briefcase', label: t('calendar.iconWork') },
{ value: 'laptop', label: t('calendar.iconLaptop') },
{ value: 'presentation', label: t('calendar.iconPresentation') },
{ value: 'school', label: t('calendar.iconSchool') },
{ value: 'graduation-cap', label: t('calendar.iconEducation') },
{ value: 'book-open', label: t('calendar.iconReading') },
{ value: 'pencil', label: t('calendar.iconStudy') },
{ value: 'calculator', label: t('calendar.iconCalculator') },
],
},
{
key: 'food',
label: t('calendar.iconCategoryFood'),
icons: [
{ value: 'utensils', label: t('calendar.iconMeal') },
{ value: 'cooking-pot', label: t('calendar.iconCooking') },
{ value: 'coffee', label: t('calendar.iconCoffee') },
{ value: 'cake', label: t('calendar.iconCake') },
{ value: 'pizza', label: t('calendar.iconPizza') },
{ value: 'wine', label: t('calendar.iconWine') },
{ value: 'beer', label: t('calendar.iconBeer') },
],
},
{
key: 'shopping',
label: t('calendar.iconCategoryShopping'),
icons: [
{ value: 'shopping-bag', label: t('calendar.iconShopping') },
{ value: 'shopping-cart', label: t('calendar.iconGroceries') },
{ value: 'gift', label: t('calendar.iconGift') },
{ value: 'credit-card', label: t('calendar.iconCard') },
{ value: 'wallet', label: t('calendar.iconWallet') },
{ value: 'piggy-bank', label: t('calendar.iconSavings') },
{ value: 'landmark', label: t('calendar.iconBank') },
],
},
{
key: 'leisure',
label: t('calendar.iconCategoryLeisure'),
icons: [
{ value: 'music', label: t('calendar.iconMusic') },
{ value: 'film', label: t('calendar.iconMovie') },
{ value: 'ticket', label: t('calendar.iconTicket') },
{ value: 'gamepad-2', label: t('calendar.iconGame') },
{ value: 'camera', label: t('calendar.iconPhoto') },
{ value: 'party-popper', label: t('calendar.iconParty') },
],
},
{
key: 'family',
label: t('calendar.iconCategoryFamily'),
icons: [
{ value: 'users', label: t('calendar.iconFamily') },
{ value: 'baby', label: t('calendar.iconBaby') },
{ value: 'dog', label: t('calendar.iconDog') },
{ value: 'cat', label: t('calendar.iconCat') },
{ value: 'paw-print', label: t('calendar.iconPet') },
],
},
{
key: 'home',
label: t('calendar.iconCategoryHome'),
icons: [
{ value: 'home', label: t('calendar.iconHome') },
{ value: 'building', label: t('calendar.iconBuilding') },
{ value: 'wrench', label: t('calendar.iconRepair') },
{ value: 'hammer', label: t('calendar.iconMaintenance') },
{ value: 'paintbrush', label: t('calendar.iconDecoration') },
{ value: 'sofa', label: t('calendar.iconFurniture') },
{ value: 'washing-machine', label: t('calendar.iconLaundry') },
],
},
{
key: 'nature',
label: t('calendar.iconCategoryNature'),
icons: [
{ value: 'leaf', label: t('calendar.iconLeaf') },
{ value: 'tree-pine', label: t('calendar.iconTree') },
{ value: 'flower', label: t('calendar.iconFlower') },
{ value: 'sun', label: t('calendar.iconSun') },
{ value: 'moon', label: t('calendar.iconMoon') },
{ value: 'cloud-sun', label: t('calendar.iconWeather') },
],
},
];
// Flache Liste aller Icons für Kompatibilität (z.B. eventIconName-Validierung)
const EVENT_ICONS = EVENT_ICON_CATEGORIES().flatMap((cat) => cat.icons);
const CUSTOM_EVENT_ICONS = new Set(['tooth']);
const MAX_ATTACHMENT_BYTES = 5 * 1024 * 1024;
const ATTACHMENT_IMAGE_MIME = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif']);
const CALENDAR_VIEW_STORAGE_KEY = 'oikos-calendar-view';
const HOUR_HEIGHT = 56; // px pro Stunde in Wochen-/Tagesansicht
/**
* Gibt eine lesbare Textfarbe für eine Hintergrundfarbe zurück.
* Helle Hintergründe (z.B. Hellgelb, Hellgrün) → dunkles Grau statt Weiß.
*/
function getContrastColor(hex) {
if (!hex || hex.length < 7) return null;
try {
const r = parseInt(hex.slice(1, 3), 16) / 255;
const g = parseInt(hex.slice(3, 5), 16) / 255;
const b = parseInt(hex.slice(5, 7), 16) / 255;
const lin = (c) => c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
const L = 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
return L > 0.30 ? '#3D3D3D' : null; // null → CSS-Standard (weiß) bleibt
} catch { return null; }
}
// --------------------------------------------------------
// State
// --------------------------------------------------------
let state = {
view: 'month',
today: '',
cursor: null, // aktuell angezeigte Referenz-Datum (YYYY-MM-DD)
events: [],
users: [],
rangeFrom: '',
rangeTo: '',
};
let _container = null;
// --------------------------------------------------------
// Datumshelfer
// --------------------------------------------------------
function pad(n) { return String(n).padStart(2, '0'); }
function isoDate(d) { return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; }
function getSavedCalendarView() {
try {
const saved = localStorage.getItem(CALENDAR_VIEW_STORAGE_KEY);
return VIEWS.includes(saved) ? saved : 'month';
} catch {
return 'month';
}
}
function setSavedCalendarView(view) {
if (!VIEWS.includes(view)) return;
try {
localStorage.setItem(CALENDAR_VIEW_STORAGE_KEY, view);
} catch {}
}
// Extract YYYY-MM-DD in the browser's local timezone from any datetime string.
// For date-only strings (≤10 chars) slicing is safe; for datetime strings with an
// explicit UTC offset or 'Z' suffix, new Date() converts to local before extraction.
function localDate(str) {
if (!str || str.length <= 10) return (str || '').slice(0, 10);
const d = new Date(str);
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
// Extract HH:MM in the browser's local timezone from a datetime string.
function localTime(str) {
if (!str || str.length <= 10) return '00:00';
const d = new Date(str);
return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function addMonths(dateStr, n) {
const d = new Date(dateStr + 'T00:00:00');
d.setMonth(d.getMonth() + n);
return isoDate(d);
}
function addDays(dateStr, n) {
const d = new Date(dateStr + 'T00:00:00');
d.setDate(d.getDate() + n);
return isoDate(d);
}
function getMondayOf(dateStr) {
const d = new Date(dateStr + 'T00:00:00');
const day = d.getDay();
const diff = (day === 0 ? -6 : 1 - day);
d.setDate(d.getDate() + diff);
return isoDate(d);
}
function formatDate(dateStr, { long = false, weekday = false } = {}) {
if (weekday) {
const d = new Date(dateStr + 'T00:00:00');
const wd = long ? DAY_NAMES_LONG()[d.getDay()] : DAY_NAMES_SHORT()[d.getDay()];
return `${wd}, ${formatPreferredDate(dateStr)}`;
}
return formatPreferredDate(dateStr);
}
function formatDateTime(datetimeStr) {
if (!datetimeStr) return '';
const date = localDate(datetimeStr);
const hasTime = datetimeStr.length > 10;
const time = hasTime ? formatTime(datetimeStr) : '';
return time ? `${formatDate(date)} ${time} ${t('calendar.timeSuffix')}`.trimEnd() : formatDate(date);
}
function eventIconName(icon) {
const normalized = EVENT_ICON_ALIASES[icon] || icon;
return EVENT_ICONS.some((item) => item.value === normalized) ? normalized : 'calendar';
}
function customEventIconHtml(icon, className) {
if (icon !== 'tooth') return '';
return ``;
}
function eventIconHtml(icon, className = 'event-icon') {
const name = eventIconName(icon);
if (CUSTOM_EVENT_ICONS.has(name)) return customEventIconHtml(name, className);
return ``;
}
function eventIconElement(icon, className = 'event-icon') {
const name = eventIconName(icon);
if (name === 'tooth') {
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('class', `${className} event-icon--custom`);
svg.setAttribute('viewBox', '0 0 24 24');
svg.setAttribute('fill', 'none');
svg.setAttribute('stroke', 'currentColor');
svg.setAttribute('stroke-width', '2');
svg.setAttribute('stroke-linecap', 'round');
svg.setAttribute('stroke-linejoin', 'round');
svg.setAttribute('aria-hidden', 'true');
const outline = document.createElementNS('http://www.w3.org/2000/svg', 'path');
outline.setAttribute('d', 'M8.5 3.5c1.2 0 2.1.5 3.5.5s2.3-.5 3.5-.5c2.4 0 4 1.8 4 4.4 0 2.2-1 4.2-1.7 5.7-.7 1.6-.8 3.1-1.1 4.7-.3 1.7-1.1 3.2-2.4 3.2-1.1 0-1.5-1.1-1.8-2.7-.2-1.2-.4-2.1-.5-2.1s-.3.9-.5 2.1c-.3 1.6-.7 2.7-1.8 2.7-1.3 0-2.1-1.5-2.4-3.2-.3-1.6-.4-3.1-1.1-4.7C5.5 12.1 4.5 10.1 4.5 7.9c0-2.6 1.6-4.4 4-4.4Z');
const ridge = document.createElementNS('http://www.w3.org/2000/svg', 'path');
ridge.setAttribute('d', 'M10 6.2c.7.3 1.3.5 2 .5s1.3-.2 2-.5');
svg.append(outline, ridge);
return svg;
}
const el = document.createElement('i');
el.className = className;
el.dataset.lucide = name;
el.setAttribute('aria-hidden', 'true');
return el;
}
function isImageAttachment(mime) {
return ATTACHMENT_IMAGE_MIME.has(String(mime || '').toLowerCase());
}
function readFileAsDataUrl(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ''));
reader.onerror = () => reject(new Error(t('calendar.attachmentReadError')));
reader.readAsDataURL(file);
});
}
function attachmentDataUrl(data, mime) {
const raw = String(data || '');
if (!raw) return '';
if (raw.startsWith('data:')) return raw;
return mime ? `data:${mime};base64,${raw}` : raw;
}
function attachmentHtml(event) {
if (!event?.attachment_data) return '';
const name = esc(event.attachment_name || t('calendar.attachmentFallback'));
const src = esc(attachmentDataUrl(event.attachment_data, event.attachment_mime));
if (isImageAttachment(event.attachment_mime)) {
return `
`;
}
return `
`;
}
function attachmentPreviewHtml(event) {
if (!event?.attachment_data) return '';
const name = esc(event.attachment_name || t('calendar.attachmentFallback'));
const src = esc(attachmentDataUrl(event.attachment_data, event.attachment_mime));
return isImageAttachment(event.attachment_mime)
? `
`
: `${name}`;
}
function selectedAttachmentLabel(name) {
return t('documents.selectedFileLabel', { name: name || t('calendar.attachmentFallback') });
}
function bindDateInputs(root) {
root.querySelectorAll('.js-date-input').forEach((input) => {
input.addEventListener('blur', () => {
const parsed = parseDateInput(input.value);
if (parsed) input.value = formatDateInput(parsed);
});
});
}
function readDateInput(root, selector) {
return parseDateInput(root.querySelector(selector)?.value || '');
}
function getMonthRange(dateStr) {
const d = new Date(dateStr + 'T00:00:00');
const year = d.getFullYear();
const month = d.getMonth();
const from = `${year}-${pad(month + 1)}-01`;
// Extra Tage für Kalenderraster (6 Wochen × 7 = 42 Tage)
const to = addDays(from, 41);
return { from, to };
}
function getWeekRange(dateStr) {
const monday = getMondayOf(dateStr);
return { from: monday, to: addDays(monday, 6) };
}
function getAgendaRange(dateStr) {
return { from: dateStr, to: addDays(dateStr, 30) };
}
function eventsOnDay(dateStr) {
return state.events.filter((e) => {
const start = localDate(e.start_datetime);
const end = e.end_datetime ? localDate(e.end_datetime) : start;
return start <= dateStr && end >= dateStr;
});
}
// --------------------------------------------------------
// API
// --------------------------------------------------------
async function loadRange(from, to) {
try {
const res = await api.get(`/calendar?from=${from}&to=${to}`);
state.events = res.data;
} catch (err) {
console.error('[Calendar] loadRange Fehler:', err);
state.events = [];
window.oikos?.showToast(t('calendar.loadError'), 'danger');
}
state.rangeFrom = from;
state.rangeTo = to;
}
async function loadUsers() {
try {
const res = await api.get('/auth/users');
state.users = res.data;
} catch {
state.users = [];
}
}
// --------------------------------------------------------
// Entry Point
// --------------------------------------------------------
export async function render(container, { user }) {
_container = container;
state.today = isoDate(new Date());
state.cursor = state.today;
state.view = getSavedCalendarView();
container.innerHTML = `
`;
const { from, to } = getMonthRange(state.cursor);
await Promise.all([loadRange(from, to), loadUsers()]);
renderToolbar();
renderView();
container.querySelector('#fab-new-event')?.addEventListener('click', () => openEventModal({ mode: 'create' }));
}
// --------------------------------------------------------
// Toolbar
// --------------------------------------------------------
function renderToolbar() {
const bar = _container.querySelector('#cal-toolbar');
if (!bar) return;
bar.innerHTML = `
${t('calendar.title')}
${VIEWS.map((v) => `
`).join('')}
`;
if (window.lucide) lucide.createIcons();
updateLabel();
bar.querySelector('#cal-prev').addEventListener('click', () => navigate(-1));
bar.querySelector('#cal-next').addEventListener('click', () => navigate(1));
bar.querySelector('#cal-today').addEventListener('click', goToday);
bar.querySelector('#cal-add').addEventListener('click', () => openEventModal({ mode: 'create' }));
bar.querySelectorAll('[data-view]').forEach((btn) => {
btn.addEventListener('click', async () => {
if (btn.dataset.view === state.view) return;
state.view = btn.dataset.view;
setSavedCalendarView(state.view);
bar.querySelectorAll('[data-view]').forEach((b) =>
b.classList.toggle('cal-toolbar__view-btn--active', b.dataset.view === state.view)
);
await reloadForView();
renderView();
});
});
}
function updateLabel() {
const lbl = _container.querySelector('#cal-label');
if (!lbl) return;
const d = new Date(state.cursor + 'T00:00:00');
const year = d.getFullYear();
const mon = MONTH_NAMES()[d.getMonth()];
if (state.view === 'month') lbl.textContent = `${mon} ${year}`;
if (state.view === 'week') lbl.textContent = t('calendar.weekNumberLabel', { week: getWeekNumber(state.cursor), month: mon, year });
if (state.view === 'day') lbl.textContent = formatDate(state.cursor, { weekday: true, long: true });
if (state.view === 'agenda') lbl.textContent = t('calendar.agendaFrom', { date: formatDate(state.cursor) });
}
function getWeekNumber(dateStr) {
const d = new Date(dateStr + 'T00:00:00');
const jan = new Date(d.getFullYear(), 0, 1);
return Math.ceil(((d - jan) / 86400000 + jan.getDay() + 1) / 7);
}
async function navigate(dir) {
if (state.view === 'month') {
state.cursor = addMonths(state.cursor, dir);
} else if (state.view === 'week') {
const isMobile = window.matchMedia('(max-width: 639px)').matches;
state.cursor = addDays(state.cursor, dir * (isMobile ? 3 : 7));
} else if (state.view === 'day') {
state.cursor = addDays(state.cursor, dir);
} else if (state.view === 'agenda') {
state.cursor = addDays(state.cursor, dir * 30);
}
await reloadForView();
updateLabel();
renderView();
}
async function goToday() {
state.cursor = state.today;
await reloadForView();
updateLabel();
renderView();
}
async function reloadForView() {
let from, to;
if (state.view === 'month') ({ from, to } = getMonthRange(state.cursor));
if (state.view === 'week') ({ from, to } = getWeekRange(state.cursor));
if (state.view === 'day') { from = state.cursor; to = state.cursor; }
if (state.view === 'agenda') ({ from, to } = getAgendaRange(state.cursor));
if (from !== state.rangeFrom || to !== state.rangeTo) {
await loadRange(from, to);
}
}
// --------------------------------------------------------
// Ansicht-Dispatcher
// --------------------------------------------------------
function renderView() {
const body = _container.querySelector('#cal-body');
if (!body) return;
body.innerHTML = '';
if (state.view === 'month') renderMonthView(body);
if (state.view === 'week') renderWeekView(body);
if (state.view === 'day') renderDayView(body);
if (state.view === 'agenda') renderAgendaView(body);
if (window.lucide) lucide.createIcons();
}
// --------------------------------------------------------
// Monatsansicht
// --------------------------------------------------------
function renderMonthView(container) {
const d = new Date(state.cursor + 'T00:00:00');
const year = d.getFullYear();
const month = d.getMonth();
// Erster Tag des Monats
const firstDay = new Date(year, month, 1);
// Montag-basiert: 0=Mo … 6=So
let startOffset = firstDay.getDay() - 1;
if (startOffset < 0) startOffset = 6;
// 42 Tage anzeigen (6 Wochen)
const startDate = new Date(firstDay);
startDate.setDate(startDate.getDate() - startOffset);
const days = Array.from({ length: 42 }, (_, i) => {
const dt = new Date(startDate);
dt.setDate(startDate.getDate() + i);
return { date: isoDate(dt), inMonth: dt.getMonth() === month };
});
container.innerHTML = `
${[t('calendar.dayShortMonday'),t('calendar.dayShortTuesday'),t('calendar.dayShortWednesday'),t('calendar.dayShortThursday'),t('calendar.dayShortFriday'),t('calendar.dayShortSaturday'),t('calendar.dayShortSunday')].map((n) => `
${n}
`).join('')}
${days.map(({ date, inMonth }) => renderMonthDay(date, inMonth)).join('')}
`;
container.querySelector('#month-grid').addEventListener('click', (e) => {
const evEl = e.target.closest('.month-day__event');
if (evEl) {
e.stopPropagation();
const ev = state.events.find((ev) => ev.id === parseInt(evEl.dataset.id, 10));
if (ev) showEventPopup(ev, evEl);
return;
}
const dayEl = e.target.closest('.month-day');
if (dayEl) {
openEventModal({ mode: 'create', date: dayEl.dataset.date });
}
});
}
function renderMonthDay(date, inMonth) {
const evs = eventsOnDay(date);
const isToday = date === state.today;
const classes = [
'month-day',
!inMonth ? 'month-day--outside' : '',
isToday ? 'month-day--today' : '',
].filter(Boolean).join(' ');
const MAX_SHOW = 3;
const shown = evs.slice(0, MAX_SHOW);
const extra = evs.length - MAX_SHOW;
const evHtml = shown.map((ev) => {
const bg = ev.cal_color || ev.color;
const fg = getContrastColor(bg);
return `
${eventIconHtml(ev.icon, 'event-icon event-icon--compact')}${esc(ev.title)}${(ev.recurrence_rule || ev.is_recurring_instance) ? '' : ''}
`;
}).join('');
return `
${new Date(date + 'T00:00:00').getDate()}
${evHtml}
${extra > 0 ? `
${t('calendar.moreEvents', { count: extra })}
` : ''}
`;
}
// --------------------------------------------------------
// Wochenansicht
// --------------------------------------------------------
function renderWeekView(container) {
const isMobile = window.matchMedia('(max-width: 639px)').matches;
// Auf Mobile: 3-Tage-Fenster zentriert um state.cursor statt vollem Mo–So
const days = isMobile
? Array.from({ length: 3 }, (_, i) => addDays(state.cursor, i - 1))
: (() => {
const monday = getMondayOf(state.cursor);
return Array.from({ length: 7 }, (_, i) => addDays(monday, i));
})();
const colCount = days.length;
const alldayEvs = days.map((d) =>
eventsOnDay(d).filter((e) => e.all_day || !e.start_datetime.includes('T'))
);
const timedEvs = days.map((d) =>
eventsOnDay(d).filter((e) => !e.all_day && e.start_datetime.includes('T'))
);
const layouts = timedEvs.map((events) => layoutOverlaps(events));
container.innerHTML = `
${t('calendar.allDayShort')}
${days.map((d, i) => `
${alldayEvs[i].map((ev) => `
${eventIconHtml(ev.icon, 'event-icon event-icon--compact')}${esc(ev.title)}
`).join('')}
`).join('')}
`;
// Event-Delegation
container.querySelector('#week-cols').addEventListener('click', (e) => {
const evEl = e.target.closest('.week-event');
if (evEl) {
const ev = state.events.find((ev) => ev.id === parseInt(evEl.dataset.id, 10));
if (ev) showEventPopup(ev, evEl);
return;
}
const col = e.target.closest('[data-date]');
if (col) openEventModal({ mode: 'create', date: col.dataset.date });
});
container.querySelector('.allday-row').addEventListener('click', (e) => {
const evEl = e.target.closest('.allday-event');
if (evEl) {
const ev = state.events.find((ev) => ev.id === parseInt(evEl.dataset.id, 10));
if (ev) showEventPopup(ev, evEl);
}
});
// Scrollen zu aktueller Zeit
const scroll = container.querySelector('#week-scroll');
if (scroll) {
const h = new Date().getHours();
scroll.scrollTop = Math.max(0, h * HOUR_HEIGHT - 80);
}
}
function renderWeekEvent(ev, layout = null) {
const { start, end } = timeRangeForEvent(ev);
const duration = Math.max(end - start, 30);
const top = (start / 60) * HOUR_HEIGHT;
const height = (duration / 60) * HOUR_HEIGHT - 2;
const left = layout ? `calc(${(layout.colIndex / layout.totalCols) * 100}% + 2px)` : '2px';
const width = layout ? `calc(${100 / layout.totalCols}% - 4px)` : 'auto';
return `
${eventIconHtml(ev.icon, 'event-icon event-icon--compact')}${esc(ev.title)}${(ev.recurrence_rule || ev.is_recurring_instance) ? '' : ''}
${formatTime(ev.start_datetime)}${ev.end_datetime ? '–' + formatTime(ev.end_datetime) : ''}
`;
}
function timeToMinutes(timeStr) {
if (!timeStr) return 0;
const [h, m] = timeStr.split(':').map(Number);
return h * 60 + (m || 0);
}
function nowTop() {
const now = new Date();
const minutes = now.getHours() * 60 + now.getMinutes();
return (minutes / 60) * HOUR_HEIGHT;
}
function timeRangeForEvent(ev) {
const start = timeToMinutes(localTime(ev.start_datetime));
const end = ev.end_datetime
? timeToMinutes(localTime(ev.end_datetime))
: start + 60;
return {
start,
end: Math.max(end, start + 30),
};
}
function layoutOverlaps(events) {
const groups = [];
const sorted = [...events].sort((a, b) => {
const aRange = timeRangeForEvent(a);
const bRange = timeRangeForEvent(b);
return aRange.start - bRange.start || aRange.end - bRange.end;
});
let current = [];
let currentEnd = -1;
for (const ev of sorted) {
const range = timeRangeForEvent(ev);
if (!current.length || range.start < currentEnd) {
current.push(ev);
currentEnd = current.length === 1 ? range.end : Math.max(currentEnd, range.end);
} else {
groups.push(current);
current = [ev];
currentEnd = range.end;
}
}
if (current.length) groups.push(current);
const layout = new Map();
for (const group of groups) {
const columns = [];
const placements = [];
for (const ev of group) {
const range = timeRangeForEvent(ev);
let colIndex = columns.findIndex((end) => end <= range.start);
if (colIndex === -1) {
colIndex = columns.length;
columns.push(range.end);
} else {
columns[colIndex] = range.end;
}
placements.push({ ev, colIndex });
}
const totalCols = Math.max(columns.length, 1);
for (const placement of placements) {
layout.set(placement.ev.id, {
colIndex: placement.colIndex,
totalCols,
});
}
}
return layout;
}
// --------------------------------------------------------
// Tagesansicht
// --------------------------------------------------------
function renderDayView(container) {
const dt = new Date(state.cursor + 'T00:00:00');
const dayEvs = eventsOnDay(state.cursor);
const allday = dayEvs.filter((e) => e.all_day || !e.start_datetime.includes('T'));
const timed = dayEvs.filter((e) => !e.all_day && e.start_datetime.includes('T'));
const layout = layoutOverlaps(timed);
container.innerHTML = `
${allday.length ? `
${t('calendar.allDayShort')}
${allday.map((ev) => `
${eventIconHtml(ev.icon, 'event-icon event-icon--compact')}${esc(ev.title)}
`).join('')}
` : ''}
`;
container.querySelector('#day-col').addEventListener('click', (e) => {
const evEl = e.target.closest('.week-event');
if (evEl) {
const ev = state.events.find((ev) => ev.id === parseInt(evEl.dataset.id, 10));
if (ev) showEventPopup(ev, evEl);
return;
}
openEventModal({ mode: 'create', date: state.cursor });
});
const scroll = container.querySelector('#day-scroll');
if (scroll) {
const h = new Date().getHours();
scroll.scrollTop = Math.max(0, h * HOUR_HEIGHT - 80);
}
}
// --------------------------------------------------------
// Agenda-Ansicht
// --------------------------------------------------------
function renderAgendaView(container) {
const { from, to } = getAgendaRange(state.cursor);
const days = Array.from({ length: 31 }, (_, i) => addDays(from, i));
const groups = days
.map((d) => ({ date: d, events: eventsOnDay(d) }))
.filter((g) => g.events.length > 0);
container.innerHTML = `
${groups.length === 0
? `
${t('calendar.noEvents')}
`
: groups.map(({ date, events }) => `
${events.map((ev) => renderAgendaEvent(ev)).join('')}
`).join('')
}
`;
stagger(container.querySelectorAll('.agenda-event'));
container.querySelector('#agenda-view').addEventListener('click', (e) => {
const evEl = e.target.closest('.agenda-event');
if (evEl) {
const ev = state.events.find((ev) => ev.id === parseInt(evEl.dataset.id, 10));
if (ev) showEventPopup(ev, evEl);
}
});
}
function renderAgendaEvent(ev) {
const timeStr = ev.all_day
? t('calendar.allDay')
: formatTime(ev.start_datetime)
+ (ev.end_datetime ? ` – ${formatTime(ev.end_datetime)} ${t('calendar.timeSuffix')}`.trimEnd() : ` ${t('calendar.timeSuffix')}`.trimEnd());
const initials = ev.assigned_name
? ev.assigned_name.split(' ').map((w) => w[0]).join('').toUpperCase().slice(0, 2)
: '';
const displayColor = ev.cal_color || ev.color;
return `
${eventIconHtml(ev.icon)}${esc(ev.title)}${(ev.recurrence_rule || ev.is_recurring_instance) ? ' ' : ''}
${timeStr}
${ev.location ? `📍 ${esc(fmtLocation(ev.location))}` : ''}
${ev.cal_name ? `${esc(ev.cal_name)}` : ''}
${ev.assigned_name ? `
${initials}
${esc(ev.assigned_name)}
` : ''}
`;
}
// --------------------------------------------------------
// Event-Popup (Detail-Ansicht bei Klick auf Termin)
// --------------------------------------------------------
function showEventPopup(ev, anchor) {
document.querySelector('#event-popup')?.remove();
const popup = document.createElement('div');
popup.id = 'event-popup';
popup.className = 'event-popup';
const timeStr = ev.all_day
? t('calendar.allDay')
: formatDateTime(ev.start_datetime)
+ (ev.end_datetime ? ` – ${formatTime(ev.end_datetime)}${t('calendar.timeSuffix') ? ' ' + t('calendar.timeSuffix') : ''}`.trim() : '');
const displayColor = ev.cal_color || ev.color;
popup.innerHTML = `
`;
document.body.appendChild(popup);
if (window.lucide) lucide.createIcons();
if (ev.external_source === 'ics' && ev.user_modified === 1) {
const resetLink = document.createElement('a');
resetLink.href = '#';
resetLink.className = 'event-popup__reset-link';
resetLink.textContent = t('calendar.ics.reset');
resetLink.style.cssText = 'display:block;text-align:center;font-size:var(--text-xs);color:var(--color-text-secondary);margin-top:var(--space-2);cursor:pointer;text-decoration:underline;';
resetLink.addEventListener('click', async (e) => {
e.preventDefault();
try {
await api.post(`/calendar/${ev.id}/reset`, {});
popup.remove();
await reloadForView();
window.oikos?.showToast(t('calendar.ics.resetToast'), 'success');
} catch (err) {
window.oikos?.showToast(err.message, 'danger');
}
});
popup.querySelector('.event-popup__actions').before(resetLink);
}
// Positionierung: erst messen, dann im Viewport halten.
const rect = anchor.getBoundingClientRect();
const gap = 8;
const margin = 8;
const popupRect = popup.getBoundingClientRect();
const viewportWidth = document.documentElement.clientWidth;
const viewportHeight = document.documentElement.clientHeight;
const fitsBelow = rect.bottom + gap + popupRect.height <= viewportHeight - margin;
const top = fitsBelow
? rect.bottom + gap
: Math.max(margin, rect.top - gap - popupRect.height);
const left = Math.min(
Math.max(margin, rect.left),
Math.max(margin, viewportWidth - popupRect.width - margin)
);
const maxTop = Math.max(margin, viewportHeight - popupRect.height - margin);
popup.style.top = `${Math.min(Math.max(margin, top), maxTop)}px`;
popup.style.left = `${left}px`;
popup.querySelector('#popup-edit').addEventListener('click', async () => {
popup.remove();
const reminder = await loadReminderForEvent(ev.id);
openEventModal({ mode: 'edit', event: ev, reminder });
});
popup.querySelector('#popup-delete').addEventListener('click', async () => {
popup.remove();
await deleteEvent(ev.id);
});
// Schließen bei Klick außerhalb
setTimeout(() => {
document.addEventListener('click', function closePopup(e) {
if (!popup.isConnected || !popup.contains(e.target)) {
popup.remove();
document.removeEventListener('click', closePopup);
}
});
}, 0);
}
// --------------------------------------------------------
// Reminder-Helfer für Kalender-Events
// --------------------------------------------------------
async function loadReminderForEvent(eventId) {
try {
const data = await api.get(`/reminders?entity_type=event&entity_id=${eventId}`);
return data.data;
} catch {
return null;
}
}
const REMINDER_OFFSETS = () => [
{ value: '', label: t('reminders.offsetNone') },
{ value: '0', label: t('reminders.offsetAtTime') },
{ value: '15', label: t('reminders.offset15min') },
{ value: '60', label: t('reminders.offset1hour') },
{ value: '1440', label: t('reminders.offset1day') },
{ value: '2880', label: t('reminders.offset2days') },
{ value: '10080', label: t('reminders.offset1week') },
{ value: '20160', label: t('reminders.offset2weeks') },
{ value: 'custom', label: t('reminders.offsetCustom') },
];
function reminderOffsetFromEvent(event, reminder) {
if (!reminder || !event?.start_datetime) return '';
const remindMs = new Date(reminder.remind_at).getTime();
const startMs = new Date(reminderStartValue(event.start_datetime)).getTime();
const diffMin = Math.round((startMs - remindMs) / 60000);
const opts = [0, 15, 60, 1440, 2880, 10080, 20160];
const match = opts.find((o) => o === diffMin);
return match !== undefined ? String(match) : 'custom';
}
function customReminderFromEvent(event, reminder) {
const fallback = { amount: 1, unit: 'days' };
if (!reminder || !event?.start_datetime) return fallback;
const diffMin = Math.max(0, Math.round(
(new Date(reminderStartValue(event.start_datetime)).getTime() - new Date(reminder.remind_at).getTime()) / 60000
));
if (diffMin % 10080 === 0 && diffMin >= 10080) return { amount: diffMin / 10080, unit: 'weeks' };
if (diffMin % 1440 === 0 && diffMin >= 1440) return { amount: diffMin / 1440, unit: 'days' };
if (diffMin % 60 === 0 && diffMin >= 60) return { amount: diffMin / 60, unit: 'hours' };
return { amount: Math.max(diffMin, 1), unit: 'minutes' };
}
function customReminderMinutes(amount, unit) {
const value = Math.max(parseInt(amount, 10) || 1, 1);
if (unit === 'weeks') return value * 10080;
if (unit === 'days') return value * 1440;
if (unit === 'hours') return value * 60;
return value;
}
function reminderStartValue(startDatetime) {
return startDatetime?.includes('T') ? startDatetime : `${startDatetime}T09:00`;
}
function toLocalDateTimeString(date) {
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
function renderCalendarReminderSection(reminder = null, event = null) {
const currentOffset = event ? reminderOffsetFromEvent(event, reminder) : '';
const custom = customReminderFromEvent(event, reminder);
return `
`;
}
function bindTimeInputs(root) {
root.querySelectorAll('.js-time-input').forEach((input) => {
input.addEventListener('blur', () => {
const parsed = parseTimeInput(input.value);
if (parsed) input.value = formatTimeInput(parsed);
});
});
}
// --------------------------------------------------------
// Event-Modal (Erstellen / Bearbeiten)
// --------------------------------------------------------
function openEventModal({ mode, event = null, date = null, reminder = null }) {
const isEdit = mode === 'edit';
const content = buildEventModalContent({ mode, event, date, reminder });
openSharedModal({
title: isEdit ? t('calendar.editEvent') : t('calendar.newEvent'),
content,
size: 'md',
onSave(panel) {
// RRULE-Events binden
bindRRuleEvents(panel, 'event');
const selectedColor = isEdit ? (event?.color || EVENT_COLORS[0]) : EVENT_COLORS[0];
// Farb-Auswahl: Auswahl + ARIA + Keyboard (Roving Tabindex)
function selectSwatch(target) {
panel.querySelectorAll('.color-swatch').forEach((s) => {
s.classList.remove('color-swatch--active');
s.setAttribute('aria-checked', 'false');
s.setAttribute('tabindex', '-1');
});
target.classList.add('color-swatch--active');
target.setAttribute('aria-checked', 'true');
target.setAttribute('tabindex', '0');
}
panel.querySelectorAll('.color-swatch').forEach((sw) => {
if (sw.dataset.color === selectedColor) selectSwatch(sw);
sw.addEventListener('click', () => { selectSwatch(sw); sw.focus(); });
sw.addEventListener('keydown', (e) => {
const swatches = [...panel.querySelectorAll('.color-swatch')];
const idx = swatches.indexOf(sw);
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
e.preventDefault();
const next = swatches[(idx + 1) % swatches.length];
selectSwatch(next); next.focus();
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
e.preventDefault();
const prev = swatches[(idx - 1 + swatches.length) % swatches.length];
selectSwatch(prev); prev.focus();
} else if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
selectSwatch(sw);
}
});
});
// Ganztägig-Toggle
const alldayCheck = panel.querySelector('#modal-allday');
const timeFields = panel.querySelector('#time-fields');
const alldayFields = panel.querySelector('#allday-fields');
alldayCheck.addEventListener('change', () => {
if (alldayCheck.checked) { timeFields.style.display = 'none'; alldayFields.style.display = ''; }
else { timeFields.style.display = ''; alldayFields.style.display = 'none'; }
});
if (isEdit && event?.all_day) { timeFields.style.display = 'none'; alldayFields.style.display = ''; }
bindDateInputs(panel);
bindTimeInputs(panel);
const iconInput = panel.querySelector('#modal-icon');
const iconTrigger = panel.querySelector('#modal-icon-trigger');
const iconGrid = panel.querySelector('#modal-icon-grid');
const selectIcon = (icon) => {
const nextIcon = eventIconName(icon);
if (iconInput) iconInput.value = nextIcon;
if (iconTrigger) {
iconTrigger.dataset.icon = nextIcon;
iconTrigger.replaceChildren(eventIconElement(nextIcon, 'event-icon-picker__trigger-icon'));
}
iconGrid?.querySelectorAll('.event-icon-picker__option').forEach((btn) => {
const active = btn.dataset.icon === nextIcon;
btn.classList.toggle('event-icon-picker__option--active', active);
btn.setAttribute('aria-checked', active ? 'true' : 'false');
});
if (window.lucide) lucide.createIcons();
};
iconTrigger?.addEventListener('click', () => {
if (!iconGrid) return;
iconGrid.hidden = !iconGrid.hidden;
iconTrigger.setAttribute('aria-expanded', iconGrid.hidden ? 'false' : 'true');
});
iconGrid?.addEventListener('click', (e) => {
const btn = e.target.closest('.event-icon-picker__option');
if (!btn) return;
selectIcon(btn.dataset.icon);
iconGrid.hidden = true;
iconTrigger?.setAttribute('aria-expanded', 'false');
iconTrigger?.focus();
});
const iconSearch = iconGrid?.querySelector('#modal-icon-search');
iconSearch?.addEventListener('input', () => {
const q = iconSearch.value.trim().toLowerCase();
const resultsEl = iconGrid?.querySelector('#modal-icon-results');
if (!resultsEl) return;
if (!q) {
resultsEl.replaceChildren();
resultsEl.insertAdjacentHTML('afterbegin', EVENT_ICON_CATEGORIES().map((cat) => `
${esc(cat.label)}
${cat.icons.map((icon) => `
`).join('')}
`).join(''));
if (window.lucide) lucide.createIcons({ el: resultsEl });
return;
}
const allIcons = EVENT_ICON_CATEGORIES().flatMap((c) => c.icons);
const filtered = allIcons.filter((i) => i.label.toLowerCase().includes(q) || i.value.includes(q));
resultsEl.replaceChildren();
if (filtered.length === 0) {
resultsEl.insertAdjacentHTML('afterbegin', `${esc(t('calendar.iconSearchEmpty'))}
`);
return;
}
resultsEl.insertAdjacentHTML('afterbegin', `
${filtered.map((icon) => `
`).join('')}
`);
if (window.lucide) lucide.createIcons({ el: resultsEl });
});
document.addEventListener('click', function closeIconPicker(e) {
if (!panel.isConnected) {
document.removeEventListener('click', closeIconPicker);
return;
}
if (iconGrid?.hidden || iconGrid?.contains(e.target) || iconTrigger?.contains(e.target)) return;
iconGrid.hidden = true;
iconTrigger?.setAttribute('aria-expanded', 'false');
});
const reminderOffset = panel.querySelector('#modal-reminder-offset');
const reminderCustom = panel.querySelector('#modal-reminder-custom');
const attachmentInput = panel.querySelector('#modal-attachment');
const selectedAttachment = panel.querySelector('#modal-selected-attachment');
const attachmentPreview = panel.querySelector('#modal-attachment-preview');
const attachmentState = {
name: event?.attachment_name || null,
mime: event?.attachment_mime || null,
size: event?.attachment_size || null,
data: event?.attachment_data || null,
};
const syncSelectedAttachment = () => {
if (!selectedAttachment) return;
selectedAttachment.hidden = !attachmentState.name;
selectedAttachment.textContent = attachmentState.name ? selectedAttachmentLabel(attachmentState.name) : '';
};
const syncAttachmentSelection = () => {
if (!selectedAttachment) return;
const file = attachmentInput.files?.[0];
if (file) {
selectedAttachment.hidden = false;
selectedAttachment.textContent = selectedAttachmentLabel(file.name);
if (attachmentPreview) {
attachmentPreview.replaceChildren();
attachmentPreview.hidden = true;
}
return;
}
syncSelectedAttachment();
};
attachmentInput?.addEventListener('change', syncAttachmentSelection);
const attachmentDropzone = panel.querySelector('#modal-attachment-dropzone');
if (attachmentDropzone && attachmentInput) {
['dragenter', 'dragover'].forEach((eventName) => {
attachmentDropzone.addEventListener(eventName, (dropEvent) => {
dropEvent.preventDefault();
attachmentDropzone.classList.add('document-dropzone--active');
});
});
['dragleave', 'drop'].forEach((eventName) => {
attachmentDropzone.addEventListener(eventName, (dropEvent) => {
dropEvent.preventDefault();
attachmentDropzone.classList.remove('document-dropzone--active');
});
});
attachmentDropzone.addEventListener('drop', (dropEvent) => {
const file = dropEvent.dataTransfer?.files?.[0];
if (!file) return;
const transfer = new DataTransfer();
transfer.items.add(file);
attachmentInput.files = transfer.files;
syncAttachmentSelection();
});
}
syncSelectedAttachment();
reminderOffset?.addEventListener('change', () => {
if (reminderCustom) reminderCustom.hidden = reminderOffset.value !== 'custom';
});
panel.querySelector('#modal-cancel').addEventListener('click', closeModal);
panel.querySelector('#modal-delete')?.addEventListener('click', async () => {
closeModal({ force: true });
await deleteEvent(event.id);
});
panel.querySelector('#modal-save').addEventListener('click', () => saveEvent(panel, mode, event?.id, reminder, attachmentState));
if (window.lucide) lucide.createIcons();
},
});
}
function buildEventModalContent({ mode, event, date, reminder = null }) {
const isEdit = mode === 'edit';
const today = date || state.today;
const startDate = isEdit ? localDate(event.start_datetime) : today;
const startTime = isEdit && event.start_datetime.length > 10
? localTime(event.start_datetime) : '09:00';
const endDate = isEdit && event.end_datetime ? localDate(event.end_datetime) : startDate;
const endTime = isEdit && event.end_datetime && event.end_datetime.length > 10
? localTime(event.end_datetime) : '10:00';
const selectedIcon = eventIconName(isEdit ? event.icon : 'calendar');
const iconCats = EVENT_ICON_CATEGORIES();
const iconCategoryButtons = iconCats.map((cat) => `
${esc(cat.label)}
${cat.icons.map((icon) => `
`).join('')}
`).join('');
const userOpts = [
``,
...state.users.map((u) =>
``
),
].join('');
return `
${renderRRuleFields('event', isEdit ? event.recurrence_rule : null)}
${renderCalendarReminderSection(reminder, event)}
`;
}
async function saveEvent(overlay, mode, eventId, existingReminder = null, attachmentState = null) {
const saveBtn = overlay.querySelector('#modal-save');
const title = overlay.querySelector('#modal-title').value.trim();
if (!title) {
window.oikos?.showToast(t('calendar.titleRequired'), 'error');
return;
}
const allday = overlay.querySelector('#modal-allday').checked;
const color = overlay.querySelector('.color-swatch--active')?.dataset.color || EVENT_COLORS[0];
const icon = eventIconName(overlay.querySelector('#modal-icon')?.value);
const location = overlay.querySelector('#modal-location').value.trim() || null;
const assigned_to = overlay.querySelector('#modal-assigned').value || null;
const description = overlay.querySelector('#modal-description').value.trim() || null;
let start_datetime, end_datetime;
if (allday) {
start_datetime = readDateInput(overlay, '#modal-allday-start')
|| readDateInput(overlay, '#modal-start-date');
end_datetime = readDateInput(overlay, '#modal-allday-end')
|| readDateInput(overlay, '#modal-end-date');
end_datetime = end_datetime || null;
} else {
const sd = readDateInput(overlay, '#modal-start-date');
const stRaw = overlay.querySelector('#modal-start-time').value;
const st = parseTimeInput(stRaw);
const ed = readDateInput(overlay, '#modal-end-date');
const etRaw = overlay.querySelector('#modal-end-time').value;
const et = parseTimeInput(etRaw);
if ((stRaw && !st) || (etRaw && !et)) {
window.oikos?.showToast(t('calendar.invalidDate'), 'error');
return;
}
start_datetime = st ? `${sd}T${st}` : sd;
end_datetime = ed ? (et ? `${ed}T${et}` : ed) : null;
}
const visibleDateFields = allday
? ['#modal-allday-start', '#modal-allday-end']
: ['#modal-start-date', '#modal-end-date'];
const hasInvalidDate = visibleDateFields.some((selector) => !isDateInputValid(overlay.querySelector(selector)?.value));
if (!start_datetime || hasInvalidDate) {
window.oikos?.showToast(t('calendar.invalidDate'), 'error');
return;
}
saveBtn.disabled = true;
saveBtn.textContent = '…';
try {
const rrule = getRRuleValues(overlay, 'event');
if (!rrule.valid_until) {
window.oikos?.showToast(t('calendar.invalidDate'), 'error');
saveBtn.disabled = false;
saveBtn.textContent = mode === 'edit' ? t('common.save') : t('common.create');
return;
}
const attachmentPayload = {
name: attachmentState?.name || null,
mime: attachmentState?.mime || null,
size: attachmentState?.size || null,
data: attachmentState?.data || null,
};
const attachmentFile = overlay.querySelector('#modal-attachment')?.files?.[0];
if (attachmentFile) {
if (attachmentFile.size > MAX_ATTACHMENT_BYTES) throw new Error(t('calendar.attachmentTooLarge'));
attachmentPayload.name = attachmentFile.name;
attachmentPayload.mime = attachmentFile.type || 'application/octet-stream';
attachmentPayload.size = attachmentFile.size;
attachmentPayload.data = await readFileAsDataUrl(attachmentFile);
}
const body = {
title, description, start_datetime, end_datetime,
all_day: allday ? 1 : 0,
location, color, icon, assigned_to: assigned_to ? parseInt(assigned_to, 10) : null,
recurrence_rule: rrule.recurrence_rule,
attachment_name: attachmentPayload.name,
attachment_mime: attachmentPayload.mime,
attachment_size: attachmentPayload.size,
attachment_data: attachmentPayload.data,
};
let savedEventId = eventId;
if (mode === 'create') {
const res = await api.post('/calendar', body);
state.events.push(res.data);
savedEventId = res.data?.id;
} else {
const res = await api.put(`/calendar/${eventId}`, body);
const idx = state.events.findIndex((e) => e.id === eventId);
if (idx !== -1) state.events[idx] = res.data;
}
// Erinnerung speichern oder löschen
if (savedEventId) {
const offsetSel = overlay.querySelector('#modal-reminder-offset');
const offsetVal = offsetSel?.value;
if (offsetVal !== '' && offsetVal !== undefined) {
// Remind-Zeitpunkt = start_datetime - offset (in Minuten)
const startMs = new Date(reminderStartValue(start_datetime)).getTime();
const offsetMinutes = offsetVal === 'custom'
? customReminderMinutes(
overlay.querySelector('#modal-reminder-custom-amount')?.value,
overlay.querySelector('#modal-reminder-custom-unit')?.value
)
: parseInt(offsetVal, 10);
const remindAt = toLocalDateTimeString(new Date(startMs - offsetMinutes * 60000));
await api.post('/reminders', { entity_type: 'event', entity_id: savedEventId, remind_at: remindAt });
refreshReminders();
} else {
api.delete(`/reminders?entity_type=event&entity_id=${savedEventId}`).catch(() => {});
refreshReminders();
}
}
closeModal({ force: true });
renderView();
window.oikos?.showToast(mode === 'create' ? t('calendar.createdToast') : t('calendar.savedToast'), 'success');
} catch (err) {
window.oikos?.showToast(err.data?.error ?? err.message ?? t('calendar.saveError'), 'error');
saveBtn.disabled = false;
saveBtn.textContent = mode === 'edit' ? t('common.save') : t('common.create');
}
}
async function deleteEvent(id) {
const event = state.events.find((e) => e.id === id);
state.events = state.events.filter((e) => e.id !== id);
renderView();
let undone = false;
window.oikos?.showToast(t('calendar.deletedToast'), 'default', 5000, () => {
undone = true;
if (event) {
state.events = [...state.events, event];
renderView();
}
});
setTimeout(async () => {
if (undone) return;
try {
await api.delete(`/calendar/${id}`);
api.delete(`/reminders?entity_type=event&entity_id=${id}`).catch(() => {});
refreshReminders();
} catch (err) {
if (event) {
state.events = [...state.events, event];
renderView();
}
window.oikos?.showToast(err.data?.error ?? t('calendar.deleteError'), 'danger');
}
}, 5000);
}