/*!
 * pp-calendar.js — PPPHP Calendar / event-management app (no CDN, no framework).
 *
 * PPCalendarApp.mount(el, { base, token, firstDay, defaultView, emailReminders, allowPersonal })
 *   `base` is the generated/calendar/index.php URL (both API + page).
 *
 * Talks to the CalendarService JSON API: bootstrap, events, event, create_event,
 * update_event, delete_event, reschedule, create_calendar, rsvp, principals,
 * import, export. Views: month / week / day / agenda. Full event editor with
 * sharing (everyone/group/user · view/edit), attendees + RSVP, recurrence and
 * reminders. Drag-to-move + resize. Theme-token styled (see pp-calendar.css).
 */
(function (global) {
    'use strict';

    /* ───────── date helpers ───────── */
    var DOW = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
    var MON = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
    function pad(n) { return (n < 10 ? '0' : '') + n; }
    function isoD(d) { return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()); }
    function isoDT(d) { return isoD(d) + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':00'; }
    function fmtDt(d, allDay) { return allDay ? isoD(d) : (isoD(d) + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes())); }
    function parse(s) {
        if (!s) return null;
        var m = String(s).match(/^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2})(?::(\d{2}))?)?/);
        if (!m) { var d = new Date(s); return isNaN(d) ? null : d; }
        return new Date(+m[1], +m[2] - 1, +m[3], +(m[4] || 0), +(m[5] || 0), +(m[6] || 0));
    }
    function addDays(d, n) { var x = new Date(d); x.setDate(x.getDate() + n); return x; }
    function sameDay(a, b) { return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate(); }
    function startOfWeek(d, fd) { var x = new Date(d.getFullYear(), d.getMonth(), d.getDate()); return addDays(x, -(((x.getDay() - fd) + 7) % 7)); }
    function timeLbl(d) { var h = d.getHours(), m = d.getMinutes(), ap = h < 12 ? 'am' : 'pm', hh = h % 12 || 12; return hh + (m ? ':' + pad(m) : '') + ap; }
    function esc(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) { return ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]; }); }
    // Render any icon class (bootstrap / FA / material) — mirrors the Designer.
    function iconHtml(cls) {
        var v = String(cls == null ? '' : cls).trim();
        if (!v) return '';
        if (v.indexOf('material-symbols') === 0) { var mp = v.split(/\s+/); return '<span class="material-symbols-outlined">' + esc(mp[mp.length - 1]) + '</span>'; }
        if (v.indexOf(' ') > -1) return '<i class="' + esc(v) + '"></i>';
        return '<i class="bi ' + esc(v) + '"></i>';
    }
    // Per-button inline style from a build-sanitised {bg,color,font,size} bundle.
    function btnStyleStr(cfg) {
        cfg = cfg || {}; var s = '';
        if (cfg.bg) s += 'background:' + cfg.bg + ';border-color:' + cfg.bg + ';';
        if (cfg.color) s += 'color:' + cfg.color + ';';
        if (cfg.font) s += 'font-family:' + cfg.font + ';';
        if (cfg.size) s += 'font-size:' + cfg.size + ';';
        return s;
    }
    function el(tag, cls, html) { var e = document.createElement(tag); if (cls) e.className = cls; if (html != null) e.innerHTML = html; return e; }
    function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
    // A small stacked-avatar row from an event's attendees (initials circles).
    function avatars(atts, max) {
        atts = atts || []; if (!atts.length) return '';
        max = max || 3;
        var shown = atts.slice(0, max).map(function (a) { return '<span class="ppcal-av" title="' + esc(a.name || '') + '">' + esc(a.initials || '?') + '</span>'; }).join('');
        var more = atts.length > max ? '<span class="ppcal-av ppcal-av-more">+' + (atts.length - max) + '</span>' : '';
        return '<span class="ppcal-avs">' + shown + more + '</span>';
    }

    var PALETTE = ['#2563eb', '#16a34a', '#dc2626', '#d97706', '#7c3aed', '#0891b2', '#db2777', '#4b5563', '#65a30d', '#0d9488'];

    function App(root, opts) {
        this.root = root;
        this.o = opts || {};
        this.base = this.o.base;
        this.token = this.o.token || '';
        this.firstDay = this.o.firstDay || 0;
        this.view = this.o.defaultView || 'month';
        // Never open on a view the Designer disabled.
        if (this.o.views && this.o.views.length && this.o.views.indexOf(this.view) < 0) this.view = this.o.views[0];
        this.cursor = new Date();
        this.me = 0;
        this.calendars = [];
        this.hidden = {};       // calendarId -> true when toggled off
        this.events = [];
        this.principals = null; // {users, groups} lazy
        this._build();
        this.boot();
    }

    /* ───────── API ───────── */
    App.prototype.api = function (action, params, body) {
        var url = this.base + '?action=' + encodeURIComponent(action);
        if (params) Object.keys(params).forEach(function (k) { if (params[k] != null) url += '&' + k + '=' + encodeURIComponent(params[k]); });
        var init = { credentials: 'same-origin' };
        if (body) {
            body._token = this.token;
            init.method = 'POST';
            init.headers = { 'Content-Type': 'application/json', 'X-CSRF-Token': this.token };
            init.body = JSON.stringify(body);
        }
        return fetch(url, init).then(function (r) { return r.json(); });
    };

    App.prototype.boot = function () {
        var self = this;
        this.api('bootstrap').then(function (d) {
            if (!d || !d.success) { self.body.innerHTML = '<div class="ppcal-empty">Calendar unavailable.</div>'; return; }
            self.me = d.me; self.calendars = d.calendars || []; self.settings = d.settings || {};
            self.renderShell();
            self.reload();
        }).catch(function () { self.body.innerHTML = '<div class="ppcal-empty">Could not load the calendar.</div>'; });
    };

    App.prototype.loadPrincipals = function () {
        var self = this;
        if (this.principals) { App._princ = this.principals; return Promise.resolve(this.principals); }
        return this.api('principals').then(function (d) { self.principals = { users: (d && d.users) || [], groups: (d && d.groups) || [] }; App._princ = self.principals; return self.principals; });
    };

    /* ───────── layout ───────── */
    App.prototype._build = function () {
        this.root.classList.add('ppcal-app');
        this.root.innerHTML = '';
        this.side = el('aside', 'ppcal-side');
        this.main = el('section', 'ppcal-main');
        this.bar = el('div', 'ppcal-bar');
        this.body = el('div', 'ppcal-body');
        this.main.appendChild(this.bar); this.main.appendChild(this.body);
        this.root.appendChild(this.side); this.root.appendChild(this.main);
    };

    App.prototype.renderShell = function () {
        var self = this;
        // Toolbar
        this.bar.innerHTML = '';
        var o = self.o || {};
        var nav = el('div', 'ppcal-nav');
        var prev = el('button', 'ppcal-btn ppcal-ic', '<i class="bi bi-chevron-left"></i>');
        var today = el('button', 'ppcal-btn', 'Today');
        var next = el('button', 'ppcal-btn ppcal-ic', '<i class="bi bi-chevron-right"></i>');
        prev.title = 'Previous'; next.title = 'Next'; today.title = 'Jump to today';
        this.title = el('div', 'ppcal-title');
        prev.onclick = function () { self.step(-1); };
        next.onclick = function () { self.step(1); };
        today.onclick = function () { self.cursor = new Date(); self.reload(); };
        // iCal import / export — kept on the LEFT, away from the view switcher, with confirmation.
        var ics = el('div', 'ppcal-ics');
        var exp = el('button', 'ppcal-btn ppcal-ics-btn', '<i class="bi bi-box-arrow-down"></i> <span>Export</span>');
        exp.title = 'Export events to .ics'; exp.onclick = function () { self.confirmExport(); };
        var imp = el('button', 'ppcal-btn ppcal-ics-btn', '<i class="bi bi-box-arrow-up"></i> <span>Import</span>');
        imp.title = 'Import events from .ics'; imp.onclick = function () { self.importIcs(); };
        ics.appendChild(exp); ics.appendChild(imp);
        // Toolbar visibility honours the Designer's Toolbar region.
        nav.appendChild(prev);
        if (o.showToday !== false) nav.appendChild(today);
        nav.appendChild(next); nav.appendChild(this.title);
        if (o.showIcs !== false) nav.appendChild(ics);
        if (o.showNav === false) { prev.style.display = 'none'; next.style.display = 'none'; }

        var right = el('div', 'ppcal-bar-r');
        var views = el('div', 'ppcal-views');
        // Which views are offered (Month is always available). self.view is
        // already guarded to an enabled view by the build.
        var enabled = (o.views && o.views.length) ? o.views : ['month', 'week', 'day', 'agenda', 'year'];
        ['month', 'week', 'day', 'agenda', 'year'].forEach(function (v) {
            if (enabled.indexOf(v) < 0) return;
            var b = el('button', 'ppcal-btn ppcal-vw' + (v === self.view ? ' is-active' : ''), v.charAt(0).toUpperCase() + v.slice(1));
            b.title = v.charAt(0).toUpperCase() + v.slice(1) + ' view';
            b.onclick = function () { self.view = v; self.render(); };
            views.appendChild(b);
        });

        right.appendChild(views);
        if (o.showNewEvent !== false) {
            var nb = o.newBtn || {};
            var create = el('button', 'ppcal-btn ppcal-primary', iconHtml(nb.icon || 'bi-plus-lg') + ' ' + esc(nb.label || o.newEventLabel || 'New event'));
            var nst = btnStyleStr(nb); if (nst) create.setAttribute('style', nst);
            create.title = 'Create a new event'; create.onclick = function () { self.openEditor(null, {}); };
            right.appendChild(create);
        }
        this.bar.appendChild(nav); this.bar.appendChild(right);

        this.renderSide();
    };

    App.prototype.renderSide = function () {
        var self = this, o = this.o || {};
        this.side.innerHTML = '';
        // Whole side panel can be turned off in the Designer (Sidebar region).
        if (o.showSidebar === false) {
            this.root.classList.add('ppcal-no-side');
            this.side.style.display = 'none';
            return;
        }
        this.root.classList.remove('ppcal-no-side');
        this.side.style.display = '';

        if (o.sideCreate !== false) {
            var cb = o.createBtn || {};
            var newBtn = el('button', 'ppcal-btn ppcal-primary ppcal-side-new', iconHtml(cb.icon || 'bi-plus-lg') + ' ' + esc(cb.label || 'Create'));
            var cst = btnStyleStr(cb); if (cst) newBtn.setAttribute('style', cst);
            newBtn.title = 'Create a new event';
            newBtn.onclick = function () { self.openEditor(null, {}); };
            this.side.appendChild(newBtn);
        }

        // Mini-month date navigator
        if (o.sideMiniMonth !== false) {
            this.mini = el('div', 'ppcal-mini');
            this.side.appendChild(this.mini);
            this.renderMiniMonth();
        }

        // My calendars + shared calendars, each a toggle
        if (o.sideCalendars !== false) {
            var h = el('div', 'ppcal-side-h', 'Calendars');
            // The "+ new calendar" affordance honours the Designer's Personal
            // calendars setting (enforced server-side too).
            if (o.allowPersonal !== false) {
                var addCal = el('button', 'ppcal-mini-add', '<i class="bi bi-plus"></i>');
                addCal.title = 'New calendar';
                addCal.onclick = function () { self.newCalendar(); };
                h.appendChild(addCal);
            }
            this.side.appendChild(h);

            var list = el('div', 'ppcal-callist');
            this.calendars.forEach(function (c) {
                var row = el('label', 'ppcal-calrow');
                var on = !self.hidden[c.id];
                row.title = 'Show / hide “' + c.name + '”';
                row.innerHTML = '<input type="checkbox"' + (on ? ' checked' : '') + '><span class="ppcal-swatch" style="background:' + esc(c.color) + '"></span><span class="ppcal-calname">' + esc(c.name) + '</span>'
                    + (c.mine ? '' : '<i class="bi bi-people ppcal-shared-ic" title="Shared with you"></i>');
                row.querySelector('input').onchange = function (e) { if (e.target.checked) delete self.hidden[c.id]; else self.hidden[c.id] = true; self.reload(); };
                list.appendChild(row);
            });
            this.side.appendChild(list);
        }

        // Categories = colour filters over the loaded events
        if (o.sideCategories !== false) {
            this.side.appendChild(el('div', 'ppcal-side-h', 'Categories'));
            this.cats = el('div', 'ppcal-cats');
            this.side.appendChild(this.cats);
            this.renderCats();
        }

        // Upcoming events (colour-coded), refreshed whenever events load.
        if (o.sideUpcoming !== false) {
            this.side.appendChild(el('div', 'ppcal-side-h', 'Upcoming'));
            this.sideEvents = el('div', 'ppcal-upcoming');
            this.side.appendChild(this.sideEvents);
            this.renderUpcoming();
        }
    };

    var COLOR_NAMES = { '#2563eb': 'Blue', '#16a34a': 'Green', '#dc2626': 'Red', '#d97706': 'Orange', '#7c3aed': 'Purple', '#0891b2': 'Cyan', '#db2777': 'Pink', '#4b5563': 'Grey', '#65a30d': 'Lime', '#0d9488': 'Teal' };

    /* ───────── mini-month navigator ───────── */
    App.prototype.renderMiniMonth = function () {
        var self = this;
        if (!this.mini) return;
        if (!this.miniCursor) this.miniCursor = new Date(this.cursor.getFullYear(), this.cursor.getMonth(), 1);
        var mc = this.miniCursor, today = new Date();
        var evDays = {}; this.events.forEach(function (e) { evDays[isoD(e._s)] = true; });
        var head = '<div class="ppcal-mini-h"><button class="ppcal-mini-nav" data-mp title="Previous month"><i class="bi bi-chevron-left"></i></button>' +
            '<span class="ppcal-mini-title">' + MON[mc.getMonth()] + ' ' + mc.getFullYear() + '</span>' +
            '<button class="ppcal-mini-nav" data-mn title="Next month"><i class="bi bi-chevron-right"></i></button></div>';
        var dow = ''; for (var i = 0; i < 7; i++) dow += '<span>' + DOW[(this.firstDay + i) % 7][0] + '</span>';
        var start = startOfWeek(new Date(mc.getFullYear(), mc.getMonth(), 1), this.firstDay), days = '';
        for (var d = 0; d < 42; d++) {
            var day = addDays(start, d);
            if (day.getMonth() !== mc.getMonth()) { days += '<span class="ppcal-mini-d is-out"></span>'; continue; }
            days += '<button class="ppcal-mini-d' + (sameDay(day, today) ? ' is-today' : '') + (sameDay(day, self.cursor) ? ' is-sel' : '') + (evDays[isoD(day)] ? ' has-ev' : '') + '" data-d="' + isoD(day) + '">' + day.getDate() + '</button>';
        }
        this.mini.innerHTML = head + '<div class="ppcal-mini-dow">' + dow + '</div><div class="ppcal-mini-grid">' + days + '</div>';
        this.mini.querySelector('[data-mp]').onclick = function () { self.miniCursor = new Date(mc.getFullYear(), mc.getMonth() - 1, 1); self.renderMiniMonth(); };
        this.mini.querySelector('[data-mn]').onclick = function () { self.miniCursor = new Date(mc.getFullYear(), mc.getMonth() + 1, 1); self.renderMiniMonth(); };
        this.mini.querySelectorAll('.ppcal-mini-d[data-d]').forEach(function (b) {
            b.title = 'Go to ' + b.getAttribute('data-d');
            b.onclick = function () { var p = parse(b.getAttribute('data-d')); self.cursor = p; self.miniCursor = new Date(p.getFullYear(), p.getMonth(), 1); self.reload(); };
        });
    };

    /* ───────── category (colour) filters ───────── */
    App.prototype.renderCats = function () {
        var self = this;
        if (!this.cats) return;
        if (!this.hiddenColors) this.hiddenColors = {};
        var seen = {};
        this.events.forEach(function (e) { var c = (e.color || '').toLowerCase(); if (c) seen[c] = (seen[c] || 0) + 1; });
        var colors = Object.keys(seen);
        this.cats.innerHTML = '';
        if (!colors.length) { this.cats.appendChild(el('div', 'ppcal-up-empty', 'No events yet')); return; }
        colors.forEach(function (c) {
            var row = el('label', 'ppcal-catrow');
            row.title = 'Show / hide ' + (COLOR_NAMES[c] || c) + ' events';
            row.innerHTML = '<input type="checkbox"' + (!self.hiddenColors[c] ? ' checked' : '') + '><span class="ppcal-catdot" style="background:' + esc(c) + '"></span><span class="ppcal-catname">' + esc(COLOR_NAMES[c] || c) + '</span><span class="ppcal-catn">' + seen[c] + '</span>';
            row.querySelector('input').onchange = function (e) { if (e.target.checked) delete self.hiddenColors[c]; else self.hiddenColors[c] = true; self.render(); self.renderUpcoming(); };
            self.cats.appendChild(row);
        });
    };

    App.prototype.renderUpcoming = function () {
        var self = this;
        if (!this.sideEvents) return;
        var now = new Date(), floor = new Date(now.getFullYear(), now.getMonth(), now.getDate());
        var up = this.events.filter(function (e) { return (e._e || e._s) >= floor && !(self.hiddenColors && self.hiddenColors[(e.color || '').toLowerCase()]); })
            .sort(function (a, b) { return a._s - b._s; }).slice(0, 12);
        this.sideEvents.innerHTML = '';
        if (!up.length) { this.sideEvents.appendChild(el('div', 'ppcal-up-empty', 'Nothing coming up')); return; }
        up.forEach(function (ev) {
            var when = MON[ev._s.getMonth()].slice(0, 3) + ' ' + ev._s.getDate() + (ev.allDay ? '' : ' · ' + timeLbl(ev._s));
            var it = el('button', 'ppcal-up');
            it.innerHTML = '<span class="ppcal-up-dot" style="background:' + esc(ev.color || '#2563eb') + '"></span>' +
                '<span class="ppcal-up-b"><span class="ppcal-up-t">' + (ev.icon ? esc(ev.icon) + ' ' : '') + esc(ev.title) + '</span>' +
                '<span class="ppcal-up-d">' + esc(when) + '</span></span>';
            it.onclick = function () { self.openDetail(ev, it); };
            self.sideEvents.appendChild(it);
        });
    };

    App.prototype.step = function (dir) {
        if (this.view === 'month') this.cursor = new Date(this.cursor.getFullYear(), this.cursor.getMonth() + dir, 1);
        else if (this.view === 'week') this.cursor = addDays(this.cursor, 7 * dir);
        else if (this.view === 'day') this.cursor = addDays(this.cursor, dir);
        else if (this.view === 'year') this.cursor = new Date(this.cursor.getFullYear() + dir, 0, 1);
        else this.cursor = addDays(this.cursor, 30 * dir);
        this.reload();
    };

    App.prototype.windowRange = function () {
        if (this.view === 'day') return [new Date(this.cursor.getFullYear(), this.cursor.getMonth(), this.cursor.getDate()), this.cursor];
        if (this.view === 'week') { var s = startOfWeek(this.cursor, this.firstDay); return [s, addDays(s, 6)]; }
        if (this.view === 'agenda') return [new Date(this.cursor.getFullYear(), this.cursor.getMonth(), this.cursor.getDate()), addDays(this.cursor, 30)];
        if (this.view === 'year') return [new Date(this.cursor.getFullYear(), 0, 1), new Date(this.cursor.getFullYear(), 11, 31)];
        var f = new Date(this.cursor.getFullYear(), this.cursor.getMonth(), 1);
        var l = new Date(this.cursor.getFullYear(), this.cursor.getMonth() + 1, 0);
        return [startOfWeek(f, this.firstDay), addDays(startOfWeek(l, this.firstDay), 6)];
    };

    App.prototype.reload = function () {
        var self = this, w = this.windowRange();
        this.render();
        var cals = this.calendars.filter(function (c) { return !self.hidden[c.id]; }).map(function (c) { return c.id; });
        this.api('events', { from: isoD(w[0]), to: isoD(w[1]), calendars: cals.join(',') }).then(function (evs) {
            self.events = (evs || []).map(function (e) { e._s = parse(e.start); e._e = e.end ? parse(e.end) : null; return e; }).filter(function (e) { return e._s; });
            self.render(); self.renderUpcoming(); self.renderMiniMonth(); self.renderCats();
        }).catch(function () { self.events = []; self.render(); self.renderUpcoming(); });
    };

    App.prototype.render = function () {
        if (!this.title) return;
        this.title.textContent = this.titleText();
        this.root.querySelectorAll('.ppcal-vw').forEach(function (b) { b.classList.remove('is-active'); });
        var self = this;
        this.root.querySelectorAll('.ppcal-vw').forEach(function (b) { if (b.textContent.toLowerCase() === self.view) b.classList.add('is-active'); });
        if (this.view === 'month') this.renderMonth();
        else if (this.view === 'agenda') this.renderAgenda();
        else if (this.view === 'year') this.renderYear();
        else this.renderTime(this.view === 'week' ? 7 : 1);
    };

    App.prototype.titleText = function () {
        if (this.view === 'day') return DOW[this.cursor.getDay()] + ', ' + MON[this.cursor.getMonth()] + ' ' + this.cursor.getDate() + ', ' + this.cursor.getFullYear();
        if (this.view === 'week') { var s = startOfWeek(this.cursor, this.firstDay), e = addDays(s, 6); return MON[s.getMonth()].slice(0, 3) + ' ' + s.getDate() + ' – ' + MON[e.getMonth()].slice(0, 3) + ' ' + e.getDate() + ', ' + e.getFullYear(); }
        if (this.view === 'agenda') return 'Agenda · ' + MON[this.cursor.getMonth()] + ' ' + this.cursor.getDate();
        if (this.view === 'year') return String(this.cursor.getFullYear());
        return MON[this.cursor.getMonth()] + ' ' + this.cursor.getFullYear();
    };

    /* ───────── year view ───────── */
    App.prototype.renderYear = function () {
        var self = this, yr = this.cursor.getFullYear(), today = new Date();
        this.body.className = 'ppcal-body ppcal-year';
        this.body.innerHTML = '';
        // event-count per ISO day for density dots
        var counts = {};
        this.events.forEach(function (e) { var k = isoD(e._s); counts[k] = (counts[k] || 0) + 1; });
        var grid = el('div', 'ppcal-year-grid');
        for (var m = 0; m < 12; m++) {
            var mo = el('div', 'ppcal-ym');
            var h = el('button', 'ppcal-ym-h', MON[m]);
            (function (mm) { h.onclick = function () { self.cursor = new Date(yr, mm, 1); self.view = 'month'; self.reload(); }; })(m);
            mo.appendChild(h);
            var dow = el('div', 'ppcal-ym-dow');
            for (var i = 0; i < 7; i++) dow.appendChild(el('span', '', DOW[(this.firstDay + i) % 7][0]));
            mo.appendChild(dow);
            var g = el('div', 'ppcal-ym-days');
            var start = startOfWeek(new Date(yr, m, 1), this.firstDay);
            for (var d = 0; d < 42; d++) {
                var day = addDays(start, d);
                // Spillover days from adjacent months are blank spacers — never
                // numbered, highlighted or dotted (else today/events bleed across).
                if (day.getMonth() !== m) { g.appendChild(el('span', 'ppcal-ym-d is-out', '')); continue; }
                var cls = 'ppcal-ym-d' + (sameDay(day, today) ? ' is-today' : '') + (counts[isoD(day)] ? ' has-ev' : '');
                var cell = el('button', cls, String(day.getDate()));
                (function (dd) { cell.onclick = function () { self.cursor = dd; self.view = 'day'; self.reload(); }; })(day);
                g.appendChild(cell);
            }
            mo.appendChild(g);
            grid.appendChild(mo);
        }
        this.body.appendChild(grid);
    };

    App.prototype.eventsOn = function (day) {
        var self = this;
        return this.events.filter(function (e) {
            if (self.hiddenColors && self.hiddenColors[(e.color || '').toLowerCase()]) return false;
            var s = new Date(e._s.getFullYear(), e._s.getMonth(), e._s.getDate());
            var en = e._e ? new Date(e._e.getFullYear(), e._e.getMonth(), e._e.getDate()) : s;
            var d = new Date(day.getFullYear(), day.getMonth(), day.getDate());
            return d >= s && d <= en;
        }).sort(function (a, b) { return a._s - b._s; });
    };

    /* ───────── month view ───────── */
    App.prototype.renderMonth = function () {
        var self = this, w = this.windowRange(), start = w[0], today = new Date(), mo = this.cursor.getMonth();
        this.body.className = 'ppcal-body ppcal-month';
        this.body.innerHTML = '';
        var head = el('div', 'ppcal-dow');
        for (var i = 0; i < 7; i++) head.appendChild(el('div', 'ppcal-dow-c', DOW[(this.firstDay + i) % 7]));
        this.body.appendChild(head);
        var grid = el('div', 'ppcal-grid');
        var shadeWk = self.o.weekendShading === true;
        for (var d = 0; d < 42; d++) {
            var day = addDays(start, d);
            var wknd = shadeWk && (day.getDay() === 0 || day.getDay() === 6) ? ' is-weekend' : '';
            var cell = el('div', 'ppcal-cell' + (day.getMonth() !== mo ? ' is-other' : '') + (sameDay(day, today) ? ' is-today' : '') + wknd);
            var hd = el('div', 'ppcal-cell-h');
            var num = el('button', 'ppcal-daynum', String(day.getDate()));
            (function (dd) { num.onclick = function (e) { e.stopPropagation(); self.openEditor(null, { start: isoD(dd) }); }; })(day);
            hd.appendChild(num); cell.appendChild(hd);
            var evs = this.eventsOn(day), evbox = el('div', 'ppcal-cell-evs'), max = 4;
            for (var k = 0; k < evs.length && k < max; k++) evbox.appendChild(this.chip(evs[k], true));
            if (evs.length > max) evbox.appendChild(el('div', 'ppcal-more', '+' + (evs.length - max) + ' more'));
            cell.appendChild(evbox);
            (function (dd) { cell.ondblclick = function () { self.openEditor(null, { start: isoD(dd) }); }; })(day);
            this.dropDay(cell, day);
            grid.appendChild(cell);
        }
        this.body.appendChild(grid);
    };

    App.prototype.chip = function (ev, withTime) {
        var self = this;
        var chip = el('div', 'ppcal-ev' + (ev.allDay ? ' is-allday' : ''));
        chip.style.setProperty('--ev', ev.color || '#2563eb');
        chip.title = ev.title;
        chip.innerHTML = (ev.icon ? '<span class="ppcal-ev-ic">' + esc(ev.icon) + '</span>' : '') + (withTime && !ev.allDay ? '<span class="ppcal-ev-t">' + esc(timeLbl(ev._s)) + '</span> ' : '') + '<span class="ppcal-ev-x">' + esc(ev.title) + '</span>' + avatars(ev.attendees, 3);
        chip.onclick = function (e) { e.stopPropagation(); self.openDetail(ev, chip); };
        if (ev.editable) {
            chip.setAttribute('draggable', 'true');
            chip.addEventListener('dragstart', function (e) { self._drag = ev; chip.classList.add('is-dragging'); try { e.dataTransfer.setData('text/plain', String(ev.id)); } catch (x) { } });
            chip.addEventListener('dragend', function () { chip.classList.remove('is-dragging'); self._drag = null; });
        }
        return chip;
    };

    App.prototype.dropDay = function (cell, day) {
        var self = this;
        cell.addEventListener('dragover', function (e) { if (self._drag) { e.preventDefault(); cell.classList.add('is-drop'); } });
        cell.addEventListener('dragleave', function () { cell.classList.remove('is-drop'); });
        cell.addEventListener('drop', function (e) { e.preventDefault(); cell.classList.remove('is-drop'); self.dropTo(day); });
    };

    App.prototype.dropTo = function (day) {
        var self = this, ev = this._drag;
        if (!ev) return;
        var ns = new Date(day.getFullYear(), day.getMonth(), day.getDate(), ev._s.getHours(), ev._s.getMinutes());
        if (sameDay(ns, ev._s)) return;
        var start = ev.allDay ? isoD(ns) : isoDT(ns);
        this.api('reschedule', null, { id: ev.id, start: start }).then(function (r) {
            if (r && r.success === false) { self.toast(r.error || 'Could not move', true); return; }
            self.reload();
        });
    };

    /* ───────── week / day time-grid ───────── */
    App.prototype.renderTime = function (days) {
        var self = this, today = new Date();
        this.body.className = 'ppcal-body ppcal-time' + (days === 1 ? ' is-day' : '');
        this.body.innerHTML = '';
        var start = days === 7 ? startOfWeek(this.cursor, this.firstDay) : new Date(this.cursor.getFullYear(), this.cursor.getMonth(), this.cursor.getDate());
        var HOUR = 46;

        // Header row (day names) + all-day lane
        var head = el('div', 'ppcal-th');
        head.style.gridTemplateColumns = '56px repeat(' + days + ',1fr)';
        head.appendChild(el('div', 'ppcal-th-gutter', ''));
        var allday = el('div', 'ppcal-allday');
        allday.style.gridTemplateColumns = '56px repeat(' + days + ',1fr)';
        allday.appendChild(el('div', 'ppcal-th-gutter', 'all-day'));
        for (var i = 0; i < days; i++) {
            var day = addDays(start, i);
            var c = el('div', 'ppcal-th-c' + (sameDay(day, today) ? ' is-today' : ''));
            c.innerHTML = '<span class="ppcal-th-dow">' + DOW[day.getDay()] + '</span> <span class="ppcal-th-num">' + day.getDate() + '</span>';
            head.appendChild(c);
            var ad = el('div', 'ppcal-allday-c');
            var adEvs = this.eventsOn(day).filter(function (e) { return e.allDay; });
            for (var a = 0; a < adEvs.length; a++) ad.appendChild(this.chip(adEvs[a], false));
            this.dropDay(ad, day);
            allday.appendChild(ad);
        }
        this.body.appendChild(head);
        this.body.appendChild(allday);

        // Scrollable hour grid
        var scroll = el('div', 'ppcal-grid-scroll');
        var gutter = el('div', 'ppcal-hours');
        for (var hh = 0; hh < 24; hh++) { var hr = el('div', 'ppcal-hour'); hr.style.height = HOUR + 'px'; hr.innerHTML = '<span>' + (hh % 12 || 12) + (hh < 12 ? 'am' : 'pm') + '</span>'; gutter.appendChild(hr); }
        var cols = el('div', 'ppcal-cols'); cols.style.gridTemplateColumns = 'repeat(' + days + ',1fr)';
        var gctx = { cols: cols, start: start, days: days, HOUR: HOUR };
        for (var j = 0; j < days; j++) {
            var dcol = el('div', 'ppcal-col'); dcol.style.height = (24 * HOUR) + 'px';
            for (var g = 0; g < 24; g++) { var slot = el('div', 'ppcal-slot'); slot.style.height = HOUR + 'px'; dcol.appendChild(slot); }
            (function (dayIdx, colEl) {
                var day = addDays(start, dayIdx);
                colEl.addEventListener('dblclick', function (e) {
                    var rect = colEl.getBoundingClientRect();
                    var mins = clamp(Math.floor((e.clientY - rect.top) / HOUR * 60 / 15) * 15, 0, 23 * 60 + 45);
                    var d = new Date(day.getFullYear(), day.getMonth(), day.getDate(), Math.floor(mins / 60), mins % 60);
                    self.openEditor(null, { start: isoDT(d), end: isoDT(new Date(d.getTime() + 3600000)) });
                });
                self.placeTimed(colEl, dayIdx, gctx);
            })(j, dcol);
            cols.appendChild(dcol);
        }
        var gridWrap = el('div', 'ppcal-timegrid');
        gridWrap.appendChild(gutter); gridWrap.appendChild(cols);
        scroll.appendChild(gridWrap);
        this.body.appendChild(scroll);
        scroll.scrollTop = 7 * HOUR; // 7am
    };

    App.prototype.placeTimed = function (colEl, dayIdx, gctx) {
        var self = this, HOUR = gctx.HOUR, day = addDays(gctx.start, dayIdx);
        var evs = this.eventsOn(day).filter(function (e) { return !e.allDay; });
        evs.forEach(function (ev) {
            var s = ev._s, e = ev._e || new Date(s.getTime() + 3600000);
            var startMin = (sameDay(s, day) ? s.getHours() * 60 + s.getMinutes() : 0);
            var endMin = (sameDay(e, day) ? e.getHours() * 60 + e.getMinutes() : 24 * 60);
            if (endMin <= startMin) endMin = startMin + 30;
            var block = el('div', 'ppcal-tev');
            block.style.setProperty('--ev', ev.color || '#2563eb');
            block.style.top = (startMin / 60 * HOUR) + 'px';
            block.style.height = Math.max(20, (endMin - startMin) / 60 * HOUR - 2) + 'px';
            block.innerHTML = (ev.icon ? '<span class="ppcal-tev-ic">' + esc(ev.icon) + '</span> ' : '') + '<span class="ppcal-tev-t">' + esc(timeLbl(s)) + '</span> <span class="ppcal-tev-x">' + esc(ev.title) + '</span>' + avatars(ev.attendees, 3) + (ev.editable ? '<span class="ppcal-tev-rz"></span>' : '');
            block.onclick = function (ce) { ce.stopPropagation(); self.openDetail(ev, block); };
            if (ev.editable) self.timeDrag(block, ev, gctx, dayIdx);
            colEl.appendChild(block);
        });
    };

    // Mouse drag: move (vertical time + horizontal across day columns) + resize.
    App.prototype.timeDrag = function (block, ev, gctx, dayIdx) {
        var self = this, HOUR = gctx.HOUR, cols = gctx.cols, start = gctx.start;
        function colAtX(x) {
            var els = cols.querySelectorAll('.ppcal-col');
            for (var i = 0; i < els.length; i++) { var r = els[i].getBoundingClientRect(); if (x >= r.left && x < r.right) return { idx: i, el: els[i] }; }
            if (els.length) { var f = els[0].getBoundingClientRect(); return x < f.left ? { idx: 0, el: els[0] } : { idx: els.length - 1, el: els[els.length - 1] }; }
            return { idx: dayIdx, el: block.parentElement };
        }
        function onDown(e, mode) {
            e.preventDefault(); e.stopPropagation();
            var startY = e.clientY;
            var origTop = parseFloat(block.style.top), origH = parseFloat(block.style.height);
            var targetIdx = dayIdx;
            function mm(ev2) {
                var dy = ev2.clientY - startY;
                if (mode === 'move') {
                    block.style.top = clamp(origTop + dy, 0, 24 * HOUR - origH) + 'px';
                    var c = colAtX(ev2.clientX);           // follow the cursor across columns
                    targetIdx = c.idx;
                    if (c.el && c.el !== block.parentElement) c.el.appendChild(block);
                } else {
                    block.style.height = Math.max(20, origH + dy) + 'px';
                }
                block.classList.add('is-live');
            }
            function mu() {
                document.removeEventListener('mousemove', mm); document.removeEventListener('mouseup', mu);
                block.classList.remove('is-live');
                var top = parseFloat(block.style.top), h = parseFloat(block.style.height);
                var startMin = Math.round(top / HOUR * 60 / 15) * 15;
                var endMin = Math.round((top + h) / HOUR * 60 / 15) * 15;
                var day = addDays(start, mode === 'resize' ? dayIdx : targetIdx);
                var s = new Date(day.getFullYear(), day.getMonth(), day.getDate(), Math.floor(startMin / 60), startMin % 60);
                var en = new Date(day.getFullYear(), day.getMonth(), day.getDate(), Math.floor(endMin / 60), endMin % 60);
                self.api('reschedule', null, { id: ev.id, start: isoDT(s), end: isoDT(en) }).then(function (r) {
                    if (r && r.success === false) self.toast(r.error || 'Could not update', true);
                    self.reload();
                });
            }
            document.addEventListener('mousemove', mm); document.addEventListener('mouseup', mu);
        }
        block.addEventListener('mousedown', function (e) {
            if (e.target.classList.contains('ppcal-tev-rz')) onDown(e, 'resize'); else onDown(e, 'move');
        });
    };

    /* ───────── agenda ───────── */
    App.prototype.renderAgenda = function () {
        var self = this, w = this.windowRange(), day = new Date(w[0]);
        this.body.className = 'ppcal-body ppcal-agenda';
        this.body.innerHTML = '';
        var any = false;
        for (var i = 0; i < 31; i++) {
            var evs = this.eventsOn(day);
            if (evs.length) {
                any = true;
                var row = el('div', 'ppcal-ag-day');
                row.innerHTML = '<div class="ppcal-ag-date"><span class="ppcal-ag-dow">' + DOW[day.getDay()] + '</span><span class="ppcal-ag-num">' + day.getDate() + '</span><span class="ppcal-ag-mon">' + MON[day.getMonth()].slice(0, 3) + '</span></div>';
                var lst = el('div', 'ppcal-ag-evs');
                evs.forEach(function (ev) {
                    var it = el('div', 'ppcal-ag-ev');
                    it.style.setProperty('--ev', ev.color || '#2563eb');
                    it.innerHTML = '<span class="ppcal-ag-time">' + (ev.allDay ? 'all day' : esc(timeLbl(ev._s))) + '</span><span class="ppcal-ag-title">' + esc(ev.title) + '</span>' + (ev.location ? '<span class="ppcal-ag-loc"><i class="bi bi-geo-alt"></i> ' + esc(ev.location) + '</span>' : '');
                    (function (evc, itc) { itc.onclick = function () { self.openDetail(evc, itc); }; })(ev, it);
                    lst.appendChild(it);
                });
                row.appendChild(lst); this.body.appendChild(row);
            }
            day = addDays(day, 1);
        }
        if (!any) this.body.appendChild(el('div', 'ppcal-empty', 'No events in the next 30 days.'));
    };

    /* ───────── event detail popover ───────── */
    App.prototype.openDetail = function (ev, anchor) {
        var self = this;
        this.api('event', { id: ev.id }).then(function (d) { self._detail(ev, d && !d.error ? d : null, anchor); });
    };

    App.prototype._detail = function (ev, data, anchor) {
        var self = this;
        var old = document.querySelector('.ppcal-pop'); if (old) old.remove();
        var color = ev.color || '#2563eb';
        var canEdit = data ? data.can_edit : ev.editable;
        var when = ev.allDay
            ? (MON[ev._s.getMonth()] + ' ' + ev._s.getDate() + ', ' + ev._s.getFullYear() + ' · all day')
            : (MON[ev._s.getMonth()] + ' ' + ev._s.getDate() + ' · ' + timeLbl(ev._s) + (ev._e ? ' – ' + timeLbl(ev._e) : ''));
        var byId = {}; if (self.principals) (self.principals.users || []).forEach(function (u) { byId[u.id] = u.name; });
        var atts = (data && data.attendees) || [];
        var ic = { accepted: '✓', declined: '✕', tentative: '?', pending: '·' };
        var attHtml = atts.length ? '<div class="ppcal-pop-atts">' + atts.map(function (a) {
            return '<span class="ppcal-att">' + (ic[a.response] || '·') + ' ' + esc(byId[a.user_id] || ('#' + a.user_id)) + (+a.is_organizer === 1 ? ' ·org' : '') + '</span>';
        }).join('') + '</div>' : '';

        var pop = el('div', 'ppcal-pop');
        pop.innerHTML =
            (ev.image ? '<div class="ppcal-pop-img" style="background-image:url(' + JSON.stringify(ev.image) + ')"></div>' : '<div class="ppcal-pop-bar" style="background:' + esc(color) + '"></div>') +
            '<div class="ppcal-pop-b">' +
              '<div class="ppcal-pop-h"><span class="ppcal-pop-title">' + (ev.icon ? esc(ev.icon) + ' ' : '') + esc(ev.title) + '</span>' +
                '<button class="ppcal-x" data-x>&times;</button></div>' +
              '<div class="ppcal-pop-row"><i class="bi bi-clock"></i><span>' + esc(when) + '</span></div>' +
              '<div class="ppcal-pop-row"><i class="bi bi-calendar3"></i><span>' + esc(ev.calendar || '') + '</span></div>' +
              (ev.location ? '<div class="ppcal-pop-row"><i class="bi bi-geo-alt"></i><span>' + esc(ev.location) + '</span></div>' : '') +
              ((data && data.description) ? '<div class="ppcal-pop-desc">' + esc(data.description) + '</div>' : '') +
              attHtml +
              '<div class="ppcal-pop-f">' +
                (canEdit ? '<button class="ppcal-btn ppcal-danger ppcal-sm" data-del><i class="bi bi-trash"></i></button>' : '<span></span>') +
                '<span class="ppcal-pop-f-r">' +
                  (canEdit ? '<button class="ppcal-btn ppcal-sm" data-edit><i class="bi bi-pencil"></i> Edit</button>' : '') +
                  '<a class="ppcal-btn ppcal-sm ppcal-primary" href="' + esc(ev.url || '#') + '"><i class="bi bi-box-arrow-up-right"></i> Open</a>' +
                '</span>' +
              '</div>' +
            '</div>';
        document.body.appendChild(pop);

        var pw = pop.offsetWidth || 300, ph = pop.offsetHeight || 220;
        if (anchor) {
            var r = anchor.getBoundingClientRect();
            pop.style.left = Math.max(8, Math.min(r.left, window.innerWidth - pw - 10)) + 'px';
            pop.style.top = (r.bottom + ph + 12 < window.innerHeight ? r.bottom + 8 : Math.max(8, r.top - ph - 8)) + 'px';
        } else {
            pop.style.left = Math.max(8, (window.innerWidth - pw) / 2) + 'px';
            pop.style.top = '15vh';
        }

        function close() { pop.remove(); document.removeEventListener('mousedown', out, true); document.removeEventListener('keydown', esckey); }
        function out(e) { if (!pop.contains(e.target)) close(); }
        function esckey(e) { if (e.key === 'Escape') close(); }
        setTimeout(function () { document.addEventListener('mousedown', out, true); document.addEventListener('keydown', esckey); }, 0);
        pop.querySelector('[data-x]').onclick = close;
        var edt = pop.querySelector('[data-edit]'); if (edt) edt.onclick = function () { close(); self.openEditor(ev); };
        var del = pop.querySelector('[data-del]'); if (del) del.onclick = function () {
            if (!confirm('Delete this event?')) return;
            self.api('delete_event', null, { id: ev.id }).then(function (rr) { close(); if (rr.success) self.reload(); else self.toast(rr.error || 'Delete failed', true); });
        };
    };

    /* ───────── event editor ───────── */
    App.prototype.openEditor = function (ev, defaults) {
        var self = this;
        this.loadPrincipals().then(function () { self._editor(ev, defaults || {}); });
    };

    App.prototype._editor = function (ev, defaults) {
        var self = this, isNew = !ev || !ev.id;
        var full = null;
        var render = function (data) {
            var overlay = el('div', 'ppcal-modal');
            var box = el('div', 'ppcal-modal-box');
            var canEdit = isNew || (data && data.can_edit);
            var start = data && data.starts_at ? data.starts_at : (defaults.start || isoD(self.cursor));
            var end = data && data.ends_at ? data.ends_at : (defaults.end || '');
            var allDay = data ? (+data.all_day === 1) : (start.length <= 10);
            var s = parse(start) || new Date();
            var e = end ? parse(end) : null;
            var calOpts = self.calendars.filter(function (c) { return c.mine || c.id === (data && +data.calendar_id); }).map(function (c) {
                return '<option value="' + c.id + '"' + ((data && +data.calendar_id === c.id) ? ' selected' : '') + '>' + esc(c.name) + '</option>';
            }).join('');
            var vis = (data && data.visibility) || 'inherit';
            var rrule = (data && data.rrule) || '';
            var rp = self.parseRrule(rrule);

            box.innerHTML =
                '<div class="ppcal-modal-h"><b>' + (isNew ? 'New event' : (canEdit ? 'Edit event' : 'Event')) + '</b><button class="ppcal-x" data-x>&times;</button></div>' +
                '<div class="ppcal-modal-b">' +
                  fieldRow('Title', '<input class="ppcal-in" data-f="title" title="A short name for this event" value="' + esc(data ? data.title : '') + '" placeholder="Add a title"' + (canEdit ? '' : ' readonly') + '>') +
                  '<div class="ppcal-when-block">' +
                    '<div class="ppcal-when-top">' +
                      '<label class="ppcal-switch" title="Toggle an all-day event"><input type="checkbox" data-f="allDay"' + (allDay ? ' checked' : '') + (canEdit ? '' : ' disabled') + '><span class="ppcal-switch-ui"></span><span class="ppcal-switch-lbl">All-day</span></label>' +
                      '<select class="ppcal-in ppcal-cal" data-f="calendarId"' + (canEdit ? '' : ' disabled') + ' title="Which calendar this event belongs to">' + (calOpts || '<option value="0">My Calendar</option>') + '</select>' +
                    '</div>' +
                    '<div class="ppcal-times">' +
                      '<div class="ppcal-when"><span class="ppcal-when-l"><i class="bi bi-calendar-event"></i> Starts</span>' +
                        '<input class="ppcal-in ppcal-dt" data-f="start" title="When the event starts" value="' + fmtDt(s, allDay) + '"' + (canEdit ? '' : ' readonly') + '></div>' +
                      '<div class="ppcal-when"><span class="ppcal-when-l"><i class="bi bi-calendar-check"></i> Ends</span>' +
                        '<input class="ppcal-in ppcal-dt" data-f="end" title="When the event ends" value="' + fmtDt(e || new Date(s.getTime() + 3600000), allDay) + '"' + (canEdit ? '' : ' readonly') + '></div>' +
                    '</div>' +
                  '</div>' +
                  fieldRow('Location', '<input class="ppcal-in" data-f="location" title="Where the event takes place" value="' + esc(data ? (data.location || '') : '') + '" placeholder="Add location"' + (canEdit ? '' : ' readonly') + '>') +
                  fieldRow('Description', '<textarea class="ppcal-in" data-f="description" title="Notes and details about the event" rows="2"' + (canEdit ? '' : ' readonly') + '>' + esc(data ? (data.description || '') : '') + '</textarea>') +
                  '<div class="ppcal-frow"><span class="ppcal-flbl" title="A colour to identify this event">Colour</span>' + colorPicker(data && data.color) + '</div>' +
                  (canEdit ? '<div class="ppcal-frow"><span class="ppcal-flbl" title="An emoji shown on the event">Symbol</span>' + iconPicker(data && data.icon) + '</div>' : '') +
                  (canEdit ? '<div class="ppcal-frow"><span class="ppcal-flbl">Image</span><input class="ppcal-in" data-f="image" title="Optional image shown on the event (paste an image URL)" value="' + esc(data ? (data.image || '') : '') + '" placeholder="Image URL (optional)"></div>' : '') +
                  (canEdit ? recurrenceUi(rp) : (rrule ? '<div class="ppcal-frow"><span class="ppcal-flbl">Repeats</span><span>' + esc(self.rruleLabel(rp)) + '</span></div>' : '')) +
                  (canEdit ? remindersUi(data && data.reminders, self.o) : '') +
                  (canEdit ? sharingUi(self.principals, vis, data && data.shares) : visibilityBadge(vis)) +
                  (canEdit ? attendeesUi(self.principals, data && data.attendees) : attendeeList(self.principals, data && data.attendees)) +
                  (canEdit ? deliveryUi(self.o.notifEnabled, isNew ? undefined : 'none', isNew ? 'Notify everyone this event reaches (attendees + shared).' : 'Notifies only the people you add or share with here.') : '') +
                  rsvpUi(self, data) +
                '</div>' +
                '<div class="ppcal-modal-f">' +
                  (!isNew && canEdit ? '<button class="ppcal-btn ppcal-danger" data-del title="Delete this event"><i class="bi bi-trash"></i> Delete</button>' : '<span></span>') +
                  '<div><button class="ppcal-btn" data-x title="Discard changes and close">Close</button>' + (canEdit ? '<button class="ppcal-btn ppcal-primary" data-save title="Save this event">Save</button>' : '') + '</div>' +
                '</div>';

            overlay.appendChild(box);
            document.body.appendChild(overlay);
            var close = function () {
                if (box._fps) Object.keys(box._fps).forEach(function (k) { try { box._fps[k] && box._fps[k].destroy(); } catch (e) { } });
                overlay.remove();
            };
            overlay.addEventListener('mousedown', function (e2) { if (e2.target === overlay) close(); });
            box.querySelectorAll('[data-x]').forEach(function (b) { b.onclick = close; });
            wireEditor(box, self.o);

            var del = box.querySelector('[data-del]');
            if (del) del.onclick = function () {
                if (!confirm('Delete this event?')) return;
                self.api('delete_event', null, { id: ev.id }).then(function (r) { close(); if (r.success) self.reload(); else self.toast(r.error || 'Delete failed', true); });
            };
            var save = box.querySelector('[data-save]');
            if (save) save.onclick = function () {
                var payload = collectEditor(box);
                var act = isNew ? 'create_event' : 'update_event';
                var body = isNew ? { event: payload } : { id: ev.id, event: payload };
                save.disabled = true;
                self.api(act, null, body).then(function (r) {
                    save.disabled = false;
                    if (r && r.success) {
                        close(); self.reload();
                        var bits = [];
                        if (r.notified) bits.push(r.notified + ' notified');
                        if (r.emailed) bits.push(r.emailed + ' emailed');
                        self.toast('Event saved' + (bits.length ? ' · ' + bits.join(' · ') : ''));
                    } else self.toast((r && r.error) || 'Could not save', true);
                });
            };
            // RSVP buttons
            box.querySelectorAll('[data-rsvp]').forEach(function (b) {
                b.onclick = function () { self.api('rsvp', null, { id: ev.id, response: b.getAttribute('data-rsvp') }).then(function () { self.toast('Response saved'); close(); self.reload(); }); };
            });
        };

        if (isNew) render(null);
        else this.api('event', { id: ev.id }).then(function (d) { render(d && !d.error ? d : null); });
    };

    /* editor helpers (pure DOM string builders) */
    function fieldRow(label, ctrl) { return '<div class="ppcal-frow"><span class="ppcal-flbl">' + label + '</span>' + ctrl + '</div>'; }
    function colorPicker(cur) {
        return '<span class="ppcal-colors" data-f="color">' + PALETTE.map(function (c) {
            return '<button type="button" class="ppcal-color' + (cur === c ? ' is-sel' : '') + '" style="background:' + c + '" data-color="' + c + '" title="Use this colour"></button>';
        }).join('') + '<button type="button" class="ppcal-color ppcal-color-none' + (!cur ? ' is-sel' : '') + '" data-color="" title="Use calendar colour">∅</button></span>';
    }
    function visibilityBadge(v) { return '<div class="ppcal-frow"><span class="ppcal-flbl">Visibility</span><span class="ppcal-badge">' + esc(v) + '</span></div>'; }
    function recurrenceUi(rp) {
        var freqs = [['', 'Does not repeat'], ['DAILY', 'Daily'], ['WEEKLY', 'Weekly'], ['MONTHLY', 'Monthly'], ['YEARLY', 'Yearly']];
        var sel = freqs.map(function (f) { return '<option value="' + f[0] + '"' + (rp.freq === f[0] ? ' selected' : '') + '>' + f[1] + '</option>'; }).join('');
        var days = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'].map(function (d) { return '<button type="button" class="ppcal-byday' + ((rp.byday || []).indexOf(d) > -1 ? ' is-sel' : '') + '" data-day="' + d + '">' + d[0] + '</button>'; }).join('');
        return '<div class="ppcal-frow ppcal-rec"><span class="ppcal-flbl">Repeats</span>' +
            '<select class="ppcal-in ppcal-rec-freq" data-f="rfreq" title="How often this event repeats">' + sel + '</select>' +
            '<span class="ppcal-rec-int">every <input class="ppcal-in ppcal-num" type="number" min="1" value="' + (rp.interval || 1) + '" data-f="rint" title="Repeat interval (e.g. every 2 weeks)"></span></div>' +
            '<div class="ppcal-frow ppcal-rec-week"' + (rp.freq === 'WEEKLY' ? '' : ' hidden') + '><span class="ppcal-flbl"></span><span class="ppcal-bydays" data-f="rbyday">' + days + '</span></div>' +
            '<div class="ppcal-frow ppcal-rec-until"' + (rp.freq ? '' : ' hidden') + '><span class="ppcal-flbl">Until</span><input class="ppcal-in" type="date" data-f="runtil" value="' + (rp.until || '') + '"></div>';
    }
    function remindersUi(rems, opts) {
        rems = rems || [];
        var rows = rems.map(function (r) { return reminderRow(r.minutes_before, r.method, opts); }).join('');
        return '<div class="ppcal-sec"><div class="ppcal-sec-h"><span class="ppcal-flbl">Reminders</span><button type="button" class="ppcal-add-btn" data-addrem title="Add a reminder before the event"><i class="bi bi-plus-lg"></i> Add reminder</button></div><div class="ppcal-rems" data-rems>' + rows + '</div></div>';
    }
    // Reminder delivery options honour the project's calendar settings: In-app
    // only when appReminders is on, Email only when emailReminders is on. Popup
    // is a client-side browser alert and is always available.
    function reminderRow(min, method, opts) {
        opts = opts || {};
        var o = '<option value="popup"' + (method === 'popup' || !method ? ' selected' : '') + '>Popup</option>';
        if (opts.appReminders !== false) o += '<option value="notify"' + (method === 'notify' ? ' selected' : '') + '>In-app</option>';
        if (opts.emailReminders) o += '<option value="email"' + (method === 'email' ? ' selected' : '') + '>Email</option>';
        return '<div class="ppcal-rem"><input class="ppcal-in ppcal-num" type="number" min="0" value="' + (min == null ? 15 : min) + '" title="Minutes before the event"><span>min before ·</span><select class="ppcal-in" title="How to deliver this reminder">' + o + '</select><button type="button" class="ppcal-x-sm" data-delrem title="Remove this reminder">&times;</button></div>';
    }
    // "Notify via" — how attendees / shared people are told about a NEW event.
    function deliveryUi(notifEnabled, cur, hint) {
        cur = cur || (notifEnabled ? 'notify' : 'email');
        var opts = [['notify', 'bi-bell', 'In-app'], ['email', 'bi-envelope', 'Email'], ['both', 'bi-broadcast', 'Both'], ['none', 'bi-slash-circle', 'Off']];
        return '<div class="ppcal-frow"><span class="ppcal-flbl" title="Send an in-app and/or email notification">Notify via</span><span class="ppcal-seg" data-f="delivery" data-val="' + cur + '">' +
            opts.map(function (o) { return '<button type="button" class="ppcal-seg-b' + (cur === o[0] ? ' is-sel' : '') + '" data-seg="' + o[0] + '" title="' + o[2] + '"><i class="bi ' + o[1] + '"></i> ' + o[2] + '</button>'; }).join('') +
            '</span></div>' +
            (hint ? '<div class="ppcal-frow"><span class="ppcal-flbl"></span><span class="ppcal-hint">' + esc(hint) + '</span></div>' : '');
    }
    function sharingUi(princ, vis, shares) {
        shares = shares || [];
        var opts = [['inherit', 'Calendar default'], ['private', 'Private (only me)'], ['public', 'Public (everyone)'], ['groups', 'Specific groups'], ['users', 'Specific people']]
            .map(function (o) { return '<option value="' + o[0] + '"' + (vis === o[0] ? ' selected' : '') + '>' + o[1] + '</option>'; }).join('');
        var rows = shares.filter(function (s) { return s.event_id; }).map(function (s) { return shareRow(princ, s.principal_type, +s.principal_id, s.permission); }).join('');
        return '<div class="ppcal-sec"><div class="ppcal-sec-h"><span class="ppcal-flbl">Sharing</span></div>' +
            '<div class="ppcal-frow"><select class="ppcal-in" data-f="visibility">' + opts + '</select></div>' +
            '<div class="ppcal-sec-h"><span class="ppcal-flbl-sm">Shared with</span><button type="button" class="ppcal-add-btn" data-addshare title="Share this event with a person or group"><i class="bi bi-plus-lg"></i> Add person</button></div>' +
            '<div class="ppcal-shares" data-shares>' + rows + '</div></div>';
    }
    function shareRow(princ, type, id, perm) {
        type = type || 'user';
        var userOpts = (princ.users || []).map(function (u) { return '<option value="user:' + u.id + '"' + (type === 'user' && id === u.id ? ' selected' : '') + '>👤 ' + esc(u.name) + '</option>'; }).join('');
        var grpOpts = (princ.groups || []).map(function (g) { return '<option value="group:' + g.id + '"' + (type === 'group' && id === g.id ? ' selected' : '') + '>👥 ' + esc(g.name) + '</option>'; }).join('');
        var everyone = '<option value="everyone:0"' + (type === 'everyone' ? ' selected' : '') + '>🌐 Everyone</option>';
        return '<div class="ppcal-share"><select class="ppcal-in ppcal-share-who">' + everyone + grpOpts + userOpts + '</select>' +
            '<select class="ppcal-in ppcal-share-perm"><option value="view"' + (perm !== 'edit' ? ' selected' : '') + '>Can view</option><option value="edit"' + (perm === 'edit' ? ' selected' : '') + '>Can edit</option></select>' +
            '<button type="button" class="ppcal-x-sm" data-delshare>&times;</button></div>';
    }
    function iconPicker(cur) {
        cur = cur || '';
        var icons = ['', '📅', '🎉', '💼', '📞', '✈️', '🎂', '🍽️', '🏥', '🎓', '🏋️', '🎵', '⚽', '🛒', '💡', '⭐', '❤️', '🔔', '🚀', '📌'];
        return '<span class="ppcal-icons" data-f="icon" data-val="' + esc(cur) + '">' + icons.map(function (ic) {
            return '<button type="button" class="ppcal-icbtn' + (cur === ic ? ' is-sel' : '') + '" data-ic="' + esc(ic) + '" title="' + (ic === '' ? 'None' : ic) + '">' + (ic === '' ? '<i class="bi bi-slash-circle"></i>' : ic) + '</button>';
        }).join('') + '</span>';
    }
    // Token input: search people (client-side over the loaded user list), add as
    // removable chips. Stores the chosen ids in the container's data-ids.
    function attendeesUi(princ, attendees) {
        attendees = attendees || [];
        var byId = {}; (princ.users || []).forEach(function (u) { byId[u.id] = u.name; });
        var chosen = attendees.filter(function (a) { return +a.is_organizer !== 1; }).map(function (a) { return +a.user_id; });
        var chips = chosen.map(function (id) { return tokenChip(id, byId[id] || ('#' + id)); }).join('');
        return '<div class="ppcal-sec"><div class="ppcal-sec-h"><span class="ppcal-flbl">Invite people</span></div>' +
            '<div class="ppcal-tokens" data-f="attendees" data-ids="' + chosen.join(',') + '">' +
              '<span class="ppcal-tok-list">' + chips + '</span>' +
              '<input type="text" class="ppcal-tok-in" placeholder="Search people…" autocomplete="off">' +
              '<div class="ppcal-tok-menu" hidden></div>' +
            '</div>' +
            '<div class="ppcal-hint">Invitees can RSVP and get view access.</div></div>';
    }
    function tokenChip(id, name) {
        return '<span class="ppcal-tok" data-id="' + id + '">' + esc(name) + '<button type="button" class="ppcal-tok-x" data-rm title="Remove">&times;</button></span>';
    }
    function attendeeList(princ, attendees) {
        attendees = attendees || [];
        if (!attendees.length) return '';
        var byId = {}; (princ.users || []).forEach(function (u) { byId[u.id] = u.name; });
        var icon = { accepted: '✓', declined: '✕', tentative: '?', pending: '·' };
        var rows = attendees.map(function (a) { return '<span class="ppcal-att">' + (icon[a.response] || '·') + ' ' + esc(byId[a.user_id] || ('#' + a.user_id)) + (+a.is_organizer === 1 ? ' (organizer)' : '') + '</span>'; }).join('');
        return '<div class="ppcal-sec"><span class="ppcal-flbl">Attendees</span><div class="ppcal-atts">' + rows + '</div></div>';
    }
    function rsvpUi(app, data) {
        if (!data || !data.attendees) return '';
        var mine = data.attendees.filter(function (a) { return +a.user_id === app.me && +a.is_organizer !== 1; })[0];
        if (!mine) return '';
        function b(v, l) { return '<button type="button" class="ppcal-btn ppcal-rsvp' + (mine.response === v ? ' is-active' : '') + '" data-rsvp="' + v + '">' + l + '</button>'; }
        return '<div class="ppcal-sec ppcal-rsvpbox"><span class="ppcal-flbl">Your response</span><div>' + b('accepted', 'Yes') + b('tentative', 'Maybe') + b('declined', 'No') + '</div></div>';
    }

    function wireEditor(box, opts) {
        opts = opts || {};
        // colour picker
        box.querySelectorAll('.ppcal-color').forEach(function (b) {
            b.onclick = function () { box.querySelectorAll('.ppcal-color').forEach(function (x) { x.classList.remove('is-sel'); }); b.classList.add('is-sel'); box.querySelector('[data-f="color"]').setAttribute('data-val', b.getAttribute('data-color')); };
        });
        // Date/time pickers — flatpickr (pretty, theme-consistent). All-day
        // toggles between date and date-time pickers, preserving the value.
        var fps = box._fps = {};
        function initFp() {
            var allDay = box.querySelector('[data-f="allDay"]').checked;
            ['start', 'end'].forEach(function (k) {
                var inp = box.querySelector('[data-f="' + k + '"]');
                if (!inp) return;
                var cur = fps[k] && fps[k].selectedDates[0];
                if (fps[k]) { fps[k].destroy(); fps[k] = null; }
                if (cur) inp.value = fmtDt(cur, allDay);
                if (window.flatpickr && !inp.hasAttribute('readonly')) {
                    fps[k] = window.flatpickr(inp, {
                        enableTime: !allDay,
                        dateFormat: allDay ? 'Y-m-d' : 'Y-m-d H:i',
                        altInput: true,
                        altInputClass: 'ppcal-in ppcal-dt',
                        altFormat: allDay ? 'D, M j Y' : 'D, M j Y · h:i K',
                        minuteIncrement: 15, time_24hr: false, disableMobile: true
                    });
                }
            });
        }
        initFp();
        var ad = box.querySelector('[data-f="allDay"]');
        if (ad) ad.addEventListener('change', initFp);
        // recurrence
        var rf = box.querySelector('[data-f="rfreq"]');
        if (rf) rf.onchange = function () {
            box.querySelector('.ppcal-rec-week').hidden = rf.value !== 'WEEKLY';
            box.querySelector('.ppcal-rec-until').hidden = !rf.value;
        };
        box.querySelectorAll('.ppcal-byday').forEach(function (b) { b.onclick = function () { b.classList.toggle('is-sel'); }; });
        // delivery segmented control
        var seg = box.querySelector('[data-f="delivery"]');
        if (seg) seg.querySelectorAll('.ppcal-seg-b').forEach(function (b) {
            b.onclick = function () { seg.querySelectorAll('.ppcal-seg-b').forEach(function (x) { x.classList.remove('is-sel'); }); b.classList.add('is-sel'); seg.setAttribute('data-val', b.getAttribute('data-seg')); };
        });
        // reminders add/del
        var addRem = box.querySelector('[data-addrem]');
        if (addRem) addRem.onclick = function () { var w = box.querySelector('[data-rems]'); var t = el('div'); t.innerHTML = reminderRow(15, 'popup', opts); var node = t.firstChild; node.querySelector('[data-delrem]').onclick = function () { node.remove(); }; w.appendChild(node); };
        box.querySelectorAll('[data-delrem]').forEach(function (b) { b.onclick = function () { b.closest('.ppcal-rem').remove(); }; });
        // shares add/del
        var addShare = box.querySelector('[data-addshare]');
        if (addShare) addShare.onclick = function () {
            var princ = { users: [], groups: [] };
            // reuse existing selects' options by cloning the first share select if present, else build from window cache
            var w = box.querySelector('[data-shares]');
            var tmp = el('div'); tmp.innerHTML = shareRow(App._princ || { users: [], groups: [] }, 'everyone', 0, 'view');
            var node = tmp.firstChild; node.querySelector('[data-delshare]').onclick = function () { node.remove(); }; w.appendChild(node);
        };
        box.querySelectorAll('[data-delshare]').forEach(function (b) { b.onclick = function () { b.closest('.ppcal-share').remove(); }; });

        // Symbol (emoji) picker
        box.querySelectorAll('.ppcal-icbtn').forEach(function (b) {
            b.onclick = function () { var w = b.closest('.ppcal-icons'); w.querySelectorAll('.ppcal-icbtn').forEach(function (x) { x.classList.remove('is-sel'); }); b.classList.add('is-sel'); w.setAttribute('data-val', b.getAttribute('data-ic')); };
        });

        // Invite-people token input with live search over the loaded user list.
        var tk = box.querySelector('.ppcal-tokens');
        if (tk) {
            var input = tk.querySelector('.ppcal-tok-in');
            var listEl = tk.querySelector('.ppcal-tok-list');
            var menu = tk.querySelector('.ppcal-tok-menu');
            var users = (App._princ && App._princ.users) || [];
            var ids = function () { return (tk.getAttribute('data-ids') || '').split(',').filter(Boolean).map(Number); };
            var setIds = function (arr) { tk.setAttribute('data-ids', arr.join(',')); };
            var wireChip = function (node, id) { node.querySelector('[data-rm]').onclick = function () { node.remove(); setIds(ids().filter(function (x) { return x !== id; })); }; };
            listEl.querySelectorAll('.ppcal-tok').forEach(function (node) { wireChip(node, +node.getAttribute('data-id')); });
            var addUser = function (u) {
                var cur = ids(); if (cur.indexOf(u.id) > -1) return;
                cur.push(u.id); setIds(cur);
                var t = document.createElement('span'); t.innerHTML = tokenChip(u.id, u.name);
                var node = t.firstChild; wireChip(node, u.id); listEl.appendChild(node);
                input.value = ''; menu.hidden = true; menu.innerHTML = '';
            };
            input.addEventListener('input', function () {
                var q = input.value.trim().toLowerCase();
                if (!q) { menu.hidden = true; menu.innerHTML = ''; return; }
                var cur = ids();
                var matches = users.filter(function (u) { return cur.indexOf(u.id) < 0 && String(u.name).toLowerCase().indexOf(q) > -1; }).slice(0, 8);
                menu.innerHTML = matches.length ? matches.map(function (u) { return '<button type="button" class="ppcal-tok-opt" data-uid="' + u.id + '"><i class="bi bi-person-circle"></i> ' + esc(u.name) + '</button>'; }).join('') : '<div class="ppcal-tok-none">No matches</div>';
                menu.hidden = false;
                menu.querySelectorAll('.ppcal-tok-opt').forEach(function (b) { b.onmousedown = function (e) { e.preventDefault(); var u = users.filter(function (x) { return x.id === +b.getAttribute('data-uid'); })[0]; if (u) addUser(u); input.focus(); }; });
            });
            input.addEventListener('keydown', function (e) {
                if (e.key === 'Enter') { e.preventDefault(); var first = menu.querySelector('.ppcal-tok-opt'); if (first) first.onmousedown({ preventDefault: function () { } }); }
                else if (e.key === 'Escape') { menu.hidden = true; }
                else if (e.key === 'Backspace' && input.value === '') { var last = listEl.querySelector('.ppcal-tok:last-child'); if (last) { var id = +last.getAttribute('data-id'); last.remove(); setIds(ids().filter(function (x) { return x !== id; })); } }
            });
            input.addEventListener('blur', function () { setTimeout(function () { menu.hidden = true; }, 160); });
        }
    }

    function collectEditor(box) {
        var g = function (f) { var n = box.querySelector('[data-f="' + f + '"]'); return n ? n.value : ''; };
        var allDay = box.querySelector('[data-f="allDay"]').checked;
        var start = g('start') || isoD(new Date());
        var end = g('end') || start;
        var colorNode = box.querySelector('[data-f="color"]');
        var color = colorNode ? (colorNode.getAttribute('data-val') || '') : '';
        // recurrence
        var rrule = '';
        var rf = box.querySelector('[data-f="rfreq"]');
        if (rf && rf.value) {
            rrule = 'FREQ=' + rf.value;
            var ri = parseInt(box.querySelector('[data-f="rint"]').value, 10) || 1; if (ri > 1) rrule += ';INTERVAL=' + ri;
            if (rf.value === 'WEEKLY') { var bd = []; box.querySelectorAll('.ppcal-byday.is-sel').forEach(function (b) { bd.push(b.getAttribute('data-day')); }); if (bd.length) rrule += ';BYDAY=' + bd.join(','); }
            var ru = box.querySelector('[data-f="runtil"]').value; if (ru) rrule += ';UNTIL=' + ru.replace(/-/g, '') + 'T235959Z';
        }
        // reminders
        var reminders = [];
        box.querySelectorAll('.ppcal-rem').forEach(function (r) { reminders.push({ minutes: parseInt(r.querySelector('input').value, 10) || 0, method: r.querySelector('select').value }); });
        // shares
        var shares = [];
        box.querySelectorAll('.ppcal-share').forEach(function (r) { var who = r.querySelector('.ppcal-share-who').value.split(':'); shares.push({ type: who[0], id: +who[1], permission: r.querySelector('.ppcal-share-perm').value }); });
        // attendees (from the token input's stored ids)
        var tkEl = box.querySelector('[data-f="attendees"]');
        var attendees = tkEl ? (tkEl.getAttribute('data-ids') || '').split(',').filter(Boolean).map(Number) : [];
        var segEl = box.querySelector('[data-f="delivery"]');
        var delivery = segEl ? (segEl.getAttribute('data-val') || 'none') : 'none';
        var iconEl = box.querySelector('[data-f="icon"]');
        var icon = iconEl ? (iconEl.getAttribute('data-val') || '') : '';

        return {
            title: g('title'), description: g('description'), location: g('location'),
            start: start, end: end, allDay: allDay, color: color,
            icon: icon, image: g('image'),
            calendarId: parseInt(g('calendarId'), 10) || 0,
            visibility: g('visibility') || 'inherit',
            rrule: rrule, reminders: reminders, shares: shares, attendees: attendees,
            delivery: delivery
        };
    }

    /* ───────── recurrence parse/label ───────── */
    App.prototype.parseRrule = function (rr) {
        var o = { freq: '', interval: 1, byday: [], until: '' };
        if (!rr) return o;
        String(rr).replace(/^RRULE:/i, '').split(';').forEach(function (p) {
            var kv = p.split('='); if (kv.length < 2) return;
            var k = kv[0].toUpperCase(), v = kv[1];
            if (k === 'FREQ') o.freq = v.toUpperCase();
            else if (k === 'INTERVAL') o.interval = parseInt(v, 10) || 1;
            else if (k === 'BYDAY') o.byday = v.split(',');
            else if (k === 'UNTIL') { var m = v.match(/^(\d{4})(\d{2})(\d{2})/); if (m) o.until = m[1] + '-' + m[2] + '-' + m[3]; }
        });
        return o;
    };
    App.prototype.rruleLabel = function (rp) {
        if (!rp.freq) return 'Does not repeat';
        var f = { DAILY: 'day', WEEKLY: 'week', MONTHLY: 'month', YEARLY: 'year' }[rp.freq] || 'time';
        return 'Every ' + (rp.interval > 1 ? rp.interval + ' ' + f + 's' : f) + (rp.byday && rp.byday.length ? ' on ' + rp.byday.join(', ') : '') + (rp.until ? ' until ' + rp.until : '');
    };

    /* ───────── calendars / ics / toast ───────── */
    App.prototype.newCalendar = function () {
        var self = this;
        var name = prompt('New calendar name:'); if (!name) return;
        var color = PALETTE[this.calendars.length % PALETTE.length];
        this.api('create_calendar', null, { name: name, color: color, visibility: 'private' }).then(function (r) {
            if (r && r.success) self.boot(); else self.toast('Could not create calendar', true);
        });
    };
    App.prototype.primaryColor = function () {
        try { return (getComputedStyle(document.body).getPropertyValue('--pp-primary') || '').trim() || '#2563eb'; } catch (e) { return '#2563eb'; }
    };
    App.prototype.confirm = function (opts) {
        var pc = this.primaryColor();
        if (window.Swal) {
            return window.Swal.fire({ icon: opts.icon || 'question', title: opts.title, text: opts.text, showCancelButton: true, confirmButtonText: opts.confirmText || 'OK', confirmButtonColor: pc, reverseButtons: true }).then(function (r) { return !!r.isConfirmed; });
        }
        return Promise.resolve(window.confirm((opts.title ? opts.title + '\n' : '') + (opts.text || '')));
    };

    App.prototype.confirmExport = function () {
        var self = this, url = this.base + '?action=export';
        this.confirm({ title: 'Export calendar', text: 'Download your events as a .ics file (importable into Google, Outlook, Apple Calendar…)?', confirmText: 'Download' }).then(function (ok) {
            if (!ok) return;
            var a = document.createElement('a'); a.href = url; a.download = 'calendar.ics'; document.body.appendChild(a); a.click(); a.remove();
            self.toast('Downloading calendar.ics');
        });
    };

    App.prototype.importIcs = function () {
        var self = this;
        var inp = el('input'); inp.type = 'file'; inp.accept = '.ics,text/calendar';
        inp.onchange = function () {
            var f = inp.files[0]; if (!f) return;
            self.confirm({ title: 'Import events', text: 'Import events from “' + f.name + '” into your calendar?', confirmText: 'Import' }).then(function (ok) {
                if (!ok) return;
                var rd = new FileReader();
                rd.onload = function () {
                    self.api('import', null, { ics: rd.result }).then(function (r) {
                        if (r && r.success) { self.toast('Imported ' + r.imported + ' event(s)'); self.reload(); }
                        else self.toast('Import failed', true);
                    });
                };
                rd.readAsText(f);
            });
        };
        inp.click();
    };
    App.prototype.toast = function (msg, bad) {
        var t = el('div', 'ppcal-toast' + (bad ? ' is-bad' : ''), esc(msg));
        this.root.appendChild(t);
        requestAnimationFrame(function () { t.classList.add('show'); });
        setTimeout(function () { t.classList.remove('show'); setTimeout(function () { t.remove(); }, 250); }, 2400);
    };

    global.PPCalendarApp = {
        mount: function (elm, opts) { var a = new App(elm, opts); App._princ = null; a.loadPrincipals().then(function (p) { App._princ = p; }); return a; }
    };

    /* ───────── fast custom tooltips ─────────
       Native `title` tooltips have a browser-controlled ~1s delay. We convert any
       title on a calendar element to a snappy (~120ms) styled tooltip, scoped to
       .ppcal-* so the rest of the app's tooltips are untouched. */
    (function () {
        var tip, showT, cur;
        function ensure() { if (!tip) { tip = document.createElement('div'); tip.className = 'ppcal-tip'; document.body.appendChild(tip); } return tip; }
        function inCal(el) { return el && el.closest && el.closest('.ppcal-app, .ppcal-modal, .ppcal-pop, .ppcal-mini, .ppcal-bar, .ppcal-side'); }
        function show(t, text) {
            var e = ensure(); e.textContent = text;
            var r = t.getBoundingClientRect();
            e.style.left = '-9999px'; e.style.top = '0';
            var tw = e.offsetWidth, th = e.offsetHeight;
            var left = Math.min(Math.max(6, r.left + r.width / 2 - tw / 2), window.innerWidth - tw - 6);
            var top = r.bottom + 6; if (top + th > window.innerHeight - 6) top = r.top - th - 6;
            e.style.left = left + 'px'; e.style.top = Math.max(6, top) + 'px';
            e.classList.add('show');
        }
        function hide() { if (tip) tip.classList.remove('show'); clearTimeout(showT); cur = null; }
        document.addEventListener('mouseover', function (ev) {
            var t = ev.target && ev.target.closest ? ev.target.closest('[title], [data-pptip]') : null;
            if (!t || !inCal(t)) return;
            if (t.hasAttribute('title')) { var v = t.getAttribute('title'); if (v) t.setAttribute('data-pptip', v); t.removeAttribute('title'); }
            var text = t.getAttribute('data-pptip'); if (!text) return;
            cur = t; clearTimeout(showT);
            showT = setTimeout(function () { if (cur === t) show(t, text); }, 120);
        }, true);
        document.addEventListener('mouseout', function (ev) {
            var t = ev.target && ev.target.closest ? ev.target.closest('[data-pptip]') : null;
            if (t && t === cur) hide();
        }, true);
        document.addEventListener('mousedown', hide, true);
        window.addEventListener('scroll', hide, true);
    })();
})(window);
