/* * Copyright 2026 Alessandro Chiapparini * SPDX-License-Identifier: Apache-2.0 * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * See the LICENSE file in the project root for the full license text. */ /** * Sprint planning view — /sprints/{id} and /sprints/{id}/present. * * Vanilla JS. Drops jQuery / jQuery UI in favour of: * - native DOM APIs + delegated event listeners * - SortableJS (vendored at public/assets/js/vendor/sortable.min.js) for * drag-reorder of worker rows and task rows * - the existing JSON-envelope PATCH/POST endpoints for live edits * * Capacity math MUST stay in lock-step with App\Services\CapacityCalculator. * Behaviours (all preserved verbatim from the previous jQuery version): * - Day cells (per worker, per week) snap to 0.5 on blur, batch-saved via * PATCH /sprints/{id}/week-cells with 400 ms debounce. * - The Arbeitstage row is read-only (Phase 12); weekday selection lives * on /sprints/{id}/settings. * - RTB inputs snap to 0.05, save via PATCH /sprints/{id}/workers/{sw_id}. * - Worker rows + task rows are sortable via SortableJS handles. * - Task list: create / edit / delete + per-cell assignment days + * per-cell status (Phase 18) with their own debounced pipelines. * - Filters: search, prio, multi-select owner, single focus worker, * multi-select status (Phase 18), columns. Reset clears the lot. * - Sort: client-side, three states per column. * - Beamer flag: namespaces localStorage keys, seeds hidden columns, * flips on vertical headers when the table overflows. */ (function () { 'use strict'; const root = document.querySelector('[data-sprint-root]'); if (!root) { return; } const sprintId = parseInt(root.getAttribute('data-sprint-id'), 10); const csrf = String(root.getAttribute('data-csrf') || ''); const reserveFraction = Number(root.getAttribute('data-reserve-fraction') || 0); const isBeamer = Number(root.getAttribute('data-beamer')) === 1; const keySuffix = isBeamer ? ':beamer' : ''; const taskSection = root.querySelector('[data-task-section]'); const taskStatusEnabled = taskSection !== null && taskSection.getAttribute('data-task-status-enabled') === '1'; const STATUSES = ['zugewiesen', 'gestartet', 'abgeschlossen', 'abgebrochen']; // --------------------------------------------------------------------- // Tiny DOM helpers // --------------------------------------------------------------------- function qs(sel, ctx) { return (ctx || root).querySelector(sel); } function qsa(sel, ctx) { return Array.from((ctx || root).querySelectorAll(sel)); } function on(ctx, ev, sel, fn) { ctx.addEventListener(ev, function (e) { const t = e.target.closest(sel); if (t && ctx.contains(t)) { fn.call(t, e, t); } }); } function show(el, visible) { if (!el) { return; } el.style.display = visible ? '' : 'none'; } function setHidden(el, hidden) { if (!el) { return; } if (hidden) { el.classList.add('hidden'); } else { el.classList.remove('hidden'); } } // --------------------------------------------------------------------- // Capacity math — must match App\Services\CapacityCalculator // --------------------------------------------------------------------- function roundHalf(x) { return Math.round(x * 2) / 2; } const snap05 = roundHalf; const snap005 = (x) => Math.round(x * 20) / 20; function fmtDays(x) { const n = Number(x); if (Math.abs(n - Math.round(n)) < 1e-9) { return String(Math.round(n)); } return n.toFixed(1); } function fmtRtb(x) { return Number(x).toFixed(2); } function capacity(ressourcen, committedPrio1) { committedPrio1 = committedPrio1 || 0; const afterReserves = roundHalf(ressourcen * (1 - reserveFraction)); return { ressourcen, afterReserves, committedPrio1, available: afterReserves - committedPrio1, }; } // Sum of prio-1 task assignment cells per sprint worker, read from DOM. function committedPrio1FromDom() { const per = {}; qsa('tr[data-task-row]').forEach(function (row) { if (parseInt(row.getAttribute('data-prio'), 10) !== 1) { return; } qsa('[data-assign]', row).forEach(function (inp) { const key = String(inp.getAttribute('data-sw-id')); const v = Number(inp.value); if (!Number.isNaN(v) && v > 0) { per[key] = (per[key] || 0) + v; } }); }); return per; } // --------------------------------------------------------------------- // HTTP helper — spec §7 envelopes // --------------------------------------------------------------------- function request(method, url, body) { const opts = { method, headers: { Accept: 'application/json', 'X-CSRF-Token': csrf }, credentials: 'same-origin', }; if (body !== undefined) { opts.headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(body); } return fetch(url, opts).then(async function (res) { let payload = null; try { payload = await res.json(); } catch (_) { /* ignore */ } if (!res.ok || !payload || payload.ok !== true) { const msg = (payload && payload.error && payload.error.message) ? payload.error.message : (res.statusText || 'Request failed'); const err = new Error(msg); err.status = res.status; err.payload = payload; throw err; } return payload.data; }); } // --------------------------------------------------------------------- // Status flash // --------------------------------------------------------------------- const statusEl = qs('[data-status]'); let statusTimer = null; const successCls = ['text-green-700', 'bg-green-50', 'border-green-200']; const errorCls = ['text-red-700', 'bg-red-50', 'border-red-200']; function flash(text, isError) { if (!statusEl) { return; } statusEl.textContent = text; successCls.concat(errorCls).forEach((c) => statusEl.classList.remove(c)); (isError ? errorCls : successCls).forEach((c) => statusEl.classList.add(c)); statusEl.classList.remove('opacity-0'); statusEl.classList.add('opacity-100'); clearTimeout(statusTimer); statusTimer = setTimeout(function () { statusEl.classList.remove('opacity-100'); statusEl.classList.add('opacity-0'); }, 2500); } // --------------------------------------------------------------------- // Recompute worker row sum + capacity summary locally // --------------------------------------------------------------------- function recomputeRow(swId, commitMap) { const row = qs('[data-sw-row][data-sw-id="' + swId + '"]'); if (!row) { return; } let sum = 0; qsa('[data-day]', row).forEach(function (inp) { const v = Number(inp.value); if (!Number.isNaN(v)) { sum += v; } }); const committed = (commitMap || committedPrio1FromDom())[String(swId)] || 0; const cap = capacity(sum, committed); const sumEl = qs('[data-sum-days]', row); if (sumEl) { sumEl.textContent = fmtDays(cap.ressourcen); } qsa('[data-cap-ressourcen][data-sw-id="' + swId + '"]').forEach(function (r) { r.textContent = fmtDays(cap.ressourcen); }); qsa('[data-cap-after-reserves][data-sw-id="' + swId + '"]').forEach(function (a) { a.textContent = fmtDays(cap.afterReserves); }); qsa('[data-cap-available][data-sw-id="' + swId + '"]').forEach(function (av) { av.textContent = fmtDays(cap.available); av.classList.toggle('text-red-700', cap.available < 0); av.classList.toggle('text-slate-900', cap.available >= 0); }); } function recomputeAllCapacity() { const commit = committedPrio1FromDom(); qsa('[data-sw-row]').forEach(function (row) { recomputeRow(parseInt(row.getAttribute('data-sw-id'), 10), commit); }); } function applyServerCapacity(perWorker) { if (!perWorker || typeof perWorker !== 'object') { return; } Object.keys(perWorker).forEach(function (swIdStr) { const c = perWorker[swIdStr]; qsa('[data-cap-ressourcen][data-sw-id="' + swIdStr + '"]').forEach(function (r) { r.textContent = fmtDays(c.ressourcen); }); qsa('[data-cap-after-reserves][data-sw-id="' + swIdStr + '"]').forEach(function (a) { a.textContent = fmtDays(c.after_reserves); }); qsa('[data-cap-available][data-sw-id="' + swIdStr + '"]').forEach(function (av) { av.textContent = fmtDays(c.available); av.classList.toggle('text-red-700', c.available < 0); av.classList.toggle('text-slate-900', c.available >= 0); }); }); } // --------------------------------------------------------------------- // Pending-cell queue, debounced batch save (Arbeitstage day cells) // --------------------------------------------------------------------- const pendingCells = new Map(); let cellDebounce = null; function queueCell(swId, weekId, days) { pendingCells.set(swId + ':' + weekId, { sprint_worker_id: swId, sprint_week_id: weekId, days, }); clearTimeout(cellDebounce); cellDebounce = setTimeout(flushCells, 400); } function flushCells() { if (pendingCells.size === 0) { return; } const cells = Array.from(pendingCells.values()); pendingCells.clear(); request('PATCH', '/sprints/' + sprintId + '/week-cells', cells) .then(function (data) { if (data.applied === 0 && data.noop > 0) { flash('No changes'); } else { flash('Saved ' + data.applied + (data.applied === 1 ? ' cell' : ' cells')); } applyServerCapacity(data.per_worker); if (data.per_worker) { Object.keys(data.per_worker).forEach(function (swIdStr) { const sumEl = qs('[data-sw-row][data-sw-id="' + swIdStr + '"] [data-sum-days]'); if (sumEl) { sumEl.textContent = fmtDays(data.per_worker[swIdStr].ressourcen); } }); } }) .catch((e) => flash(e.message, true)); } // Day cell edit — clamp / snap / queue on(root, 'change', '[data-day]', function () { let v = Number(this.value); if (Number.isNaN(v)) { v = 0; } if (v < 0) { v = 0; } if (v > 5) { v = 5; } v = snap05(v); this.value = fmtDays(v); const swId = parseInt(this.getAttribute('data-sw-id'), 10); const weekId = parseInt(this.getAttribute('data-week-id'), 10); queueCell(swId, weekId, v); recomputeRow(swId); }); on(root, 'blur', '[data-day]', function () { this.dispatchEvent(new Event('change', { bubbles: true })); }); // RTB cell edit on(root, 'change', '[data-rtb]', function () { let v = Number(this.value); if (Number.isNaN(v)) { v = 0; } if (v < 0) { v = 0; } if (v > 1) { v = 1; } v = snap005(v); this.value = fmtRtb(v); const swId = parseInt(this.getAttribute('data-sw-id'), 10); request('PATCH', '/sprints/' + sprintId + '/workers/' + swId, { rtb: v }) .then(() => flash('Saved')) .catch((e) => flash(e.message, true)); }); on(root, 'blur', '[data-rtb]', function () { this.dispatchEvent(new Event('change', { bubbles: true })); }); // --------------------------------------------------------------------- // SortableJS — worker rows // --------------------------------------------------------------------- const tbody = qs('[data-tbody]'); const sortableAvailable = typeof window.Sortable === 'function'; if (!sortableAvailable) { // eslint-disable-next-line no-console console.warn('[sprint-planner] SortableJS not loaded — drag reorder disabled.'); } if (sortableAvailable && tbody && qs('.handle', tbody)) { window.Sortable.create(tbody, { handle: '.handle', draggable: 'tr[data-sw-row]', animation: 150, onEnd: function () { const ordering = qsa('tr[data-sw-row]', tbody).map(function (el, i) { return { sprint_worker_id: parseInt(el.getAttribute('data-sw-id'), 10), sort_order: i + 1, }; }); request('POST', '/sprints/' + sprintId + '/workers/reorder', ordering) .then(function (data) { if (data.moved) { // Column order in the task list below depends on // this ordering — simplest to re-render. window.location.reload(); } else { flash('No changes'); } }) .catch((e) => flash(e.message, true)); }, }); } // ===================================================================== // Task list // ===================================================================== const taskTbody = qs('[data-task-tbody]'); const hasTaskUi = !!taskTbody; function sprintWorkerHeaders() { return qsa('[data-task-table] thead th[data-sort-col^="sw-"]').map(function (th) { const col = String(th.getAttribute('data-sort-col')); // strip the inline sort indicator const clone = th.cloneNode(true); qsa('.sort-ind', clone).forEach((s) => s.remove()); return { id: parseInt(col.slice(3), 10), name: clone.textContent.trim() }; }); } function ownerChoices() { const out = []; qsa('[data-owner-filter-opt]').forEach(function (inp) { const v = String(inp.value); if (v === '' || v === '__none__') { return; } const id = parseInt(v, 10); if (!Number.isFinite(id)) { return; } const span = inp.closest('label') ? inp.closest('label').querySelector('span') : null; const name = span ? span.textContent.trim() : ''; out.push({ id, name }); }); return out; } // Build a task from an object — vanilla JS DOM construction. function buildTaskRow(task, assignments) { assignments = assignments || {}; const tr = document.createElement('tr'); tr.setAttribute('data-task-row', ''); tr.className = 'hover:bg-slate-50 dark:hover:bg-slate-700'; tr.setAttribute('data-task-id', String(task.id)); tr.setAttribute('data-prio', String(task.priority)); tr.setAttribute('data-owner', task.owner_worker_id || ''); tr.setAttribute('data-sort-order', String(task.sort_order)); tr.setAttribute('data-description', task.description || ''); tr.setAttribute('data-url', task.url || ''); tr.setAttribute('data-task-title', task.title || ''); tr.setAttribute('data-links', JSON.stringify(Array.isArray(task.links) ? task.links : [])); // hamburger trigger const tdMenu = document.createElement('td'); tdMenu.className = 'px-2 py-1'; const trig = document.createElement('button'); trig.type = 'button'; trig.setAttribute('data-task-menu-trigger', ''); trig.className = 'task-menu-trigger inline-flex items-center justify-center w-6 h-6 rounded text-slate-500 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-700 focus:outline-none focus:ring-2 focus:ring-slate-400'; trig.setAttribute('aria-haspopup', 'true'); trig.setAttribute('aria-expanded', 'false'); trig.setAttribute('aria-label', 'Task actions'); trig.innerHTML = ''; tdMenu.appendChild(trig); tr.appendChild(tdMenu); // title cell — title input + small "open URL" anchor; description / // refs / actions all live in the hamburger popup. const tdTitle = document.createElement('td'); tdTitle.className = 'px-2 py-1 min-w-[14rem]'; const titleWrap = document.createElement('div'); titleWrap.className = 'flex items-center gap-1.5'; const title = document.createElement('input'); title.type = 'text'; title.setAttribute('data-title', ''); title.value = task.title || ''; title.className = 'flex-1 rounded border border-slate-200 px-2 py-1 focus:outline-none focus:ring-2 focus:ring-slate-400 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-100 dark:focus:ring-slate-500'; titleWrap.appendChild(title); const urlLink = document.createElement('a'); urlLink.setAttribute('data-task-url-link', ''); urlLink.target = '_blank'; urlLink.rel = 'noopener noreferrer'; urlLink.href = task.url || ''; urlLink.title = 'Open task link'; urlLink.setAttribute('aria-label', 'Open task link'); urlLink.className = 'task-url-link inline-flex items-center justify-center w-5 h-5 rounded text-blue-600 hover:bg-slate-100 dark:text-blue-400 dark:hover:bg-slate-700'; if (!task.url) { urlLink.classList.add('hidden'); } urlLink.innerHTML = ''; titleWrap.appendChild(urlLink); tdTitle.appendChild(titleWrap); tr.appendChild(tdTitle); // owner const tdOwner = document.createElement('td'); tdOwner.className = 'px-2 py-1'; tdOwner.setAttribute('data-col', 'owner'); const ownerSel = document.createElement('select'); ownerSel.setAttribute('data-owner-select', ''); ownerSel.className = 'w-full rounded border border-slate-200 px-2 py-1 bg-white focus:outline-none focus:ring-2 focus:ring-slate-400 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-100 dark:focus:ring-slate-500'; const empty = document.createElement('option'); empty.value = ''; empty.textContent = '—'; ownerSel.appendChild(empty); ownerChoices().forEach(function (o) { const opt = document.createElement('option'); opt.value = String(o.id); opt.textContent = o.name; if (Number(o.id) === Number(task.owner_worker_id)) { opt.selected = true; } ownerSel.appendChild(opt); }); tdOwner.appendChild(ownerSel); tr.appendChild(tdOwner); // priority const tdPrio = document.createElement('td'); tdPrio.className = 'px-2 py-1 text-center'; tdPrio.setAttribute('data-col', 'prio'); const prioSel = document.createElement('select'); prioSel.setAttribute('data-prio-select', ''); prioSel.className = 'rounded border border-slate-200 px-2 py-1 bg-white font-mono focus:outline-none focus:ring-2 focus:ring-slate-400 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-100 dark:focus:ring-slate-500'; ['1', '2'].forEach(function (p) { const opt = document.createElement('option'); opt.value = p; opt.textContent = p; if (String(task.priority) === p) { opt.selected = true; } prioSel.appendChild(opt); }); tdPrio.appendChild(prioSel); tr.appendChild(tdPrio); // tot let tot = 0; Object.keys(assignments).forEach((k) => { tot += Number(assignments[k]) || 0; }); const tdTot = document.createElement('td'); tdTot.className = 'px-2 py-1 text-center font-mono font-semibold'; tdTot.setAttribute('data-col', 'tot'); tdTot.setAttribute('data-task-tot', ''); tdTot.textContent = fmtDays(tot); tr.appendChild(tdTot); // per-worker assignment cells sprintWorkerHeaders().forEach(function (sw) { const v = Number(assignments[sw.id] || 0); const td = document.createElement('td'); td.className = 'px-1 py-1 text-center whitespace-nowrap'; td.setAttribute('data-col', 'sw-' + sw.id); td.setAttribute('data-sort-value-sw-' + sw.id, v.toFixed(2)); if (taskStatusEnabled) { td.classList.add('assign-status-zugewiesen'); td.setAttribute('data-assign-cell', ''); td.setAttribute('data-status', 'zugewiesen'); td.setAttribute('data-sw-id', String(sw.id)); } const inp = document.createElement('input'); inp.type = 'number'; inp.min = '0'; inp.step = '0.5'; inp.value = fmtDays(v); inp.setAttribute('data-assign', ''); inp.setAttribute('data-sw-id', String(sw.id)); inp.className = 'w-14 rounded border border-slate-200 px-1 py-1 text-center font-mono focus:outline-none focus:ring-2 focus:ring-slate-400 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-100 dark:focus:ring-slate-500'; td.appendChild(inp); tr.appendChild(td); }); return tr; } // --- Add task --------------------------------------------------------- on(root, 'click', '[data-add-task]', function () { request('POST', '/sprints/' + sprintId + '/tasks', { title: '', priority: 1 }) .then(function (data) { const empty = qs('[data-empty-tasks]'); if (empty) { empty.remove(); } const row = buildTaskRow(data.task, data.assignments || {}); taskTbody.appendChild(row); clearSort(); applyColumnVisibility(); applyFilters(); const t = qs('[data-title]', row); if (t) { t.focus(); t.select(); } flash('Task added'); }) .catch((e) => flash(e.message, true)); }); // --- Title, owner, prio edits ---------------------------------------- const titleDebounce = {}; on(root, 'input', '[data-title]', function () { const inp = this; const tr = inp.closest('tr'); const id = parseInt(tr.getAttribute('data-task-id'), 10); clearTimeout(titleDebounce[id]); titleDebounce[id] = setTimeout(function () { const title = String(inp.value).trim(); if (title === '') { flash('Title cannot be empty', true); return; } request('PATCH', '/tasks/' + id, { title }) .then(() => flash('Saved')) .catch((e) => flash(e.message, true)); }, 400); }); on(root, 'change', '[data-owner-select]', function () { const tr = this.closest('tr'); const id = parseInt(tr.getAttribute('data-task-id'), 10); const v = this.value; const own = v === '' ? null : parseInt(String(v), 10); tr.setAttribute('data-owner', own === null ? '' : String(own)); request('PATCH', '/tasks/' + id, { owner_worker_id: own }) .then(() => { flash('Saved'); applyFilters(); }) .catch((e) => flash(e.message, true)); }); on(root, 'change', '[data-prio-select]', function () { const tr = this.closest('tr'); const id = parseInt(tr.getAttribute('data-task-id'), 10); const prio = parseInt(String(this.value), 10); tr.setAttribute('data-prio', String(prio)); request('PATCH', '/tasks/' + id, { priority: prio }) .then(function (data) { flash('Saved'); applyFilters(); applyServerCapacity(data && data.per_worker); recomputeAllCapacity(); }) .catch((e) => flash(e.message, true)); }); // --- Delete task (invoked from the task hamburger menu) -------------- function deleteTask(taskId) { const tr = qs('tr[data-task-id="' + taskId + '"]', taskTbody); if (!tr) { return; } const titleInp = qs('[data-title]', tr); const title = titleInp ? String(titleInp.value || '') : String(tr.getAttribute('data-task-title') || '(untitled)'); if (!window.confirm('Delete task "' + title + '"?')) { return; } request('DELETE', '/tasks/' + taskId) .then(function (data) { tr.remove(); applyServerCapacity(data && data.per_worker); recomputeAllCapacity(); flash('Task deleted'); if (qsa('tr[data-task-row]', taskTbody).length === 0) { window.location.reload(); } }) .catch((e) => flash(e.message, true)); } // --- Per-cell assignment days pipeline ------------------------------- const pendingAssign = new Map(); const assignTimers = {}; function queueAssign(taskId, swId, days) { if (!pendingAssign.has(taskId)) { pendingAssign.set(taskId, new Map()); } pendingAssign.get(taskId).set(swId, days); clearTimeout(assignTimers[taskId]); assignTimers[taskId] = setTimeout(function () { flushAssign(taskId); }, 400); } function flushAssign(taskId) { const m = pendingAssign.get(taskId); if (!m || m.size === 0) { return; } const cells = []; m.forEach(function (days, swId) { cells.push({ sprint_worker_id: swId, days }); }); pendingAssign.delete(taskId); request('PATCH', '/tasks/' + taskId + '/assignments', cells) .then(function (data) { if (data.applied === 0 && data.noop > 0) { flash('No changes'); } else { flash('Saved ' + data.applied + (data.applied === 1 ? ' cell' : ' cells')); } applyServerCapacity(data && data.per_worker); }) .catch((e) => flash(e.message, true)); } on(root, 'change', '[data-assign]', function () { let v = Number(this.value); if (Number.isNaN(v) || v < 0) { v = 0; } v = snap05(v); this.value = fmtDays(v); const swId = parseInt(this.getAttribute('data-sw-id'), 10); const td = this.closest('td'); if (td) { td.setAttribute('data-sort-value-sw-' + swId, v.toFixed(2)); } const tr = this.closest('tr'); const taskId = parseInt(tr.getAttribute('data-task-id'), 10); queueAssign(taskId, swId, v); // row total let tot = 0; qsa('[data-assign]', tr).forEach(function (i) { const n = Number(i.value); if (!Number.isNaN(n)) { tot += n; } }); const totEl = qs('[data-task-tot]', tr); if (totEl) { totEl.textContent = fmtDays(tot); } recomputeAllCapacity(); }); on(root, 'blur', '[data-assign]', function () { this.dispatchEvent(new Event('change', { bubbles: true })); }); // --- Per-cell status pipeline (Phase 18) ----------------------------- const pendingStatus = new Map(); const statusTimers = {}; function queueStatus(taskId, swId, status) { if (!pendingStatus.has(taskId)) { pendingStatus.set(taskId, new Map()); } pendingStatus.get(taskId).set(swId, status); clearTimeout(statusTimers[taskId]); statusTimers[taskId] = setTimeout(function () { flushStatus(taskId); }, 400); } function flushStatus(taskId) { const m = pendingStatus.get(taskId); if (!m || m.size === 0) { return; } const cells = []; m.forEach(function (status, swId) { cells.push({ sprint_worker_id: swId, status }); }); pendingStatus.delete(taskId); request('PATCH', '/tasks/' + taskId + '/assignments/status', cells) .then(function (data) { if (data.applied === 0 && data.noop > 0) { flash('No changes'); } else { flash('Saved ' + data.applied + (data.applied === 1 ? ' status' : ' statuses')); } }) .catch((e) => flash(e.message, true)); } // --- Cell popover (replaces the chevron status select) -------------- // // Single body-attached panel that opens to the right of the bound // input (admin) or read-only span (non-admin) on click. Carries a // 0..max slider for the days value (admin only — slider is hidden // when there's no input to mirror) plus a vertical list of status // pills with coloured bullets. Slider drag mirrors into // input.value + dispatches `change` so the existing // queueAssign / queueStatus / applyFilters pipelines fire // unchanged. Closes on outside-pointerdown, Escape, scroll, resize, // mouseleave-grace (250 ms), and any status pick (terminal). const sliderMax = (function () { const raw = taskSection ? taskSection.getAttribute('data-assignment-slider-max') : '10'; const v = parseInt(raw, 10); if (!Number.isFinite(v) || v < 1) { return 10; } return Math.min(100, v); })(); const STATUS_LABELS = { zugewiesen: 'Zugewiesen', gestartet: 'Gestartet', abgeschlossen: 'Abgeschlossen', abgebrochen: 'Abgebrochen', }; let cellPopover = null; let cellPopoverInput = null; // bound or null (non-admin) let cellPopoverCell = null; // bound let cellPopoverGrace = null; let cellPopoverOpenAt = 0; function cancelCellPopoverGrace() { if (cellPopoverGrace) { clearTimeout(cellPopoverGrace); cellPopoverGrace = null; } } function scheduleCellPopoverGrace() { cancelCellPopoverGrace(); cellPopoverGrace = setTimeout(closeCellPopover, 250); } function buildCellPopover() { if (cellPopover) { return cellPopover; } const r = document.createElement('div'); r.className = 'cell-popover hidden'; r.setAttribute('role', 'dialog'); r.setAttribute('aria-label', 'Edit cell'); const grid = document.createElement('div'); grid.className = 'cell-popover-grid'; const sliderWrap = document.createElement('div'); sliderWrap.className = 'cell-popover-slider'; const valEl = document.createElement('span'); valEl.className = 'cell-popover-value'; valEl.textContent = '0'; const slider = document.createElement('input'); slider.type = 'range'; slider.min = '0'; slider.max = String(sliderMax); slider.step = '0.5'; slider.value = '0'; slider.setAttribute('aria-label', 'Days'); // Firefox honours the legacy `orient` attribute; modern engines use // the writing-mode/direction CSS in input.css. Both pull the same // direction (low at the bottom, high at the top). slider.setAttribute('orient', 'vertical'); sliderWrap.appendChild(valEl); sliderWrap.appendChild(slider); grid.appendChild(sliderWrap); const statusList = document.createElement('div'); statusList.className = 'cell-popover-status'; STATUSES.forEach(function (s) { const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'cell-popover-status-btn'; btn.setAttribute('data-status-pick', s); const bullet = document.createElement('span'); bullet.className = 'cell-popover-bullet bullet-' + s; const lbl = document.createElement('span'); lbl.textContent = STATUS_LABELS[s] || s; btn.appendChild(bullet); btn.appendChild(lbl); statusList.appendChild(btn); }); grid.appendChild(statusList); r.appendChild(grid); document.body.appendChild(r); // Slider → mirror into bound input.value + dispatch change so the // existing 400 ms days-save pipeline fires. slider.addEventListener('input', function () { const v = Number(slider.value); valEl.textContent = fmtDays(v); if (!cellPopoverInput) { return; } cellPopoverInput.value = fmtDays(v); cellPopoverInput.dispatchEvent(new Event('change', { bubbles: true })); }); // Status pick → set status, save, close (terminal action). statusList.addEventListener('click', function (ev) { const btn = ev.target.closest('[data-status-pick]'); if (!btn || !cellPopoverCell) { return; } const next = btn.getAttribute('data-status-pick'); if (STATUSES.indexOf(next) === -1) { return; } STATUSES.forEach(function (s) { cellPopoverCell.classList.remove('assign-status-' + s); }); cellPopoverCell.classList.add('assign-status-' + next); cellPopoverCell.setAttribute('data-status', next); const tr = cellPopoverCell.closest('tr'); const taskId = parseInt(tr ? tr.getAttribute('data-task-id') : 'NaN', 10); const swId = parseInt(cellPopoverCell.getAttribute('data-sw-id') || '', 10); if (Number.isFinite(taskId) && Number.isFinite(swId)) { queueStatus(taskId, swId, next); } applyFilters(); closeCellPopover(); }); r.addEventListener('mouseenter', cancelCellPopoverGrace); r.addEventListener('mouseleave', scheduleCellPopoverGrace); cellPopover = r; return r; } function positionCellPopover() { if (!cellPopover || !cellPopoverCell) { return; } const anchor = cellPopoverInput || cellPopoverCell.querySelector('[data-assign-readonly]') || cellPopoverCell; const rect = anchor.getBoundingClientRect(); const ph = cellPopover.offsetHeight; const pw = cellPopover.offsetWidth; let top = window.scrollY + rect.top + rect.height / 2 - ph / 2; let left = window.scrollX + rect.right + 8; const vw = document.documentElement.clientWidth; if (left + pw > window.scrollX + vw - 8) { left = window.scrollX + rect.left - pw - 8; } if (top < window.scrollY + 8) { top = window.scrollY + 8; } cellPopover.style.top = top + 'px'; cellPopover.style.left = left + 'px'; } function openCellPopover(cell) { if (!taskStatusEnabled || !cell) { return; } buildCellPopover(); cellPopoverCell = cell; cellPopoverInput = cell.querySelector('input[data-assign]'); const slider = cellPopover.querySelector('input[type="range"]'); const valEl = cellPopover.querySelector('.cell-popover-value'); let cur = 0; if (cellPopoverInput) { cur = Number(cellPopoverInput.value) || 0; } else { const ro = cell.querySelector('[data-assign-readonly]'); cur = ro ? (Number(ro.textContent) || 0) : 0; } valEl.textContent = fmtDays(cur); slider.value = String(Math.min(sliderMax, Math.max(0, cur))); // Slider only meaningful when an input exists (admin path). const showSlider = cellPopoverInput !== null; slider.disabled = !showSlider; slider.parentElement.style.display = showSlider ? '' : 'none'; const cs = String(cell.getAttribute('data-status') || 'zugewiesen'); cellPopover.querySelectorAll('[data-status-pick]').forEach(function (b) { b.classList.toggle('cell-popover-active', b.getAttribute('data-status-pick') === cs); }); cancelCellPopoverGrace(); cellPopover.classList.remove('hidden'); positionCellPopover(); cellPopoverOpenAt = Date.now(); } function closeCellPopover() { cancelCellPopoverGrace(); if (cellPopover) { cellPopover.classList.add('hidden'); } cellPopoverInput = null; cellPopoverCell = null; } if (taskStatusEnabled) { on(root, 'click', '[data-assign-cell] input[data-assign], [data-assign-cell] [data-assign-readonly]', function () { const cell = this.closest('[data-assign-cell]'); if (cell) { openCellPopover(cell); } }); // Outside-pointerdown closes (capture so a downstream stopPropagation // can't strand the popup). 50 ms grace skips the opening click itself. document.addEventListener('pointerdown', function (ev) { if (!cellPopover || cellPopover.classList.contains('hidden')) { return; } if (Date.now() - cellPopoverOpenAt < 50) { return; } if (cellPopover.contains(ev.target)) { return; } if (cellPopoverCell && cellPopoverCell.contains(ev.target)) { return; } closeCellPopover(); }, true); document.addEventListener('keydown', function (ev) { if (ev.key === 'Escape' && cellPopover && !cellPopover.classList.contains('hidden')) { closeCellPopover(); } }); // Any scroll / resize closes — don't try to follow a moving anchor. window.addEventListener('scroll', function () { if (cellPopover && !cellPopover.classList.contains('hidden')) { closeCellPopover(); } }, true); window.addEventListener('resize', function () { if (cellPopover && !cellPopover.classList.contains('hidden')) { closeCellPopover(); } }); } // --- Task hamburger menu + details modal + click-pickup reorder ----- // // Single body-attached popup. Opens to the right of the trigger, // vertically centred on it (cellPopover positioning model). Three // panes: // - Left: vertical menu (Edit task / Move task to sprint / // Copy task to sprint / Move up/down / divider / // Delete task). // - Right: read-only task info — title, description, URL link, // and reference chips at the bottom. // - Flyout (third column, hidden by default): sprint chooser // for the Move / Copy actions. // Closing mirrors the cell popover: outside-pointerdown, Escape, // scroll / resize, and a 250 ms mouseleave grace. The popup is // shown to non-admins too — the corresponding TaskController // endpoints (update / delete / move / copy / reorder) accept any // signed-in user. const sprintChoices = (function () { if (!taskSection) { return []; } try { const raw = taskSection.getAttribute('data-sprint-choices') || '[]'; const arr = JSON.parse(raw); if (!Array.isArray(arr)) { return []; } return arr.filter(function (s) { return s && typeof s.id === 'number' && typeof s.name === 'string'; }); } catch (_) { return []; } })(); let taskMenu = null; // root container (popup) let taskMenuFlyout = null; // third-column sprint chooser let taskMenuListEl = null; // left vertical menu let taskMenuInfoEl = null; // right info pane let taskMenuTaskId = null; let taskMenuTrigger = null; // the hamburger button it was opened from let taskMenuAction = null; // 'move' | 'copy' when flyout is open let taskMenuOpenAt = 0; let taskMenuGrace = null; function cancelTaskMenuGrace() { if (taskMenuGrace) { clearTimeout(taskMenuGrace); taskMenuGrace = null; } } function scheduleTaskMenuGrace() { cancelTaskMenuGrace(); taskMenuGrace = setTimeout(closeTaskMenu, 250); } function buildTaskMenu() { if (taskMenu) { return taskMenu; } const r = document.createElement('div'); r.className = 'task-menu hidden'; r.setAttribute('role', 'dialog'); r.setAttribute('aria-label', 'Task actions'); const inner = document.createElement('div'); inner.className = 'task-menu-inner'; // Left column: vertical menu list. const list = document.createElement('div'); list.className = 'task-menu-list'; list.setAttribute('role', 'menu'); function item(label, action, opts) { const b = document.createElement('button'); b.type = 'button'; b.setAttribute('role', 'menuitem'); b.setAttribute('data-task-menu-item', action); b.className = 'task-menu-item'; if (opts && opts.danger) { b.classList.add('task-menu-item-danger'); } if (opts && opts.withSub) { b.classList.add('task-menu-item-with-sub'); b.setAttribute('data-task-submenu-trigger', action); b.innerHTML = '' + label + ''; } else { b.textContent = label; } return b; } list.appendChild(item('Edit task', 'edit')); list.appendChild(item('Move task to sprint', 'move', { withSub: true })); list.appendChild(item('Copy task to sprint', 'copy', { withSub: true })); list.appendChild(item('Move up/down', 'pickup')); const divider = document.createElement('div'); divider.className = 'task-menu-divider'; list.appendChild(divider); list.appendChild(item('Delete task', 'delete', { danger: true })); inner.appendChild(list); taskMenuListEl = list; // Right column: read-only task info pane. const info = document.createElement('div'); info.className = 'task-menu-info'; info.innerHTML = '
' + '
' + '' + ''; inner.appendChild(info); taskMenuInfoEl = info; // Third column (flyout): hidden until "Move" or "Copy" picked. const flyout = document.createElement('div'); flyout.className = 'task-menu-flyout hidden'; flyout.innerHTML = '
' + '
'; inner.appendChild(flyout); taskMenuFlyout = flyout; r.appendChild(inner); document.body.appendChild(r); // Mouseleave-grace identical to cellPopover. r.addEventListener('mouseenter', cancelTaskMenuGrace); r.addEventListener('mouseleave', scheduleTaskMenuGrace); taskMenu = r; return r; } function fillTaskMenuFlyout(action, currentSprintId) { if (!taskMenuFlyout) { return; } const headEl = taskMenuFlyout.querySelector('[data-flyout-head]'); const listEl = taskMenuFlyout.querySelector('[data-task-submenu-list]'); if (!headEl || !listEl) { return; } listEl.setAttribute('data-task-submenu-list', action); headEl.textContent = action === 'move' ? 'Move to sprint' : 'Copy to sprint'; listEl.innerHTML = ''; const others = sprintChoices.filter((s) => Number(s.id) !== Number(currentSprintId)); if (others.length === 0) { const empty = document.createElement('div'); empty.className = 'task-menu-sub-empty'; empty.textContent = 'No other sprints'; listEl.appendChild(empty); return; } others.forEach(function (s) { const b = document.createElement('button'); b.type = 'button'; b.setAttribute('role', 'menuitem'); b.setAttribute('data-task-' + action + '-target', String(s.id)); b.className = 'task-menu-sub-item'; b.textContent = s.name; listEl.appendChild(b); }); } function setFlyoutActiveTrigger(action) { if (!taskMenuListEl) { return; } qsa('[data-task-submenu-trigger]', taskMenuListEl).forEach(function (b) { b.classList.toggle('task-menu-item-active', b.getAttribute('data-task-submenu-trigger') === action); }); } function openTaskMenuFlyout(action) { if (!taskMenu) { return; } taskMenuAction = action; fillTaskMenuFlyout(action, sprintId); setFlyoutActiveTrigger(action); taskMenuFlyout.classList.remove('hidden'); positionTaskMenu(); } function closeTaskMenuFlyout() { if (taskMenuFlyout) { taskMenuFlyout.classList.add('hidden'); } setFlyoutActiveTrigger(null); taskMenuAction = null; } function fillTaskMenuInfo(tr) { if (!taskMenuInfoEl) { return; } const titleEl = taskMenuInfoEl.querySelector('[data-info-title]'); const descEl = taskMenuInfoEl.querySelector('[data-info-desc]'); const urlWrap = taskMenuInfoEl.querySelector('[data-info-url-wrap]'); const urlLink = taskMenuInfoEl.querySelector('[data-info-url]'); const refsWrap = taskMenuInfoEl.querySelector('[data-info-refs-wrap]'); const refsList = taskMenuInfoEl.querySelector('[data-info-refs]'); const titleAttr = tr.getAttribute('data-task-title') || ''; const titleInp = qs('input[data-title]', tr); const liveTitle = titleInp ? String(titleInp.value || '') : titleAttr; const desc = tr.getAttribute('data-description') || ''; const url = tr.getAttribute('data-url') || ''; let links = []; try { const parsed = JSON.parse(tr.getAttribute('data-links') || '[]'); if (Array.isArray(parsed)) { links = parsed; } } catch (_) { /* ignore */ } if (titleEl) { titleEl.textContent = liveTitle; } if (descEl) { descEl.textContent = desc; descEl.classList.toggle('hidden', desc === ''); } if (urlWrap && urlLink) { if (url) { urlLink.href = url; urlLink.textContent = url; urlWrap.classList.remove('hidden'); } else { urlLink.removeAttribute('href'); urlLink.textContent = ''; urlWrap.classList.add('hidden'); } } if (refsWrap && refsList) { refsList.innerHTML = ''; if (links.length > 0) { refsWrap.classList.remove('hidden'); links.forEach(function (l) { if (!l || typeof l !== 'object') { return; } const dirLabel = l.direction === 'source' ? 'Copied from' : 'Copied to'; const arrow = l.direction === 'source' ? '←' : '→'; const a = document.createElement('a'); a.href = '/sprints/' + l.sprint_id; a.className = 'task-menu-info-ref-chip'; a.title = dirLabel + ': ' + (l.title || '') + ' (' + (l.sprint_name || '') + ')'; a.innerHTML = '' + arrow + ' ' + '' + (l.sprint_name ? String(l.sprint_name) : '') + ''; refsList.appendChild(a); }); } else { refsWrap.classList.add('hidden'); } } } function positionTaskMenu() { if (!taskMenu || !taskMenuTrigger) { return; } const rect = taskMenuTrigger.getBoundingClientRect(); const mh = taskMenu.offsetHeight; const mw = taskMenu.offsetWidth; const vw = document.documentElement.clientWidth; const vh = document.documentElement.clientHeight; let top = window.scrollY + rect.top + rect.height / 2 - mh / 2; let left = window.scrollX + rect.right + 8; if (left + mw > window.scrollX + vw - 8) { left = window.scrollX + rect.left - mw - 8; } if (left < window.scrollX + 8) { left = window.scrollX + 8; } if (top < window.scrollY + 8) { top = window.scrollY + 8; } if (top + mh > window.scrollY + vh - 8) { top = window.scrollY + vh - mh - 8; } taskMenu.style.top = top + 'px'; taskMenu.style.left = left + 'px'; } function openTaskMenu(trigger) { if (!hasTaskUi) { return; } const tr = trigger.closest('tr[data-task-row]'); if (!tr) { return; } const taskId = parseInt(tr.getAttribute('data-task-id'), 10); if (!Number.isFinite(taskId)) { return; } buildTaskMenu(); closeTaskMenuFlyout(); fillTaskMenuInfo(tr); taskMenuTaskId = taskId; taskMenuTrigger = trigger; trigger.setAttribute('aria-expanded', 'true'); cancelTaskMenuGrace(); taskMenu.classList.remove('hidden'); positionTaskMenu(); taskMenuOpenAt = Date.now(); } function closeTaskMenu() { cancelTaskMenuGrace(); if (taskMenu) { taskMenu.classList.add('hidden'); } if (taskMenuTrigger) { taskMenuTrigger.setAttribute('aria-expanded', 'false'); } closeTaskMenuFlyout(); taskMenuTaskId = null; taskMenuTrigger = null; } on(root, 'click', '[data-task-menu-trigger]', function (ev) { ev.preventDefault(); ev.stopPropagation(); if (taskMenuTrigger === this && taskMenu && !taskMenu.classList.contains('hidden')) { closeTaskMenu(); } else { openTaskMenu(this); } }); document.addEventListener('pointerdown', function (ev) { if (!taskMenu || taskMenu.classList.contains('hidden')) { return; } if (Date.now() - taskMenuOpenAt < 50) { return; } if (taskMenu.contains(ev.target)) { return; } if (taskMenuTrigger && taskMenuTrigger.contains(ev.target)) { return; } closeTaskMenu(); }, true); document.addEventListener('keydown', function (ev) { if (ev.key === 'Escape' && taskMenu && !taskMenu.classList.contains('hidden')) { closeTaskMenu(); } }); // Any scroll / resize closes — don't try to follow a moving anchor. window.addEventListener('scroll', function () { if (taskMenu && !taskMenu.classList.contains('hidden')) { closeTaskMenu(); } }, true); window.addEventListener('resize', function () { if (taskMenu && !taskMenu.classList.contains('hidden')) { closeTaskMenu(); } }); document.addEventListener('click', function (ev) { if (!taskMenu || taskMenu.classList.contains('hidden')) { return; } const subTrig = ev.target.closest('[data-task-submenu-trigger]'); if (subTrig && taskMenu.contains(subTrig)) { ev.preventDefault(); const action = subTrig.getAttribute('data-task-submenu-trigger'); if (taskMenuAction === action && !taskMenuFlyout.classList.contains('hidden')) { closeTaskMenuFlyout(); } else { openTaskMenuFlyout(action); } return; } const moveTarget = ev.target.closest('[data-task-move-target]'); if (moveTarget && taskMenu.contains(moveTarget)) { ev.preventDefault(); const destId = parseInt(moveTarget.getAttribute('data-task-move-target'), 10); const taskId = taskMenuTaskId; closeTaskMenu(); if (Number.isFinite(destId) && Number.isFinite(taskId)) { request('POST', '/tasks/' + taskId + '/move', { sprint_id: destId }) .then(function () { flash('Moved to sprint'); const tr = qs('tr[data-task-id="' + taskId + '"]', taskTbody); if (tr) { tr.remove(); } recomputeAllCapacity(); if (qsa('tr[data-task-row]', taskTbody).length === 0) { window.location.reload(); } }) .catch((e) => flash(e.message, true)); } return; } const copyTarget = ev.target.closest('[data-task-copy-target]'); if (copyTarget && taskMenu.contains(copyTarget)) { ev.preventDefault(); const destId = parseInt(copyTarget.getAttribute('data-task-copy-target'), 10); const taskId = taskMenuTaskId; closeTaskMenu(); if (Number.isFinite(destId) && Number.isFinite(taskId)) { request('POST', '/tasks/' + taskId + '/copy', { sprint_id: destId }) .then(function () { flash('Copied to sprint'); window.location.reload(); }) .catch((e) => flash(e.message, true)); } return; } const it = ev.target.closest('[data-task-menu-item]'); if (it && taskMenu.contains(it) && !it.hasAttribute('data-task-submenu-trigger')) { ev.preventDefault(); const action = it.getAttribute('data-task-menu-item'); const taskId = taskMenuTaskId; closeTaskMenu(); if (!Number.isFinite(taskId)) { return; } if (action === 'edit') { openDetailsModal(taskId); } if (action === 'pickup') { startPickup(taskId); } if (action === 'delete') { deleteTask(taskId); } } }); // --- Details modal (description + URL) ------------------------------ let detailsModal = null; let detailsTaskId = null; function buildDetailsModal() { if (detailsModal) { return detailsModal; } const overlay = document.createElement('div'); overlay.className = 'task-modal-overlay hidden'; overlay.setAttribute('role', 'dialog'); overlay.setAttribute('aria-modal', 'true'); overlay.setAttribute('aria-label', 'Edit task details'); const panel = document.createElement('div'); panel.className = 'task-modal-panel'; panel.innerHTML = '

Edit details

' + '
' + '' + '' + '
' + ''; overlay.appendChild(panel); document.body.appendChild(overlay); overlay.addEventListener('click', function (ev) { if (ev.target === overlay) { closeDetailsModal(); } }); panel.querySelector('[data-modal-cancel]').addEventListener('click', closeDetailsModal); panel.querySelector('[data-modal-save]').addEventListener('click', saveDetailsModal); detailsModal = overlay; return overlay; } function openDetailsModal(taskId) { const tr = qs('tr[data-task-id="' + taskId + '"]', taskTbody); if (!tr) { return; } buildDetailsModal(); detailsTaskId = taskId; detailsModal.querySelector('[data-modal-description]').value = tr.getAttribute('data-description') || ''; detailsModal.querySelector('[data-modal-url]').value = tr.getAttribute('data-url') || ''; detailsModal.classList.remove('hidden'); detailsModal.querySelector('[data-modal-description]').focus(); } function closeDetailsModal() { if (detailsModal) { detailsModal.classList.add('hidden'); } detailsTaskId = null; } function saveDetailsModal() { if (!detailsModal || !Number.isFinite(detailsTaskId)) { return; } const desc = String(detailsModal.querySelector('[data-modal-description]').value || ''); const url = String(detailsModal.querySelector('[data-modal-url]').value || '').trim(); if (url !== '' && !/^https?:\/\//i.test(url)) { flash('URL must start with http:// or https://', true); return; } const taskId = detailsTaskId; request('PATCH', '/tasks/' + taskId, { description: desc, url }) .then(function () { const tr = qs('tr[data-task-id="' + taskId + '"]', taskTbody); if (tr) { tr.setAttribute('data-description', desc); tr.setAttribute('data-url', url); const link = qs('[data-task-url-link]', tr); if (link) { link.href = url; link.classList.toggle('hidden', url === ''); } } closeDetailsModal(); flash('Saved'); }) .catch((e) => flash(e.message, true)); } document.addEventListener('keydown', function (ev) { if (ev.key === 'Escape' && detailsModal && !detailsModal.classList.contains('hidden')) { closeDetailsModal(); } }); // --- Click-pickup reorder ------------------------------------------- // // The user picks "Move (pick up)" from the menu, the row tracks the // cursor, and a horizontal indicator marks the target slot. Click // anywhere to drop. Escape cancels and reverts to original position. let pickupTaskId = null; let pickupRow = null; let pickupOriginNext = null; // sibling that was below pickupRow at start let pickupIndicator = null; let pickupOriginalIndex = -1; function buildPickupIndicator() { if (pickupIndicator) { return pickupIndicator; } const ind = document.createElement('div'); ind.className = 'task-pickup-indicator hidden'; document.body.appendChild(ind); pickupIndicator = ind; return ind; } function rowsExceptPickup() { return qsa('tr[data-task-row]', taskTbody).filter((r) => r !== pickupRow); } function startPickup(taskId) { if (!hasTaskUi) { return; } const tr = qs('tr[data-task-id="' + taskId + '"]', taskTbody); if (!tr) { return; } if (currentSort.col !== null) { clearSort(); } pickupTaskId = taskId; pickupRow = tr; pickupOriginNext = tr.nextElementSibling; pickupOriginalIndex = qsa('tr[data-task-row]', taskTbody).indexOf(tr); tr.classList.add('task-pickup-active'); buildPickupIndicator(); pickupIndicator.classList.remove('hidden'); document.body.style.cursor = 'grabbing'; flash('Pick up active — move the cursor and click to drop, Escape to cancel'); document.addEventListener('mousemove', onPickupMove); document.addEventListener('click', onPickupClick, true); document.addEventListener('keydown', onPickupKey, true); } function onPickupMove(ev) { if (!pickupRow) { return; } const peers = rowsExceptPickup(); let inserted = false; for (let i = 0; i < peers.length; i++) { const r = peers[i]; const rect = r.getBoundingClientRect(); const mid = rect.top + rect.height / 2; if (ev.clientY < mid) { if (pickupRow.nextElementSibling !== r) { taskTbody.insertBefore(pickupRow, r); } placePickupIndicator(r, 'before'); inserted = true; break; } } if (!inserted) { if (peers.length > 0) { const last = peers[peers.length - 1]; if (pickupRow.previousElementSibling !== last) { taskTbody.appendChild(pickupRow); } placePickupIndicator(last, 'after'); } else { pickupIndicator.classList.add('hidden'); } } } function placePickupIndicator(refRow, side) { if (!pickupIndicator) { return; } const rect = refRow.getBoundingClientRect(); const y = side === 'before' ? rect.top : rect.bottom; pickupIndicator.style.top = (window.scrollY + y - 1) + 'px'; pickupIndicator.style.left = (window.scrollX + rect.left) + 'px'; pickupIndicator.style.width = rect.width + 'px'; pickupIndicator.classList.remove('hidden'); } function onPickupClick(ev) { if (!pickupRow) { return; } ev.preventDefault(); ev.stopPropagation(); finishPickup(true); } function onPickupKey(ev) { if (ev.key === 'Escape' && pickupRow) { ev.preventDefault(); finishPickup(false); } } function finishPickup(commit) { document.removeEventListener('mousemove', onPickupMove); document.removeEventListener('click', onPickupClick, true); document.removeEventListener('keydown', onPickupKey, true); document.body.style.cursor = ''; if (pickupIndicator) { pickupIndicator.classList.add('hidden'); } if (pickupRow) { pickupRow.classList.remove('task-pickup-active'); } if (!commit) { // Restore original DOM position. if (pickupRow) { if (pickupOriginNext && pickupOriginNext.parentElement === taskTbody) { taskTbody.insertBefore(pickupRow, pickupOriginNext); } else if (pickupRow.parentElement === taskTbody) { taskTbody.appendChild(pickupRow); } } pickupTaskId = null; pickupRow = null; pickupOriginNext = null; return; } const ordering = qsa('tr[data-task-row]', taskTbody).map(function (el, i) { return { task_id: parseInt(el.getAttribute('data-task-id'), 10), sort_order: i + 1 }; }); const newIndex = ordering.findIndex((o) => o.task_id === pickupTaskId); const moved = newIndex !== pickupOriginalIndex; const taskId = pickupTaskId; pickupTaskId = null; pickupRow = null; pickupOriginNext = null; if (!moved) { flash('No changes'); return; } request('POST', '/sprints/' + sprintId + '/tasks/reorder', ordering) .then(function (data) { ordering.forEach(function (o) { const r = qs('tr[data-task-id="' + o.task_id + '"]', taskTbody); if (r) { r.setAttribute('data-sort-order', String(o.sort_order)); } }); flash(data.moved ? 'Order saved' : 'No changes'); }) .catch((e) => flash(e.message, true)); } // --- Multi-select owner filter --------------------------------------- const ownerFilterKey = 'sp:' + sprintId + ':ownerFilter' + keySuffix; const ownerFilterSet = (function () { try { const raw = window.localStorage.getItem(ownerFilterKey); if (raw) { const arr = JSON.parse(raw); if (Array.isArray(arr)) { return new Set(arr.map(String)); } } } catch (_) { /* ignore */ } return new Set(); })(); function persistOwnerFilter() { try { window.localStorage.setItem(ownerFilterKey, JSON.stringify(Array.from(ownerFilterSet))); } catch (_) { /* ignore */ } } function updateOwnerFilterUi() { qsa('[data-owner-filter-opt]').forEach(function (inp) { inp.checked = ownerFilterSet.has(String(inp.value)); }); const cnt = qs('[data-owner-filter-count]'); if (cnt) { cnt.textContent = ownerFilterSet.size === 0 ? '' : '(' + ownerFilterSet.size + ')'; } } on(root, 'change', '[data-owner-filter-opt]', function () { const v = String(this.value); if (this.checked) { ownerFilterSet.add(v); } else { ownerFilterSet.delete(v); } persistOwnerFilter(); updateOwnerFilterUi(); applyFilters(); }); on(root, 'click', '[data-owner-filter-clear]', function () { ownerFilterSet.clear(); persistOwnerFilter(); updateOwnerFilterUi(); applyFilters(); setHidden(qs('[data-owner-filter-dropdown]'), true); }); on(root, 'click', '[data-owner-filter-trigger]', function (ev) { ev.stopPropagation(); setHidden(qs('[data-columns-dropdown]'), true); const dd = qs('[data-owner-filter-dropdown]'); if (dd) { dd.classList.toggle('hidden'); } }); // --- Focus filter ----------------------------------------------------- const focusKey = 'sp:' + sprintId + ':focusWorker' + keySuffix; let focusWorker = (function () { try { const raw = window.localStorage.getItem(focusKey); return raw === null ? '' : String(raw); } catch (_) { return ''; } })(); function persistFocus() { try { window.localStorage.setItem(focusKey, String(focusWorker)); } catch (_) { /* ignore */ } } function updateFocusUi() { const sel = qs('[data-focus-select]'); if (!sel) { return; } if (focusWorker !== '' && !sel.querySelector('option[value="' + focusWorker + '"]')) { focusWorker = ''; persistFocus(); } sel.value = focusWorker; } on(root, 'change', '[data-focus-select]', function () { focusWorker = String(this.value || ''); persistFocus(); applyFilters(); }); // --- Status filter (Phase 18) ---------------------------------------- const statusFilterKey = 'sp:' + sprintId + ':statusFilter' + keySuffix; const statusFilterSet = (function () { if (!taskStatusEnabled) { return new Set(); } try { const raw = window.localStorage.getItem(statusFilterKey); if (raw) { const arr = JSON.parse(raw); if (Array.isArray(arr)) { return new Set(arr.map(String)); } } } catch (_) { /* ignore */ } return new Set(); })(); function persistStatusFilter() { try { window.localStorage.setItem(statusFilterKey, JSON.stringify(Array.from(statusFilterSet))); } catch (_) { /* ignore */ } } function updateStatusFilterUi() { qsa('[data-status-filter-opt]').forEach(function (inp) { inp.checked = statusFilterSet.has(String(inp.value)); }); const cnt = qs('[data-status-filter-count]'); if (cnt) { cnt.textContent = statusFilterSet.size === 0 ? '' : '(' + statusFilterSet.size + ')'; } } on(root, 'change', '[data-status-filter-opt]', function () { const v = String(this.value); if (STATUSES.indexOf(v) === -1) { return; } if (this.checked) { statusFilterSet.add(v); } else { statusFilterSet.delete(v); } persistStatusFilter(); updateStatusFilterUi(); applyFilters(); }); on(root, 'click', '[data-status-filter-clear]', function () { statusFilterSet.clear(); persistStatusFilter(); updateStatusFilterUi(); applyFilters(); setHidden(qs('[data-status-filter-dropdown]'), true); }); on(root, 'click', '[data-status-filter-trigger]', function (ev) { ev.stopPropagation(); setHidden(qs('[data-owner-filter-dropdown]'), true); setHidden(qs('[data-columns-dropdown]'), true); const dd = qs('[data-status-filter-dropdown]'); if (dd) { dd.classList.toggle('hidden'); } }); function rowMatchesStatusFilter(tr) { if (statusFilterSet.size === 0) { return true; } const cells = qsa('[data-assign-cell]', tr); for (let i = 0; i < cells.length; i++) { const cell = cells[i]; const status = String(cell.getAttribute('data-status') || 'zugewiesen'); if (!statusFilterSet.has(status)) { continue; } if (status === 'zugewiesen') { const inp = qs('[data-assign]', cell); let days; if (inp) { days = Number(inp.value) || 0; } else { const span = qs('.font-mono', cell); days = span ? (Number(span.textContent) || 0) : 0; } if (days > 0) { return true; } } else { return true; } } return false; } // --- Filters --------------------------------------------------------- function applyFilters() { if (!taskTbody) { return; } const searchEl = qs('[data-task-search]'); const prioEl = qs('[data-prio-filter]'); const q = String(searchEl ? searchEl.value : '').trim().toLowerCase(); const prio = String(prioEl ? prioEl.value : ''); const focus = String(focusWorker || ''); let visible = 0; qsa('tr[data-task-row]', taskTbody).forEach(function (tr) { const titleEl = qs('[data-title]', tr); const title = String((titleEl && (titleEl.value || titleEl.textContent)) || '').toLowerCase(); const rowPrio = String(tr.getAttribute('data-prio')); const rowOwner = String(tr.getAttribute('data-owner') || ''); const ownerKey = rowOwner === '' ? '__none__' : rowOwner; let ok = true; if (q !== '' && !title.includes(q)) { ok = false; } if (prio !== '' && rowPrio !== prio) { ok = false; } if (ownerFilterSet.size > 0 && !ownerFilterSet.has(ownerKey)) { ok = false; } if (focus !== '') { const inp = qs('[data-assign][data-sw-id="' + focus + '"]', tr); const v = inp ? Number(inp.value) : 0; if (!(v > 0)) { ok = false; } } if (ok && taskStatusEnabled && !rowMatchesStatusFilter(tr)) { ok = false; } tr.style.display = ok ? '' : 'none'; if (ok) { visible++; } }); const total = qsa('tr[data-task-row]', taskTbody).length; const empty = qs('[data-task-empty-filter]'); if (empty) { empty.style.display = (total > 0 && visible === 0) ? '' : 'none'; } applyFocusColumnVisibility(); updateResetVisibility(); } function applyFocusColumnVisibility() { qsa('.focus-auto-hidden').forEach((el) => el.classList.remove('focus-auto-hidden')); if (!focusWorker) { return; } qsa('[data-task-table] thead th[data-sort-col^="sw-"]').forEach(function (th) { const col = String(th.getAttribute('data-sort-col')); const swId = col.slice(3); let any = false; qsa('tr[data-task-row]', taskTbody).forEach(function (tr) { if (tr.style.display === 'none') { return; } if (any) { return; } const inp = qs('[data-assign][data-sw-id="' + swId + '"]', tr); if (inp && Number(inp.value) > 0) { any = true; } }); if (!any) { qsa('[data-col="' + col + '"]').forEach((el) => el.classList.add('focus-auto-hidden')); } }); } let searchDebounce = null; on(root, 'input', '[data-task-search]', function () { clearTimeout(searchDebounce); searchDebounce = setTimeout(applyFilters, 120); }); on(root, 'change', '[data-prio-filter]', applyFilters); // --- Column visibility ----------------------------------------------- const columnsKey = 'sp:' + sprintId + ':hiddenCols' + keySuffix; const hiddenCols = (function () { try { const raw = window.localStorage.getItem(columnsKey); if (raw) { const arr = JSON.parse(raw); if (Array.isArray(arr)) { return new Set(arr.map(String)); } } if (isBeamer) { const defaults = ['owner', 'prio', 'tot']; window.localStorage.setItem(columnsKey, JSON.stringify(defaults)); return new Set(defaults); } } catch (_) { /* ignore */ } return new Set(); })(); function persistHiddenCols() { try { window.localStorage.setItem(columnsKey, JSON.stringify(Array.from(hiddenCols))); } catch (_) { /* ignore */ } } function applyColumnVisibility() { qsa('[data-col]').forEach(function (el) { const col = String(el.getAttribute('data-col')); el.classList.toggle('hidden', hiddenCols.has(col)); }); qsa('[data-column-opt]').forEach(function (inp) { inp.checked = !hiddenCols.has(String(inp.value)); }); } on(root, 'change', '[data-column-opt]', function () { const v = String(this.value); if (this.checked) { hiddenCols.delete(v); } else { hiddenCols.add(v); } persistHiddenCols(); applyColumnVisibility(); updateResetVisibility(); }); on(root, 'click', '[data-columns-trigger]', function (ev) { ev.stopPropagation(); setHidden(qs('[data-owner-filter-dropdown]'), true); const dd = qs('[data-columns-dropdown]'); if (dd) { dd.classList.toggle('hidden'); } }); // Close dropdowns on outside click. document.addEventListener('click', function (ev) { if (!ev.target.closest('[data-owner-filter-root]')) { setHidden(qs('[data-owner-filter-dropdown]'), true); } if (!ev.target.closest('[data-columns-root]')) { setHidden(qs('[data-columns-dropdown]'), true); } if (!ev.target.closest('[data-status-filter-root]')) { setHidden(qs('[data-status-filter-dropdown]'), true); } }); // Close dropdowns when the cursor leaves both the trigger and the // panel. The panel is absolutely positioned with a small mt-1 gap, // so a naive mouseleave on the root fires while the cursor is in // transit between button and panel — we use a 250 ms grace timer // that is cancelled if the cursor enters the panel (or re-enters // the root) within the window. [ ['[data-owner-filter-root]', '[data-owner-filter-dropdown]'], ['[data-status-filter-root]', '[data-status-filter-dropdown]'], ['[data-columns-root]', '[data-columns-dropdown]'], ].forEach(function (pair) { const r = qs(pair[0]); const dd = qs(pair[1]); if (!r || !dd) { return; } let timer = null; const cancel = function () { if (timer) { clearTimeout(timer); timer = null; } }; const schedule = function () { cancel(); timer = setTimeout(function () { setHidden(dd, true); timer = null; }, 250); }; r.addEventListener('mouseenter', cancel); r.addEventListener('mouseleave', schedule); dd.addEventListener('mouseenter', cancel); dd.addEventListener('mouseleave', schedule); }); // --- Reset filters --------------------------------------------------- function filtersActive() { const searchEl = qs('[data-task-search]'); const prioEl = qs('[data-prio-filter]'); const q = String(searchEl ? searchEl.value : '').trim(); const prio = String(prioEl ? prioEl.value : ''); return q !== '' || prio !== '' || ownerFilterSet.size > 0 || String(focusWorker || '') !== '' || hiddenCols.size > 0 || (taskStatusEnabled && statusFilterSet.size > 0); } function updateResetVisibility() { const btn = qs('[data-reset-filters]'); if (btn) { btn.classList.toggle('hidden', !filtersActive()); } } on(root, 'click', '[data-reset-filters]', function () { const s = qs('[data-task-search]'); if (s) { s.value = ''; } const p = qs('[data-prio-filter]'); if (p) { p.value = ''; } ownerFilterSet.clear(); persistOwnerFilter(); focusWorker = ''; persistFocus(); hiddenCols.clear(); persistHiddenCols(); if (taskStatusEnabled) { statusFilterSet.clear(); persistStatusFilter(); } updateOwnerFilterUi(); updateFocusUi(); if (taskStatusEnabled) { updateStatusFilterUi(); } applyColumnVisibility(); applyFilters(); }); // --- Sort ------------------------------------------------------------ const currentSort = { col: null, dir: null }; function clearSort() { currentSort.col = null; currentSort.dir = null; qsa('[data-sort-col]').forEach(function (th) { const ind = qs('.sort-ind', th); if (ind) { ind.textContent = '↕'; ind.classList.add('opacity-30'); ind.classList.remove('opacity-100'); } }); if (!taskTbody) { return; } const rows = qsa('tr[data-task-row]', taskTbody); rows.sort((a, b) => Number(a.getAttribute('data-sort-order')) - Number(b.getAttribute('data-sort-order'))); rows.forEach((r) => taskTbody.appendChild(r)); } function rowValueFor(col, tr) { if (col === 'title') { const t = qs('[data-title]', tr); return String((t && (t.value || t.textContent)) || '').toLowerCase(); } if (col === 'owner') { const id = String(tr.getAttribute('data-owner') || ''); if (id === '') { return '￿'; } const sel = qs('[data-owner-select]', tr); if (sel && sel.selectedOptions[0]) { return String(sel.selectedOptions[0].textContent || '').toLowerCase(); } return id.toLowerCase(); } if (col === 'prio') { return Number(tr.getAttribute('data-prio')); } if (col === 'tot') { const t = qs('[data-task-tot]', tr); return t ? (Number(t.textContent) || 0) : 0; } if (col.indexOf('sw-') === 0) { const swId = col.slice(3); const inp = qs('[data-assign][data-sw-id="' + swId + '"]', tr); return inp ? (Number(inp.value) || 0) : 0; } return 0; } function applySort(col) { let dir; if (currentSort.col !== col) { dir = 'asc'; } else if (currentSort.dir === 'asc') { dir = 'desc'; } else { clearSort(); return; } currentSort.col = col; currentSort.dir = dir; qsa('[data-sort-col] .sort-ind').forEach(function (ind) { ind.textContent = '↕'; ind.classList.add('opacity-30'); ind.classList.remove('opacity-100'); }); const ind = qs('[data-sort-col="' + col + '"] .sort-ind'); if (ind) { ind.textContent = dir === 'asc' ? '↑' : '↓'; ind.classList.remove('opacity-30'); ind.classList.add('opacity-100'); } const rows = qsa('tr[data-task-row]', taskTbody); rows.sort(function (a, b) { const va = rowValueFor(col, a); const vb = rowValueFor(col, b); if (va < vb) { return dir === 'asc' ? -1 : 1; } if (va > vb) { return dir === 'asc' ? 1 : -1; } return Number(a.getAttribute('data-sort-order')) - Number(b.getAttribute('data-sort-order')); }); rows.forEach((r) => taskTbody.appendChild(r)); } on(root, 'click', '[data-sort-col]', function () { applySort(String(this.getAttribute('data-sort-col'))); }); // ===================================================================== // Boot // ===================================================================== qsa('[data-sw-row]').forEach(function (row) { recomputeRow(parseInt(row.getAttribute('data-sw-id'), 10)); }); updateOwnerFilterUi(); updateFocusUi(); if (taskStatusEnabled) { updateStatusFilterUi(); } applyColumnVisibility(); applyFilters(); updateResetVisibility(); if (isBeamer && hasTaskUi) { const table = qs('[data-task-table]'); const container = table ? table.parentElement : null; if (table && container && table.scrollWidth > container.clientWidth) { root.classList.add('beamer-vertical-headers'); if (table.scrollWidth > container.clientWidth) { // eslint-disable-next-line no-console console.warn('[sprint-planner] beamer: table still overflows after vertical headers; horizontal scroll enabled.'); } } } // --------------------------------------------------------------------- // Tabs (show.twig only) — persist active tab in localStorage // --------------------------------------------------------------------- (function initTabs() { const nav = qs('[data-tab-nav]'); if (!nav) { return; } const buttons = qsa('[data-tab-btn]', nav); const panels = qsa('[data-tab-panel]'); if (buttons.length === 0 || panels.length === 0) { return; } const tabKey = 'sp:' + sprintId + ':tab' + keySuffix; let active = 'arbeitstage'; try { const stored = localStorage.getItem(tabKey); if (stored && buttons.some((b) => b.getAttribute('data-tab-btn') === stored)) { active = stored; } } catch (e) { /* private mode — ignore */ } function activate(name) { buttons.forEach(function (btn) { btn.setAttribute('data-active', btn.getAttribute('data-tab-btn') === name ? 'true' : 'false'); }); panels.forEach(function (p) { setHidden(p, p.getAttribute('data-tab-panel') !== name); }); try { localStorage.setItem(tabKey, name); } catch (e) { /* ignore */ } } buttons.forEach(function (btn) { btn.addEventListener('click', function () { activate(String(btn.getAttribute('data-tab-btn'))); }); }); activate(active); })(); // --------------------------------------------------------------------- // Present view: smart Close button // history.length > 1 → go back (in-tab nav) // otherwise → close the tab; fall back to navigation if blocked // --------------------------------------------------------------------- (function initSmartClose() { const btn = qs('[data-close-present]'); if (!btn) { return; } btn.addEventListener('click', function (ev) { ev.preventDefault(); if (window.history.length > 1) { window.history.back(); return; } const fallback = btn.getAttribute('href'); window.close(); setTimeout(function () { if (!window.closed && fallback) { window.location.href = fallback; } }, 100); }); })(); // --------------------------------------------------------------------- // Present view: sprint switcher dropdown // --------------------------------------------------------------------- (function initPresentSprintSwitcher() { const sel = qs('[data-present-sprint-select]'); if (!sel) { return; } sel.addEventListener('change', function () { const id = parseInt(sel.value, 10); if (!Number.isFinite(id) || id <= 0 || id === sprintId) { return; } window.location.href = '/sprints/' + id + '/present'; }); })(); })();