Ver código fonte

Phase 15: big-screen (beamer) task viewer at /sprints/{id}/present

Sprint-planning stand-ups and retros happen around a projector / TV at
roughly 1920×1080 or 3840×2160. The regular /sprints/{id} page is tuned
for editing from a laptop — max-w-7xl centred, full header chrome, plus
the Arbeitstage matrix and capacity summary stacked on top of the task
list. On a beamer that wastes 40%+ of the screen width and forces
horizontal scroll the moment a sprint has more than ~7 workers,
precisely when the group needs to see every column at once.

This phase adds a dedicated presentation route that strips the chrome,
drops the planning sections the group isn't discussing, and renders
only the task list full-viewport. Filters, focus, and column-visibility
reuse the existing Phase 10/13 plumbing unchanged — sprint-planner.js
just grows a data-beamer flag that namespaces its localStorage keys
(so discussion-mode filters don't clobber the user's workflow on the
regular page) and flips on vertical-header rotation if the task table
still overflows the viewport after the first applyFilters() pass.

Routing + controller:
- public/index.php: new GET /sprints/{id}/present row, signed-in
  (any auth level), same gate as show().
- src/Controllers/SprintController.php: extract the data-loading fan-
  out from show() into a private loadSprintPage(int $id): ?array helper
  that returns null when the sprint is missing. Both show() and the new
  present() method call it. Scope kept tight — no renames, no other
  controller changes.
- present() renders views/sprints/present.php with layout=null so the
  view's own <!doctype html> stands alone.

View:
- views/sprints/present.php (new): minimal <head> reusing the compiled
  /assets/css/app.css + the same jQuery / jQuery UI CDN tags from
  layout.php, and /assets/js/sprint-planner.js defer. Root <main>
  carries beamer-root + data-sprint-root + data-sprint-id + data-csrf
  + data-reserve-fraction + data-beamer="1". Thin top bar (sprint name
  + date range + Close -> /sprints/{id}), then only the task-list
  toolbar + table from show.php — no Arbeitstage, no capacity summary,
  no footer hint. Status chip retained for error flashes. + Add task
  stays admin-only; non-admin cells are plain <span>s.

CSS (assets/css/input.css, @layer components):
- .beamer-root table { table-layout: fixed; font-size:
  clamp(0.75rem, 0.95vw, 1.05rem); }
- Tighter td/th padding (0.25rem 0.35rem).
- .beamer-root .handle, .beamer-root [data-delete-task] { display:
  none; } — drag-reorder and row delete don't belong in a shared-screen
  discussion.
- .beamer-vertical-headers thead th[data-sort-col^="sw-"] {
  writing-mode: vertical-rl; transform: rotate(180deg); padding:
  0.5rem 0.25rem; } — only worker-column headers rotate; Task / Owner
  / Prio / Tot stay horizontal.

JS (public/assets/js/sprint-planner.js):
- Near sprintId, derive isBeamer = Number($root.data('beamer')) === 1
  and keySuffix = isBeamer ? ':beamer' : ''.
- The three localStorage keys (hiddenCols, ownerFilter, focusWorker)
  gain keySuffix so presentation mode lives in its own sandbox:
  sp:{id}:hiddenCols:beamer, sp:{id}:ownerFilter:beamer,
  sp:{id}:focusWorker:beamer.
- When the beamer hiddenCols key is missing (first-time-ever in
  presentation mode), seed ["owner","prio","tot"] AND persist before
  returning. applyColumnVisibility() is already called before the
  first applyFilters() at boot, so default-hidden columns never flash.
- After the boot applyFilters() call, if isBeamer and the task table's
  scrollWidth > container clientWidth, add .beamer-vertical-headers to
  the root and re-measure. If it still overflows (e.g. 20+ workers),
  console.warn and fall through — horizontal scroll is a fallback, never
  a hang.
- Every other handler (cell save, focus, reset, sort) unchanged.

Entry point (views/sprints/show.php):
- "Present" anchor next to the existing "Settings" button in the
  header, target="_blank" rel="noopener", same border / padding /
  hover styling. Visible to every signed-in user, not just admins.

Tests: unchanged at 88. The plan allowed an optional controller-level
sanity test (HTTP 200 for a seeded sprint, 404 for an unknown id) if
loadSprintPage() was extracted. Skipped here to match the existing
tests/Controllers/ harness, which only exercises pure statics — full
controller tests would need PDO + session wiring that Phase 15 doesn't
otherwise need. ACCEPTANCE.md's new "Phase 15 — Big-screen viewer"
section covers the six manual scenarios from the plan (1920×1080 fit,
4K scaling, stacked filters, admin edit persistence, non-admin read-
only, vertical-header rotation on a wide sprint).

Smoke tests:
- php -l / vendor/bin/phpunit were not executable in this sandbox run;
  PHP changes verified by careful manual inspection. Test count
  unchanged per the plan (still 88 / 208).
- node --check public/assets/js/sprint-planner.js clean.
- Strict CSP (Phase 11) unchanged: no inline handlers, no new external
  hosts. Present view uses the same code.jquery.com sources as
  layout.php.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
achiappa 2 semanas atrás
pai
commit
d1dda4f4ab

+ 66 - 0
ACCEPTANCE.md

@@ -217,3 +217,69 @@ log / Sign out live behind the new hamburger button on the right.
      public home with a "Sign in" CTA). `audit_log` has a new
      `LOGOUT user` row. No JS-driven POST — the native `<form>`
      carries the `_csrf` hidden input and submits the usual way.
+
+## Phase 15 — Big-screen viewer
+
+Runs against a sprint with at least 4 workers and a handful of tasks.
+The presentation view lives at `/sprints/{id}/present`; open it from
+the **Present** button in the sprint header (opens in a new tab).
+
+1. **Admin opens `/sprints/{id}/present` at 1920×1080 — all worker
+   columns fit without horizontal scroll.**
+   - Sign in as admin. Open the sprint, click **Present**.
+   - Expected: new tab at the `/present` URL. The page has no
+     site nav/header chrome, no Arbeitstage matrix, and no
+     capacity summary — just a thin top bar (sprint name +
+     dates + Close) and the task-list toolbar + table.
+     At 1920×1080 (resize the tab to roughly that if on a
+     bigger display), every worker column is visible without
+     horizontal scrolling. The Owner / Prio / Tot columns
+     are hidden by default (un-check / re-check them in the
+     **Columns** dropdown to verify the seed persists).
+
+2. **Same URL at 3840×2160 (HiDPI beamer).**
+   - Simulate by setting browser zoom to 50% on a 1920×1080
+     display or use a 4K external screen.
+   - Expected: the `clamp(0.75rem, 0.95vw, 1.05rem)` font-size
+     scales up (cell text stays legible from across the room).
+     No layout break; horizontal scroll remains absent.
+
+3. **Filters + focus stack on the present view.**
+   - Toolbar → type something in **Search**, set **Prio** to
+     "Prio 1 only", check one **Owner**, and pick a **Focus**
+     worker.
+   - Expected: same client-side predicate AND'd together as on
+     `/sprints/{id}`. The Focus-driven column auto-hide still
+     fires (sw columns with all-zero visible rows collapse).
+     Reloading the tab preserves all four filters — state is
+     namespaced to the `:beamer` localStorage keys, so the
+     regular `/sprints/{id}` tab's filters are untouched.
+
+4. **Admin edits a task cell in present mode; `/sprints/{id}`
+   reload shows the new value.**
+   - In the present tab, change a worker's day count on a task
+     (blur to trigger save). Status chip flashes "Saved 1 cell".
+   - Switch to a tab at `/sprints/{id}` and reload.
+   - Expected: the new value is persisted there as well, and a
+     new `audit_log` row (`CREATE` or `UPDATE` task_assignment)
+     is present.
+
+5. **Non-admin opens the same URL — read-only, same layout.**
+   - Demote yourself (or open an incognito window as a seeded
+     non-admin user). Visit `/sprints/{id}/present`.
+   - Expected: page renders with the same structure, but task
+     title / owner / prio / per-cell inputs are replaced by
+     plain-text spans. No drag handles, no **+ Add task**
+     button, no per-row delete buttons. Filtering still works.
+
+6. **Very wide sprint (12+ workers) — vertical header rotation
+   kicks in.**
+   - Seed or add enough workers to the sprint so the task table
+     would otherwise overflow the viewport width at 1920×1080.
+   - Load `/sprints/{id}/present`.
+   - Expected: sw column headers rotate 90° (vertical-rl +
+     180° flip so the text reads bottom-to-top). The table
+     fits without horizontal scroll. If it still overflows
+     (20+ workers), the browser console shows
+     `[sprint-planner] beamer: table still overflows …` and
+     horizontal scroll is enabled — the page does not spin.

+ 30 - 0
assets/css/input.css

@@ -11,3 +11,33 @@
         display: none;
     }
 }
+
+@layer components {
+    /* Phase 15: big-screen / beamer presentation scope. Applied via
+       views/sprints/present.php on the root <main>. Tightens typography
+       and cell padding so the task table fits the viewport without
+       horizontal scroll at 1920×1080; hides drag handles and per-row
+       delete buttons (not meaningful during a group discussion). */
+    .beamer-root table {
+        table-layout: fixed;
+        font-size: clamp(0.75rem, 0.95vw, 1.05rem);
+    }
+    .beamer-root td,
+    .beamer-root th {
+        padding: 0.25rem 0.35rem;
+    }
+    .beamer-root .handle,
+    .beamer-root [data-delete-task] {
+        display: none;
+    }
+
+    /* JS-driven toggle (sprint-planner.js): when the rendered task table
+       is wider than the viewport, rotate worker-column headers to save
+       horizontal space. Leaves non-worker headers (Task / Owner / Prio
+       / Tot) untouched. */
+    .beamer-vertical-headers thead th[data-sort-col^="sw-"] {
+        writing-mode: vertical-rl;
+        transform: rotate(180deg);
+        padding: 0.5rem 0.25rem;
+    }
+}

+ 35 - 3
public/assets/js/sprint-planner.js

@@ -24,6 +24,12 @@
     const sprintId        = parseInt($root.data('sprint-id'), 10);
     const csrf            = String($root.data('csrf') || '');
     const reserveFraction = Number($root.data('reserve-fraction') || 0);
+    // Phase 15: the presentation view stamps data-beamer="1" on the root so
+    // we can namespace its localStorage keys (don't clobber the user's
+    // workflow on /sprints/{id}) and flip on vertical-header rotation if
+    // the task table overflows after the first filter pass.
+    const isBeamer   = Number($root.data('beamer')) === 1;
+    const keySuffix  = isBeamer ? ':beamer' : '';
 
     // ---------------------------------------------------------------------
     // Capacity math — MUST match App\Services\CapacityCalculator
@@ -586,7 +592,7 @@
 
     // --- Multi-select owner filter (persisted in localStorage) -------------
 
-    const ownerFilterKey = 'sp:' + sprintId + ':ownerFilter';
+    const ownerFilterKey = 'sp:' + sprintId + ':ownerFilter' + keySuffix;
     /** @type {Set<string>} */
     const ownerFilterSet = (function () {
         try {
@@ -638,7 +644,7 @@
 
     // --- Focus filter (single sprint worker, persisted) -------------------
 
-    const focusKey = 'sp:' + sprintId + ':focusWorker';
+    const focusKey = 'sp:' + sprintId + ':focusWorker' + keySuffix;
     let focusWorker = (function () {
         try {
             const raw = window.localStorage.getItem(focusKey);
@@ -743,7 +749,7 @@
 
     // --- Column visibility toggle (persisted in localStorage) --------------
 
-    const columnsKey = 'sp:' + sprintId + ':hiddenCols';
+    const columnsKey = 'sp:' + sprintId + ':hiddenCols' + keySuffix;
     /** @type {Set<string>} */
     const hiddenCols = (function () {
         try {
@@ -752,6 +758,15 @@
                 const arr = JSON.parse(raw);
                 if (Array.isArray(arr)) { return new Set(arr.map(String)); }
             }
+            // Phase 15: first-time-ever in beamer mode seeds the hidden set
+            // with the non-discussion columns so the task table fits the
+            // viewport at 1920×1080. User toggles from the Columns dropdown
+            // persist from then on — we only seed when the key is missing.
+            if (isBeamer) {
+                const defaults = ['owner', 'prio', 'tot'];
+                window.localStorage.setItem(columnsKey, JSON.stringify(defaults));
+                return new Set(defaults);
+            }
         } catch (_) { /* ignore */ }
         return new Set();
     })();
@@ -918,4 +933,21 @@
     // but this is defensive).
     updateResetVisibility();
 
+    // Phase 15: in beamer mode, if the rendered task table is wider than
+    // its scroll container, rotate worker-column headers to vertical.
+    // Measure once after the first applyFilters() run (inputs laid out,
+    // hidden columns collapsed) and escalate a single step — horizontal
+    // scroll is a fallback but never a spinlock.
+    if (isBeamer && hasTaskUi) {
+        const table = $root.find('[data-task-table]').get(0);
+        const container = table ? table.parentElement : null;
+        if (table && container && table.scrollWidth > container.clientWidth) {
+            $root.addClass('beamer-vertical-headers');
+            if (table.scrollWidth > container.clientWidth) {
+                // eslint-disable-next-line no-console
+                console.warn('[sprint-planner] beamer: table still overflows after vertical headers; horizontal scroll enabled.');
+            }
+        }
+    }
+
 })(jQuery);

+ 1 - 0
public/index.php

@@ -151,6 +151,7 @@ $router->post('/users/{id}',     $userCtrl->update(...));
 $router->get('/sprints/new',              $sprintCtrl->newForm(...));
 $router->post('/sprints',                 $sprintCtrl->create(...));
 $router->get('/sprints/{id}',             $sprintCtrl->show(...));
+$router->get('/sprints/{id}/present',     $sprintCtrl->present(...));
 $router->get('/sprints/{id}/settings',    $sprintCtrl->settings(...));
 
 // JSON mutation endpoints (admin, CSRF via X-CSRF-Token header):

+ 60 - 7
src/Controllers/SprintController.php

@@ -163,10 +163,66 @@ final class SprintController
             return $actor;
         }
 
-        $id = (int) $params['id'];
+        $id   = (int) $params['id'];
+        $data = $this->loadSprintPage($id);
+        if ($data === null) {
+            return Response::text('Not Found', 404);
+        }
+
+        return Response::html($this->view->render('sprints/show', [
+            'title'       => $data['sprint']->name,
+            'currentUser' => $actor,
+            'csrfToken'   => SessionGuard::csrfToken(),
+        ] + $data));
+    }
+
+    /**
+     * GET /sprints/{id}/present — big-screen / beamer presentation view.
+     * Strips chrome + Arbeitstage + capacity summary; renders only the
+     * task-list toolbar + table full-viewport.
+     */
+    public function present(Request $req, array $params): Response
+    {
+        $actor = SessionGuard::requireAuth($this->users);
+        if ($actor instanceof Response) {
+            return $actor;
+        }
+
+        $id   = (int) $params['id'];
+        $data = $this->loadSprintPage($id);
+        if ($data === null) {
+            return Response::text('Not Found', 404);
+        }
+
+        // Present view emits its own <!doctype html>; layout=null skips the
+        // shared nav chrome that lives in views/layout.php.
+        return Response::html($this->view->render('sprints/present', [
+            'title'       => $data['sprint']->name . ' — present',
+            'currentUser' => $actor,
+            'csrfToken'   => SessionGuard::csrfToken(),
+        ] + $data, null));
+    }
+
+    /**
+     * Shared data fan-out for show() and present(). Returns null if the
+     * sprint is missing so each caller can render its own 404.
+     *
+     * @return array{
+     *   sprint: \App\Domain\Sprint,
+     *   weeks: list<\App\Domain\SprintWeek>,
+     *   sprintWorkers: list<\App\Domain\SprintWorker>,
+     *   grid: array<int, array<int, float>>,
+     *   capacity: array<int, array{ressourcen:float, after_reserves:float, committed_prio1:float, available:float}>,
+     *   tasks: list<\App\Domain\Task>,
+     *   taskGrid: array<int, array<int, float>>,
+     *   ownerChoices: list<\App\Domain\Worker>,
+     * }|null
+     */
+    private function loadSprintPage(int $id): ?array
+    {
         $sprint = $this->sprints->find($id);
         if ($sprint === null) {
-            return Response::text('Not Found', 404);
+            return null;
         }
 
         $weeks         = $this->weeks->allForSprint($id);
@@ -194,10 +250,7 @@ final class SprintController
         // they are one of the sprint workers. Keep it restrictive for the UI).
         $ownerChoices = $this->workers->all();
 
-        return Response::html($this->view->render('sprints/show', [
-            'title'         => $sprint->name,
-            'currentUser'   => $actor,
-            'csrfToken'     => SessionGuard::csrfToken(),
+        return [
             'sprint'        => $sprint,
             'weeks'         => $weeks,
             'sprintWorkers' => $sprintWorkers,
@@ -206,7 +259,7 @@ final class SprintController
             'tasks'         => $tasks,
             'taskGrid'      => $taskGrid,
             'ownerChoices'  => $ownerChoices,
-        ]));
+        ];
     }
 
     // -----------------------------------------------------------------------

+ 325 - 0
views/sprints/present.php

@@ -0,0 +1,325 @@
+<?php
+/**
+ * Big-screen / beamer presentation view (Phase 15).
+ *
+ * Renders its own <!doctype html> — intentionally does NOT extend
+ * views/layout.php. We drop the nav chrome, Arbeitstage matrix, and
+ * capacity summary; the task-list toolbar + table get the entire
+ * viewport. Reuses the compiled /assets/css/app.css + the jQuery
+ * and jQuery UI CDN tags from layout.php, and /assets/js/sprint-planner.js
+ * picks up the data-beamer="1" flag on the root to namespace its
+ * localStorage keys and flip on vertical-header rotation when the table
+ * overflows.
+ *
+ * @var \App\Domain\Sprint $sprint
+ * @var \App\Domain\User   $currentUser
+ * @var string             $csrfToken
+ * @var list<\App\Domain\SprintWeek>   $weeks
+ * @var list<\App\Domain\SprintWorker> $sprintWorkers
+ * @var list<\App\Domain\Task>         $tasks
+ * @var array<int, array<int, float>>  $taskGrid    task_id => sw_id => days
+ * @var list<\App\Domain\Worker>       $ownerChoices
+ */
+use function App\Http\e;
+$tasks        = $tasks        ?? [];
+$taskGrid     = $taskGrid     ?? [];
+$ownerChoices = $ownerChoices ?? [];
+$sprintWorkers = $sprintWorkers ?? [];
+
+if (!function_exists('fmt_days')) {
+    function fmt_days(float $x): string
+    {
+        if (abs($x - round($x)) < 1e-9) {
+            return (string) (int) round($x);
+        }
+        return number_format($x, 1);
+    }
+}
+?>
+<!doctype html>
+<html lang="en">
+<head>
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width,initial-scale=1">
+    <title><?= e($title ?? ($sprint->name . ' — present')) ?></title>
+    <link rel="stylesheet" href="/assets/css/app.css">
+    <link rel="stylesheet"
+          href="https://code.jquery.com/ui/1.13.3/themes/base/jquery-ui.css">
+    <script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
+    <script src="https://code.jquery.com/ui/1.13.3/jquery-ui.min.js"></script>
+    <script src="/assets/js/sprint-planner.js" defer></script>
+</head>
+<body class="bg-white text-slate-900 antialiased">
+<main class="min-h-screen w-screen overflow-hidden beamer-root"
+      data-sprint-root
+      data-sprint-id="<?= (int) $sprint->id ?>"
+      data-csrf="<?= e($csrfToken) ?>"
+      data-reserve-fraction="<?= e(number_format($sprint->reserveFraction, 4, '.', '')) ?>"
+      data-beamer="1">
+
+    <header class="flex items-center justify-between gap-4 px-4 py-2 border-b bg-slate-50">
+        <div class="flex items-baseline gap-3">
+            <h1 class="text-lg font-semibold tracking-tight"><?= e($sprint->name) ?></h1>
+            <p class="text-slate-600 text-xs">
+                <?= e($sprint->startDate) ?> – <?= e($sprint->endDate) ?>
+                <?php if ($sprint->isArchived): ?>
+                    · <span class="inline-block px-1.5 py-0.5 text-[10px] bg-slate-200 text-slate-600 rounded">archived</span>
+                <?php endif; ?>
+            </p>
+        </div>
+        <div class="flex items-center gap-3">
+            <div data-status
+                 class="text-xs border rounded px-2 py-0.5 opacity-0 transition-opacity duration-200 border-slate-200 bg-slate-50 text-slate-700">
+            </div>
+            <a href="/sprints/<?= (int) $sprint->id ?>"
+               class="inline-flex items-center gap-2 rounded-md border border-slate-300 bg-white text-slate-700 px-3 py-1 text-sm hover:bg-slate-100">
+                Close
+            </a>
+        </div>
+    </header>
+
+    <?php if ($sprintWorkers === [] || $weeks === []): ?>
+        <div class="m-4 rounded-md border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900">
+            <?php if ($weeks === []): ?>
+                No weeks yet. <a href="/sprints/<?= (int) $sprint->id ?>/settings" class="underline">Open settings</a> to add some.
+            <?php elseif ($sprintWorkers === []): ?>
+                No workers on this sprint yet. <a href="/sprints/<?= (int) $sprint->id ?>/settings" class="underline">Open settings</a> to add some.
+            <?php endif; ?>
+        </div>
+    <?php else: ?>
+
+    <!-- Task list — same structure as views/sprints/show.php's
+         [data-task-section]. sprint-planner.js wires everything from the
+         data-* attributes, so this is essentially a copy of that block. -->
+    <section class="rounded-lg border bg-white overflow-hidden m-2"
+             data-task-section>
+        <div class="px-4 py-3 border-b bg-slate-50 flex flex-wrap items-center gap-2">
+            <h2 class="text-sm font-semibold text-slate-700 uppercase tracking-wider">Tasks</h2>
+
+            <!-- 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">
+
+                <select data-prio-filter
+                        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 prios</option>
+                    <option value="1">Prio 1 only</option>
+                    <option value="2">Prio 2 only</option>
+                </select>
+
+                <!-- Multi-select owner filter -->
+                <div class="relative" data-owner-filter-root>
+                    <button type="button" data-owner-filter-trigger
+                            class="rounded border border-slate-300 px-2 py-1 text-sm bg-white hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-slate-400">
+                        Owners <span data-owner-filter-count class="text-slate-500"></span>
+                    </button>
+                    <div data-owner-filter-dropdown
+                         class="hidden absolute right-0 z-20 mt-1 w-56 rounded-md border border-slate-200 bg-white shadow-lg">
+                        <div class="px-3 py-2 text-xs text-slate-500 flex items-center justify-between">
+                            <span>Owner</span>
+                            <button type="button" data-owner-filter-clear
+                                    class="text-blue-700 hover:underline">Clear</button>
+                        </div>
+                        <div class="max-h-64 overflow-y-auto">
+                            <label class="flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-slate-50">
+                                <input type="checkbox" data-owner-filter-opt value="__none__"
+                                       class="rounded border-slate-300">
+                                <span class="text-slate-500 italic">No owner</span>
+                            </label>
+                            <?php foreach ($ownerChoices as $ow): ?>
+                                <label class="flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-slate-50">
+                                    <input type="checkbox" data-owner-filter-opt value="<?= (int) $ow->id ?>"
+                                           class="rounded border-slate-300">
+                                    <span><?= e($ow->name) ?></span>
+                                </label>
+                            <?php endforeach; ?>
+                        </div>
+                    </div>
+                </div>
+
+                <!-- Focus filter -->
+                <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
+                            class="rounded border border-slate-300 px-2 py-1 text-sm bg-white hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-slate-400">
+                        Columns
+                    </button>
+                    <div data-columns-dropdown
+                         class="hidden absolute right-0 z-20 mt-1 w-56 rounded-md border border-slate-200 bg-white shadow-lg">
+                        <div class="px-3 py-2 text-xs text-slate-500">Show columns</div>
+                        <div class="max-h-64 overflow-y-auto">
+                            <label class="flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-slate-50">
+                                <input type="checkbox" data-column-opt value="owner" checked class="rounded border-slate-300">
+                                <span>Owner</span>
+                            </label>
+                            <label class="flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-slate-50">
+                                <input type="checkbox" data-column-opt value="prio"  checked class="rounded border-slate-300">
+                                <span>Prio</span>
+                            </label>
+                            <label class="flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-slate-50">
+                                <input type="checkbox" data-column-opt value="tot"   checked class="rounded border-slate-300">
+                                <span>Tot</span>
+                            </label>
+                            <?php foreach ($sprintWorkers as $sw): ?>
+                                <label class="flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-slate-50">
+                                    <input type="checkbox" data-column-opt value="sw-<?= (int) $sw->id ?>" checked class="rounded border-slate-300">
+                                    <span><?= e($sw->workerName) ?></span>
+                                </label>
+                            <?php endforeach; ?>
+                        </div>
+                    </div>
+                </div>
+
+                <?php if ($currentUser->isAdmin): ?>
+                    <button type="button" data-add-task
+                            class="rounded bg-slate-900 text-white px-3 py-1 text-sm font-medium hover:bg-slate-800">
+                        + Add task
+                    </button>
+                <?php endif; ?>
+            </div>
+        </div>
+
+        <div class="overflow-x-auto">
+            <table class="min-w-full text-sm" data-task-table>
+                <thead class="bg-slate-50 text-slate-600 text-xs uppercase tracking-wider">
+                    <tr>
+                        <th class="w-6 px-2 py-2"></th>
+                        <th class="text-left px-2 py-2 font-semibold cursor-pointer select-none"
+                            data-sort-col="title">Task <span class="sort-ind opacity-30">↕</span></th>
+                        <th class="text-left px-2 py-2 font-semibold cursor-pointer select-none"
+                            data-sort-col="owner" data-col="owner">Owner <span class="sort-ind opacity-30">↕</span></th>
+                        <th class="text-center px-2 py-2 font-semibold cursor-pointer select-none"
+                            data-sort-col="prio" data-col="prio">Prio <span class="sort-ind opacity-30">↕</span></th>
+                        <th class="text-center px-2 py-2 font-semibold cursor-pointer select-none"
+                            data-sort-col="tot" data-col="tot">Tot <span class="sort-ind opacity-30">↕</span></th>
+                        <?php foreach ($sprintWorkers as $sw): ?>
+                            <th class="text-center px-2 py-2 font-semibold cursor-pointer select-none whitespace-nowrap"
+                                data-sort-col="sw-<?= (int) $sw->id ?>" data-col="sw-<?= (int) $sw->id ?>">
+                                <?= e($sw->workerName) ?>
+                                <span class="sort-ind opacity-30">↕</span>
+                            </th>
+                        <?php endforeach; ?>
+                        <th class="w-8 px-2 py-2"></th>
+                    </tr>
+                </thead>
+                <tbody class="divide-y divide-slate-100" data-task-tbody>
+                    <?php if ($tasks === []): ?>
+                        <tr data-empty-tasks>
+                            <td colspan="<?= 6 + count($sprintWorkers) ?>" class="px-3 py-8 text-center text-slate-500 text-sm">
+                                No tasks yet.
+                                <?php if ($currentUser->isAdmin): ?>
+                                    Click <b>+ Add task</b> to start.
+                                <?php endif; ?>
+                            </td>
+                        </tr>
+                    <?php else: ?>
+                        <?php foreach ($tasks as $t): ?>
+                            <?php $assign = $taskGrid[$t->id] ?? []; $tot = array_sum($assign); ?>
+                            <tr data-task-row
+                                data-task-id="<?= (int) $t->id ?>"
+                                data-prio="<?= (int) $t->priority ?>"
+                                data-owner="<?= $t->ownerWorkerId !== null ? (int) $t->ownerWorkerId : '' ?>"
+                                data-sort-order="<?= (int) $t->sortOrder ?>">
+                                <td class="px-2 py-1">
+                                    <?php if ($currentUser->isAdmin): ?>
+                                        <span class="handle cursor-grab text-slate-400 select-none">&#8801;</span>
+                                    <?php endif; ?>
+                                </td>
+                                <td class="px-2 py-1 min-w-[14rem]">
+                                    <?php if ($currentUser->isAdmin): ?>
+                                        <input type="text" data-title
+                                               value="<?= e($t->title) ?>"
+                                               class="w-full rounded border border-slate-200 px-2 py-1 focus:outline-none focus:ring-2 focus:ring-slate-400">
+                                    <?php else: ?>
+                                        <span><?= e($t->title) ?></span>
+                                    <?php endif; ?>
+                                </td>
+                                <td class="px-2 py-1" data-col="owner">
+                                    <?php 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">
+                                            <option value="">—</option>
+                                            <?php foreach ($ownerChoices as $ow): ?>
+                                                <option value="<?= (int) $ow->id ?>" <?= $t->ownerWorkerId === $ow->id ? 'selected' : '' ?>>
+                                                    <?= e($ow->name) ?>
+                                                </option>
+                                            <?php endforeach; ?>
+                                        </select>
+                                    <?php else: ?>
+                                        <?php
+                                        $ownerName = '—';
+                                        foreach ($ownerChoices as $ow) {
+                                            if ($ow->id === $t->ownerWorkerId) { $ownerName = $ow->name; break; }
+                                        }
+                                        echo e($ownerName);
+                                        ?>
+                                    <?php endif; ?>
+                                </td>
+                                <td class="px-2 py-1 text-center" data-col="prio">
+                                    <?php 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">
+                                            <option value="1" <?= $t->priority === 1 ? 'selected' : '' ?>>1</option>
+                                            <option value="2" <?= $t->priority === 2 ? 'selected' : '' ?>>2</option>
+                                        </select>
+                                    <?php else: ?>
+                                        <span class="font-mono"><?= (int) $t->priority ?></span>
+                                    <?php endif; ?>
+                                </td>
+                                <td class="px-2 py-1 text-center font-mono font-semibold"
+                                    data-col="tot" data-task-tot>
+                                    <?= e(fmt_days($tot)) ?>
+                                </td>
+                                <?php foreach ($sprintWorkers as $sw): $d = (float) ($assign[$sw->id] ?? 0.0); ?>
+                                    <td class="px-1 py-1 text-center"
+                                        data-col="sw-<?= (int) $sw->id ?>"
+                                        data-sort-value-sw-<?= (int) $sw->id ?>="<?= e(number_format($d, 2, '.', '')) ?>">
+                                        <?php if ($currentUser->isAdmin): ?>
+                                            <input type="number" min="0" step="0.5"
+                                                   value="<?= e(fmt_days($d)) ?>"
+                                                   data-assign
+                                                   data-sw-id="<?= (int) $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">
+                                        <?php else: ?>
+                                            <span class="font-mono"><?= e(fmt_days($d)) ?></span>
+                                        <?php endif; ?>
+                                    </td>
+                                <?php endforeach; ?>
+                                <td class="px-1 py-1 text-right">
+                                    <?php if ($currentUser->isAdmin): ?>
+                                        <button type="button" data-delete-task
+                                                class="text-sm text-red-600 hover:underline">×</button>
+                                    <?php endif; ?>
+                                </td>
+                            </tr>
+                        <?php endforeach; ?>
+                    <?php endif; ?>
+                </tbody>
+            </table>
+        </div>
+        <div data-task-empty-filter class="hidden p-4 text-center text-slate-500 text-sm">
+            No tasks match the current filters.
+        </div>
+    </section>
+    <?php endif; ?>
+</main>
+</body>
+</html>

+ 5 - 0
views/sprints/show.php

@@ -50,6 +50,11 @@ if (!function_exists('fmt_days')) {
             <div data-status
                  class="text-sm border rounded px-3 py-1 opacity-0 transition-opacity duration-200 border-slate-200 bg-slate-50 text-slate-700">
             </div>
+            <a href="/sprints/<?= (int) $sprint->id ?>/present"
+               target="_blank" rel="noopener"
+               class="inline-flex items-center gap-2 rounded-md border border-slate-300 bg-white text-slate-700 px-3 py-2 text-sm hover:bg-slate-100">
+                Present
+            </a>
             <?php if ($currentUser->isAdmin): ?>
                 <a href="/sprints/<?= (int) $sprint->id ?>/settings"
                    class="inline-flex items-center gap-2 rounded-md border border-slate-300 bg-white text-slate-700 px-3 py-2 text-sm hover:bg-slate-100">