/**
* 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 } 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 = {
tooth: 'drill',
};
const EVENT_ICONS = [
{ value: 'calendar', label: 'Calendar' },
{ value: 'drill', label: 'Dentist' },
{ value: 'alarm-clock', label: 'Alarm' },
{ value: 'clock', label: 'Time' },
{ value: 'bell', label: 'Reminder' },
{ value: 'map-pin', label: 'Location' },
{ value: 'home', label: 'Home' },
{ value: 'house', label: 'House' },
{ value: 'building', label: 'Building' },
{ value: 'hospital', label: 'Hospital' },
{ value: 'stethoscope', label: 'Doctor' },
{ value: 'syringe', label: 'Vaccine' },
{ value: 'pill', label: 'Medicine' },
{ value: 'tablets', label: 'Tablets' },
{ value: 'bandage', label: 'Bandage' },
{ value: 'ambulance', label: 'Ambulance' },
{ value: 'heart-pulse', label: 'Health' },
{ value: 'activity', label: 'Activity' },
{ value: 'cross', label: 'Care' },
{ value: 'scissors', label: 'Haircut' },
{ value: 'shower-head', label: 'Personal care' },
{ value: 'dumbbell', label: 'Sports' },
{ value: 'trophy', label: 'Competition' },
{ value: 'car', label: 'Car' },
{ value: 'bus', label: 'Bus' },
{ value: 'train', label: 'Train' },
{ value: 'tram-front', label: 'Transit' },
{ value: 'fuel', label: 'Fuel' },
{ value: 'parking-meter', label: 'Parking' },
{ value: 'traffic-cone', label: 'Traffic' },
{ value: 'navigation', label: 'Navigation' },
{ value: 'route', label: 'Route' },
{ value: 'briefcase', label: 'Work' },
{ value: 'laptop', label: 'Laptop' },
{ value: 'monitor', label: 'Computer' },
{ value: 'presentation', label: 'Presentation' },
{ value: 'plane', label: 'Travel' },
{ value: 'plane-takeoff', label: 'Flight' },
{ value: 'school', label: 'School' },
{ value: 'graduation-cap', label: 'Education' },
{ value: 'book-open', label: 'Reading' },
{ value: 'library', label: 'Library' },
{ value: 'pencil', label: 'Study' },
{ value: 'notebook-pen', label: 'Notes' },
{ value: 'calculator', label: 'Calculator' },
{ value: 'utensils', label: 'Meal' },
{ value: 'cooking-pot', label: 'Cooking' },
{ value: 'coffee', label: 'Coffee' },
{ value: 'cake', label: 'Birthday' },
{ value: 'croissant', label: 'Bakery' },
{ value: 'pizza', label: 'Pizza' },
{ value: 'ice-cream', label: 'Dessert' },
{ value: 'beer', label: 'Bar' },
{ value: 'wine', label: 'Wine' },
{ value: 'popcorn', label: 'Cinema' },
{ value: 'sandwich', label: 'Snack' },
{ value: 'salad', label: 'Salad' },
{ value: 'shopping-bag', label: 'Shopping' },
{ value: 'shopping-cart', label: 'Groceries' },
{ value: 'gift', label: 'Gift' },
{ value: 'package', label: 'Package' },
{ value: 'shirt', label: 'Clothing' },
{ value: 'tag', label: 'Tag' },
{ value: 'credit-card', label: 'Card' },
{ value: 'wallet', label: 'Wallet' },
{ value: 'banknote', label: 'Cash' },
{ value: 'coins', label: 'Coins' },
{ value: 'piggy-bank', label: 'Savings' },
{ value: 'receipt', label: 'Receipt' },
{ value: 'landmark', label: 'Bank' },
{ value: 'music', label: 'Music' },
{ value: 'guitar', label: 'Guitar' },
{ value: 'film', label: 'Movie' },
{ value: 'theater', label: 'Theater' },
{ value: 'ticket', label: 'Ticket' },
{ value: 'gamepad-2', label: 'Game' },
{ value: 'camera', label: 'Photo' },
{ value: 'party-popper', label: 'Party' },
{ value: 'users', label: 'Family' },
{ value: 'baby', label: 'Baby' },
{ value: 'dog', label: 'Dog' },
{ value: 'cat', label: 'Cat' },
{ value: 'paw-print', label: 'Pet' },
{ value: 'wrench', label: 'Repair' },
{ value: 'hammer', label: 'Maintenance' },
{ value: 'paintbrush', label: 'Decoration' },
{ value: 'lightbulb', label: 'Idea' },
{ value: 'sofa', label: 'Furniture' },
{ value: 'bed', label: 'Bed' },
{ value: 'bath', label: 'Bath' },
{ value: 'washing-machine', label: 'Laundry' },
{ value: 'refrigerator', label: 'Fridge' },
{ value: 'star', label: 'Favorite' },
{ value: 'flag', label: 'Flag' },
{ value: 'target', label: 'Goal' },
{ value: 'flame', label: 'Important' },
{ value: 'leaf', label: 'Nature' },
{ value: 'tree-pine', label: 'Outdoors' },
{ value: 'flower', label: 'Flower' },
{ value: 'sun', label: 'Day' },
{ value: 'moon', label: 'Night' },
{ value: 'cloud-sun', label: 'Weather' },
];
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())}`; }
// 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 eventIconHtml(icon, className = 'event-icon') {
return ``;
}
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 = 'month';
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;
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') {
state.cursor = addDays(state.cursor, dir * 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)}
`;
}).join('');
return `
${new Date(date + 'T00:00:00').getDate()}
${evHtml}
${extra > 0 ? `
${t('calendar.moreEvents', { count: extra })}
` : ''}
`;
}
// --------------------------------------------------------
// Wochenansicht
// --------------------------------------------------------
function renderWeekView(container) {
const monday = getMondayOf(state.cursor);
const days = Array.from({ length: 7 }, (_, i) => addDays(monday, i));
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'))
);
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) {
const start = timeToMinutes(localTime(ev.start_datetime));
const end = ev.end_datetime
? timeToMinutes(localTime(ev.end_datetime))
: start + 60;
const duration = Math.max(end - start, 30);
const top = (start / 60) * HOUR_HEIGHT;
const height = (duration / 60) * HOUR_HEIGHT - 2;
return `
${eventIconHtml(ev.icon, 'event-icon event-icon--compact')}${esc(ev.title)}
${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;
}
// --------------------------------------------------------
// 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'));
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
const rect = anchor.getBoundingClientRect();
const top = Math.min(rect.bottom + 8, window.innerHeight - 280);
const left = Math.min(rect.left, window.innerWidth - 340);
popup.style.top = `${Math.max(8, top)}px`;
popup.style.left = `${Math.max(8, 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 `
`;
}
// --------------------------------------------------------
// 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);
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;
const iconEl = iconTrigger.querySelector('[data-lucide]');
iconEl?.setAttribute('data-lucide', nextIcon);
}
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();
});
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');
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));
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 iconButtons = EVENT_ICONS.map((icon) =>
``
).join('');
const userOpts = [
``,
...state.users.map((u) =>
``
),
].join('');
return `
${iconButtons}
${renderRRuleFields('event', isEdit ? event.recurrence_rule : null)}
${renderCalendarReminderSection(reminder, event)}
`;
}
async function saveEvent(overlay, mode, eventId, existingReminder = 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 st = overlay.querySelector('#modal-start-time').value;
const ed = readDateInput(overlay, '#modal-end-date');
const et = overlay.querySelector('#modal-end-time').value;
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 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,
};
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 ?? 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);
}