Prechádzať zdrojové kódy

Phase 13: Focus filter + Reset in the task list

With more than a handful of workers on a sprint the task list is wide
and noisy for someone who only cares about what *they* (or one
teammate) are on the hook for. The existing owner filter answers
"who owns this?" — not "who has time assigned?" — so scanning a
12-worker grid for Bob's non-zero cells was up to the human. And
stacking search + prio + owner + hidden columns meant clearing four
separate controls to get the list back.

Both are pure client-side UI; no schema, route, or audit changes.

views/sprints/show.php:
- Toolbar gets two new controls on the task-list row:
  - `<button data-reset-filters>` prepended to the toolbar (far
    left), starts hidden via Tailwind's `.hidden`. JS toggles it
    whenever any filter is active.
  - `[data-focus-filter-root]` with a `<select data-focus-select>`
    inserted between `[data-owner-filter-root]` and
    `[data-columns-root]`. Options: one "All workers" sentinel +
    one per sprint worker, keyed by sprint_worker.id (the same id
    that's on every `[data-assign][data-sw-id]` input).
- CSP-safe: both controls are wired via data-attributes from
  sprint-planner.js. No inline script or onclick.

public/assets/js/sprint-planner.js:
- New localStorage key `sp:{sprintId}:focusWorker` (string; either
  "" or an sw_id). Hydrated on boot; `updateFocusUi` falls back to
  "" if the stored worker is no longer a sprint member (drops
  through the removed-worker case the plan called out).
- `applyFilters()` grows a fourth AND predicate: when focus is set,
  the row's `[data-assign][data-sw-id="{focus}"]` must be > 0.
  Uses `Number(v) > 0` rather than `!== 0` so 0.0 / "" / NaN all
  count as "no assignment" — matches the zero-tolerance in
  `committedPrio1FromDom`.
- New `applyFocusColumnVisibility()` called at the end of
  `applyFilters`:
    - Always strips every existing `.focus-auto-hidden` first
      (idempotent; needed when focus clears).
    - When focus is active, scans every sw column. If no
      currently-visible row has > 0 for that sw, every
      `[data-col="sw-{id}"]` cell (header + body) gets
      `.focus-auto-hidden`.
    - Does NOT mutate `hiddenCols` — clearing focus restores the
      user's manual column picks from the Columns dropdown.
- New `filtersActive()` / `updateResetVisibility()` toggles the
  Reset button whenever {search, prio, ownerFilterSet.size,
  focusWorker, hiddenCols.size} is non-empty. Called from
  `applyFilters` and the Columns change handler.
- `[data-reset-filters]` click handler: clears search, prio,
  ownerFilterSet, focusWorker, hiddenCols (persisting each), then
  runs updateOwnerFilterUi + updateFocusUi + applyColumnVisibility
  + applyFilters. One code path, no reload.
- Boot: updateFocusUi runs before applyFilters so the hydrated
  value reflects into the `<select>` before the first render.

assets/css/input.css:
- Adds `.focus-auto-hidden { display: none; }` in a new
  `@layer utilities` block so Tailwind picks it up on next build.
  Kept separate from `.hidden` precisely so the two
  column-hide mechanisms don't collide.
- The compiled `public/assets/css/app.css` is gitignored (see
  `.gitignore`); the Dockerfile's css-builder stage rebuilds it on
  image build, and `npm run watch:css` handles local dev.

ACCEPTANCE.md:
- Appends a "Phase 13 — Focus filter + reset" section with the four
  manual scenarios from the plan: (a) worker with assignments →
  filtered list + collapsed columns; (b) worker with no assignments
  → empty-filter banner + every sw column collapsed; (c) stacked
  filters AND together; (d) Reset clears everything and the button
  hides. Follows the existing imperative-checklist voice.

Known minor UX surprise, acceptable:
- When a new task is created via `+ Add task` while focus is on,
  the new row starts with zero assignments, so the focus filter
  hides it immediately. Clearing focus reveals it. `buildTaskRow`
  also doesn't stamp `data-col="sw-{id}"` on its new cells — a
  pre-existing limitation (Columns dropdown also ignores new rows
  for the same reason). Noted in the plan; not fixed here to keep
  the phase scope to the promised surface.

phpunit: 88 tests, 208 assertions, OK (unchanged — no PHPUnit
change, pure client-side JS over existing HTML, matches the Phase
10 pattern).

Smoke tests run:
- php -l views/sprints/show.php → clean.
- node --check public/assets/js/sprint-planner.js → clean.
- vendor/bin/phpunit → 88/88.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
achiappa 2 týždňov pred
rodič
commit
b027c5d6be

+ 51 - 0
ACCEPTANCE.md

@@ -120,3 +120,54 @@ Content-Security-Policy: default-src 'self'; script-src 'self' …; …
 ```
 
 `Strict-Transport-Security` appears only when `APP_BASE_URL` uses `https://`.
+
+## Phase 13 — Focus filter + reset
+
+Runs against a sprint with at least 4 workers and a handful of tasks —
+set up via steps 3–5 above if starting from scratch. All scenarios below
+are pure client-side UI; no audit rows are written and no API requests
+should fire (verify with the Network panel open).
+
+1. **Focus on a worker with assignments → only their tasks + only their
+   non-zero columns remain.**
+   - Pick a sprint worker (say **Bob**) who is the assignee on two tasks
+     out of five.
+   - Toolbar → **Focus** select → choose **Bob**.
+   - Expected:
+     - Only the two tasks where Bob's cell is > 0 stay visible.
+     - Any worker column where every visible row is 0 collapses. Bob's
+       own column stays visible (by definition > 0).
+     - The **Reset** button appears to the left of the search box.
+     - Reloading the page preserves the focus (localStorage key
+       `sp:{sprintId}:focusWorker`).
+
+2. **Focus on a worker with no assignments → empty-filter banner + every
+   sw column collapses.**
+   - Add a new worker to the sprint who has no task assignments (or pick
+     an existing one — e.g. **Frank** — and zero out all their cells
+     first).
+   - Toolbar → **Focus** → choose that worker.
+   - Expected:
+     - Task rows all hidden → the "No tasks match the current filters"
+       banner shows (NOT the "No tasks yet" empty-state row).
+     - Every worker column in the task-list header is collapsed (no sw
+       column has a non-zero visible row).
+     - Title / Owner / Prio / Tot columns stay visible.
+
+3. **Stacked filters AND together.**
+   - Type "report" in **Search**, set **Prio** to "Prio 1 only", check
+     one owner in **Owners**, and pick a **Focus** worker.
+   - Expected: only rows that satisfy *every* predicate show —
+     title matches, prio = 1, owner matches, focus worker's assignment
+     > 0. Flipping any one filter off broadens the set as expected.
+
+4. **Reset clears everything and the button hides.**
+   - With all four filters from (3) active (plus at least one column
+     hidden via the **Columns** dropdown), click **Reset**.
+   - Expected:
+     - Search empties, Prio resets to "All prios", Owners count clears,
+       Focus returns to "All workers", every column re-appears in the
+     Columns dropdown (all checkboxes re-checked), every row visible.
+     - The **Reset** button itself disappears.
+     - Reload the page — nothing returns (all five localStorage keys
+       cleared/reset to empty state).

+ 10 - 0
assets/css/input.css

@@ -1,3 +1,13 @@
 @tailwind base;
 @tailwind components;
 @tailwind utilities;
+
+@layer utilities {
+    /* Phase 13: the Focus filter temporarily hides entire sw columns when the
+       focused worker has no assignment on any visible row. Separate from the
+       .hidden set driven by the Columns dropdown so clearing Focus restores
+       the user's manual column picks. */
+    .focus-auto-hidden {
+        display: none;
+    }
+}

+ 110 - 1
public/assets/js/sprint-planner.js

@@ -634,11 +634,39 @@
         $root.find('[data-owner-filter-dropdown]').toggleClass('hidden');
     });
 
-    // --- Filters (search / prio / owner) ----------------------------------
+    // --- Focus filter (single sprint worker, persisted) -------------------
+
+    const focusKey = 'sp:' + sprintId + ':focusWorker';
+    let focusWorker = (function () {
+        try {
+            const raw = window.localStorage.getItem(focusKey);
+            if (raw === null) { return ''; }
+            return String(raw);
+        } catch (_) { return ''; }
+    })();
+
+    function persistFocus() {
+        try { window.localStorage.setItem(focusKey, String(focusWorker)); }
+        catch (_) { /* ignore */ }
+    }
+
+    function updateFocusUi() {
+        const $sel = $root.find('[data-focus-select]');
+        if ($sel.length === 0) { return; }
+        // If the stored worker is no longer a sprint member, fall back to "".
+        if (focusWorker !== '' && $sel.find('option[value="' + focusWorker + '"]').length === 0) {
+            focusWorker = '';
+            persistFocus();
+        }
+        $sel.val(focusWorker);
+    }
+
+    // --- Filters (search / prio / owner / focus) --------------------------
 
     function applyFilters() {
         const q    = String($root.find('[data-task-search]').val() || '').trim().toLowerCase();
         const prio = String($root.find('[data-prio-filter]').val() || '');
+        const focus = String(focusWorker || '');
         let visibleCount = 0;
 
         $taskTbody.children('tr[data-task-row]').each(function () {
@@ -653,6 +681,12 @@
             if (q !== '' && !title.includes(q)) { ok = false; }
             if (prio !== '' && rowPrio !== prio) { ok = false; }
             if (ownerFilterSet.size > 0 && !ownerFilterSet.has(ownerKey)) { ok = false; }
+            if (focus !== '') {
+                // Zero-tolerance matches capacity math: treat 0 / 0.0 / empty
+                // as "no assignment" (Number(v) > 0, not !== 0).
+                const v = Number($row.find('[data-assign][data-sw-id="' + focus + '"]').val());
+                if (!(v > 0)) { ok = false; }
+            }
 
             $row.toggle(ok);
             if (ok) { visibleCount++; }
@@ -660,6 +694,37 @@
 
         const totalRows = $taskTbody.children('tr[data-task-row]').length;
         $root.find('[data-task-empty-filter]').toggle(totalRows > 0 && visibleCount === 0);
+
+        applyFocusColumnVisibility();
+        updateResetVisibility();
+    }
+
+    // Column auto-hide: when a focus worker is selected, any sw column that
+    // is all-zero for the currently visible rows collapses. We tag cells
+    // with `.focus-auto-hidden` rather than mutating `hiddenCols` so that
+    // clearing Focus restores whatever the user picked in the Columns
+    // dropdown.
+    function applyFocusColumnVisibility() {
+        // Always clear the transient class first — keeps the function
+        // idempotent and correct when focus goes from set → "".
+        $root.find('.focus-auto-hidden').removeClass('focus-auto-hidden');
+
+        if (!focusWorker) { return; }
+
+        // Walk every sw column. If no currently visible row has a > 0
+        // assignment for that sw, hide every `[data-col="sw-{id}"]` cell.
+        $root.find('[data-task-table] thead th[data-sort-col^="sw-"]').each(function () {
+            const col = String($(this).attr('data-sort-col'));       // e.g. "sw-42"
+            const swId = col.slice(3);
+            let anyNonZero = false;
+            $taskTbody.children('tr[data-task-row]:visible').each(function () {
+                const v = Number($(this).find('[data-assign][data-sw-id="' + swId + '"]').val());
+                if (v > 0) { anyNonZero = true; return false; /* break */ }
+            });
+            if (!anyNonZero) {
+                $root.find('[data-col="' + col + '"]').addClass('focus-auto-hidden');
+            }
+        });
     }
 
     let searchDebounce = null;
@@ -668,6 +733,11 @@
         searchDebounce = setTimeout(applyFilters, 120);
     });
     $root.on('change', '[data-prio-filter]', applyFilters);
+    $root.on('change', '[data-focus-select]', function () {
+        focusWorker = String($(this).val() || '');
+        persistFocus();
+        applyFilters();
+    });
 
     // --- Column visibility toggle (persisted in localStorage) --------------
 
@@ -707,6 +777,39 @@
         if ($(this).is(':checked')) { hiddenCols.delete(v); } else { hiddenCols.add(v); }
         persistHiddenCols();
         applyColumnVisibility();
+        updateResetVisibility();
+    });
+
+    // --- Reset button (clears every filter + column-hide in one click) ----
+
+    function filtersActive() {
+        const q    = String($root.find('[data-task-search]').val() || '').trim();
+        const prio = String($root.find('[data-prio-filter]').val() || '');
+        return q !== ''
+            || prio !== ''
+            || ownerFilterSet.size > 0
+            || String(focusWorker || '') !== ''
+            || hiddenCols.size > 0;
+    }
+
+    function updateResetVisibility() {
+        $root.find('[data-reset-filters]').toggleClass('hidden', !filtersActive());
+    }
+
+    $root.on('click', '[data-reset-filters]', function () {
+        $root.find('[data-task-search]').val('');
+        $root.find('[data-prio-filter]').val('');
+        ownerFilterSet.clear();
+        persistOwnerFilter();
+        focusWorker = '';
+        persistFocus();
+        hiddenCols.clear();
+        persistHiddenCols();
+
+        updateOwnerFilterUi();
+        updateFocusUi();
+        applyColumnVisibility();
+        applyFilters();
     });
 
     $root.on('click', '[data-columns-trigger]', function (ev) {
@@ -804,7 +907,13 @@
     // Restore persisted task-list UI state BEFORE applyFilters so hidden
     // columns don't briefly flash in before being toggled off.
     updateOwnerFilterUi();
+    updateFocusUi();
     applyColumnVisibility();
     applyFilters();
+    // Reset button visibility is a function of every filter; applyFilters
+    // already calls updateResetVisibility, but call it once more at boot
+    // in case the tbody is empty (applyFilters short-circuits nothing,
+    // but this is defensive).
+    updateResetVisibility();
 
 })(jQuery);

+ 20 - 0
views/sprints/show.php

@@ -220,6 +220,12 @@ if (!function_exists('fmt_days')) {
 
             <!-- Toolbar -->
             <div class="ml-auto flex flex-wrap items-center gap-2">
+                <!-- Reset (only visible while any filter is active — JS toggles the hidden class) -->
+                <button type="button" data-reset-filters
+                        class="hidden rounded border border-slate-300 px-2 py-1 text-sm bg-white text-slate-700 hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-slate-400">
+                    Reset
+                </button>
+
                 <input type="search" data-task-search placeholder="Search…"
                        class="rounded border border-slate-300 px-3 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-slate-400">
 
@@ -260,6 +266,20 @@ if (!function_exists('fmt_days')) {
                     </div>
                 </div>
 
+                <!-- Focus filter — one sprint worker; hides rows where their
+                     assignment is 0 and collapses worker columns that are
+                     all-zero for the remaining rows. -->
+                <div class="flex items-center gap-1" data-focus-filter-root>
+                    <label for="data-focus-select" class="text-xs uppercase tracking-wider text-slate-500">Focus</label>
+                    <select id="data-focus-select" data-focus-select
+                            class="rounded border border-slate-300 px-2 py-1 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-slate-400">
+                        <option value="">All workers</option>
+                        <?php foreach ($sprintWorkers as $sw): ?>
+                            <option value="<?= (int) $sw->id ?>"><?= e($sw->workerName) ?></option>
+                        <?php endforeach; ?>
+                    </select>
+                </div>
+
                 <!-- Column visibility -->
                 <div class="relative" data-columns-root>
                     <button type="button" data-columns-trigger