Kaynağa Gözat

Phase 22: per-task hamburger menu — move/copy/edit/reorder

Replaces the SortableJS drag handle on tasks with a per-row hamburger
menu (admin only) that gathers all task-level actions:

 - Edit details…  → modal with a description textarea (≤8000 chars,
   plain text) and a URL input (empty or http(s)://, ≤2048 chars).
   Both fields are PATCHed through the existing /tasks/{id} surface.
 - Move to sprint → POST /tasks/{id}/move drops every task_assignment
   (audited per cell), updates sprint_id, lands the task at the bottom
   of the destination's sort order.
 - Copy to sprint → POST /tasks/{id}/copy clones title/owner/priority/
   description/url into another sprint with linked_task_id pointing at
   the source. Assignments are dropped (fresh slate).
 - Move (pick up) → click-to-pickup, row follows cursor Y with an
   amber drop indicator, click anywhere to drop, Escape to cancel.
 - Delete → folds the standalone × column into the menu.

The title cell now carries:
 - a small external-link anchor (visible to everyone) when a URL is
   set, target="_blank" rel="noopener";
 - a description marker that opens a read-only popover (so non-admins
   can also read the description without an edit modal);
 - linked-task chips, bidirectional — "← Sprint X" for the row this
   task was copied from, "→ Sprint Y" for every copy that points back.

Schema (migration 004): tasks gains description / url / linked_task_id
(FK with ON DELETE SET NULL so a deleted source doesn't take its
copies with it), plus idx_tasks_linked. Existing rows stay valid via
'' / NULL defaults — no backfill needed.

TaskRepository::linkedSummariesForTasks() resolves both directions in
two queries off the per-sprint task list. SprintController::loadSprintPage
hands sprintChoices (every sprint except this one) and linkedMap into
the show + present views; the JS reads sprintChoices from a JSON-encoded
data-sprint-choices attribute on data-task-section.

CSS: new @layer components block defines .task-menu (+ submenu),
.task-modal-{overlay,panel,header,body,footer}, .task-pickup-{active,
indicator}, .task-desc-popover. The Phase 15 beamer rule grows a
[data-task-menu-trigger] hide so the present view stays read-only.
SortableJS still drives worker-row reordering — only the task-table
draggable was removed.

Tests: 148 / 406 (no change). The new fields default empty/NULL so
the existing TaskRepository / cascade / status tests keep passing
without modification; the migrator picks up 004 on next request.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
chiappa 2 gün önce
ebeveyn
işleme
c2dad80d1e

+ 170 - 1
assets/css/input.css

@@ -42,7 +42,8 @@
         padding: 0.25rem 0.35rem;
     }
     .beamer-root .handle,
-    .beamer-root [data-delete-task] {
+    .beamer-root [data-delete-task],
+    .beamer-root [data-task-menu-trigger] {
         display: none;
     }
 
@@ -191,4 +192,172 @@
     .dark .cell-popover-bullet.bullet-gestartet     { background-color: theme('colors.yellow.500'); }
     .dark .cell-popover-bullet.bullet-abgeschlossen { background-color: theme('colors.green.500'); }
     .dark .cell-popover-bullet.bullet-abgebrochen   { background-color: theme('colors.red.500'); }
+
+    /* Phase 22: per-task hamburger menu, details modal, click-to-pickup
+       reorder indicator, description popover. All single body-attached
+       nodes; the JS positions them with absolute coordinates. */
+
+    .task-menu {
+        position: absolute;
+        z-index: 60;
+        min-width: 12rem;
+        padding: 0.25rem;
+        border-radius: 0.5rem;
+        border: 1px solid theme('colors.slate.200');
+        background-color: theme('colors.white');
+        box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.12), 0 4px 6px -4px rgb(0 0 0 / 0.1);
+    }
+    .dark .task-menu {
+        border-color: theme('colors.slate.700');
+        background-color: theme('colors.slate.800');
+    }
+    .task-menu-item,
+    .task-menu-sub-item {
+        display: flex;
+        width: 100%;
+        align-items: center;
+        justify-content: space-between;
+        gap: 0.5rem;
+        padding: 0.4rem 0.6rem;
+        border-radius: 4px;
+        text-align: left;
+        font-size: 0.875rem;
+        color: theme('colors.slate.700');
+        background-color: transparent;
+        cursor: pointer;
+    }
+    .dark .task-menu-item,
+    .dark .task-menu-sub-item {
+        color: theme('colors.slate.200');
+    }
+    .task-menu-item:hover,
+    .task-menu-sub-item:hover {
+        background-color: theme('colors.slate.100');
+    }
+    .dark .task-menu-item:hover,
+    .dark .task-menu-sub-item:hover {
+        background-color: theme('colors.slate.700');
+    }
+    .task-menu-item-danger {
+        color: theme('colors.red.600');
+    }
+    .dark .task-menu-item-danger {
+        color: theme('colors.red.400');
+    }
+    .task-menu-subwrap {
+        position: relative;
+    }
+    .task-menu-sub {
+        margin-top: 0.15rem;
+        margin-left: 0.5rem;
+        padding: 0.25rem;
+        border-radius: 0.4rem;
+        border: 1px solid theme('colors.slate.200');
+        background-color: theme('colors.slate.50');
+        max-height: 14rem;
+        overflow-y: auto;
+    }
+    .dark .task-menu-sub {
+        border-color: theme('colors.slate.700');
+        background-color: theme('colors.slate.700');
+    }
+    .task-menu-sub-empty {
+        padding: 0.4rem 0.6rem;
+        font-size: 0.8rem;
+        font-style: italic;
+        color: theme('colors.slate.500');
+    }
+    .dark .task-menu-sub-empty {
+        color: theme('colors.slate.400');
+    }
+
+    .task-modal-overlay {
+        position: fixed;
+        inset: 0;
+        z-index: 70;
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        padding: 1rem;
+        background-color: rgb(15 23 42 / 0.45);
+    }
+    .task-modal-panel {
+        width: 100%;
+        max-width: 32rem;
+        border-radius: 0.5rem;
+        background-color: theme('colors.white');
+        border: 1px solid theme('colors.slate.200');
+        box-shadow: 0 25px 50px -12px rgb(0 0 0 / 0.3);
+        display: flex;
+        flex-direction: column;
+        overflow: hidden;
+    }
+    .dark .task-modal-panel {
+        background-color: theme('colors.slate.800');
+        border-color: theme('colors.slate.700');
+    }
+    .task-modal-header {
+        padding: 0.75rem 1rem;
+        border-bottom: 1px solid theme('colors.slate.200');
+        background-color: theme('colors.slate.50');
+        color: theme('colors.slate.800');
+    }
+    .dark .task-modal-header {
+        background-color: theme('colors.slate.700');
+        border-color: theme('colors.slate.700');
+        color: theme('colors.slate.100');
+    }
+    .task-modal-body {
+        padding: 1rem;
+        font-size: 0.875rem;
+    }
+    .task-modal-footer {
+        padding: 0.5rem 1rem 0.75rem;
+        display: flex;
+        justify-content: flex-end;
+        gap: 0.5rem;
+        border-top: 1px solid theme('colors.slate.100');
+        background-color: theme('colors.slate.50');
+    }
+    .dark .task-modal-footer {
+        background-color: theme('colors.slate.700');
+        border-color: theme('colors.slate.700');
+    }
+
+    .task-pickup-active {
+        background-color: theme('colors.amber.100') !important;
+        outline: 2px dashed theme('colors.amber.400');
+        outline-offset: -2px;
+    }
+    .dark .task-pickup-active {
+        background-color: theme('colors.amber.900') !important;
+        outline-color: theme('colors.amber.500');
+    }
+    .task-pickup-indicator {
+        position: absolute;
+        height: 2px;
+        background-color: theme('colors.amber.500');
+        z-index: 65;
+        pointer-events: none;
+        box-shadow: 0 0 0 1px rgb(255 255 255 / 0.5);
+    }
+
+    .task-desc-popover {
+        position: absolute;
+        z-index: 55;
+        max-width: 24rem;
+        padding: 0.5rem 0.75rem;
+        border-radius: 0.4rem;
+        border: 1px solid theme('colors.slate.200');
+        background-color: theme('colors.white');
+        color: theme('colors.slate.700');
+        font-size: 0.825rem;
+        white-space: pre-wrap;
+        box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1);
+    }
+    .dark .task-desc-popover {
+        border-color: theme('colors.slate.700');
+        background-color: theme('colors.slate.800');
+        color: theme('colors.slate.100');
+    }
 }

+ 12 - 0
migrations/004_task_metadata_and_links.sql

@@ -0,0 +1,12 @@
+-- Phase 22: per-task description + URL + bidirectional copy linkage.
+--
+-- description / url default to '' so existing rows stay valid without a
+-- backfill. linked_task_id is set on a *copy* and points at its source task;
+-- the reverse direction is reached by `WHERE linked_task_id = ?`. ON DELETE
+-- SET NULL keeps the copy alive when the source is deleted.
+
+ALTER TABLE tasks ADD COLUMN description     TEXT NOT NULL DEFAULT '';
+ALTER TABLE tasks ADD COLUMN url             TEXT NOT NULL DEFAULT '';
+ALTER TABLE tasks ADD COLUMN linked_task_id  INTEGER REFERENCES tasks(id) ON DELETE SET NULL;
+
+CREATE INDEX idx_tasks_linked ON tasks(linked_task_id);

+ 622 - 51
public/assets/js/sprint-planner.js

@@ -357,25 +357,70 @@
         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 = '&#8801;';
-        tdHandle.appendChild(handle);
-        tr.appendChild(tdHandle);
-
-        // title
+        tr.setAttribute('data-description', task.description || '');
+        tr.setAttribute('data-url',         task.url || '');
+
+        // 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 = '<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true">'
+            + '<line x1="2" y1="4"  x2="14" y2="4"  stroke="currentColor" stroke-width="2"/>'
+            + '<line x1="2" y1="8"  x2="14" y2="8"  stroke="currentColor" stroke-width="2"/>'
+            + '<line x1="2" y1="12" x2="14" y2="12" stroke="currentColor" stroke-width="2"/>'
+            + '</svg>';
+        tdMenu.appendChild(trig);
+        tr.appendChild(tdMenu);
+
+        // title cell with URL + description affordances
         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 = '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);
+        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';
+        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 = '<svg viewBox="0 0 16 16" width="12" height="12" aria-hidden="true">'
+            + '<path d="M9 2h5v5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>'
+            + '<path d="M14 2L7 9" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>'
+            + '<path d="M12 9v4a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>'
+            + '</svg>';
+        titleWrap.appendChild(urlLink);
+
+        const descBtn = document.createElement('button');
+        descBtn.type = 'button';
+        descBtn.setAttribute('data-task-desc-trigger', '');
+        descBtn.className = 'task-desc-trigger inline-flex items-center justify-center w-5 h-5 rounded text-slate-500 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-700';
+        descBtn.title = 'View description';
+        descBtn.setAttribute('aria-label', 'View description');
+        if (!task.description) { descBtn.classList.add('hidden'); }
+        descBtn.innerHTML = '<svg viewBox="0 0 16 16" width="12" height="12" aria-hidden="true">'
+            + '<rect x="2" y="3" width="12" height="10" rx="1" fill="none" stroke="currentColor" stroke-width="1.5"/>'
+            + '<line x1="4" y1="6"  x2="12" y2="6"  stroke="currentColor" stroke-width="1.5"/>'
+            + '<line x1="4" y1="9"  x2="10" y2="9"  stroke="currentColor" stroke-width="1.5"/>'
+            + '</svg>';
+        titleWrap.appendChild(descBtn);
+
+        tdTitle.appendChild(titleWrap);
         tr.appendChild(tdTitle);
 
         // owner
@@ -453,17 +498,6 @@
             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;
     }
 
@@ -529,15 +563,15 @@
             .catch((e) => flash(e.message, true));
     });
 
-    // --- Delete task ------------------------------------------------------
+    // --- Delete task (invoked from the task hamburger menu) --------------
 
-    on(root, 'click', '[data-delete-task]', function () {
-        const tr = this.closest('tr');
-        const id = parseInt(tr.getAttribute('data-task-id'), 10);
+    function deleteTask(taskId) {
+        const tr = qs('tr[data-task-id="' + taskId + '"]', taskTbody);
+        if (!tr) { return; }
         const titleInp = qs('[data-title]', tr);
         const title = titleInp ? titleInp.value : '(untitled)';
         if (!window.confirm('Delete task "' + title + '"?')) { return; }
-        request('DELETE', '/tasks/' + id)
+        request('DELETE', '/tasks/' + taskId)
             .then(function (data) {
                 tr.remove();
                 applyServerCapacity(data && data.per_worker);
@@ -548,7 +582,7 @@
                 }
             })
             .catch((e) => flash(e.message, true));
-    });
+    }
 
     // --- Per-cell assignment days pipeline -------------------------------
 
@@ -850,31 +884,568 @@
         });
     }
 
-    // --- Task reorder (SortableJS) ---------------------------------------
+    // --- Task hamburger menu + details modal + click-pickup reorder -----
+    //
+    // Replaces SortableJS for tasks. The menu, modal, description popover,
+    // and pickup overlay are all single body-attached nodes built lazily.
 
-    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');
+    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
+    let taskMenuTaskId = null;
+    let taskMenuTrigger = null;    // the button it was opened from
+    let taskMenuOpenAt = 0;
+
+    function buildTaskMenu() {
+        if (taskMenu) { return taskMenu; }
+        const r = document.createElement('div');
+        r.className = 'task-menu hidden';
+        r.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'); }
+            b.textContent = label;
+            return b;
+        }
+
+        function submenu(label, action) {
+            const wrap = document.createElement('div');
+            wrap.className = 'task-menu-subwrap';
+            wrap.setAttribute('data-task-submenu', action);
+            const b = document.createElement('button');
+            b.type = 'button';
+            b.setAttribute('role', 'menuitem');
+            b.setAttribute('data-task-submenu-trigger', action);
+            b.className = 'task-menu-item task-menu-item-with-sub';
+            b.innerHTML = '<span>' + label + '</span><span class="opacity-60">▸</span>';
+            wrap.appendChild(b);
+
+            const list = document.createElement('div');
+            list.className = 'task-menu-sub hidden';
+            list.setAttribute('data-task-submenu-list', action);
+            wrap.appendChild(list);
+            return wrap;
+        }
+
+        r.appendChild(item('Edit details…',     'edit'));
+        r.appendChild(submenu('Move to sprint',  'move'));
+        r.appendChild(submenu('Copy to sprint',  'copy'));
+        r.appendChild(item('Move (pick up)',     'pickup'));
+        r.appendChild(item('Delete',             'delete', { danger: true }));
+
+        document.body.appendChild(r);
+        taskMenu = r;
+        return r;
+    }
+
+    function refreshTaskMenuChoices(currentSprintId) {
+        const moveList = taskMenu.querySelector('[data-task-submenu-list="move"]');
+        const copyList = taskMenu.querySelector('[data-task-submenu-list="copy"]');
+        [moveList, copyList].forEach((el) => { if (el) { el.innerHTML = ''; } });
+        if (sprintChoices.length === 0) {
+            [moveList, copyList].forEach(function (el) {
+                if (!el) { return; }
+                const empty = document.createElement('div');
+                empty.className = 'task-menu-sub-empty';
+                empty.textContent = 'No other sprints';
+                el.appendChild(empty);
+            });
+            return;
+        }
+        sprintChoices.forEach(function (s) {
+            if (Number(s.id) === Number(currentSprintId)) { return; }
+            ['move', 'copy'].forEach(function (action) {
+                const list = action === 'move' ? moveList : copyList;
+                if (!list) { return; }
+                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;
+                list.appendChild(b);
+            });
+        });
+    }
+
+    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.bottom + 4;
+        let left = window.scrollX + rect.left;
+        if (left + mw > window.scrollX + vw - 8) {
+            left = window.scrollX + vw - mw - 8;
+        }
+        if (top + mh > window.scrollY + vh - 8) {
+            top = window.scrollY + rect.top - mh - 4;
+            if (top < window.scrollY + 8) { top = window.scrollY + 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();
+        refreshTaskMenuChoices(sprintId);
+        closeAllTaskSubmenus();
+        taskMenuTaskId  = taskId;
+        taskMenuTrigger = trigger;
+        trigger.setAttribute('aria-expanded', 'true');
+        taskMenu.classList.remove('hidden');
+        positionTaskMenu();
+        taskMenuOpenAt = Date.now();
+    }
+
+    function closeAllTaskSubmenus() {
+        if (!taskMenu) { return; }
+        qsa('[data-task-submenu-list]', taskMenu).forEach(function (el) {
+            el.classList.add('hidden');
+        });
+    }
+
+    function closeTaskMenu() {
+        if (taskMenu) { taskMenu.classList.add('hidden'); }
+        if (taskMenuTrigger) { taskMenuTrigger.setAttribute('aria-expanded', 'false'); }
+        closeAllTaskSubmenus();
+        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();
+        }
+    });
+
+    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');
+            const list = taskMenu.querySelector('[data-task-submenu-list="' + action + '"]');
+            const wasHidden = list && list.classList.contains('hidden');
+            closeAllTaskSubmenus();
+            if (list && wasHidden) { list.classList.remove('hidden'); }
+            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)) {
+            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 =
+            '<header class="task-modal-header"><h2 class="text-base font-semibold">Edit details</h2></header>' +
+            '<div class="task-modal-body space-y-3">' +
+              '<label class="block text-sm">' +
+                '<span class="block mb-1 text-slate-700 dark:text-slate-200">Description</span>' +
+                '<textarea data-modal-description rows="6" maxlength="8000" ' +
+                  'class="w-full rounded border border-slate-300 px-2 py-1 font-sans text-sm 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"></textarea>' +
+              '</label>' +
+              '<label class="block text-sm">' +
+                '<span class="block mb-1 text-slate-700 dark:text-slate-200">URL</span>' +
+                '<input type="url" data-modal-url maxlength="2048" placeholder="https://…" ' +
+                  'class="w-full rounded border border-slate-300 px-2 py-1 text-sm 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">' +
+                '<span class="block mt-1 text-xs text-slate-500 dark:text-slate-400">Must start with http:// or https://. Leave empty to remove.</span>' +
+              '</label>' +
+            '</div>' +
+            '<footer class="task-modal-footer">' +
+              '<button type="button" data-modal-cancel class="rounded border border-slate-300 bg-white text-slate-700 px-3 py-1 text-sm hover:bg-slate-100 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-200 dark:hover:bg-slate-700">Cancel</button>' +
+              '<button type="button" data-modal-save class="rounded bg-slate-900 text-white px-3 py-1 text-sm font-medium hover:bg-slate-800 dark:bg-slate-700 dark:hover:bg-slate-600">Save</button>' +
+            '</footer>';
+
+        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);
+                    const descBtn = qs('[data-task-desc-trigger]', tr);
+                    if (link) {
+                        link.href = url;
+                        link.classList.toggle('hidden', url === '');
+                    }
+                    if (descBtn) {
+                        descBtn.classList.toggle('hidden', desc === '');
+                    }
+                }
+                closeDetailsModal();
+                flash('Saved');
+            })
+            .catch((e) => flash(e.message, true));
+    }
+
+    document.addEventListener('keydown', function (ev) {
+        if (ev.key === 'Escape' && detailsModal && !detailsModal.classList.contains('hidden')) {
+            closeDetailsModal();
+        }
+    });
+
+    // --- Description popover (click on the description marker) ----------
+    //
+    // Read-only popover so non-admins can see the description, and admins
+    // can peek without opening the full edit modal.
+
+    let descPopover = null;
+    let descPopoverAnchor = null;
+    let descPopoverOpenAt = 0;
+
+    function buildDescPopover() {
+        if (descPopover) { return descPopover; }
+        const r = document.createElement('div');
+        r.className = 'task-desc-popover hidden';
+        r.setAttribute('role', 'tooltip');
+        document.body.appendChild(r);
+        descPopover = r;
+        return r;
+    }
+
+    function openDescPopover(anchor, text) {
+        if (!text) { return; }
+        buildDescPopover();
+        descPopoverAnchor = anchor;
+        descPopover.textContent = text;
+        descPopover.classList.remove('hidden');
+        const rect = anchor.getBoundingClientRect();
+        const ph = descPopover.offsetHeight;
+        const pw = descPopover.offsetWidth;
+        const vw = document.documentElement.clientWidth;
+        let top  = window.scrollY + rect.bottom + 4;
+        let left = window.scrollX + rect.left;
+        if (left + pw > window.scrollX + vw - 8) {
+            left = window.scrollX + vw - pw - 8;
+        }
+        if (top + ph > window.scrollY + document.documentElement.clientHeight - 8) {
+            top = window.scrollY + rect.top - ph - 4;
+        }
+        descPopover.style.top  = top + 'px';
+        descPopover.style.left = left + 'px';
+        descPopoverOpenAt = Date.now();
+    }
+
+    function closeDescPopover() {
+        if (descPopover) { descPopover.classList.add('hidden'); }
+        descPopoverAnchor = null;
+    }
+
+    on(root, 'click', '[data-task-desc-trigger]', function (ev) {
+        ev.preventDefault();
+        ev.stopPropagation();
+        const tr = this.closest('tr[data-task-row]');
+        if (!tr) { return; }
+        const text = tr.getAttribute('data-description') || '';
+        openDescPopover(this, text);
+    });
+
+    document.addEventListener('pointerdown', function (ev) {
+        if (!descPopover || descPopover.classList.contains('hidden')) { return; }
+        if (Date.now() - descPopoverOpenAt < 50) { return; }
+        if (descPopover.contains(ev.target)) { return; }
+        if (descPopoverAnchor && descPopoverAnchor.contains(ev.target)) { return; }
+        closeDescPopover();
+    }, true);
+
+    document.addEventListener('keydown', function (ev) {
+        if (ev.key === 'Escape' && descPopover && !descPopover.classList.contains('hidden')) {
+            closeDescPopover();
+        }
+    });
+
+    // --- 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 ---------------------------------------

+ 3 - 0
public/index.php

@@ -199,6 +199,9 @@ $router->delete('/tasks/{id}',                        $taskCtrl->delete(...));
 $router->patch('/tasks/{id}/assignments',             $taskCtrl->updateAssignments(...));
 // Phase 18 — task-cell status (any signed-in user, gated by global flag):
 $router->patch('/tasks/{id}/assignments/status',      $taskCtrl->updateAssignmentsStatus(...));
+// Phase 22 — task move/copy across sprints (admin):
+$router->post('/tasks/{id}/move',                     $taskCtrl->moveToSprint(...));
+$router->post('/tasks/{id}/copy',                     $taskCtrl->copyToSprint(...));
 
 // Phase 18 — global app settings (admin):
 $router->get('/settings',                             $settingsCtrl->show(...));

+ 15 - 0
src/Controllers/SprintController.php

@@ -255,6 +255,19 @@ final class SprintController
         // they are one of the sprint workers. Keep it restrictive for the UI).
         $ownerChoices = $this->workers->all();
 
+        // Phase 22: candidate destination sprints for Move/Copy (everything
+        // except this one), plus bidirectional linked-task summaries for the
+        // tasks on this sprint.
+        $sprintChoices = [];
+        foreach ($this->sprints->allWithCounts() as $row) {
+            $s = $row['sprint'];
+            if ($s->id === $id) {
+                continue;
+            }
+            $sprintChoices[] = ['id' => $s->id, 'name' => $s->name];
+        }
+        $linkedMap = $this->tasks->linkedSummariesForTasks($tasks);
+
         return [
             'sprint'            => $sprint,
             'weeks'             => $weeks,
@@ -265,6 +278,8 @@ final class SprintController
             'taskGrid'          => $taskGrid,
             'statusGrid'        => $statusGrid,
             'ownerChoices'      => $ownerChoices,
+            'sprintChoices'     => $sprintChoices,
+            'linkedMap'         => $linkedMap,
             'taskStatusEnabled'   => $this->appSettings->getBool('task_status_enabled', false),
             'assignmentSliderMax' => max(
                 1,

+ 144 - 0
src/Controllers/TaskController.php

@@ -144,6 +144,27 @@ final class TaskController
             }
         }
 
+        if (array_key_exists('description', $body)) {
+            $desc = is_string($body['description']) ? $body['description'] : '';
+            if (strlen($desc) > 8000) {
+                return Response::err('validation', 'description too long', 422);
+            }
+            $changes['description'] = $desc;
+        }
+
+        if (array_key_exists('url', $body)) {
+            $u = is_string($body['url']) ? trim($body['url']) : '';
+            if ($u !== '') {
+                if (strlen($u) > 2048) {
+                    return Response::err('validation', 'url too long', 422);
+                }
+                if (!preg_match('#^https?://#i', $u)) {
+                    return Response::err('validation', 'url must start with http:// or https://', 422);
+                }
+            }
+            $changes['url'] = $u;
+        }
+
         if ($changes === []) {
             return Response::ok(['task' => $task->toAuditSnapshot()]);
         }
@@ -474,6 +495,129 @@ final class TaskController
         ]);
     }
 
+    /**
+     * POST /tasks/{id}/move — reassign a task to another sprint.
+     *
+     * All task_assignments are dropped (audited per-cell before the wipe);
+     * task lands at the bottom of the destination sprint's list. Capacity is
+     * affected on both sides for prio-1 tasks, but the client just reloads
+     * the page after a successful move so we don't need to send fresh
+     * per-worker numbers in the response.
+     */
+    public function moveToSprint(Request $req, array $params): Response
+    {
+        $actor = SessionGuard::requireAdminJson($req, $this->users);
+        if ($actor instanceof Response) {
+            return $actor;
+        }
+
+        $id   = (int) $params['id'];
+        $task = $this->tasks->find($id);
+        if ($task === null) {
+            return Response::err('not_found', 'Task not found', 404);
+        }
+
+        $body         = $req->json() ?? [];
+        $destSprintId = isset($body['sprint_id']) ? (int) $body['sprint_id'] : 0;
+        if ($destSprintId <= 0) {
+            return Response::err('validation', 'sprint_id required', 422);
+        }
+        if ($destSprintId === $task->sprintId) {
+            return Response::err('validation', 'task already in this sprint', 422);
+        }
+        if ($this->sprints->find($destSprintId) === null) {
+            return Response::err('not_found', 'Destination sprint not found', 404);
+        }
+
+        $oldAssignments = $this->assignments->allForTask($id);
+
+        $this->pdo->beginTransaction();
+        try {
+            // Audit each assignment before they vanish.
+            foreach ($oldAssignments as $a) {
+                $this->audit->recordForRequest(
+                    'DELETE', 'task_assignment', $a->id,
+                    $a->toAuditSnapshot(), null,
+                    $req, $actor,
+                );
+            }
+
+            $result = $this->tasks->moveToSprint($id, $destSprintId);
+            $this->audit->recordForRequest(
+                'UPDATE', 'task', $id,
+                $result['before']->toAuditSnapshot(),
+                $result['after']->toAuditSnapshot(),
+                $req, $actor,
+            );
+
+            $this->pdo->commit();
+        } catch (Throwable) {
+            $this->pdo->rollBack();
+            return Response::err('db_error', 'Could not move task', 500);
+        }
+
+        return Response::ok([
+            'task' => $result['after']->toAuditSnapshot(),
+        ]);
+    }
+
+    /**
+     * POST /tasks/{id}/copy — clone a task into another sprint.
+     *
+     * Carries title / owner / priority / description / url; assignments are
+     * NOT carried (fresh slate per the design call). The new task records
+     * `linked_task_id = source.id`; the bidirectional UI link is rendered
+     * by the sprint view via TaskRepository::linkedSummariesForTasks.
+     */
+    public function copyToSprint(Request $req, array $params): Response
+    {
+        $actor = SessionGuard::requireAdminJson($req, $this->users);
+        if ($actor instanceof Response) {
+            return $actor;
+        }
+
+        $id   = (int) $params['id'];
+        $task = $this->tasks->find($id);
+        if ($task === null) {
+            return Response::err('not_found', 'Task not found', 404);
+        }
+
+        $body         = $req->json() ?? [];
+        $destSprintId = isset($body['sprint_id']) ? (int) $body['sprint_id'] : 0;
+        if ($destSprintId <= 0) {
+            return Response::err('validation', 'sprint_id required', 422);
+        }
+        if ($this->sprints->find($destSprintId) === null) {
+            return Response::err('not_found', 'Destination sprint not found', 404);
+        }
+
+        $this->pdo->beginTransaction();
+        try {
+            $copy = $this->tasks->create(
+                sprintId:      $destSprintId,
+                title:         $task->title,
+                ownerWorkerId: $task->ownerWorkerId,
+                priority:      $task->priority,
+                description:   $task->description,
+                url:           $task->url,
+                linkedTaskId:  $task->id,
+            );
+            $this->audit->recordForRequest(
+                'CREATE', 'task', $copy->id,
+                null, $copy->toAuditSnapshot(),
+                $req, $actor,
+            );
+            $this->pdo->commit();
+        } catch (Throwable) {
+            $this->pdo->rollBack();
+            return Response::err('db_error', 'Could not copy task', 500);
+        }
+
+        return Response::ok([
+            'task' => $copy->toAuditSnapshot(),
+        ]);
+    }
+
     /**
      * Full per-worker capacity recompute for a sprint. Used to keep the
      * client-side capacity strip in sync when changes cascade across rows.

+ 6 - 0
src/Domain/Task.php

@@ -15,6 +15,9 @@ final class Task
         public readonly int    $sortOrder,
         public readonly string $createdAt,
         public readonly string $updatedAt,
+        public readonly string $description = '',
+        public readonly string $url          = '',
+        public readonly ?int   $linkedTaskId = null,
     ) {
     }
 
@@ -29,6 +32,9 @@ final class Task
             'sort_order'      => $this->sortOrder,
             'created_at'      => $this->createdAt,
             'updated_at'      => $this->updatedAt,
+            'description'     => $this->description,
+            'url'             => $this->url,
+            'linked_task_id'  => $this->linkedTaskId,
         ];
     }
 }

+ 155 - 4
src/Repositories/TaskRepository.php

@@ -11,7 +11,14 @@ use RuntimeException;
 final class TaskRepository
 {
     /** Whitelisted updatable columns. */
-    private const UPDATABLE = ['title', 'owner_worker_id', 'priority'];
+    private const UPDATABLE = [
+        'title',
+        'owner_worker_id',
+        'priority',
+        'description',
+        'url',
+        'linked_task_id',
+    ];
 
     public function __construct(private readonly PDO $pdo)
     {
@@ -44,16 +51,24 @@ final class TaskRepository
         string $title,
         ?int $ownerWorkerId,
         int $priority,
+        string $description = '',
+        string $url = '',
+        ?int $linkedTaskId = null,
     ): Task {
         $now = gmdate('Y-m-d\TH:i:s\Z');
         $max = (int) $this->pdo
             ->query('SELECT COALESCE(MAX(sort_order), 0) FROM tasks WHERE sprint_id = ' . $sprintId)
             ->fetchColumn();
         $stmt = $this->pdo->prepare(
-            'INSERT INTO tasks (sprint_id, title, owner_worker_id, priority, sort_order, created_at, updated_at)
-             VALUES (?, ?, ?, ?, ?, ?, ?)'
+            'INSERT INTO tasks
+                (sprint_id, title, owner_worker_id, priority, sort_order,
+                 created_at, updated_at, description, url, linked_task_id)
+             VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
         );
-        $stmt->execute([$sprintId, $title, $ownerWorkerId, $priority, $max + 1, $now, $now]);
+        $stmt->execute([
+            $sprintId, $title, $ownerWorkerId, $priority, $max + 1,
+            $now, $now, $description, $url, $linkedTaskId,
+        ]);
         $id = (int) $this->pdo->lastInsertId();
         $task = $this->find($id);
         if ($task === null) {
@@ -62,6 +77,134 @@ final class TaskRepository
         return $task;
     }
 
+    /**
+     * Move a task to a different sprint. Caller is responsible for auditing
+     * task_assignment deletes BEFORE this method (cascade rule, spec §7) and
+     * for auditing the task UPDATE after. The task lands at the bottom of the
+     * destination sprint's task list.
+     *
+     * @return array{before: Task, after: Task}
+     */
+    public function moveToSprint(int $taskId, int $destSprintId): array
+    {
+        $before = $this->find($taskId);
+        if ($before === null) {
+            throw new RuntimeException("Task {$taskId} not found");
+        }
+
+        // Wipe assignments first — cells from one sprint don't belong to another.
+        $this->pdo->prepare('DELETE FROM task_assignments WHERE task_id = ?')
+            ->execute([$taskId]);
+
+        $maxOrder = (int) $this->pdo
+            ->query('SELECT COALESCE(MAX(sort_order), 0) FROM tasks WHERE sprint_id = ' . $destSprintId)
+            ->fetchColumn();
+
+        $stmt = $this->pdo->prepare(
+            'UPDATE tasks
+             SET sprint_id = ?, sort_order = ?, updated_at = ?
+             WHERE id = ?'
+        );
+        $stmt->execute([$destSprintId, $maxOrder + 1, gmdate('Y-m-d\TH:i:s\Z'), $taskId]);
+
+        $after = $this->find($taskId) ?? $before;
+        return ['before' => $before, 'after' => $after];
+    }
+
+    /**
+     * Resolve linked-task summaries for a list of tasks in one sprint, in both
+     * directions: each task's source (when this row was a copy) and each
+     * task's copies (rows that point back to it). Used by the sprint view to
+     * render bidirectional "linked" chips.
+     *
+     * @param list<Task> $tasks
+     * @return array<int, list<array{id:int, title:string, sprint_id:int, sprint_name:string, direction:string}>>
+     */
+    public function linkedSummariesForTasks(array $tasks): array
+    {
+        if ($tasks === []) {
+            return [];
+        }
+
+        $ids        = [];
+        $forwardIds = [];
+        foreach ($tasks as $t) {
+            $ids[] = $t->id;
+            if ($t->linkedTaskId !== null) {
+                $forwardIds[] = $t->linkedTaskId;
+            }
+        }
+
+        $lookupIds = array_values(array_unique(array_merge($ids, $forwardIds)));
+        $byId = [];
+        if ($lookupIds !== []) {
+            $place = implode(',', array_fill(0, count($lookupIds), '?'));
+            $stmt  = $this->pdo->prepare(
+                'SELECT t.id, t.title, t.sprint_id, t.linked_task_id, s.name AS sprint_name
+                 FROM tasks t
+                 LEFT JOIN sprints s ON s.id = t.sprint_id
+                 WHERE t.id IN (' . $place . ')'
+            );
+            $stmt->execute($lookupIds);
+            foreach ($stmt as $row) {
+                $byId[(int) $row['id']] = [
+                    'id'             => (int) $row['id'],
+                    'title'          => (string) $row['title'],
+                    'sprint_id'      => (int) $row['sprint_id'],
+                    'sprint_name'    => (string) ($row['sprint_name'] ?? ''),
+                    'linked_task_id' => isset($row['linked_task_id']) && $row['linked_task_id'] !== null
+                        ? (int) $row['linked_task_id']
+                        : null,
+                ];
+            }
+        }
+
+        // Reverse: every row whose linked_task_id is one of our task ids.
+        $reverseByTarget = [];
+        if ($ids !== []) {
+            $place = implode(',', array_fill(0, count($ids), '?'));
+            $stmt  = $this->pdo->prepare(
+                'SELECT t.id, t.title, t.sprint_id, t.linked_task_id, s.name AS sprint_name
+                 FROM tasks t
+                 LEFT JOIN sprints s ON s.id = t.sprint_id
+                 WHERE t.linked_task_id IN (' . $place . ')'
+            );
+            $stmt->execute($ids);
+            foreach ($stmt as $row) {
+                $tgt = (int) $row['linked_task_id'];
+                $reverseByTarget[$tgt][] = [
+                    'id'          => (int) $row['id'],
+                    'title'       => (string) $row['title'],
+                    'sprint_id'   => (int) $row['sprint_id'],
+                    'sprint_name' => (string) ($row['sprint_name'] ?? ''),
+                    'direction'   => 'copy',
+                ];
+            }
+        }
+
+        $out = [];
+        foreach ($tasks as $t) {
+            $entries = [];
+            if ($t->linkedTaskId !== null && isset($byId[$t->linkedTaskId])) {
+                $src = $byId[$t->linkedTaskId];
+                $entries[] = [
+                    'id'          => $src['id'],
+                    'title'       => $src['title'],
+                    'sprint_id'   => $src['sprint_id'],
+                    'sprint_name' => $src['sprint_name'],
+                    'direction'   => 'source',
+                ];
+            }
+            foreach ($reverseByTarget[$t->id] ?? [] as $rev) {
+                $entries[] = $rev;
+            }
+            if ($entries !== []) {
+                $out[$t->id] = $entries;
+            }
+        }
+        return $out;
+    }
+
     /**
      * @param array<string,mixed> $changes
      * @return array{before: Task, after: Task}
@@ -86,6 +229,9 @@ final class TaskRepository
                 'title'           => (string) $v,
                 'owner_worker_id' => $v === null ? null : (int) $v,
                 'priority'        => (int) $v,
+                'description'     => (string) ($v ?? ''),
+                'url'             => (string) ($v ?? ''),
+                'linked_task_id'  => $v === null ? null : (int) $v,
                 default           => $v,
             };
         }
@@ -186,6 +332,11 @@ final class TaskRepository
             sortOrder:     (int)    $row['sort_order'],
             createdAt:     (string) $row['created_at'],
             updatedAt:     (string) $row['updated_at'],
+            description:   (string) ($row['description'] ?? ''),
+            url:           (string) ($row['url'] ?? ''),
+            linkedTaskId:  isset($row['linked_task_id']) && $row['linked_task_id'] !== null
+                ? (int) $row['linked_task_id']
+                : null,
         );
     }
 }

+ 59 - 16
views/sprints/_task_list.twig

@@ -7,7 +7,8 @@
 <section class="rounded-lg border bg-white {% if isBeamer|default(false) %}m-2 {% endif %}dark:bg-slate-800 dark:border-slate-700"
          data-task-section
          data-task-status-enabled="{{ taskStatusEnabled ? '1' : '0' }}"
-         data-assignment-slider-max="{{ assignmentSliderMax|default(10) }}">
+         data-assignment-slider-max="{{ assignmentSliderMax|default(10) }}"
+         data-sprint-choices="{{ sprintChoices|default([])|json_encode|e('html_attr') }}">
     <div class="rounded-t-lg px-4 py-3 border-b bg-slate-50 flex flex-wrap items-center gap-2 dark:bg-slate-700 dark:border-slate-700">
         <h2 class="text-sm font-semibold text-slate-700 uppercase tracking-wider dark:text-slate-200">Tasks</h2>
 
@@ -159,13 +160,12 @@
                             <span class="sort-ind opacity-30">↕</span>
                         </th>
                     {% endfor %}
-                    <th class="w-8 px-2 py-2"></th>
                 </tr>
             </thead>
             <tbody class="divide-y divide-slate-100 dark:divide-slate-700" data-task-tbody>
                 {% if tasks is empty %}
                     <tr data-empty-tasks>
-                        <td colspan="{{ 6 + sprintWorkers|length }}" class="px-3 py-8 text-center text-slate-500 text-sm dark:text-slate-400">
+                        <td colspan="{{ 5 + sprintWorkers|length }}" class="px-3 py-8 text-center text-slate-500 text-sm dark:text-slate-400">
                             No tasks yet.
                             {% if currentUser.isAdmin %}
                                 Click <b>+ Add task</b> to start.
@@ -177,22 +177,71 @@
                         {% set assign = taskGrid[t.id]|default({}) %}
                         {% set tot = 0 %}
                         {% for v in assign %}{% set tot = tot + v %}{% endfor %}
+                        {% set links = linkedMap[t.id]|default([]) %}
                         <tr data-task-row
                             data-task-id="{{ t.id }}"
                             data-prio="{{ t.priority }}"
                             data-owner="{{ t.ownerWorkerId is not null ? t.ownerWorkerId : '' }}"
-                            data-sort-order="{{ t.sortOrder }}">
+                            data-sort-order="{{ t.sortOrder }}"
+                            data-description="{{ t.description }}"
+                            data-url="{{ t.url }}">
                             <td class="px-2 py-1">
                                 {% if currentUser.isAdmin %}
-                                    <span class="handle cursor-grab text-slate-400 select-none dark:text-slate-500">&#8801;</span>
+                                    <button type="button" data-task-menu-trigger
+                                            class="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"
+                                            aria-haspopup="true" aria-expanded="false"
+                                            aria-label="Task actions">
+                                        <svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true">
+                                            <line x1="2" y1="4"  x2="14" y2="4"  stroke="currentColor" stroke-width="2"/>
+                                            <line x1="2" y1="8"  x2="14" y2="8"  stroke="currentColor" stroke-width="2"/>
+                                            <line x1="2" y1="12" x2="14" y2="12" stroke="currentColor" stroke-width="2"/>
+                                        </svg>
+                                    </button>
                                 {% endif %}
                             </td>
                             <td class="px-2 py-1 min-w-[14rem]">
-                                {% if currentUser.isAdmin %}
-                                    <input type="text" data-title value="{{ t.title }}"
-                                           class="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">
-                                {% else %}
-                                    <span>{{ t.title }}</span>
+                                <div class="flex items-center gap-1.5">
+                                    {% if currentUser.isAdmin %}
+                                        <input type="text" data-title value="{{ t.title }}"
+                                               class="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">
+                                    {% else %}
+                                        <span class="flex-1">{{ t.title }}</span>
+                                    {% endif %}
+
+                                    <a data-task-url-link href="{{ t.url }}"
+                                       target="_blank" rel="noopener"
+                                       class="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 t.url == '' %} hidden{% endif %}"
+                                       title="Open task link"
+                                       aria-label="Open task link">
+                                        <svg viewBox="0 0 16 16" width="12" height="12" aria-hidden="true">
+                                            <path d="M9 2h5v5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+                                            <path d="M14 2L7 9" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+                                            <path d="M12 9v4a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+                                        </svg>
+                                    </a>
+
+                                    <button type="button" data-task-desc-trigger
+                                            class="task-desc-trigger inline-flex items-center justify-center w-5 h-5 rounded text-slate-500 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-700{% if t.description == '' %} hidden{% endif %}"
+                                            title="View description"
+                                            aria-label="View description">
+                                        <svg viewBox="0 0 16 16" width="12" height="12" aria-hidden="true">
+                                            <rect x="2" y="3" width="12" height="10" rx="1" fill="none" stroke="currentColor" stroke-width="1.5"/>
+                                            <line x1="4" y1="6"  x2="12" y2="6"  stroke="currentColor" stroke-width="1.5"/>
+                                            <line x1="4" y1="9"  x2="10" y2="9"  stroke="currentColor" stroke-width="1.5"/>
+                                        </svg>
+                                    </button>
+                                </div>
+                                {% if links is not empty %}
+                                    <div class="mt-0.5 flex flex-wrap gap-1 text-[10px]">
+                                        {% for l in links %}
+                                            <a href="/sprints/{{ l.sprint_id }}"
+                                               class="inline-flex items-center gap-1 rounded border border-slate-200 bg-slate-50 px-1.5 py-0.5 text-slate-600 hover:bg-slate-100 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-300 dark:hover:bg-slate-600"
+                                               title="{{ l.direction == 'source' ? 'Copied from' : 'Copied to' }}: {{ l.title }} ({{ l.sprint_name }})">
+                                                <span class="opacity-60">{{ l.direction == 'source' ? '←' : '→' }}</span>
+                                                <span>{{ l.sprint_name }}</span>
+                                            </a>
+                                        {% endfor %}
+                                    </div>
                                 {% endif %}
                             </td>
                             <td class="px-2 py-1" data-col="owner">
@@ -250,12 +299,6 @@
                                     {% endif %}
                                 </td>
                             {% endfor %}
-                            <td class="px-1 py-1 text-right">
-                                {% if currentUser.isAdmin %}
-                                    <button type="button" data-delete-task
-                                            class="text-sm text-red-600 hover:underline dark:text-red-400">×</button>
-                                {% endif %}
-                            </td>
                         </tr>
                     {% endfor %}
                 {% endif %}