Pārlūkot izejas kodu

Fix R02-N02: clone <template data-task-row-template> instead of mirroring _task_list in JS

Extract the task-row markup into views/sprints/_task_row.twig and call it
twice from _task_list.twig — once inside the for-loop, once inside a
hidden <template data-task-row-template> at the bottom (admin-only).
sprint-planner.js's buildTaskRow now clones the template and populates
the few fields that vary; the ~150 lines of hand-rolled DOM construction
are gone, along with the ownerChoices()/sprintWorkerHeaders() helpers
that only existed to feed it. Server-rendered rows and JS-built rows
share a single Twig source, so the historical drift incidents (hotfixes
7c298d3, 23ab365, and the whitespace-nowrap re-mirror in f204611) can no
longer happen.

TwigViewTest pins both directions: a marker on the template body for the
admin path, and the absence of <template data-task-row-template> for
read-only users. PHPUnit cannot run on this host (PHP install is missing
the dom/xml extensions), so the suite still needs a green run elsewhere
before merge — the new partial was render-checked manually.
chiappa 2 dienas atpakaļ
vecāks
revīzija
d5a09ff

+ 40 - 152
public/assets/js/sprint-planner.js

@@ -332,169 +332,57 @@
     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 <tr> from an object — vanilla JS DOM construction.
+    // Clone the server-rendered <template data-task-row-template> shell and
+    // populate it from the create endpoint's task envelope. Single source of
+    // truth lives in views/sprints/_task_row.twig (R02-N02): this used to be
+    // a hand-rolled DOM-construction mirror that drifted three times — once
+    // for the owner dropdown selector (hotfix 7c298d3), once for the
+    // data-col attributes (hotfix 23ab365), once for whitespace-nowrap.
     function buildTaskRow(task, assignments) {
         assignments = assignments || {};
-        const tr = document.createElement('tr');
-        tr.setAttribute('data-task-row', '');
-        tr.className = 'hover:bg-slate-50 dark:hover:bg-slate-700';
-        tr.setAttribute('data-task-id',   String(task.id));
-        tr.setAttribute('data-prio',      String(task.priority));
-        tr.setAttribute('data-owner',     task.owner_worker_id || '');
-        tr.setAttribute('data-sort-order', String(task.sort_order));
+        const tpl = qs('[data-task-row-template]');
+        if (!tpl || !tpl.content) {
+            throw new Error('[sprint-planner] task row template missing');
+        }
+        const tr = tpl.content.firstElementChild.cloneNode(true);
+
+        const ownerVal = task.owner_worker_id == null ? '' : String(task.owner_worker_id);
+        tr.setAttribute('data-task-id',     String(task.id));
+        tr.setAttribute('data-prio',        String(task.priority));
+        tr.setAttribute('data-owner',       ownerVal);
+        tr.setAttribute('data-sort-order',  String(task.sort_order));
         tr.setAttribute('data-description', task.description || '');
         tr.setAttribute('data-url',         task.url || '');
         tr.setAttribute('data-task-title',  task.title || '');
         tr.setAttribute('data-links',       JSON.stringify(Array.isArray(task.links) ? task.links : []));
 
-        // hamburger trigger
-        const tdMenu = document.createElement('td');
-        tdMenu.className = 'px-2 py-1';
-        const trig = document.createElement('button');
-        trig.type = 'button';
-        trig.setAttribute('data-task-menu-trigger', '');
-        trig.className = 'task-menu-trigger inline-flex items-center justify-center w-6 h-6 rounded text-slate-500 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-700 focus:outline-none focus:ring-2 focus:ring-slate-400';
-        trig.setAttribute('aria-haspopup', 'true');
-        trig.setAttribute('aria-expanded', 'false');
-        trig.setAttribute('aria-label', 'Task actions');
-        trig.innerHTML = '<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 — title input + small "open URL" anchor; description /
-        // refs / actions all live in the hamburger popup.
-        const tdTitle = document.createElement('td');
-        tdTitle.className = 'px-2 py-1 min-w-[14rem]';
-        const titleWrap = document.createElement('div');
-        titleWrap.className = 'flex items-center gap-1.5';
-        const title = document.createElement('input');
-        title.type = 'text';
-        title.setAttribute('data-title', '');
-        title.value = task.title || '';
-        title.className = 'flex-1 rounded border border-slate-200 px-2 py-1 focus:outline-none focus:ring-2 focus:ring-slate-400 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-100 dark:focus:ring-slate-500';
-        titleWrap.appendChild(title);
-
-        const urlLink = document.createElement('a');
-        urlLink.setAttribute('data-task-url-link', '');
-        urlLink.target = '_blank';
-        urlLink.rel = 'noopener noreferrer';
-        urlLink.href = task.url || '';
-        urlLink.title = 'Open task link';
-        urlLink.setAttribute('aria-label', 'Open task link');
-        urlLink.className = 'task-url-link inline-flex items-center justify-center w-5 h-5 rounded text-blue-600 hover:bg-slate-100 dark:text-blue-400 dark:hover:bg-slate-700';
-        if (!task.url) { urlLink.classList.add('hidden'); }
-        urlLink.innerHTML = '<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);
-
-        tdTitle.appendChild(titleWrap);
-        tr.appendChild(tdTitle);
-
-        // owner
-        const tdOwner = document.createElement('td');
-        tdOwner.className = 'px-2 py-1';
-        tdOwner.setAttribute('data-col', 'owner');
-        const ownerSel = document.createElement('select');
-        ownerSel.setAttribute('data-owner-select', '');
-        ownerSel.className = 'w-full rounded border border-slate-200 px-2 py-1 bg-white focus:outline-none focus:ring-2 focus:ring-slate-400 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-100 dark:focus:ring-slate-500';
-        const empty = document.createElement('option');
-        empty.value = '';
-        empty.textContent = '—';
-        ownerSel.appendChild(empty);
-        ownerChoices().forEach(function (o) {
-            const opt = document.createElement('option');
-            opt.value = String(o.id);
-            opt.textContent = o.name;
-            if (Number(o.id) === Number(task.owner_worker_id)) { opt.selected = true; }
-            ownerSel.appendChild(opt);
-        });
-        tdOwner.appendChild(ownerSel);
-        tr.appendChild(tdOwner);
-
-        // priority
-        const tdPrio = document.createElement('td');
-        tdPrio.className = 'px-2 py-1 text-center';
-        tdPrio.setAttribute('data-col', 'prio');
-        const prioSel = document.createElement('select');
-        prioSel.setAttribute('data-prio-select', '');
-        prioSel.className = 'rounded border border-slate-200 px-2 py-1 bg-white font-mono focus:outline-none focus:ring-2 focus:ring-slate-400 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-100 dark:focus:ring-slate-500';
-        ['1', '2'].forEach(function (p) {
-            const opt = document.createElement('option');
-            opt.value = p;
-            opt.textContent = p;
-            if (String(task.priority) === p) { opt.selected = true; }
-            prioSel.appendChild(opt);
-        });
-        tdPrio.appendChild(prioSel);
-        tr.appendChild(tdPrio);
+        const titleInp = qs('[data-title]', tr);
+        if (titleInp) { titleInp.value = task.title || ''; }
+
+        const urlLink = qs('[data-task-url-link]', tr);
+        if (urlLink) {
+            urlLink.href = task.url || '';
+            setHidden(urlLink, !task.url);
+        }
+
+        const ownerSel = qs('[data-owner-select]', tr);
+        if (ownerSel) { ownerSel.value = ownerVal; }
+
+        const prioSel = qs('[data-prio-select]', tr);
+        if (prioSel) { prioSel.value = String(task.priority); }
 
-        // 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 totEl = qs('[data-task-tot]', tr);
+        if (totEl) { totEl.textContent = fmtDays(tot); }
 
-            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);
+        Object.keys(assignments).forEach(function (swIdStr) {
+            const v = Number(assignments[swIdStr]) || 0;
+            const cell = qs('td[data-col="sw-' + swIdStr + '"]', tr);
+            if (!cell) { return; }
+            cell.setAttribute('data-sort-value-sw-' + swIdStr, v.toFixed(2));
+            const inp = qs('[data-assign]', cell);
+            if (inp) { inp.value = fmtDays(v); }
         });
 
         return tr;

+ 69 - 0
tests/Http/TwigViewTest.php

@@ -240,5 +240,74 @@ final class TwigViewTest extends TestCase
         self::assertStringContainsString('No tasks yet', $html);
         // Phase 19: page-specific JS still loaded.
         self::assertStringContainsString('/assets/js/sprint-planner.js', $html);
+        // R02-N02: + Add task clones a server-rendered <template> shell;
+        // it must be present whenever the admin can hit the button. The
+        // template re-uses _task_row.twig so JS-built rows can never drift
+        // from the for-loop rows above. Pin a few load-bearing markers from
+        // inside the template body — drift here breaks live task creation.
+        self::assertStringContainsString('<template data-task-row-template>', $html);
+        self::assertStringContainsString('data-owner-select',     $html);
+        self::assertStringContainsString('data-prio-select',      $html);
+        self::assertStringContainsString('data-task-menu-trigger', $html);
+        self::assertStringContainsString('data-col="sw-100"',     $html);
+    }
+
+    public function testSprintsShowOmitsTaskRowTemplateForReadonlyUser(): void
+    {
+        // Non-admins cannot click "+ Add task", so the template (which
+        // contains admin-only inputs) must not be served — keeps the rest
+        // of the page lighter and rules out any chance of cloned-but-orphan
+        // editor inputs being injected client-side.
+        $sprint = (new ReflectionClass(\App\Domain\Sprint::class))->newInstanceWithoutConstructor();
+        foreach ([
+            'id' => 1, 'name' => 'S1',
+            'startDate' => '2026-01-01', 'endDate' => '2026-01-15',
+            'reserveFraction' => 0.2, 'isArchived' => false, 'createdAt' => '2026-01-01',
+        ] as $k => $v) {
+            (new ReflectionProperty($sprint, $k))->setValue($sprint, $v);
+        }
+
+        $weekClass = new ReflectionClass(\App\Domain\SprintWeek::class);
+        $w = $weekClass->newInstanceWithoutConstructor();
+        foreach ([
+            'id' => 10, 'sprintId' => 1, 'isoWeek' => 1,
+            'startDate' => '2026-01-05', 'sortOrder' => 1,
+            'maxWorkingDays' => 5, 'activeDaysMask' => 31,
+        ] as $k => $v) {
+            (new ReflectionProperty($w, $k))->setValue($w, $v);
+        }
+
+        $swClass = new ReflectionClass(\App\Domain\SprintWorker::class);
+        $sw = $swClass->newInstanceWithoutConstructor();
+        foreach ([
+            'id' => 100, 'sprintId' => 1, 'workerId' => 50, 'workerName' => 'Bob',
+            'sortOrder' => 1, 'rtb' => 0.1,
+        ] as $k => $v) {
+            (new ReflectionProperty($sw, $k))->setValue($sw, $v);
+        }
+
+        $html = $this->view->render('sprints/show', [
+            'title'             => 'S1',
+            'csrfToken'         => 'tok',
+            'currentUser'       => $this->makeUser(false),
+            'sprint'            => $sprint,
+            'weeks'             => [$w],
+            'sprintWorkers'     => [$sw],
+            'grid'              => [100 => [10 => 2.5]],
+            'capacity'          => [100 => [
+                'ressourcen'      => 2.5,
+                'after_reserves'  => 2.0,
+                'committed_prio1' => 0.0,
+                'available'       => 2.0,
+            ]],
+            'tasks'             => [],
+            'taskGrid'          => [],
+            'statusGrid'        => [],
+            'ownerChoices'      => [],
+            'taskStatusEnabled' => true,
+        ]);
+
+        self::assertStringNotContainsString('<template data-task-row-template>', $html);
+        self::assertStringNotContainsString('+ Add task', $html);
     }
 }

+ 14 - 100
views/sprints/_task_list.twig

@@ -180,106 +180,9 @@
                         {% set tot = 0 %}
                         {% for v in assign %}{% set tot = tot + v %}{% endfor %}
                         {% set links = linkedMap[t.id]|default([]) %}
-                        <tr data-task-row
-                            class="hover:bg-slate-50 dark:hover:bg-slate-700"
-                            data-task-id="{{ t.id }}"
-                            data-prio="{{ t.priority }}"
-                            data-owner="{{ t.ownerWorkerId is not null ? t.ownerWorkerId : '' }}"
-                            data-sort-order="{{ t.sortOrder }}"
-                            data-description="{{ t.description }}"
-                            data-url="{{ t.url }}"
-                            data-task-title="{{ t.title }}"
-                            data-links="{{ links|json_encode|e('html_attr') }}">
-                            <td class="px-2 py-1">
-                                <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>
-                            </td>
-                            <td class="px-2 py-1 min-w-[14rem]">
-                                <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 noreferrer"
-                                       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>
-                                </div>
-                            </td>
-                            <td class="px-2 py-1" data-col="owner">
-                                {% if currentUser.isAdmin %}
-                                    <select data-owner-select
-                                            class="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">
-                                        <option value="">—</option>
-                                        {% for ow in ownerChoices %}
-                                            <option value="{{ ow.id }}" {{ t.ownerWorkerId == ow.id ? 'selected' : '' }}>
-                                                {{ ow.name }}
-                                            </option>
-                                        {% endfor %}
-                                    </select>
-                                {% else %}
-                                    {% set ownerName = '—' %}
-                                    {% for ow in ownerChoices %}
-                                        {% if ow.id == t.ownerWorkerId %}{% set ownerName = ow.name %}{% endif %}
-                                    {% endfor %}
-                                    {{ ownerName }}
-                                {% endif %}
-                            </td>
-                            <td class="px-2 py-1 text-center" data-col="prio">
-                                {% if currentUser.isAdmin %}
-                                    <select data-prio-select
-                                            class="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">
-                                        <option value="1" {{ t.priority == 1 ? 'selected' : '' }}>1</option>
-                                        <option value="2" {{ t.priority == 2 ? 'selected' : '' }}>2</option>
-                                    </select>
-                                {% else %}
-                                    <span class="font-mono">{{ t.priority }}</span>
-                                {% endif %}
-                            </td>
-                            <td class="px-2 py-1 text-center font-mono font-semibold"
-                                data-col="tot" data-task-tot>
-                                {{ fmt_days(tot) }}
-                            </td>
-                            {% for sw in sprintWorkers %}
-                                {% set d = assign[sw.id]|default(0.0) %}
-                                {% set st = statusGrid[t.id][sw.id]|default(STATUS_ZUGEWIESEN) %}
-                                {% set tdExtraClass = taskStatusEnabled ? ' assign-status-' ~ st : '' %}
-                                <td class="px-1 py-1 text-center whitespace-nowrap{{ tdExtraClass }}"
-                                    data-col="sw-{{ sw.id }}"
-                                    {% if taskStatusEnabled %}data-assign-cell data-status="{{ st }}" data-sw-id="{{ sw.id }}"{% endif %}
-                                    data-sort-value-sw-{{ sw.id }}="{{ d|number_format(2, '.', '') }}">
-                                    {% if currentUser.isAdmin %}
-                                        <input type="number" min="0" step="0.5"
-                                               value="{{ fmt_days(d) }}"
-                                               data-assign
-                                               data-sw-id="{{ sw.id }}"
-                                               class="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">
-                                    {% else %}
-                                        <span class="font-mono inline-block min-w-[2rem]{% if taskStatusEnabled %} cursor-pointer{% endif %}"
-                                              data-assign-readonly
-                                              data-sw-id="{{ sw.id }}">{{ fmt_days(d) }}</span>
-                                    {% endif %}
-                                </td>
-                            {% endfor %}
-                        </tr>
+                        {% include "sprints/_task_row.twig" with {
+                            t: t, assign: assign, links: links, tot: tot
+                        } %}
                     {% endfor %}
                 {% endif %}
             </tbody>
@@ -288,4 +191,15 @@
     <div data-task-empty-filter class="hidden p-4 text-center text-slate-500 text-sm dark:text-slate-400">
         No tasks match the current filters.
     </div>
+
+    {# Hidden row template — sprint-planner.js clones this when the admin
+       clicks "+ Add task" so JS-built rows can never drift from the
+       server-rendered ones above (R02-N02). #}
+    {% if currentUser.isAdmin %}
+        <template data-task-row-template>
+            {% include "sprints/_task_row.twig" with {
+                t: null, assign: {}, links: [], tot: 0
+            } %}
+        </template>
+    {% endif %}
 </section>

+ 124 - 0
views/sprints/_task_row.twig

@@ -0,0 +1,124 @@
+{# Single task row markup. Used both inside the tbody for-loop in
+   _task_list.twig AND once more inside the hidden <template
+   data-task-row-template> at the bottom of the same file — keeping a
+   single source of truth so JS-built rows can never drift from
+   server-rendered ones (R02-N02).
+
+   Required overrides via {% include … with %}:
+     - t      : Task object, or null when rendering the template shell
+     - assign : map sw_id → days (numeric); pass {} for the template
+     - links  : array of link descriptors; pass [] for the template
+     - tot    : numeric total of `assign`; pass 0 for the template
+
+   Inherited from the surrounding sprint show/present view:
+     - sprintWorkers, ownerChoices, taskStatusEnabled, statusGrid,
+       currentUser, STATUS_ZUGEWIESEN.
+#}
+{% set isTemplate = (t is null) %}
+{% set tId        = isTemplate ? '' : t.id %}
+{% set tTitle     = isTemplate ? '' : t.title %}
+{% set tPrio      = isTemplate ? 1  : t.priority %}
+{% set tOwnerId   = isTemplate ? null : t.ownerWorkerId %}
+{% set tDesc      = isTemplate ? '' : t.description %}
+{% set tUrl       = isTemplate ? '' : t.url %}
+{% set tSort      = isTemplate ? '' : t.sortOrder %}
+<tr data-task-row
+    class="hover:bg-slate-50 dark:hover:bg-slate-700"
+    data-task-id="{{ tId }}"
+    data-prio="{{ tPrio }}"
+    data-owner="{{ tOwnerId is not null ? tOwnerId : '' }}"
+    data-sort-order="{{ tSort }}"
+    data-description="{{ tDesc }}"
+    data-url="{{ tUrl }}"
+    data-task-title="{{ tTitle }}"
+    data-links="{{ links|json_encode|e('html_attr') }}">
+    <td class="px-2 py-1">
+        <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>
+    </td>
+    <td class="px-2 py-1 min-w-[14rem]">
+        <div class="flex items-center gap-1.5">
+            {% if currentUser.isAdmin %}
+                <input type="text" data-title value="{{ tTitle }}"
+                       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">{{ tTitle }}</span>
+            {% endif %}
+
+            <a data-task-url-link href="{{ tUrl }}"
+               target="_blank" rel="noopener noreferrer"
+               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 tUrl == '' %} 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>
+        </div>
+    </td>
+    <td class="px-2 py-1" data-col="owner">
+        {% if currentUser.isAdmin %}
+            <select data-owner-select
+                    class="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">
+                <option value="">—</option>
+                {% for ow in ownerChoices %}
+                    <option value="{{ ow.id }}" {{ tOwnerId == ow.id ? 'selected' : '' }}>
+                        {{ ow.name }}
+                    </option>
+                {% endfor %}
+            </select>
+        {% else %}
+            {% set ownerName = '—' %}
+            {% for ow in ownerChoices %}
+                {% if ow.id == tOwnerId %}{% set ownerName = ow.name %}{% endif %}
+            {% endfor %}
+            {{ ownerName }}
+        {% endif %}
+    </td>
+    <td class="px-2 py-1 text-center" data-col="prio">
+        {% if currentUser.isAdmin %}
+            <select data-prio-select
+                    class="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">
+                <option value="1" {{ tPrio == 1 ? 'selected' : '' }}>1</option>
+                <option value="2" {{ tPrio == 2 ? 'selected' : '' }}>2</option>
+            </select>
+        {% else %}
+            <span class="font-mono">{{ tPrio }}</span>
+        {% endif %}
+    </td>
+    <td class="px-2 py-1 text-center font-mono font-semibold"
+        data-col="tot" data-task-tot>
+        {{ fmt_days(tot) }}
+    </td>
+    {% for sw in sprintWorkers %}
+        {% set d = assign[sw.id]|default(0.0) %}
+        {% set st = isTemplate ? STATUS_ZUGEWIESEN : (statusGrid[t.id][sw.id]|default(STATUS_ZUGEWIESEN)) %}
+        {% set tdExtraClass = taskStatusEnabled ? ' assign-status-' ~ st : '' %}
+        <td class="px-1 py-1 text-center whitespace-nowrap{{ tdExtraClass }}"
+            data-col="sw-{{ sw.id }}"
+            {% if taskStatusEnabled %}data-assign-cell data-status="{{ st }}" data-sw-id="{{ sw.id }}"{% endif %}
+            data-sort-value-sw-{{ sw.id }}="{{ d|number_format(2, '.', '') }}">
+            {% if currentUser.isAdmin %}
+                <input type="number" min="0" step="0.5"
+                       value="{{ fmt_days(d) }}"
+                       data-assign
+                       data-sw-id="{{ sw.id }}"
+                       class="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">
+            {% else %}
+                <span class="font-mono inline-block min-w-[2rem]{% if taskStatusEnabled %} cursor-pointer{% endif %}"
+                      data-assign-readonly
+                      data-sw-id="{{ sw.id }}">{{ fmt_days(d) }}</span>
+            {% endif %}
+        </td>
+    {% endfor %}
+</tr>