/* ═══════════════════════════════════════════════════════════════
   Gantt Chart – drag-to-move, resize, create, dependencies
═══════════════════════════════════════════════════════════════ */

(function () {
    'use strict';

    const ROW_HEIGHT        = 40;
    const HEADER_HEIGHT     = 40;
    const DAY_WIDTH_WEEKS   = 28;
    const DAY_WIDTH_MONTHS  = 14;
    const HANDLE_W          = 8;

    let zoomMode  = 'months';
    let viewStart = null;
    let dayWidth  = DAY_WIDTH_MONTHS;
    let totalDays = 120;

    const chart      = document.getElementById('ganttChart');
    const wrapper    = document.getElementById('ganttWrapper');
    const prevBtn    = document.getElementById('ganttPrev');
    const nextBtn    = document.getElementById('ganttNext');
    const todayBtn   = document.getElementById('ganttToday');
    const rangeLabel = document.getElementById('ganttRangeLabel');
    if (!chart || typeof ganttTasks === 'undefined') return;

    const canEdit = typeof ganttCanEdit !== 'undefined' && ganttCanEdit;

    // Mutable local copy of dependencies
    let deps = (typeof ganttDeps !== 'undefined' && Array.isArray(ganttDeps)) ? ganttDeps.slice() : [];

    // ── Date helpers ─────────────────────────────────────────────
    function parseDate(str) {
        if (!str) return null;
        const d = new Date(str.substring(0, 10) + 'T00:00:00');
        return isNaN(d) ? null : d;
    }
    function addDays(date, n) {
        const d = new Date(date);
        d.setDate(d.getDate() + n);
        return d;
    }
    function diffDays(a, b) {
        return Math.round((b - a) / 86400000);
    }
    function toISO(date) {
        const y = date.getFullYear();
        const m = String(date.getMonth() + 1).padStart(2, '0');
        const d = String(date.getDate()).padStart(2, '0');
        return `${y}-${m}-${d}`;
    }
    function fmt(date) {
        if (!date) return '?';
        return date.toLocaleDateString(
            (typeof APP_LANG !== 'undefined' ? APP_LANG : 'de').replace('_', '-'),
            { day: 'numeric', month: 'short' }
        );
    }
    function escHtml(s) {
        if (s == null) return '';
        return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
    }

    // ── Save toast ───────────────────────────────────────────────
    let _toastTimer = null;
    function showToast(msg, type) {
        let toast = document.getElementById('ganttToast');
        if (!toast) {
            toast = document.createElement('div');
            toast.id = 'ganttToast';
            toast.style.cssText = `position:fixed;bottom:20px;right:20px;padding:8px 16px;border-radius:6px;
                font-size:.8rem;font-weight:600;z-index:9999;pointer-events:none;
                transition:opacity .3s;box-shadow:0 4px 12px rgba(0,0,0,.25);`;
            document.body.appendChild(toast);
        }
        clearTimeout(_toastTimer);
        toast.textContent = msg;
        toast.style.opacity = '1';
        if (type === 'saving') {
            toast.style.background = '#374151';
            toast.style.color = '#fff';
        } else if (type === 'ok') {
            toast.style.background = '#16a34a';
            toast.style.color = '#fff';
            _toastTimer = setTimeout(() => { toast.style.opacity = '0'; }, 2000);
        } else {
            toast.style.background = '#dc2626';
            toast.style.color = '#fff';
            _toastTimer = setTimeout(() => { toast.style.opacity = '0'; }, 4000);
        }
    }

    // ── Auto-build dependencies ───────────────────────────────────
    //
    // Phase grouping by date overlap. Two tasks belong to the same phase
    // when their windows transitively overlap (B starts strictly before
    // the latest end seen in the phase so far). Tasks in the same phase
    // are parallel by definition and never get linked to each other.
    //
    // Cross-phase rule depends on the size of the next phase:
    //
    //   • Next phase has exactly one task → fan-in. Every task of the
    //     current phase becomes a predecessor of that one task. This is
    //     the convergence pattern (e.g. six prep tasks all needed before
    //     a single contract or integration milestone can begin).
    //
    //   • Next phase has multiple tasks → fan-out. The latest-ending task
    //     of the current phase becomes the predecessor of every task in
    //     the next phase. When the current phase itself has only one
    //     task, the bridge degenerates to that single task, so a 1 → N
    //     transition fans out from it as expected.
    //
    // No task ends up isolated: every phase except the first has its
    // tasks connected to the previous phase, and within-phase parallelism
    // is preserved (no edges between siblings).
    async function autoBuildDeps() {
        if (ganttTasks.length < 2) {
            showToast('Zu wenige Aufgaben', 'error');
            return;
        }

        const dated = ganttTasks.filter(t => t.start_date && t.due_date);
        if (dated.length < 2) {
            showToast('Zu wenige Aufgaben mit Start- und Enddatum', 'error');
            return;
        }

        const items = dated.map(t => ({
            id:        t.id,
            start:     new Date(t.start_date + 'T00:00:00').getTime(),
            end:       new Date(t.due_date   + 'T00:00:00').getTime(),
            listIndex: ganttTasks.indexOf(t),
        })).sort((a, b) => {
            if (a.start !== b.start) return a.start - b.start;
            if (a.end   !== b.end)   return a.end   - b.end;
            return a.listIndex - b.listIndex;
        });

        // A task joins the current phase as long as it begins strictly
        // before the latest end date observed in the phase so far. A task
        // starting on the same day a previous task ends already belongs
        // to the next phase — that boundary is what separates Rollen-
        // konzept (May 1-15) from Custom Reports / Asset-Scanning (May
        // 16+) so the auto-builder sees three phases in May, not one.
        const phases = [];
        let currentPhase = [items[0]];
        let phaseEnd = items[0].end;
        for (let i = 1; i < items.length; i++) {
            if (items[i].start < phaseEnd) {
                currentPhase.push(items[i]);
                if (items[i].end > phaseEnd) phaseEnd = items[i].end;
            } else {
                phases.push(currentPhase);
                currentPhase = [items[i]];
                phaseEnd = items[i].end;
            }
        }
        phases.push(currentPhase);

        let added = 0;
        let fanInLinks = 0;
        let fanOutLinks = 0;
        let matrixLinks = 0;
        async function addIfNew(predId, succId) {
            if (predId == succId) return;
            if (deps.find(d => d.predecessor_id == predId && d.successor_id == succId)) return;
            await addDependency(predId, succId);
            added++;
        }

        for (let i = 0; i < phases.length - 1; i++) {
            const cur = phases[i];
            const next = phases[i + 1];

            if (next.length === 1) {
                const target = next[0];
                for (const pred of cur) {
                    const before = added;
                    await addIfNew(pred.id, target.id);
                    if (added > before) fanInLinks++;
                }
            } else if (cur.length === 1) {
                const bridge = cur[0];
                for (const succ of next) {
                    const before = added;
                    await addIfNew(bridge.id, succ.id);
                    if (added > before) fanOutLinks++;
                }
            } else {
                // N:M — both phases have multiple parallel tasks. Without
                // a single bridge or convergence task, the conservative
                // dependency is the full bipartite link (every cur task
                // must finish before any next task can start), since the
                // next phase as a whole only begins after the current
                // phase as a whole has finished.
                for (const pred of cur) {
                    for (const succ of next) {
                        const before = added;
                        await addIfNew(pred.id, succ.id);
                        if (added > before) matrixLinks++;
                    }
                }
            }
        }

        if (added > 0) {
            const parts = [`${added} Verknüpfung${added !== 1 ? 'en' : ''}`];
            parts.push(`${phases.length} Phase${phases.length !== 1 ? 'n' : ''}`);
            if (fanInLinks)  parts.push(`${fanInLinks} Fan-In`);
            if (fanOutLinks) parts.push(`${fanOutLinks} Fan-Out`);
            if (matrixLinks) parts.push(`${matrixLinks} N:M`);
            showToast(parts.join(' · ') + ' ✓', 'ok');
        } else {
            showToast('Keine neuen Verknüpfungen gefunden', 'ok');
        }
    }

    // ── Sort helpers ─────────────────────────────────────────────
    function sortTasks() {
        ganttTasks.sort((a, b) => {
            const aDate = a.start_date ? new Date(a.start_date + 'T00:00:00') : null;
            const bDate = b.start_date ? new Date(b.start_date + 'T00:00:00') : null;
            if (aDate && bDate) return aDate - bDate;
            if (aDate) return -1;
            if (bDate) return 1;
            return a.title.localeCompare(b.title);
        });
    }

    function renderSidebar() {
        const sb = document.getElementById('ganttSidebar');
        if (!sb) return;
        sb.querySelectorAll('.gantt-task-label').forEach(el => el.remove());
        const handle = document.getElementById('ganttResizeHandle');
        ganttTasks.forEach(task => {
            const a = document.createElement('a');
            a.href = BASE_URL + '/task/' + task.id;
            a.className = 'gantt-task-label';
            a.title = task.title;
            const dot = document.createElement('span');
            dot.className = 'gantt-task-dot';
            dot.style.background = task.color || '#6b7280';
            const titleSpan = document.createElement('span');
            titleSpan.className = 'gantt-task-title';
            titleSpan.textContent = task.title;
            a.appendChild(dot);
            a.appendChild(titleSpan);
            sb.insertBefore(a, handle);
        });
    }

    // ── Init ─────────────────────────────────────────────────────
    function init() {
        const t = new Date(); t.setHours(0, 0, 0, 0); t.setDate(1);
        viewStart = addDays(t, -14);
        requestAnimationFrame(render);
    }

    // ── Render ───────────────────────────────────────────────────
    function render() {
        sortTasks();
        renderSidebar();
        totalDays = zoomMode === 'weeks' ? 84 : 120;
        const viewEnd    = addDays(viewStart, totalDays);
        const totalWidth = totalDays * dayWidth;
        const today      = new Date(); today.setHours(0, 0, 0, 0);

        if (rangeLabel) rangeLabel.textContent = fmt(viewStart) + ' – ' + fmt(viewEnd);

        let html = `<div style="width:${totalWidth}px;position:relative;min-height:${HEADER_HEIGHT + ganttTasks.length * ROW_HEIGHT}px" id="ganttInner">`;
        html += buildHeader(viewStart, totalDays, today);
        ganttTasks.forEach((_, i) => {
            const top = HEADER_HEIGHT + i * ROW_HEIGHT;
            html += `<div style="position:absolute;top:${top}px;left:0;right:0;height:${ROW_HEIGHT}px;border-bottom:1px solid var(--border-color)"></div>`;
        });
        const todayOff = diffDays(viewStart, today);
        if (todayOff >= 0 && todayOff <= totalDays) {
            const x = todayOff * dayWidth;
            html += `<div style="position:absolute;top:0;bottom:0;left:${x}px;width:2px;background:var(--color-danger);opacity:.7;z-index:5;pointer-events:none">
                <div style="position:absolute;top:${HEADER_HEIGHT}px;left:50%;transform:translateX(-50%);background:var(--color-danger);color:#fff;font-size:.65rem;font-weight:700;padding:1px 4px;border-radius:2px;white-space:nowrap">Heute</div>
            </div>`;
        }
        html += '</div>';
        chart.innerHTML = html;

        const inner = document.getElementById('ganttInner');
        const contentH = ganttTasks.length * ROW_HEIGHT;
        for (let i = 0; i < totalDays; i++) {
            const d = addDays(viewStart, i);
            const dow = d.getDay();
            if (dow === 0 || dow === 6) {
                const stripe = document.createElement('div');
                stripe.style.cssText = `position:absolute;top:${HEADER_HEIGHT}px;left:${i * dayWidth}px;
                    width:${dayWidth}px;height:${contentH}px;background:rgba(0,0,0,.04);pointer-events:none;z-index:1;`;
                inner.appendChild(stripe);
            }
        }

        ganttTasks.forEach((task, i) => {
            const bar = buildBar(task, i);
            if (bar) inner.appendChild(bar);
        });

        renderDependencies();
    }

    // ── Bar builder ───────────────────────────────────────────────
    function buildBar(task, i) {
        const start = parseDate(task.start_date);
        const end   = parseDate(task.due_date);
        if (!start && !end) return canEdit ? buildCreateZone(task, i) : null;

        const barStart = start || end;
        const barEnd   = end   || start;
        const startOff = diffDays(viewStart, barStart);
        const endOff   = diffDays(viewStart, barEnd) + 1;
        if (endOff < 0 || startOff > totalDays) return null;

        const clampedStart = Math.max(0, startOff);
        const clampedEnd   = Math.min(totalDays, endOff);
        const barW = Math.max((clampedEnd - clampedStart) * dayWidth, canEdit ? HANDLE_W * 2 + 4 : 4);
        const barX = clampedStart * dayWidth;
        const top  = HEADER_HEIGHT + i * ROW_HEIGHT + 8;
        const barH = ROW_HEIGHT - 16;
        const color = task.color || '#4f46e5';

        const el = document.createElement('div');
        el.className = 'gantt-bar';
        el.dataset.taskId = task.id;
        el.dataset.idx = i;
        el.style.cssText = `
            position:absolute;top:${top}px;left:${barX}px;width:${barW}px;height:${barH}px;
            background:${color};opacity:${task.is_done ? 0.5 : 1};border-radius:4px;
            display:flex;align-items:center;overflow:visible;
            box-shadow:0 1px 3px rgba(0,0,0,.2);z-index:10;
            ${canEdit ? 'cursor:grab;' : 'cursor:pointer;'}
        `;
        el.title = `${task.title} | ${fmt(barStart)} – ${end ? fmt(barEnd) : '?'}`;

        if (canEdit) {
            const lh = document.createElement('div');
            lh.className = 'gantt-handle gantt-handle-l';
            lh.style.cssText = `width:${HANDLE_W}px;height:100%;flex-shrink:0;cursor:ew-resize;
                background:rgba(0,0,0,.25);border-radius:4px 0 0 4px;`;
            el.appendChild(lh);
        }

        const label = document.createElement('a');
        label.href = BASE_URL + '/task/' + task.id;
        label.style.cssText = `flex:1;padding:0 4px;color:#fff;font-size:.72rem;font-weight:600;
            white-space:nowrap;overflow:hidden;text-overflow:ellipsis;text-decoration:none;min-width:0;`;
        label.textContent = task.title;
        label.addEventListener('click', e => { if (dragMoved) e.preventDefault(); });
        el.appendChild(label);

        if (canEdit) {
            const rh = document.createElement('div');
            rh.className = 'gantt-handle gantt-handle-r';
            rh.style.cssText = `width:${HANDLE_W}px;height:100%;flex-shrink:0;cursor:ew-resize;
                background:rgba(0,0,0,.25);border-radius:0 4px 4px 0;`;
            el.appendChild(rh);

            // Connector dot (right edge, drag to create dependency)
            const dot = document.createElement('div');
            dot.className = 'gantt-connector-dot';
            dot.style.cssText = `position:absolute;right:-6px;top:50%;transform:translateY(-50%);
                width:12px;height:12px;border-radius:50%;background:#fff;border:2px solid ${color};
                cursor:crosshair;z-index:30;opacity:0;transition:opacity .15s;
                box-shadow:0 1px 4px rgba(0,0,0,.35);flex-shrink:0;`;
            el.appendChild(dot);

            el.addEventListener('mouseenter', () => { if (!connecting && !dragging) dot.style.opacity = '1'; });
            el.addEventListener('mouseleave', () => { dot.style.opacity = '0'; });

            dot.addEventListener('mousedown', e => {
                e.stopPropagation();
                e.preventDefault();
                dot.style.opacity = '0';
                const x1 = barX + barW;
                const y1 = top + barH / 2;
                startConnect(task, i, x1, y1);
            });

            initDrag(el, task, i, barStart, barEnd, start, end);
        }

        el.addEventListener('mouseover', () => {
            if (!dragging && !connecting) el.style.filter = 'brightness(1.15)';
            highlightDepsForTask(task.id, true);
        });
        el.addEventListener('mouseout',  () => {
            el.style.filter = '';
            highlightDepsForTask(task.id, false);
        });

        return el;
    }

    // Highlights every dependency arrow attached to the given task id.
    // Both the predecessor side and the successor side are matched so the
    // user sees the full chain hop in either direction. Marker swaps are
    // gated on the path having an existing marker-end so branch segments
    // of a fan-in merge (which carry no arrowhead) do not suddenly grow
    // a stray arrow when a neighbouring task is hovered.
    function highlightDepsForTask(taskId, on) {
        const svg = document.getElementById('ganttDepsSvg');
        if (!svg) return;
        const idStr = String(taskId);
        svg.querySelectorAll('path[data-pred-id], path[data-succ-id]').forEach(p => {
            if (p.dataset.predId !== idStr && p.dataset.succId !== idStr) return;
            if (on) {
                p.setAttribute('stroke', '#3b82f6');
                p.setAttribute('stroke-width', '2');
                if (p.hasAttribute('marker-end')) {
                    p.setAttribute('marker-end', 'url(#depArrowFocus)');
                }
            } else {
                p.setAttribute('stroke', '#4b5563');
                p.setAttribute('stroke-width', '1.5');
                if (p.hasAttribute('marker-end')) {
                    p.setAttribute('marker-end', 'url(#depArrow)');
                }
            }
        });
    }

    // ── Create-zone (tasks without dates) ────────────────────────
    function buildCreateZone(task, i) {
        const top    = HEADER_HEIGHT + i * ROW_HEIGHT;
        const totalW = totalDays * dayWidth;
        const color  = task.color || '#4f46e5';

        const zone = document.createElement('div');
        zone.style.cssText = `position:absolute;top:${top}px;left:0;width:${totalW}px;height:${ROW_HEIGHT}px;
            cursor:crosshair;z-index:8;`;

        zone.addEventListener('mouseenter', () => { if (!dragging) zone.style.background = 'rgba(79,70,229,.05)'; });
        zone.addEventListener('mouseleave', () => { if (!dragging || dragging.mode !== 'create') zone.style.background = ''; });

        zone.addEventListener('mousedown', e => {
            if (e.button !== 0 || !canEdit) return;
            e.preventDefault();

            const inner     = document.getElementById('ganttInner');
            const innerRect = inner.getBoundingClientRect();
            const clickDay  = Math.floor((e.clientX - innerRect.left) / dayWidth);
            const anchorDate = addDays(viewStart, Math.max(0, Math.min(totalDays - 1, clickDay)));

            const barTop = HEADER_HEIGHT + i * ROW_HEIGHT + 8;
            const preview = document.createElement('div');
            preview.style.cssText = `position:absolute;top:${barTop}px;left:${Math.max(0,clickDay)*dayWidth}px;
                width:${dayWidth}px;height:${ROW_HEIGHT-16}px;background:${color};opacity:.75;
                border-radius:4px;z-index:50;pointer-events:none;box-shadow:0 2px 8px rgba(0,0,0,.25);`;
            inner.appendChild(preview);

            dragging = { el: preview, zoneEl: zone, task, idx: i, mode: 'create',
                startX: e.clientX, origStart: new Date(anchorDate), origEnd: new Date(anchorDate),
                hasStart: false, hasEnd: false };
            dragMoved = false;

            tooltip = document.createElement('div');
            tooltip.style.cssText = `position:fixed;background:rgba(0,0,0,.8);color:#fff;
                font-size:.72rem;padding:3px 8px;border-radius:4px;pointer-events:none;z-index:1000;white-space:nowrap;`;
            document.body.appendChild(tooltip);
        });

        return zone;
    }

    // ── Dependency rendering ──────────────────────────────────────
    function renderDependencies() {
        const old = document.getElementById('ganttDepsSvg');
        if (old) old.remove();
        if (!deps.length) return;

        const inner = document.getElementById('ganttInner');
        if (!inner) return;

        const totalW = totalDays * dayWidth;
        const totalH = HEADER_HEIGHT + ganttTasks.length * ROW_HEIGHT;

        const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
        svg.id = 'ganttDepsSvg';
        svg.setAttribute('width', totalW);
        svg.setAttribute('height', totalH);
        // Bars sit at z-index 10, so the dependency layer needs to clear them
        // or arrow segments crossing other rows disappear behind those bars.
        svg.style.cssText = `position:absolute;top:0;left:0;pointer-events:none;z-index:20;overflow:visible;`;

        const SVG_NS = 'http://www.w3.org/2000/svg';
        const defs = document.createElementNS(SVG_NS, 'defs');
        // Build markers in the explicit SVG namespace. Setting innerHTML on a
        // <defs> works in modern browsers but is brittle around quirks; using
        // createElementNS guarantees the parser cannot misclassify <marker>
        // or <path> as plain HTML and silently drop the arrowhead.
        // refX=7 with markerWidth=8 leaves a 1-unit overshoot past the line
        // endpoint so the triangle tip nestles into the day-gap and is not
        // hidden by the path's own stroke.
        const buildMarker = (id, fill) => {
            const m = document.createElementNS(SVG_NS, 'marker');
            m.setAttribute('id', id);
            m.setAttribute('markerWidth',  '8');
            m.setAttribute('markerHeight', '8');
            m.setAttribute('refX',         '7');
            m.setAttribute('refY',         '4');
            m.setAttribute('orient',       'auto');
            m.setAttribute('markerUnits',  'strokeWidth');
            const p = document.createElementNS(SVG_NS, 'path');
            p.setAttribute('d',    'M0,0 L0,8 L8,4 z');
            p.setAttribute('fill', fill);
            m.appendChild(p);
            return m;
        };
        defs.appendChild(buildMarker('depArrow',      '#4b5563'));
        defs.appendChild(buildMarker('depArrowHover', '#ef4444'));
        defs.appendChild(buildMarker('depArrowFocus', '#3b82f6'));
        svg.appendChild(defs);

        const ARROW_PAD          = 4;
        const BUS_MARGIN         = 15;
        const MIN_HORIZONTAL_GAP = 15;
        const BACKTRACK_OFFSET   = 15;

        // ── Path drawing helpers ─────────────────────────────────────
        // Single elbow path is reused for the 1-pred direct case and for
        // each branch of a fan-in (with `withArrow` toggling the marker).
        function drawDepBranch(d, dep, withArrow) {
            const path = document.createElementNS(SVG_NS, 'path');
            path.setAttribute('d', d);
            path.setAttribute('stroke', '#4b5563');
            path.setAttribute('stroke-width', '1.5');
            path.setAttribute('stroke-linejoin', 'round');
            path.setAttribute('fill', 'none');
            if (withArrow) path.setAttribute('marker-end', 'url(#depArrow)');
            path.dataset.predId = String(dep.predecessor_id);
            path.dataset.succId = String(dep.successor_id);
            svg.appendChild(path);

            if (canEdit) {
                const hit = document.createElementNS(SVG_NS, 'path');
                hit.setAttribute('d', d);
                hit.setAttribute('stroke', 'transparent');
                hit.setAttribute('stroke-width', '12');
                hit.setAttribute('fill', 'none');
                hit.style.pointerEvents = 'stroke';
                hit.style.cursor = 'pointer';

                hit.addEventListener('mouseenter', () => {
                    path.setAttribute('stroke', '#ef4444');
                    path.setAttribute('stroke-width', '2');
                    if (path.hasAttribute('marker-end')) {
                        path.setAttribute('marker-end', 'url(#depArrowHover)');
                    }
                });
                hit.addEventListener('mouseleave', () => {
                    path.setAttribute('stroke', '#4b5563');
                    path.setAttribute('stroke-width', '1.5');
                    if (path.hasAttribute('marker-end')) {
                        path.setAttribute('marker-end', 'url(#depArrow)');
                    }
                });
                hit.addEventListener('click', async e => {
                    e.stopPropagation();
                    await removeDependency(dep.predecessor_id, dep.successor_id);
                });
                svg.appendChild(hit);
            }
        }

        // Shared segments of a fan-in / fan-out group, drawn thicker than
        // the per-edge branches (2.5 vs 1.5) so the converged section
        // reads as one heavier line — the "Y-Stil-Sammler" style. Fan-in
        // uses the segment from the bus into the successor (with the
        // single shared arrowhead); fan-out uses the symmetric segment
        // from the predecessor to the bus (no arrow, the per-successor
        // branches each carry their own arrowhead afterwards).
        function drawMergeSegment(x1, y, x2, opts) {
            const path = document.createElementNS(SVG_NS, 'path');
            path.setAttribute('d', `M${x1},${y} H${x2}`);
            path.setAttribute('stroke', '#4b5563');
            path.setAttribute('stroke-width', '2.5');
            path.setAttribute('stroke-linecap', 'round');
            path.setAttribute('fill', 'none');
            if (opts.withArrow) path.setAttribute('marker-end', 'url(#depArrow)');
            if (opts.succId)    path.dataset.succId = String(opts.succId);
            if (opts.predId)    path.dataset.predId = String(opts.predId);
            svg.appendChild(path);
        }

        function drawJunctionDot(cx, cy) {
            const c = document.createElementNS(SVG_NS, 'circle');
            c.setAttribute('cx', String(cx));
            c.setAttribute('cy', String(cy));
            c.setAttribute('r', '2');
            c.setAttribute('fill', '#4b5563');
            svg.appendChild(c);
        }

        function elbowPath(x1, y1, x2adj, y2, horizontalGap) {
            // Direct 1-pred routing — H-V-H elbow if there's space, else
            // 5-segment backtrack so the arrow always approaches from the
            // left side of the successor.
            if (horizontalGap >= MIN_HORIZONTAL_GAP) {
                const mid = Math.min(x1 + 12, (x1 + x2adj) / 2);
                return `M${x1},${y1} H${mid} V${y2} H${x2adj}`;
            }
            const detourX    = x1 + 12;
            const backtrackX = x2adj - (BACKTRACK_OFFSET - ARROW_PAD);
            const midY       = (y1 + y2) / 2;
            return `M${x1},${y1} H${detourX} V${midY} H${backtrackX} V${y2} H${x2adj}`;
        }

        // ── Geometric resolvers ──────────────────────────────────────
        // Predecessor coordinates are pinned to the right-hand edge of
        // the bar (= start of the day after `due_date`); successor
        // coordinates pin to the left edge (= start of `start_date`).
        // Both fall back to the other date when only one is set.
        const predCache = new Map();
        const succCache = new Map();
        function resolvePred(predId) {
            const key = String(predId);
            if (predCache.has(key)) return predCache.get(key);
            const idx = ganttTasks.findIndex(t => String(t.id) === key);
            if (idx < 0) { predCache.set(key, null); return null; }
            const t = ganttTasks[idx];
            const end = parseDate(t.due_date) || parseDate(t.start_date);
            if (!end) { predCache.set(key, null); return null; }
            const off = diffDays(viewStart, end) + 1;
            const info = {
                idx,
                x1: Math.min(totalDays, Math.max(0, off)) * dayWidth,
                y1: HEADER_HEIGHT + idx * ROW_HEIGHT + ROW_HEIGHT / 2,
            };
            predCache.set(key, info);
            return info;
        }
        function resolveSucc(succId) {
            const key = String(succId);
            if (succCache.has(key)) return succCache.get(key);
            const idx = ganttTasks.findIndex(t => String(t.id) === key);
            if (idx < 0) { succCache.set(key, null); return null; }
            const t = ganttTasks[idx];
            const start = parseDate(t.start_date) || parseDate(t.due_date);
            if (!start) { succCache.set(key, null); return null; }
            const off = diffDays(viewStart, start);
            const info = {
                idx,
                x2: Math.min(totalDays, Math.max(0, off)) * dayWidth,
                y2: HEADER_HEIGHT + idx * ROW_HEIGHT + ROW_HEIGHT / 2,
            };
            succCache.set(key, info);
            return info;
        }

        // pickBusX returns the x-coordinate for the shared vertical
        // collector ("bus") used by fan-in and fan-out groups.
        //
        // The bus must satisfy two constraints simultaneously: it lies
        // strictly to the right of every predecessor bar end (so the
        // vertical drop never crosses a parallel sibling bar — that was
        // the visual bug fixed in 2026-05) and it lies strictly to the
        // left of every successor bar start (so the final horizontal
        // approach has room for the arrowhead).
        //
        // When the calendar gap allows it, BUS_MARGIN (15px) is used on
        // the predecessor side, matching the spec. When the gap is too
        // tight (e.g. predecessor ends one day before successor starts
        // and dayWidth = 14), the bus is pulled toward the successor
        // edge but kept past the rightmost predecessor end. If even that
        // fails (overlap or zero gap), null is returned and the caller
        // falls back to the per-branch elbow router.
        function pickBusX(rightmostPredX, leftmostSuccX) {
            const minBusX  = rightmostPredX + 1;
            const maxBusX  = leftmostSuccX - ARROW_PAD - 1;
            if (minBusX >= maxBusX) return null;
            const ideal = rightmostPredX + BUS_MARGIN;
            return Math.min(ideal, maxBusX);
        }

        // ── Group dependencies for fan-in / fan-out detection ────────
        const bySuccessor = new Map();
        const byPredecessor = new Map();
        deps.forEach(dep => {
            const sid = String(dep.successor_id);
            const pid = String(dep.predecessor_id);
            if (!bySuccessor.has(sid))   bySuccessor.set(sid, []);
            if (!byPredecessor.has(pid)) byPredecessor.set(pid, []);
            bySuccessor.get(sid).push(dep);
            byPredecessor.get(pid).push(dep);
        });

        const drawn = new Set();
        const edgeKey = (p, s) => p + '->' + s;

        // ── Pass 0: N:M matrix groups (parallel preds → parallel succs).
        // Detected by grouping successors that share the exact same set
        // of predecessors — autoBuildDeps's full-bipartite rule between
        // two multi-task phases produces exactly this pattern. Rendered
        // with one shared bus vertical between the two parallel blocks
        // so the N×M edges read as a single Y-shaped joiner instead of
        // M independent fan-ins each carrying its own bus.
        const matrixGroups = new Map();
        bySuccessor.forEach((depList, sidStr) => {
            const predIds = depList.map(d => String(d.predecessor_id)).sort();
            const key = predIds.join('|');
            if (!matrixGroups.has(key)) {
                matrixGroups.set(key, { predIds, succIds: [] });
            }
            matrixGroups.get(key).succIds.push(sidStr);
        });

        matrixGroups.forEach(group => {
            if (group.predIds.length < 2 || group.succIds.length < 2) return;

            const preds = group.predIds
                .map(pid => {
                    const p = resolvePred(pid);
                    return p ? { predId: pid, x1: p.x1, y1: p.y1 } : null;
                })
                .filter(Boolean);
            const succs = group.succIds
                .map(sid => {
                    const s = resolveSucc(sid);
                    return s ? { succId: sid, x2: s.x2, y2: s.y2 } : null;
                })
                .filter(Boolean);
            if (preds.length < 2 || succs.length < 2) return;

            const xMaxPred = Math.max(...preds.map(p => p.x1));
            const xMinSucc = Math.min(...succs.map(s => s.x2));
            const busX = pickBusX(xMaxPred, xMinSucc);
            if (busX === null) return; // tight gap → leave to Pass 1

            const allYs = [...preds.map(p => p.y1), ...succs.map(s => s.y2)];
            const busYtop = Math.min(...allYs);
            const busYbot = Math.max(...allYs);

            // Pred-side: thin H-only branches into the bus, no arrows.
            // dataset.predId hooks up the existing task-hover highlight
            // so that hovering a parallel predecessor still lights up
            // its individual feeder line.
            preds.forEach(({ predId, x1, y1 }) => {
                const p = document.createElementNS(SVG_NS, 'path');
                p.setAttribute('d', `M${x1},${y1} H${busX}`);
                p.setAttribute('stroke', '#4b5563');
                p.setAttribute('stroke-width', '1.5');
                p.setAttribute('fill', 'none');
                p.dataset.predId = predId;
                svg.appendChild(p);
            });

            // Shared vertical bus (thicker, runs across every involved row).
            const busPath = document.createElementNS(SVG_NS, 'path');
            busPath.setAttribute('d', `M${busX},${busYtop} V${busYbot}`);
            busPath.setAttribute('stroke', '#4b5563');
            busPath.setAttribute('stroke-width', '2.5');
            busPath.setAttribute('stroke-linecap', 'round');
            busPath.setAttribute('fill', 'none');
            svg.appendChild(busPath);

            // Succ-side: thin H-only branches out of the bus, each with
            // its own arrowhead so the target task is unambiguous.
            succs.forEach(({ succId, x2, y2 }) => {
                const p = document.createElementNS(SVG_NS, 'path');
                p.setAttribute('d', `M${busX},${y2} H${x2 - ARROW_PAD}`);
                p.setAttribute('stroke', '#4b5563');
                p.setAttribute('stroke-width', '1.5');
                p.setAttribute('fill', 'none');
                p.setAttribute('marker-end', 'url(#depArrow)');
                p.dataset.succId = succId;
                svg.appendChild(p);
            });

            drawJunctionDot(busX, busYtop);
            drawJunctionDot(busX, busYbot);

            preds.forEach(({ predId }) => {
                succs.forEach(({ succId }) => {
                    drawn.add(edgeKey(predId, succId));
                });
            });
        });

        // ── Pass 1: fan-in groups (one successor, multiple predecessors)
        bySuccessor.forEach((depList, sidStr) => {
            const remaining = depList.filter(d =>
                !drawn.has(edgeKey(d.predecessor_id, d.successor_id))
            );
            if (remaining.length < 2) return;
            const succ = resolveSucc(sidStr);
            if (!succ) return;
            const preds = [];
            remaining.forEach(dep => {
                const p = resolvePred(dep.predecessor_id);
                if (p) preds.push({ dep, x1: p.x1, y1: p.y1 });
            });
            if (preds.length < 2) return;

            const xMaxPred = Math.max(...preds.map(p => p.x1));
            const busX = pickBusX(xMaxPred, succ.x2);

            if (busX !== null) {
                preds.forEach(({ dep, x1, y1 }) => {
                    const d = `M${x1},${y1} H${busX} V${succ.y2}`;
                    drawDepBranch(d, dep, false);
                    drawn.add(edgeKey(dep.predecessor_id, dep.successor_id));
                });
                drawMergeSegment(busX, succ.y2, succ.x2 - ARROW_PAD, { withArrow: true, succId: sidStr });
                drawJunctionDot(busX, succ.y2);
            } else {
                // Tight gap or overlap: fall back to per-branch backtrack
                // routing. Lines may still touch sibling bars, but the
                // common case (sequential phases with > 0 day gap) takes
                // the bus path above.
                preds.forEach(({ dep, x1, y1 }) => {
                    const horizontalGap = succ.x2 - x1;
                    const forward = horizontalGap >= 0;
                    const pad = Math.min(ARROW_PAD, Math.max(0, forward ? horizontalGap : 0));
                    const x2adj = forward ? succ.x2 - pad : succ.x2;
                    const d = elbowPath(x1, y1, x2adj, succ.y2, horizontalGap);
                    drawDepBranch(d, dep, true);
                    drawn.add(edgeKey(dep.predecessor_id, dep.successor_id));
                });
            }
        });

        // ── Pass 2: fan-out groups (one predecessor, multiple successors)
        // Only edges not already consumed by a fan-in group are eligible.
        byPredecessor.forEach((depList, pidStr) => {
            const remaining = depList.filter(d =>
                !drawn.has(edgeKey(d.predecessor_id, d.successor_id))
            );
            if (remaining.length < 2) return;

            const pred = resolvePred(pidStr);
            if (!pred) return;
            const succs = [];
            remaining.forEach(dep => {
                const s = resolveSucc(dep.successor_id);
                if (s) succs.push({ dep, x2: s.x2, y2: s.y2 });
            });
            if (succs.length < 2) return;

            const xMinSucc = Math.min(...succs.map(s => s.x2));
            const busX = pickBusX(pred.x1, xMinSucc);

            if (busX !== null) {
                drawMergeSegment(pred.x1, pred.y1, busX, { withArrow: false, predId: pidStr });
                succs.forEach(({ dep, x2, y2 }) => {
                    const d = `M${busX},${pred.y1} V${y2} H${x2 - ARROW_PAD}`;
                    drawDepBranch(d, dep, true);
                    drawn.add(edgeKey(dep.predecessor_id, dep.successor_id));
                });
                drawJunctionDot(busX, pred.y1);
            } else {
                succs.forEach(({ dep, x2, y2 }) => {
                    const horizontalGap = x2 - pred.x1;
                    const forward = horizontalGap >= 0;
                    const pad = Math.min(ARROW_PAD, Math.max(0, forward ? horizontalGap : 0));
                    const x2adj = forward ? x2 - pad : x2;
                    const d = elbowPath(pred.x1, pred.y1, x2adj, y2, horizontalGap);
                    drawDepBranch(d, dep, true);
                    drawn.add(edgeKey(dep.predecessor_id, dep.successor_id));
                });
            }
        });

        // ── Pass 3: remaining 1 → 1 edges as direct elbows
        deps.forEach(dep => {
            if (drawn.has(edgeKey(dep.predecessor_id, dep.successor_id))) return;
            const pred = resolvePred(dep.predecessor_id);
            const succ = resolveSucc(dep.successor_id);
            if (!pred || !succ) return;
            const horizontalGap = succ.x2 - pred.x1;
            const forward = horizontalGap >= 0;
            const pad = Math.min(ARROW_PAD, Math.max(0, forward ? horizontalGap : 0));
            const x2adj = forward ? succ.x2 - pad : succ.x2;
            const d = elbowPath(pred.x1, pred.y1, x2adj, succ.y2, horizontalGap);
            drawDepBranch(d, dep, true);
        });

        inner.appendChild(svg);
    }

    // ── Connection drag (create dependency) ───────────────────────
    let connecting = null; // { fromTask, fromIdx, x1, y1, line, svg }

    function startConnect(fromTask, fromIdx, x1, y1) {
        const inner  = document.getElementById('ganttInner');
        const totalW = totalDays * dayWidth;
        const totalH = HEADER_HEIGHT + ganttTasks.length * ROW_HEIGHT;

        const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
        svg.setAttribute('width', totalW);
        svg.setAttribute('height', totalH);
        svg.style.cssText = `position:absolute;top:0;left:0;pointer-events:none;z-index:70;overflow:visible;`;

        const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
        line.setAttribute('x1', x1); line.setAttribute('y1', y1);
        line.setAttribute('x2', x1); line.setAttribute('y2', y1);
        line.setAttribute('stroke', '#4f46e5');
        line.setAttribute('stroke-width', '2');
        line.setAttribute('stroke-dasharray', '5,3');
        svg.appendChild(line);
        inner.appendChild(svg);

        connecting = { fromTask, fromIdx, x1, y1, line, svg };
    }

    async function addDependency(predId, succId) {
        try {
            const res = await fetch(BASE_URL + '/api/task/add-dependency', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({ predecessor_id: predId, successor_id: succId, csrf_token: CSRF_TOKEN }),
            });
            if (!res.ok) return;
            // Avoid duplicates
            if (!deps.find(d => d.predecessor_id == predId && d.successor_id == succId)) {
                deps.push({ predecessor_id: predId, successor_id: succId });
            }
            renderDependencies();
        } catch {}
    }

    async function removeDependency(predId, succId) {
        try {
            const res = await fetch(BASE_URL + '/api/task/remove-dependency', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({ predecessor_id: predId, successor_id: succId, csrf_token: CSRF_TOKEN }),
            });
            if (!res.ok) return;
            deps = deps.filter(d => !(d.predecessor_id == predId && d.successor_id == succId));
            renderDependencies();
        } catch {}
    }

    // ── Drag state ────────────────────────────────────────────────
    let dragging  = null;
    let panning   = null;
    let tooltip   = null;
    let dragMoved = false;

    function initDrag(el, task, idx, barStart, barEnd, origStart, origEnd) {
        el.addEventListener('mousedown', e => {
            if (e.button !== 0) return;
            if (e.target.classList.contains('gantt-connector-dot')) return;
            e.preventDefault();

            const mode = e.target.classList.contains('gantt-handle-l') ? 'resize-start'
                       : e.target.classList.contains('gantt-handle-r') ? 'resize-end'
                       : 'move';

            dragging = { el, task, idx, mode,
                startX: e.clientX,
                origStart: new Date(barStart),
                origEnd:   new Date(barEnd),
                hasStart:  !!origStart,
                hasEnd:    !!origEnd,
            };

            el.style.cursor    = mode === 'move' ? 'grabbing' : 'ew-resize';
            el.style.boxShadow = '0 4px 12px rgba(0,0,0,.35)';
            el.style.zIndex    = '50';

            tooltip = document.createElement('div');
            tooltip.style.cssText = `position:fixed;background:rgba(0,0,0,.8);color:#fff;
                font-size:.72rem;padding:3px 8px;border-radius:4px;pointer-events:none;z-index:1000;white-space:nowrap;`;
            document.body.appendChild(tooltip);
        });
    }

    document.addEventListener('mousemove', e => {
        // ── Connection drag ───────────────────────────────────────
        if (connecting) {
            const inner = document.getElementById('ganttInner');
            if (!inner) return;
            const rect = inner.getBoundingClientRect();
            connecting.line.setAttribute('x2', e.clientX - rect.left);
            connecting.line.setAttribute('y2', e.clientY - rect.top);
            return;
        }

        if (panning) {
            const deltaDays = Math.round(-(e.clientX - panning.startX) / dayWidth);
            if (deltaDays !== panning.lastDelta) {
                panning.lastDelta = deltaDays;
                viewStart = addDays(panning.startViewStart, deltaDays);
                render();
            }
            return;
        }
        if (!dragging) return;
        const { el, task, idx, mode, startX, origStart, origEnd } = dragging;
        const deltaPx   = e.clientX - startX;
        const deltaDays = Math.round(deltaPx / dayWidth);
        if (Math.abs(deltaPx) > 4) dragMoved = true;

        let newStart = new Date(origStart);
        let newEnd   = new Date(origEnd);

        if (mode === 'move') {
            newStart = addDays(origStart, deltaDays);
            newEnd   = addDays(origEnd,   deltaDays);
        } else if (mode === 'resize-start') {
            newStart = addDays(origStart, deltaDays);
            if (newStart >= newEnd) newStart = addDays(newEnd, -1);
        } else if (mode === 'resize-end') {
            newEnd = addDays(origEnd, deltaDays);
            if (newEnd <= newStart) newEnd = addDays(newStart, 1);
        } else if (mode === 'create') {
            if (deltaDays >= 0) {
                newStart = new Date(origStart);
                newEnd   = addDays(origStart, deltaDays);
            } else {
                newStart = addDays(origStart, deltaDays);
                newEnd   = new Date(origStart);
            }
        }

        const startOff = diffDays(viewStart, newStart);
        const endOff   = diffDays(viewStart, newEnd) + 1;
        const barX = Math.max(0, startOff) * dayWidth;
        const minBarW = mode === 'create' ? dayWidth : HANDLE_W * 2 + 4;
        const barW = Math.max((Math.min(totalDays, endOff) - Math.max(0, startOff)) * dayWidth, minBarW);
        el.style.left  = barX + 'px';
        el.style.width = barW + 'px';

        if (tooltip) {
            tooltip.style.left = (e.clientX + 12) + 'px';
            tooltip.style.top  = (e.clientY - 28) + 'px';
            tooltip.textContent = fmt(newStart) + ' → ' + fmt(newEnd);
        }

        dragging._newStart = newStart;
        dragging._newEnd   = newEnd;
    });

    document.addEventListener('mouseup', async e => {
        // ── End connection drag ───────────────────────────────────
        if (connecting) {
            const { fromTask, fromIdx, svg } = connecting;
            svg.remove();
            connecting = null;

            const targetBar = e.target.closest?.('.gantt-bar');
            if (targetBar && canEdit) {
                const toIdx = parseInt(targetBar.dataset.idx);
                const toTask = ganttTasks[toIdx];
                if (toTask && toTask.id !== fromTask.id) {
                    await addDependency(fromTask.id, toTask.id);
                }
            }
            return;
        }

        if (panning) {
            panning = null;
            chart.style.cursor = '';
            return;
        }
        if (!dragging) return;
        const { el, task, idx, mode, origStart, origEnd } = dragging;
        const _newStart = dragging._newStart;
        const _newEnd   = dragging._newEnd;

        if (mode !== 'create') {
            el.style.cursor    = 'grab';
            el.style.boxShadow = '0 1px 3px rgba(0,0,0,.2)';
            el.style.zIndex    = '10';
        }
        if (tooltip) { tooltip.remove(); tooltip = null; }
        dragging = null;
        setTimeout(() => { dragMoved = false; }, 50);

        let newStart, newEnd;
        if (mode === 'create') {
            newStart = _newStart || new Date(origStart);
            newEnd   = _newEnd   || new Date(origStart);
            el.remove();
        } else {
            if (!_newStart) return;
            newStart = _newStart;
            newEnd   = _newEnd;
        }

        // Capture originals BEFORE mutation (task === ganttTasks[idx], same reference)
        const savedStart = task.start_date;
        const savedEnd   = task.due_date;

        ganttTasks[idx].start_date = toISO(newStart);
        ganttTasks[idx].due_date   = toISO(newEnd);

        // Collect transitive successors in BFS order with their required delta
        function collectSuccessors(rootId, deltaDays) {
            const shifts = new Map(); // taskId → deltaDays
            const queue = [[rootId, deltaDays]];
            while (queue.length) {
                const [predId, delta] = queue.shift();
                for (const dep of deps) {
                    if (dep.predecessor_id !== predId) continue;
                    const sucId = dep.successor_id;
                    if (!shifts.has(sucId)) {
                        shifts.set(sucId, delta);
                        queue.push([sucId, delta]);
                    }
                }
            }
            return shifts;
        }

        const deltaDays = (mode === 'move')
            ? diffDays(parseDate(savedStart), newStart)
            : (mode === 'resize-end')
                ? diffDays(parseDate(savedEnd), newEnd) // cascade in both directions
                : 0; // resize-start: never cascade

        const successorShifts = deltaDays !== 0 ? collectSuccessors(task.id, deltaDays) : new Map();

        // Pre-compute new dates for successors
        const successorUpdates = [];
        for (const [sucId, delta] of successorShifts) {
            const sucIdx = ganttTasks.findIndex(t => t.id === sucId);
            if (sucIdx === -1) continue;
            const t = ganttTasks[sucIdx];
            const newSD = toISO(addDays(parseDate(t.start_date) || newStart, delta));
            const newED = toISO(addDays(parseDate(t.due_date)   || newEnd,   delta));
            successorUpdates.push({ sucIdx, sucId, newSD, newED, oldSD: t.start_date, oldED: t.due_date });
        }

        showToast('Speichern…', 'saving');
        try {
            const res = await fetch(BASE_URL + '/api/task/update-dates', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({
                    task_id:    task.id,
                    start_date: toISO(newStart),
                    due_date:   toISO(newEnd),
                    csrf_token: CSRF_TOKEN,
                }),
            });
            if (!res.ok) throw new Error('HTTP ' + res.status);

            // Apply successor shifts
            for (const u of successorUpdates) {
                ganttTasks[u.sucIdx].start_date = u.newSD;
                ganttTasks[u.sucIdx].due_date   = u.newED;
                fetch(BASE_URL + '/api/task/update-dates', {
                    method: 'POST',
                    headers: { 'Content-Type': 'application/json' },
                    body: JSON.stringify({
                        task_id:    u.sucId,
                        start_date: u.newSD,
                        due_date:   u.newED,
                        csrf_token: CSRF_TOKEN,
                    }),
                }).catch(err => console.error('Successor save failed:', err));
            }

            showToast('Gespeichert ✓', 'ok');
            render();
        } catch (err) {
            console.error('Gantt save failed:', err);
            showToast('Fehler beim Speichern (' + err.message + ')', 'error');
            ganttTasks[idx].start_date = savedStart;
            ganttTasks[idx].due_date   = savedEnd;
            render();
        }
    });

    // ── Header builder ───────────────────────────────────────────
    function buildHeader(viewStart, totalDays, today) {
        const lang = (typeof APP_LANG !== 'undefined' ? APP_LANG : 'de').replace('_', '-');
        let monthHtml = '', dayHtml = '';
        let cursor = new Date(viewStart), pos = 0;

        // Month row
        while (pos < totalDays) {
            const month = cursor.getMonth();
            let daysInView = 0;
            const tmp = new Date(cursor);
            while (tmp.getMonth() === month && pos + daysInView < totalDays) {
                daysInView++;
                tmp.setDate(tmp.getDate() + 1);
            }
            const w = daysInView * dayWidth;
            const label = cursor.toLocaleDateString(lang, { month: 'short', year: 'numeric' });
            monthHtml += `<div style="position:absolute;left:${pos * dayWidth}px;width:${w}px;height:20px;
                border-right:1px solid var(--border-color);border-bottom:1px solid var(--border-color);
                font-size:.68rem;font-weight:700;text-transform:uppercase;letter-spacing:.03em;
                color:var(--text-muted);display:flex;align-items:center;justify-content:center;
                background:var(--bg-table-head)">${label}</div>`;
            cursor.setDate(cursor.getDate() + daysInView);
            pos += daysInView;
        }

        // Day row
        cursor = new Date(viewStart);
        const step = zoomMode === 'months' ? 7 : 1;
        for (let i = 0; i < totalDays; i += step) {
            const cellDate  = addDays(viewStart, i);
            const dow       = cellDate.getDay();
            const isToday   = diffDays(today, cellDate) === 0;
            const isWeekend = dow === 0 || dow === 6;
            const x         = i * dayWidth;
            const w         = step * dayWidth;

            const bg = isToday ? 'rgba(220,38,38,.06)'
                     : isWeekend ? 'rgba(0,0,0,.05)'
                     : 'transparent';
            const color = isToday ? 'var(--color-danger)'
                        : isWeekend ? 'var(--text-muted)'
                        : 'var(--text-secondary)';
            const fw = isToday ? '700' : isWeekend ? '600' : '400';

            let content;
            if (zoomMode === 'weeks') {
                const dayName = cellDate.toLocaleDateString(lang, { weekday: 'short' }).replace('.', '');
                content = `<span style="font-size:.58rem;line-height:1;opacity:.85">${dayName}</span>
                           <span style="line-height:1">${cellDate.getDate()}</span>`;
            } else {
                // months mode: step=7, show start-of-week date + short day name
                const dayName = cellDate.toLocaleDateString(lang, { weekday: 'short' }).replace('.', '');
                content = `<span style="font-size:.58rem;opacity:.75;margin-right:2px">${dayName}</span>${cellDate.getDate()}`;
            }

            dayHtml += `<div style="position:absolute;left:${x}px;top:20px;width:${w}px;height:20px;
                border-right:1px solid var(--border-color);
                font-size:.65rem;color:${color};font-weight:${fw};
                display:flex;align-items:center;justify-content:center;flex-direction:${zoomMode === 'weeks' ? 'column' : 'row'};gap:1px;
                background:${bg}">${content}</div>`;
            cursor.setDate(cursor.getDate() + step);
        }

        return `<div style="position:sticky;top:0;height:${HEADER_HEIGHT}px;background:var(--bg-table-head);
            border-bottom:2px solid var(--border-color);z-index:20">${monthHtml}${dayHtml}</div>`;
    }

    // ── Controls ─────────────────────────────────────────────────
    function navigate(dir) {
        viewStart = addDays(viewStart, dir * (zoomMode === 'weeks' ? 28 : 30));
        render();
    }

    prevBtn?.addEventListener('click', () => navigate(-1));
    nextBtn?.addEventListener('click', () => navigate(1));
    todayBtn?.addEventListener('click', () => {
        const t = new Date(); t.setDate(1);
        viewStart = addDays(t, -14);
        render();
    });
    document.querySelectorAll('.gantt-zoom button').forEach(btn => {
        btn.addEventListener('click', () => {
            zoomMode = btn.dataset.zoom;
            dayWidth = zoomMode === 'weeks' ? DAY_WIDTH_WEEKS : DAY_WIDTH_MONTHS;
            document.querySelectorAll('.gantt-zoom button').forEach(b => b.classList.remove('active'));
            btn.classList.add('active');
            render();
        });
    });

    document.addEventListener('selectstart', e => { if (dragging || connecting) e.preventDefault(); });
    document.getElementById('ganttAutoDeps')?.addEventListener('click', autoBuildDeps);

    document.getElementById('ganttClearDeps')?.addEventListener('click', async () => {
        if (!confirm(typeof GANTT_CONFIRM_CLEAR_DEPS !== 'undefined' ? GANTT_CONFIRM_CLEAR_DEPS : 'Alle Verknüpfungen entfernen?')) return;
        showToast('Speichern…', 'saving');
        try {
            const res = await fetch(BASE_URL + '/api/task/clear-dependencies', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({ project_id: GANTT_PROJECT_ID, csrf_token: CSRF_TOKEN }),
            });
            if (!res.ok) throw new Error('HTTP ' + res.status);
            deps = [];
            showToast('Alle Verknüpfungen entfernt ✓', 'ok');
            render();
        } catch (err) {
            showToast('Fehler: ' + err.message, 'error');
        }
    });

    document.getElementById('ganttClearDates')?.addEventListener('click', async () => {
        if (!confirm(typeof GANTT_CONFIRM_CLEAR_DATES !== 'undefined' ? GANTT_CONFIRM_CLEAR_DATES : 'Alle Start- & Enddaten entfernen?')) return;
        showToast('Speichern…', 'saving');
        try {
            const res = await fetch(BASE_URL + '/api/task/clear-dates', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({ project_id: GANTT_PROJECT_ID, csrf_token: CSRF_TOKEN }),
            });
            if (!res.ok) throw new Error('HTTP ' + res.status);
            ganttTasks.forEach(t => { t.start_date = null; t.due_date = null; });
            showToast('Alle Datumsangaben entfernt ✓', 'ok');
            render();
        } catch (err) {
            showToast('Fehler: ' + err.message, 'error');
        }
    });

    // ── Scroll wheel zoom ─────────────────────────────────────────
    const ganttWrap = document.getElementById('ganttWrapper');
    if (ganttWrap) {
        ganttWrap.addEventListener('wheel', e => {
            e.preventDefault();
            const rect = chart.getBoundingClientRect();
            const mouseOffsetPx = e.clientX - rect.left;
            const dateUnderMouse = addDays(viewStart, mouseOffsetPx / dayWidth);

            const factor    = e.deltaY > 0 ? 0.85 : 1 / 0.85;
            const rawWidth  = dayWidth * factor;
            const newWidth  = Math.min(60, Math.max(4, rawWidth));

            if (newWidth === rawWidth) {
                // Normal zoom: keep date under cursor fixed
                dayWidth  = newWidth;
                zoomMode  = dayWidth >= 20 ? 'weeks' : 'months';
                viewStart = addDays(dateUnderMouse, -(mouseOffsetPx / dayWidth));
            } else {
                // Zoom limit reached → pan instead.
                // Direction: cursor left of today = pan to the past, cursor right of today = pan to the future.
                const today         = new Date(); today.setHours(0, 0, 0, 0);
                const todayOffsetPx = diffDays(viewStart, today) * dayWidth;
                const step          = mouseOffsetPx < todayOffsetPx ? -14 : 14;
                viewStart = addDays(viewStart, step);
            }
            render();
        }, { passive: false });
    }

    // ── Pan by dragging empty space ───────────────────────────────
    chart.addEventListener('mousedown', e => {
        if (e.button !== 0 || dragging || connecting) return;
        if (e.target.closest('.gantt-bar') || e.target.closest('.gantt-connector-dot')) return;
        e.preventDefault();
        panning = { startX: e.clientX, startViewStart: new Date(viewStart), lastDelta: 0 };
        chart.style.cursor = 'grabbing';
    });

    // ── Sidebar resize ────────────────────────────────────────────
    const sidebar      = document.getElementById('ganttSidebar');
    const resizeHandle = document.getElementById('ganttResizeHandle');
    if (sidebar && resizeHandle) {
        let resizing = false, resizeStartX = 0, resizeStartW = 0;

        resizeHandle.addEventListener('mousedown', e => {
            e.preventDefault();
            resizing     = true;
            resizeStartX = e.clientX;
            resizeStartW = sidebar.getBoundingClientRect().width;
            resizeHandle.classList.add('dragging');
            document.body.style.cursor = 'col-resize';
            document.body.style.userSelect = 'none';
        });

        document.addEventListener('mousemove', e => {
            if (!resizing) return;
            const newW = Math.min(480, Math.max(120, resizeStartW + (e.clientX - resizeStartX)));
            sidebar.style.flex = `0 0 ${newW}px`;
        });

        document.addEventListener('mouseup', () => {
            if (!resizing) return;
            resizing = false;
            resizeHandle.classList.remove('dragging');
            document.body.style.cursor = '';
            document.body.style.userSelect = '';
        });
    }

    init();
})();
