/** * 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); } const r = qs('[data-cap-ressourcen][data-sw-id="' + swId + '"]'); if (r) { r.textContent = fmtDays(cap.ressourcen); } const a = qs('[data-cap-after-reserves][data-sw-id="' + swId + '"]'); if (a) { a.textContent = fmtDays(cap.afterReserves); } const av = qs('[data-cap-available][data-sw-id="' + swId + '"]'); if (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]; const r = qs('[data-cap-ressourcen][data-sw-id="' + swIdStr + '"]'); if (r) { r.textContent = fmtDays(c.ressourcen); } const a = qs('[data-cap-after-reserves][data-sw-id="' + swIdStr + '"]'); if (a) { a.textContent = fmtDays(c.after_reserves); } const av = qs('[data-cap-available][data-sw-id="' + swIdStr + '"]'); if (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.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)); // handle const tdHandle = document.createElement('td'); tdHandle.className = 'px-2 py-1'; const handle = document.createElement('span'); handle.className = 'handle cursor-grab text-slate-400 select-none dark:text-slate-500'; handle.innerHTML = '≡'; tdHandle.appendChild(handle); tr.appendChild(tdHandle); // title const tdTitle = document.createElement('td'); tdTitle.className = 'px-2 py-1 min-w-[14rem]'; const title = document.createElement('input'); title.type = 'text'; title.setAttribute('data-title', ''); title.value = task.title || ''; title.className = 'w-full 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'; tdTitle.appendChild(title); 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); }); // delete const tdDel = document.createElement('td'); tdDel.className = 'px-1 py-1 text-right'; const delBtn = document.createElement('button'); delBtn.type = 'button'; delBtn.setAttribute('data-delete-task', ''); delBtn.className = 'text-sm text-red-600 hover:underline dark:text-red-400'; delBtn.textContent = '×'; tdDel.appendChild(delBtn); tr.appendChild(tdDel); 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 ------------------------------------------------------ on(root, 'click', '[data-delete-task]', function () { const tr = this.closest('tr'); const id = parseInt(tr.getAttribute('data-task-id'), 10); const titleInp = qs('[data-title]', tr); const title = titleInp ? titleInp.value : '(untitled)'; if (!window.confirm('Delete task "' + title + '"?')) { return; } request('DELETE', '/tasks/' + id) .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 reorder (SortableJS) --------------------------------------- if (sortableAvailable && hasTaskUi && qs('.handle', taskTbody)) { window.Sortable.create(taskTbody, { handle: '.handle', draggable: 'tr[data-task-row]', animation: 150, onStart: function () { if (currentSort.col !== null) { clearSort(); } }, onEnd: function () { 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 }; }); 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.'); } } } })();