Sfoglia il codice sorgente

Merge pull request 'Phase 19: Twig 3 + Tailwind 3 + Alpine CSP + htmx + SortableJS, jQuery removed' (#1) from phase-19-stack-shift into main

Reviewed-on: https://git.snix.ch/chiappa/sprint_planer_web/pulls/1
chiappa 3 giorni fa
parent
commit
c9e5b267c2

+ 2 - 0
.gitignore

@@ -13,3 +13,5 @@ composer.lock
 /.claude/
 # Compiled by the Docker CSS builder stage (or `npm run build:css` for local dev).
 /public/assets/css/app.css
+/public/assets/js/vendor/
+/data/twig-cache/

+ 20 - 9
Dockerfile

@@ -1,7 +1,11 @@
-# --- Stage 1: compile Tailwind CSS --------------------------------------
+# --- Stage 1: compile Tailwind CSS + vendor JS deps ----------------------
 # Runs the Tailwind JIT over views/, src/, and our JS so only classes that
 # are actually referenced end up in the output. No runtime <style> injection,
 # which lets the CSP drop 'unsafe-inline' for style-src.
+#
+# The same stage also vendors Alpine.js (CSP build), htmx, and SortableJS
+# from npm into /build/vendor/ — copied into the runtime image alongside the
+# CSS so the strict CSP can keep `script-src 'self'`.
 FROM node:20-alpine AS css-builder
 
 WORKDIR /build
@@ -17,6 +21,13 @@ COPY public/assets/js/ ./public/assets/js/
 
 RUN npx tailwindcss -i ./assets/css/input.css -o /build/app.css --minify
 
+# Pin the vendored JS bundles. Alpine CSP is the variant that doesn't need
+# `unsafe-eval`; standard Alpine would require relaxing the CSP.
+RUN mkdir -p /build/vendor \
+    && cp node_modules/@alpinejs/csp/dist/cdn.min.js /build/vendor/alpine-csp.min.js \
+    && cp node_modules/htmx.org/dist/htmx.min.js     /build/vendor/htmx.min.js \
+    && cp node_modules/sortablejs/Sortable.min.js    /build/vendor/sortable.min.js
+
 # --- Stage 2: the actual PHP runtime ------------------------------------
 FROM php:8.3-apache
 
@@ -35,15 +46,15 @@ RUN composer install --no-dev --no-interaction --prefer-dist --no-progress
 
 COPY . .
 
-# Place the compiled CSS where Apache can serve it.
-COPY --from=css-builder /build/app.css /var/www/html/public/assets/css/app.css
+# Place the compiled CSS + vendored JS where Apache can serve them.
+COPY --from=css-builder /build/app.css     /var/www/html/public/assets/css/app.css
+COPY --from=css-builder /build/vendor/     /var/www/html/public/assets/js/vendor/
 
-# Write a clean Apache site config pointing at public/ and routing every
-# unmatched URL to the front controller via FallbackResource. That replaces
-# the need for .htaccess rewrites (the .htaccess is kept as defense-in-depth
-# for deployments that don't use this image).
-RUN mkdir -p /var/www/data /var/www/data/sessions \
-    && chown -R www-data:www-data /var/www/data \
+# Twig cache lives under data/ alongside the SQLite file. www-data must be
+# able to write there at request time so first-render template compilation
+# succeeds.
+RUN mkdir -p /var/www/data /var/www/data/sessions /var/www/html/data/twig-cache \
+    && chown -R www-data:www-data /var/www/data /var/www/html/data \
     && printf '%s\n' \
         '<VirtualHost *:80>' \
         '    DocumentRoot /var/www/html/public' \

+ 99 - 37
SPEC.md

@@ -24,16 +24,31 @@ per-cell audit trail.
 
 ## 2. Tech stack (non-negotiable)
 
-- Runtime: Docker, two-stage build, `node:20-alpine` for CSS + `php:8.3-apache` for runtime.
+- Runtime: Docker, two-stage build, `node:20-alpine` for CSS + JS-vendor
+  copy + `php:8.3-apache` for runtime.
 - Language: PHP 8.3, strict types, PSR-12.
 - Database: SQLite via PDO, file at `/var/www/data/app.sqlite` (mounted volume).
-- Front end: server-rendered PHP templates + Tailwind CSS (vendored, compiled
-  by the Node stage) + jQuery 3.x + jQuery UI 1.13 (from code.jquery.com CDN).
+- Front end (Phase 19):
+  - Templates: **Twig 3** (`*.twig` under `views/`, `{% extends %}`
+    inheritance, auto-escape ON, compiled cache in `data/twig-cache/`).
+  - Styles: **Tailwind CSS 3** compiled at image-build time
+    (`assets/css/input.css` → `public/assets/css/app.css`). No CDN.
+  - Behaviour: **vanilla JS** (delegated `addEventListener`, `fetch`)
+    for the live grid pipelines (Arbeitstage cells, RTB, task days,
+    task status, filters, sort) plus **SortableJS** for drag-reorder.
+    **Alpine.js (CSP build)** drives small declarative components
+    (hamburger menu, theme toggle). **htmx** wires the simple
+    form-post pages (auth, settings, workers, users, sprint create,
+    audit filter) for AJAX swaps without controller changes.
+  - Strict CSP: `script-src 'self'` / `style-src 'self'` only — no
+    `unsafe-eval`, no `unsafe-inline`, no third-party hosts. All JS
+    deps vendored under `public/assets/js/vendor/`.
 - Auth: Microsoft Entra ID via OpenID Connect (Authorization Code + PKCE),
   plus an optional env-configured "local admin" fallback for dev / on-prem.
-- Composer deps: `jumbojett/openid-connect-php`, `vlucas/phpdotenv`,
-  `phpunit/phpunit` (dev).
-- npm deps: `tailwindcss` (build-time only).
+- Composer deps: `twig/twig`, `jumbojett/openid-connect-php`,
+  `vlucas/phpdotenv`, `phpunit/phpunit` (dev).
+- npm deps (build-time only): `tailwindcss`, `alpinejs`, `@alpinejs/csp`,
+  `htmx.org`, `sortablejs`.
 
 ## 3. Directory layout
 
@@ -59,9 +74,13 @@ per-cell audit trail.
 │       ├── css/app.css         # GENERATED at image-build time (gitignored)
 │       └── js/
 │           ├── theme-init.js       # Phase 16: synchronous dark-class set from localStorage (no FOUC)
-│           ├── app.js              # site-wide; data-href click handler + hamburger menu + theme toggle
-│           ├── sprint-planner.js   # /sprints/{id} Arbeitstage + task list
-│           └── sprint-settings.js  # /sprints/{id}/settings
+│           ├── app.js              # site-wide; data-href click delegation + Alpine appMenu + Alpine themeToggle + htmx CSRF wiring
+│           ├── sprint-planner.js   # /sprints/{id} + /sprints/{id}/present — vanilla JS + SortableJS
+│           ├── sprint-settings.js  # /sprints/{id}/settings — vanilla JS + SortableJS
+│           └── vendor/             # GENERATED at image-build time (gitignored)
+│               ├── alpine-csp.min.js   # @alpinejs/csp — Alpine without `unsafe-eval`
+│               ├── htmx.min.js         # htmx.org
+│               └── sortable.min.js     # SortableJS
 ├── src/
 │   ├── Auth/            LocalAdmin, OidcClient, SessionGuard
 │   ├── Controllers/     AuthController, WorkerController, SprintController,
@@ -80,13 +99,16 @@ per-cell audit trail.
 ├── migrations/          001_init.sql (full schema per spec §3)
 │                        002_sprint_week_active_days.sql (Phase 12 — mask column)
 │                        003_task_status_and_app_settings.sql (Phase 18 — task-cell status + KV)
-├── views/               layout.php, home.php, auth/local.php,
-│                        workers/index.php, users/index.php,
-│                        sprints/{new,show,settings,present}.php,
-│                        settings/index.php, audit/index.php
-├── tests/               TestCase + Services/ + Repositories/ + Controllers/ + Cascade/
-│                                 + Domain/ + Db/
-└── data/                SQLite + sessions directory (volume-mounted, gitignored)
+├── views/               (Twig 3) layout.twig, layout-bare.twig, home.twig,
+│                        auth/local.twig, workers/index.twig,
+│                        users/index.twig, audit/index.twig,
+│                        settings/index.twig,
+│                        sprints/{new,show,settings,present}.twig,
+│                        sprints/_task_list.twig (shared partial)
+├── tests/               TestCase + Services/ + Repositories/ + Controllers/ +
+│                        Cascade/ + Domain/ + Db/ + Http/ (Phase 19 TwigViewTest)
+└── data/                SQLite + sessions directory + twig-cache/
+                         (volume-mounted, gitignored)
 ```
 
 ## 4. Schema (migrations/001..003)
@@ -676,6 +698,52 @@ with a `BOOTSTRAP_ADMIN` audit row.
       four cases, days writes preserving status,
       `InvalidArgumentException` guard, `statusGridForSprint`).
 
+- [x] **Phase 19 — Twig 3 + Tailwind 3 + Alpine CSP + htmx + SortableJS,
+      jQuery removed** (`75e96e2`). Stack-shift of the entire UI layer
+      with zero changes to controllers, repositories, schema, capacity
+      math, or audit semantics — every behaviour preserved end-to-end.
+      All 11 `views/*.php` rewritten as `views/*.twig` using
+      `{% extends "layout.twig" %}` inheritance; new `layout-bare.twig`
+      backs `/sprints/{id}/present`'s own `<!doctype html>`; new
+      `_task_list.twig` partial is shared by `show.twig` and
+      `present.twig`. `src/Http/View.php` now wraps `Twig\Environment`
+      while keeping the historical `render($name, $data, $layout)`
+      signature so controllers don't change; auto-escape ON; compiled
+      cache written to `data/twig-cache/` (gitignored, www-data-owned
+      via the Dockerfile so first-render compilation succeeds). The
+      legacy `App\Http\e()` helper stays defined for backwards
+      compatibility but is unused by Twig templates. ~1500 lines of
+      jQuery / jQuery UI deleted from `app.js`, `sprint-planner.js`,
+      `sprint-settings.js`; each rewritten as a pure-vanilla IIFE
+      using `fetch` + delegated `addEventListener` against the
+      existing JSON-envelope endpoints. SortableJS replaces jQuery UI
+      sortable on the three drag-reorder lists. Alpine (CSP build,
+      `@alpinejs/csp` — no `unsafe-eval`) drives the hamburger menu
+      (`appMenu` factory) and theme toggle (`themeToggle` factory);
+      everything else is vanilla JS. htmx loaded site-wide; CSRF
+      token attached via `htmx:configRequest`; `hx-boost="true"`
+      sprinkled on the simple form-post pages (`/auth/local`,
+      `/workers` create+edit, `/users/{id}`, `/settings`,
+      `/sprints/new`, `/audit` filter) so submissions AJAX-swap the
+      body without a full reload. Sprint show/settings/present pages
+      stay native — their page-specific IIFEs would not re-init after
+      a body swap. CSP tightened: `script-src 'self'` and
+      `style-src 'self'` only — `https://code.jquery.com` dropped from
+      every directive. Dockerfile css-builder stage now also vendors
+      `node_modules/{@alpinejs/csp,htmx.org,sortablejs}/dist/*.min.js`
+      into `/build/vendor/`, which the runtime stage `COPY`s into
+      `public/assets/js/vendor/` (gitignored). New runtime dep
+      `twig/twig ^3.10`; new dev deps `alpinejs`, `@alpinejs/csp`,
+      `htmx.org`, `sortablejs`. Tests: 108 / 281 (was 105 / 265) —
+      new `tests/Http/TwigViewTest.php` adds three smoke renders
+      (home as signed-in admin, audit/index empty, sprints/show with
+      task grid + status filter); the prior 105 tests pass without
+      modification. `tailwind.config.js` content glob switched from
+      `views/**/*.php` to `views/**/*.twig`. The number-spinner reset
+      (`@layer base` block in `assets/css/input.css`) and Phase 18
+      `.assign-status-*` safelist + status-select styling all carry
+      over unchanged.
+
 ### Upcoming
 
 Nothing scheduled.
@@ -692,15 +760,6 @@ Nothing scheduled.
   are E_DEPRECATED but still emit — harmless, and silenced by
   `ini_set('display_errors','0')` in production. Upstream library needs a
   release.
-- **jQuery UI CDN CSS is not dark-aware.** The base theme on
-  `code.jquery.com/ui/1.13.3/themes/base/jquery-ui.css` is a light-only
-  stylesheet. It only shows up during drag operations (worker reorder on
-  `/sprints/{id}/settings`, sprint-worker / task reorder on
-  `/sprints/{id}`); the sortable ghost element reads slightly out of
-  place on a `dark:bg-slate-900` body. Accepted for now — the alternative
-  is self-hosting a custom jQuery UI theme inside the Docker css-builder
-  stage, which is a larger chunk of work than the cosmetic mismatch
-  warrants.
 - **Manual acceptance walkthrough** ([ACCEPTANCE.md](ACCEPTANCE.md))
   hasn't been executed end-to-end by a human yet — it's a documentary
   follow-up that should happen in the running container.
@@ -736,7 +795,7 @@ for f in $(git ls-files '*.php'); do php -l "$f" | tail -1 | sed "s|^|$f: |"; do
 Run the test suite:
 ```bash
 vendor/bin/phpunit
-# → OK (105 tests, 265 assertions)
+# → OK (108 tests, 281 assertions)
 ```
 
 ## 12. How to resume in a fresh Claude session
@@ -744,17 +803,18 @@ vendor/bin/phpunit
 Tell Claude:
 
 > Working on `/Users/achiappa/Development/claude_code_private/sprint_planer_web`.
-> Read `SPEC.md`, the git log, and `ACCEPTANCE.md`. Phases 1–18
-> are shipped (see §9; the Phase-17 slider popover was later
-> removed — typed entry is now the only edit path on number
-> inputs; Phase 18 added per-cell task-status colours + filter
-> + a new /settings page, all gated by a global flag that's off
-> by default). Nothing is currently scheduled. Outstanding
-> items are in §10 (mostly a human-run acceptance walkthrough in the
-> running container, plus the jQuery UI dark-mode cosmetic gap noted
-> there). If I ask you to plan or work a new phase, follow the
-> maintenance rule in §14 — commit code, then commit a SPEC.md
-> update separately that marks the new work shipped with its SHA.
+> Read `SPEC.md`, the git log, and `ACCEPTANCE.md`. Phases 1–19
+> are shipped (see §9; Phase 17's slider popover was removed —
+> typed entry is now the only edit path on number inputs; Phase 18
+> added per-cell task-status colours + filter + a new /settings page
+> gated by a global flag that's off by default; Phase 19 swapped the
+> stack to Twig 3 + Tailwind 3 + Alpine CSP + htmx + SortableJS and
+> removed jQuery + jQuery UI completely). Nothing is currently
+> scheduled. Outstanding items are in §10 (mostly a human-run
+> acceptance walkthrough in the running container). If I ask you to
+> plan or work a new phase, follow the maintenance rule in §14 —
+> commit code, then commit a SPEC.md update separately that marks
+> the new work shipped with its SHA.
 
 Claude should verify what's described here against actual repo state
 before acting — nothing here is load-bearing once it grows stale.
@@ -762,6 +822,8 @@ before acting — nothing here is load-bearing once it grows stale.
 ## 13. Git history (as of this writing)
 
 ```
+75e96e2 Phase 19: Twig 3 + Tailwind 3 + Alpine CSP + htmx + SortableJS, jQuery removed
+b3e5ec8 SPEC.md: note Phase 18 cell-markup hotfix
 3e115f5 Fix: Phase 18 cell markup — drop span wrapper, color goes on <td>
 205876a SPEC.md: mark Phase 18 shipped (task-status colours + filter)
 9cb7669 Phase 18: per-cell task-status colours + filter + global toggle

+ 2 - 1
composer.json

@@ -5,10 +5,11 @@
     "license": "proprietary",
     "require": {
         "php": "^8.3",
+        "ext-json": "*",
         "ext-pdo": "*",
         "ext-pdo_sqlite": "*",
-        "ext-json": "*",
         "jumbojett/openid-connect-php": "^1.0",
+        "twig/twig": "^3.10",
         "vlucas/phpdotenv": "^5.6"
     },
     "require-dev": {

+ 55 - 0
package-lock.json

@@ -6,6 +6,10 @@
     "": {
       "name": "sprint-planer-web-css",
       "devDependencies": {
+        "@alpinejs/csp": "^3.15.12",
+        "alpinejs": "^3.15.12",
+        "htmx.org": "^2.0.10",
+        "sortablejs": "^1.15.7",
         "tailwindcss": "^3.4.17"
       }
     },
@@ -22,6 +26,16 @@
         "url": "https://github.com/sponsors/sindresorhus"
       }
     },
+    "node_modules/@alpinejs/csp": {
+      "version": "3.15.12",
+      "resolved": "https://registry.npmjs.org/@alpinejs/csp/-/csp-3.15.12.tgz",
+      "integrity": "sha512-9jielHzVPqMlO9zQ6K1WtdhSHVX5OZfq5ZDUSVZfY10VnXRoRBx8nOadWjsG1xev/kz4EPaNN9eDFWtZSvJHxA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@vue/reactivity": "~3.1.1"
+      }
+    },
     "node_modules/@jridgewell/gen-mapping": {
       "version": "0.3.13",
       "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -99,6 +113,33 @@
         "node": ">= 8"
       }
     },
+    "node_modules/@vue/reactivity": {
+      "version": "3.1.5",
+      "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.1.5.tgz",
+      "integrity": "sha512-1tdfLmNjWG6t/CsPldh+foumYFo3cpyCHgBYQ34ylaMsJ+SNHQ1kApMIa8jN+i593zQuaw3AdWH0nJTARzCFhg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@vue/shared": "3.1.5"
+      }
+    },
+    "node_modules/@vue/shared": {
+      "version": "3.1.5",
+      "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.1.5.tgz",
+      "integrity": "sha512-oJ4F3TnvpXaQwZJNF3ZK+kLPHKarDmJjJ6jyzVNDKH9md1dptjC7lWR//jrGuLdek/U6iltWxqAnYOu8gCiOvA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/alpinejs": {
+      "version": "3.15.12",
+      "resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.15.12.tgz",
+      "integrity": "sha512-nJvPAQVNPdZZ0NrExJ/kzQco3ijR8LwvCOadQecllESiqT4NyZ/57sN9V2XyvhlBGAbmlKYgeWZvYdKq99ij/Q==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@vue/reactivity": "~3.1.1"
+      }
+    },
     "node_modules/any-promise": {
       "version": "1.3.0",
       "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
@@ -352,6 +393,13 @@
         "node": ">= 0.4"
       }
     },
+    "node_modules/htmx.org": {
+      "version": "2.0.10",
+      "resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-2.0.10.tgz",
+      "integrity": "sha512-kdeJe7ZVwaS6QMz/ebBIVtZdpwen6L0OQ5GOhPV9MKBb196TCZeZu4yA7ZIQsaLKv7EpXz+So7KSXNuHXhj7Cw==",
+      "dev": true,
+      "license": "0BSD"
+    },
     "node_modules/is-binary-path": {
       "version": "2.1.0",
       "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
@@ -840,6 +888,13 @@
         "queue-microtask": "^1.2.2"
       }
     },
+    "node_modules/sortablejs": {
+      "version": "1.15.7",
+      "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.7.tgz",
+      "integrity": "sha512-Kk8wLQPlS+yi1ZEf48a4+fzHa4yxjC30M/Sr2AnQu+f/MPwvvX9XjZ6OWejiz8crBsLwSq8GHqaxaET7u6ux0A==",
+      "dev": true,
+      "license": "MIT"
+    },
     "node_modules/source-map-js": {
       "version": "1.2.1",
       "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",

+ 4 - 0
package.json

@@ -3,6 +3,10 @@
   "private": true,
   "description": "Tailwind CSS build for sprint_planer_web (multi-stage Docker).",
   "devDependencies": {
+    "@alpinejs/csp": "^3.15.12",
+    "alpinejs": "^3.15.12",
+    "htmx.org": "^2.0.10",
+    "sortablejs": "^1.15.7",
     "tailwindcss": "^3.4.17"
   },
   "scripts": {

+ 86 - 79
public/assets/js/app.js

@@ -1,104 +1,111 @@
-/* global jQuery */
 /**
- * Tiny site-wide script. One purpose today: turn any element carrying a
- * `data-href` attribute into a clickable row / button without needing an
- * inline onclick (which would force `'unsafe-inline'` in the CSP).
+ * Site-wide behaviour. Three concerns:
+ *   1. Vanilla-JS click delegation for [data-href] rows (sprint list).
+ *   2. Alpine CSP component: appMenu (hamburger).
+ *   3. Alpine CSP component: themeToggle (dark-mode flip).
  *
- * Middle-click / Ctrl+click / Cmd+click open in a new tab, matching
- * native anchor-link behaviour.
+ * htmx is loaded site-wide and sends X-CSRF-Token automatically per the
+ * `htmx:configRequest` listener below.
  */
-(function ($) {
+
+(function () {
     'use strict';
 
-    $(document).on('click', '[data-href]', function (ev) {
-        // Ignore clicks that originated on an interactive descendant.
-        if ($(ev.target).closest('a, button, input, select, textarea, label').length > 0) {
-            return;
-        }
-        const href = String($(this).attr('data-href') || '');
+    // ----- data-href click delegation ------------------------------------
+    function navigate(el, ev) {
+        const href = String(el.getAttribute('data-href') || '');
         if (href === '') { return; }
-
         if (ev.metaKey || ev.ctrlKey || ev.button === 1) {
             window.open(href, '_blank');
         } else {
             window.location.href = href;
         }
-    });
-
-    // Make the row visibly clickable via keyboard + screen-readers.
-    $('[data-href]').attr('role', 'link').attr('tabindex', '0');
-    $(document).on('keydown', '[data-href]', function (ev) {
-        if (ev.key === 'Enter') {
-            ev.preventDefault();
-            window.location.href = String($(this).attr('data-href') || '');
-        }
-    });
-})(jQuery);
-
-/**
- * Header hamburger menu. Vanilla JS — no jQuery. Toggles #app-menu's
- * [hidden] + aria-expanded on the trigger, closes on outside-click,
- * Escape (returning focus to the trigger), and on any menuitem click
- * (so following a link / submitting the sign-out form feels snappy).
- *
- * Pattern mirrors the owner-filter / columns dropdowns in
- * sprint-planner.js, minus jQuery.
- */
-(function () {
-    'use strict';
-    const trigger = document.querySelector('[data-menu-trigger]');
-    const menu    = document.querySelector('[data-menu]');
-    if (!trigger || !menu) { return; }
-
-    function setOpen(open) {
-        menu.hidden = !open;
-        trigger.setAttribute('aria-expanded', open ? 'true' : 'false');
     }
 
-    trigger.addEventListener('click', function (ev) {
-        ev.stopPropagation();
-        setOpen(menu.hidden);
-    });
-
     document.addEventListener('click', function (ev) {
-        if (menu.hidden) { return; }
-        if (ev.target === trigger || trigger.contains(ev.target)) { return; }
-        if (!menu.contains(ev.target)) { setOpen(false); }
+        const el = ev.target.closest('[data-href]');
+        if (!el) { return; }
+        // Ignore clicks that originated on an interactive descendant.
+        if (ev.target.closest('a, button, input, select, textarea, label')) { return; }
+        navigate(el, ev);
     });
 
     document.addEventListener('keydown', function (ev) {
-        if (ev.key === 'Escape' && !menu.hidden) {
-            setOpen(false);
-            trigger.focus();
-        }
+        if (ev.key !== 'Enter') { return; }
+        const el = ev.target.closest('[data-href]');
+        if (!el) { return; }
+        if (ev.target.closest('a, button, input, select, textarea, label')) { return; }
+        ev.preventDefault();
+        window.location.href = String(el.getAttribute('data-href') || '');
     });
 
-    menu.addEventListener('click', function (ev) {
-        if (ev.target.closest('[role="menuitem"]')) { setOpen(false); }
+    // Make every data-href row keyboard-reachable + screenreader-friendly.
+    document.querySelectorAll('[data-href]').forEach(function (el) {
+        el.setAttribute('role', 'link');
+        el.setAttribute('tabindex', '0');
     });
-})();
 
-/**
- * Phase 16: dark-mode toggle. theme-init.js in <head> already applied the
- * 'dark' class from localStorage before stylesheet resolution, so this
- * handler only cares about the on-click flip + the menu label's text.
- * localStorage writes are try/catch'd so private-window denials degrade
- * gracefully (toggle still works for the session, just doesn't persist).
- */
-(function () {
-    'use strict';
-    const btn   = document.querySelector('[data-theme-toggle]');
-    const label = document.querySelector('[data-theme-label]');
-    if (!btn || !label) { return; }
-
-    function stamp() {
-        label.textContent = document.documentElement.classList.contains('dark') ? 'Dark' : 'Light';
+    // ----- htmx CSRF wiring ---------------------------------------------
+    // The CSRF token lives on every layout's form (_csrf hidden input) and on
+    // the [data-sprint-root] element (data-csrf). Pull from a hidden input if
+    // present so non-sprint pages also get the header attached.
+    function readCsrfToken() {
+        const inp = document.querySelector('input[name="_csrf"]');
+        if (inp && inp.value) { return inp.value; }
+        const root = document.querySelector('[data-sprint-root]');
+        if (root && root.getAttribute('data-csrf')) {
+            return root.getAttribute('data-csrf');
+        }
+        return '';
     }
-    stamp();
 
-    btn.addEventListener('click', function () {
-        const nowDark = document.documentElement.classList.toggle('dark');
-        try { localStorage.setItem('sp:theme', nowDark ? 'dark' : 'light'); } catch (e) { /* ignore */ }
-        stamp();
+    document.addEventListener('htmx:configRequest', function (ev) {
+        const tok = readCsrfToken();
+        if (tok && ev.detail && ev.detail.headers) {
+            ev.detail.headers['X-CSRF-Token'] = tok;
+        }
+    });
+
+    // ----- Alpine component registrations -------------------------------
+    document.addEventListener('alpine:init', function () {
+        // Hamburger menu — toggle, close on outside / Escape / item click.
+        window.Alpine.data('appMenu', function () {
+            return {
+                open: false,
+                toggle()       { this.open = !this.open; },
+                close()        { this.open = false; },
+                closeAndFocus() {
+                    if (!this.open) { return; }
+                    this.open = false;
+                    if (this.$refs.trigger) { this.$refs.trigger.focus(); }
+                },
+                closeOnItem(ev) {
+                    if (ev.target.closest('[role="menuitem"]')) { this.open = false; }
+                },
+            };
+        });
+
+        // Theme toggle — flips <html class="dark">, persists in localStorage,
+        // mirrors a "Dark" / "Light" label into the menu row.
+        window.Alpine.data('themeToggle', function () {
+            return {
+                label: '',
+                init() {
+                    this.stamp();
+                    // If theme-init.js loaded after Alpine for some reason,
+                    // reflect the current class state on next tick.
+                    this.$nextTick(() => this.stamp());
+                },
+                stamp() {
+                    this.label = document.documentElement.classList.contains('dark') ? 'Dark' : 'Light';
+                },
+                flip() {
+                    const nowDark = document.documentElement.classList.toggle('dark');
+                    try { localStorage.setItem('sp:theme', nowDark ? 'dark' : 'light'); }
+                    catch (e) { /* private mode quota — ignore */ }
+                    this.stamp();
+                },
+            };
+        });
     });
 })();

File diff suppressed because it is too large
+ 363 - 359
public/assets/js/sprint-planner.js


+ 177 - 190
public/assets/js/sprint-settings.js

@@ -1,32 +1,35 @@
-/* global jQuery */
 /**
- * Sprint settings page: JSON mutation plumbing + jQuery UI sortable wiring.
- *
- * The settings page mounts a single root element with `data-sprint-id` and
- * `data-csrf`. Everything below scopes itself to that root.
+ * /sprints/{id}/settings — vanilla JS + SortableJS.
+ * Mirrors the previous jQuery/jQuery UI implementation feature-for-feature:
+ *  - debounced PATCH /sprints/{id} for sprint meta on change
+ *  - POST /sprints/{id}/weeks for week count resize (full reload on success)
+ *  - PATCH /sprints/{id}/week/{week_id} for per-week weekday mask
+ *  - POST /sprints/{id}/workers for adding a worker, DELETE for removing
+ *  - PATCH /sprints/{id}/workers/{sw_id} for RTB and reorder
+ *  - SortableJS replaces jQuery UI sortable on the in-sprint worker list
  */
-(function ($) {
+(function () {
     'use strict';
 
-    const $root = $('[data-sprint-root]');
-    if ($root.length === 0) {
-        return;
-    }
+    const root = document.querySelector('[data-sprint-root]');
+    if (!root) { return; }
 
-    const sprintId = parseInt($root.data('sprint-id'), 10);
-    const csrf     = String($root.data('csrf') || '');
+    const sprintId = parseInt(root.getAttribute('data-sprint-id'), 10);
+    const csrf     = String(root.getAttribute('data-csrf') || '');
 
-    // ---------------------------------------------------------------------
-    // AJAX plumbing
-    // ---------------------------------------------------------------------
+    function qs(sel, ctx)  { return (ctx || root).querySelector(sel); }
+    function qsa(sel, ctx) { return Array.from((ctx || root).querySelectorAll(sel)); }
+    function on(ctx, ev, sel, fn) {
+        ctx.addEventListener(ev, function (e) {
+            const t = e.target.closest(sel);
+            if (t && ctx.contains(t)) { fn.call(t, e, t); }
+        });
+    }
 
     function request(method, url, body) {
         const opts = {
             method,
-            headers: {
-                'Accept':        'application/json',
-                'X-CSRF-Token':  csrf,
-            },
+            headers: { Accept: 'application/json', 'X-CSRF-Token': csrf },
             credentials: 'same-origin',
         };
         if (body !== undefined) {
@@ -39,9 +42,9 @@
             if (!res.ok || !payload || payload.ok !== true) {
                 const msg = (payload && payload.error && payload.error.message)
                     ? payload.error.message
-                    : res.statusText || 'Request failed';
+                    : (res.statusText || 'Request failed');
                 const err = new Error(msg);
-                err.status = res.status;
+                err.status  = res.status;
                 err.payload = payload;
                 throw err;
             }
@@ -49,237 +52,221 @@
         });
     }
 
-    // ---------------------------------------------------------------------
-    // Toast / status line
-    // ---------------------------------------------------------------------
-
-    const $status = $root.find('[data-status]');
+    const statusEl = qs('[data-status]');
+    const successCls = ['text-green-700', 'bg-green-50', 'border-green-200'];
+    const errorCls   = ['text-red-700',   'bg-red-50',   'border-red-200'];
     let statusTimer = null;
     function flash(text, isError) {
-        $status
-            .text(text)
-            .removeClass('text-green-700 text-red-700 bg-green-50 bg-red-50 border-green-200 border-red-200')
-            .addClass(isError ? 'text-red-700 bg-red-50 border-red-200' : 'text-green-700 bg-green-50 border-green-200')
-            .removeClass('opacity-0')
-            .addClass('opacity-100');
+        if (!statusEl) { return; }
+        statusEl.textContent = text;
+        successCls.concat(errorCls).forEach((c) => statusEl.classList.remove(c));
+        (isError ? errorCls : successCls).forEach((c) => statusEl.classList.add(c));
+        statusEl.classList.remove('opacity-0');
+        statusEl.classList.add('opacity-100');
         clearTimeout(statusTimer);
         statusTimer = setTimeout(function () {
-            $status.removeClass('opacity-100').addClass('opacity-0');
+            statusEl.classList.remove('opacity-100');
+            statusEl.classList.add('opacity-0');
         }, 2500);
     }
 
-    // ---------------------------------------------------------------------
-    // Sprint meta — save on change / blur
-    // ---------------------------------------------------------------------
+    // ---- Sprint meta ----------------------------------------------------
 
     function patchMeta(payload) {
         return request('PATCH', '/sprints/' + sprintId, payload)
-            .then(function () { flash('Saved'); })
-            .catch(function (e) { flash(e.message, true); });
+            .then(() => flash('Saved'))
+            .catch((e) => flash(e.message, true));
     }
-
     const metaDebounce = {};
-    function debouncedMeta(field, value, ms) {
+    on(root, 'change', '[data-meta]', function () {
+        const field = this.getAttribute('name');
+        let v = this.value;
+        if (field === 'reserve_fraction') { v = Number(v) / 100; }
         clearTimeout(metaDebounce[field]);
         metaDebounce[field] = setTimeout(function () {
-            const payload = {};
-            payload[field] = value;
+            const payload = {}; payload[field] = v;
             patchMeta(payload);
-        }, ms || 400);
-    }
-
-    $root.find('[data-meta]').on('change', function () {
-        const $el = $(this);
-        const field = $el.attr('name');
-        let v = $el.val();
-        if (field === 'reserve_fraction') {
-            v = Number(v) / 100; // form shows percent
-        }
-        debouncedMeta(field, v, 0);
+        }, 0);
     });
 
-    // ---------------------------------------------------------------------
-    // Weeks count
-    // ---------------------------------------------------------------------
+    // ---- Week count resize ---------------------------------------------
 
-    $root.find('[data-weeks-form]').on('submit', function (ev) {
+    on(root, 'submit', '[data-weeks-form]', function (ev) {
         ev.preventDefault();
-        const n = parseInt($(this).find('input[name="n_weeks"]').val(), 10);
+        const inp = this.querySelector('input[name="n_weeks"]');
+        const n = parseInt(inp ? inp.value : '', 10);
         if (!Number.isInteger(n) || n < 1) {
             flash('Weeks must be a positive integer', true);
             return;
         }
         request('POST', '/sprints/' + sprintId + '/weeks', { n_weeks: n })
-            .then(function () { window.location.reload(); })
-            .catch(function (e) { flash(e.message, true); });
+            .then(() => window.location.reload())
+            .catch((e) => flash(e.message, true));
     });
 
-    // ---------------------------------------------------------------------
-    // Per-week weekday checkboxes (Phase 12)
-    //
-    // Each row carries five [data-day-toggle] boxes. On any change we rebuild
-    // the row's mask from all five and send it in one PATCH — no debounce on
-    // per-checkbox granularity (each click is one state change), but we do
-    // delay per-row in case the user ticks several in quick succession.
-    // ---------------------------------------------------------------------
+    // ---- Per-week weekday checkboxes (Phase 12) ------------------------
 
-    const weekDebounce = {};
-
-    function maskFromRow($row) {
+    function maskFromRow(row) {
         let mask = 0;
-        $row.find('[data-day-toggle]').each(function () {
-            if ($(this).is(':checked')) {
-                const bit = parseInt($(this).data('bit'), 10);
+        qsa('[data-day-toggle]', row).forEach(function (cb) {
+            if (cb.checked) {
+                const bit = parseInt(cb.getAttribute('data-bit'), 10);
                 if (Number.isInteger(bit)) { mask |= (1 << bit); }
             }
         });
         return mask;
     }
-
     function popcount5(mask) {
         let n = 0;
         for (let i = 0; i < 5; i++) { if ((mask >> i) & 1) { n++; } }
         return n;
     }
-
-    $root.on('change', '[data-day-toggle]', function () {
-        const $row   = $(this).closest('[data-week-row]');
-        const weekId = parseInt($row.data('week-id'), 10);
-        const mask   = maskFromRow($row);
-
-        // Optimistic local update: derived count flips immediately.
-        $row.find('[data-week-count]').text(String(popcount5(mask)));
+    const weekDebounce = {};
+    on(root, 'change', '[data-day-toggle]', function () {
+        const row    = this.closest('[data-week-row]');
+        const weekId = parseInt(row.getAttribute('data-week-id'), 10);
+        const mask   = maskFromRow(row);
+        const cnt = qs('[data-week-count]', row);
+        if (cnt) { cnt.textContent = String(popcount5(mask)); }
 
         clearTimeout(weekDebounce[weekId]);
         weekDebounce[weekId] = setTimeout(function () {
             request('PATCH', '/sprints/' + sprintId + '/week/' + weekId, { active_days_mask: mask })
                 .then(function (data) {
-                    if (data && data.sprint_week) {
-                        $row.find('[data-week-count]')
-                            .text(String(data.sprint_week.max_working_days));
+                    if (data && data.sprint_week && cnt) {
+                        cnt.textContent = String(data.sprint_week.max_working_days);
                     }
                     flash('Saved');
                 })
-                .catch(function (e) { flash(e.message, true); });
+                .catch((e) => flash(e.message, true));
         }, 250);
     });
 
-    // ---------------------------------------------------------------------
-    // Worker picker
-    // ---------------------------------------------------------------------
+    // ---- Worker picker --------------------------------------------------
+
+    const available = qs('[data-available]');
+    const inSprint  = qs('[data-in-sprint]');
 
     function workerRowTemplate(sw) {
-        const nameSafe = $('<div>').text(sw.worker_name).html();
-        return $(
-            '<li class="flex items-center gap-2 px-3 py-2 border-b bg-white last:border-b-0"' +
-               ' data-sw-id="' + sw.id + '"' +
-               ' data-worker-id="' + sw.worker_id + '">' +
-                '<span class="handle cursor-grab text-slate-400">&#8801;</span>' +
-                '<span class="flex-1">' + nameSafe + '</span>' +
-                '<input type="number" step="0.05" min="0" max="1" value="' + Number(sw.rtb).toFixed(2) + '"' +
-                    ' data-rtb class="w-20 rounded border border-slate-300 px-2 py-1 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-slate-400">' +
-                '<button type="button" data-remove class="text-sm text-red-600 hover:underline">Remove</button>' +
-            '</li>'
-        );
+        const li = document.createElement('li');
+        li.className = 'flex items-center gap-2 px-3 py-2 border-b bg-white last:border-b-0 dark:bg-slate-800 dark:border-slate-700';
+        li.setAttribute('data-sw-id',     String(sw.id));
+        li.setAttribute('data-worker-id', String(sw.worker_id));
+        const handle = document.createElement('span');
+        handle.className = 'handle cursor-grab text-slate-400 select-none dark:text-slate-500';
+        handle.innerHTML = '&#8801;';
+        const name = document.createElement('span');
+        name.className = 'flex-1';
+        name.textContent = sw.worker_name || '';
+        const rtb = document.createElement('input');
+        rtb.type = 'number'; rtb.step = '0.05'; rtb.min = '0'; rtb.max = '1';
+        rtb.value = Number(sw.rtb).toFixed(2);
+        rtb.setAttribute('data-rtb', '');
+        rtb.className = 'w-20 rounded border border-slate-300 px-2 py-1 font-mono 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';
+        const remove = document.createElement('button');
+        remove.type = 'button';
+        remove.setAttribute('data-remove', '');
+        remove.className = 'text-sm text-red-600 hover:underline dark:text-red-400';
+        remove.textContent = 'Remove';
+        li.appendChild(handle);
+        li.appendChild(name);
+        li.appendChild(rtb);
+        li.appendChild(remove);
+        return li;
     }
 
     function availableRowTemplate(worker) {
-        const nameSafe = $('<div>').text(worker.name).html();
-        return $(
-            '<li class="flex items-center gap-2 px-3 py-2 border-b last:border-b-0"' +
-               ' data-worker-id="' + worker.id + '">' +
-                '<span class="flex-1">' + nameSafe + '</span>' +
-                '<button type="button" data-add class="text-sm text-blue-700 hover:underline">Add →</button>' +
-            '</li>'
-        );
+        const li = document.createElement('li');
+        li.className = 'flex items-center gap-2 px-3 py-2 border-b last:border-b-0 dark:border-slate-700';
+        li.setAttribute('data-worker-id', String(worker.id));
+        const name = document.createElement('span');
+        name.className = 'flex-1';
+        name.textContent = worker.name || '';
+        const add = document.createElement('button');
+        add.type = 'button';
+        add.setAttribute('data-add', '');
+        add.className = 'text-sm text-blue-700 hover:underline dark:text-blue-400 dark:hover:text-blue-300';
+        add.textContent = 'Add →';
+        li.appendChild(name);
+        li.appendChild(add);
+        return li;
     }
 
-    const $available = $root.find('[data-available]');
-    const $inSprint  = $root.find('[data-in-sprint]');
-
-    // Add a worker
-    $available.on('click', '[data-add]', function () {
-        const $li = $(this).closest('li');
-        const workerId = parseInt($li.data('worker-id'), 10);
-        const name = $li.find('span.flex-1').text();
-
-        request('POST', '/sprints/' + sprintId + '/workers', { worker_id: workerId })
-            .then(function (data) {
-                const sw = data.sprint_worker;
-                sw.worker_name = sw.worker_name || name;
-                $inSprint.append(workerRowTemplate(sw));
-                $li.remove();
-                flash('Worker added');
-                refreshEmptyStates();
-            })
-            .catch(function (e) { flash(e.message, true); });
-    });
+    if (available) {
+        on(available, 'click', '[data-add]', function () {
+            const li = this.closest('li');
+            const workerId = parseInt(li.getAttribute('data-worker-id'), 10);
+            const span = li.querySelector('span.flex-1');
+            const name = span ? span.textContent : '';
+            request('POST', '/sprints/' + sprintId + '/workers', { worker_id: workerId })
+                .then(function (data) {
+                    const sw = data.sprint_worker;
+                    sw.worker_name = sw.worker_name || name;
+                    inSprint.appendChild(workerRowTemplate(sw));
+                    li.remove();
+                    flash('Worker added');
+                    refreshEmptyStates();
+                })
+                .catch((e) => flash(e.message, true));
+        });
+    }
 
-    // Remove a worker
-    $inSprint.on('click', '[data-remove]', function () {
-        const $li = $(this).closest('li');
-        const swId = parseInt($li.data('sw-id'), 10);
-        const workerId = parseInt($li.data('worker-id'), 10);
-        const name = $li.find('span.flex-1').text();
+    if (inSprint) {
+        on(inSprint, 'click', '[data-remove]', function () {
+            const li = this.closest('li');
+            const swId     = parseInt(li.getAttribute('data-sw-id'),     10);
+            const workerId = parseInt(li.getAttribute('data-worker-id'), 10);
+            const span = li.querySelector('span.flex-1');
+            const name = span ? span.textContent : '';
+            request('DELETE', '/sprints/' + sprintId + '/workers/' + swId)
+                .then(function () {
+                    li.remove();
+                    available.appendChild(availableRowTemplate({ id: workerId, name }));
+                    flash('Worker removed');
+                    refreshEmptyStates();
+                })
+                .catch((e) => flash(e.message, true));
+        });
 
-        request('DELETE', '/sprints/' + sprintId + '/workers/' + swId)
-            .then(function () {
-                $li.remove();
-                $available.append(availableRowTemplate({ id: workerId, name: name }));
-                flash('Worker removed');
-                refreshEmptyStates();
-            })
-            .catch(function (e) { flash(e.message, true); });
-    });
+        on(inSprint, 'change', '[data-rtb]', function () {
+            const li = this.closest('li');
+            const swId = parseInt(li.getAttribute('data-sw-id'), 10);
+            let v = Number(this.value);
+            if (Number.isNaN(v) || v < 0 || v > 1) { flash('RTB must be 0–1', true); return; }
+            v = Math.round(v * 20) / 20;
+            this.value = v.toFixed(2);
+            request('PATCH', '/sprints/' + sprintId + '/workers/' + swId, { rtb: v })
+                .then(() => flash('Saved'))
+                .catch((e) => flash(e.message, true));
+        });
 
-    // RTB edit on blur / change
-    $inSprint.on('change', '[data-rtb]', function () {
-        const $input = $(this);
-        const swId = parseInt($input.closest('li').data('sw-id'), 10);
-        let v = Number($input.val());
-        if (Number.isNaN(v) || v < 0 || v > 1) {
-            flash('RTB must be 0–1', true);
-            return;
+        if (typeof window.Sortable === 'function') {
+            window.Sortable.create(inSprint, {
+                handle: '.handle',
+                animation: 150,
+                onEnd: function () {
+                    const ordering = qsa('li', inSprint).map(function (li, i) {
+                        return {
+                            sprint_worker_id: parseInt(li.getAttribute('data-sw-id'), 10),
+                            sort_order:       i + 1,
+                        };
+                    });
+                    request('POST', '/sprints/' + sprintId + '/workers/reorder', ordering)
+                        .then((data) => flash(data.moved ? 'Order saved' : 'No changes'))
+                        .catch((e) => flash(e.message, true));
+                },
+            });
+        } else {
+            // eslint-disable-next-line no-console
+            console.warn('[sprint-settings] SortableJS not loaded — drag reorder disabled.');
         }
-        // Snap to 0.05 step
-        v = Math.round(v * 20) / 20;
-        $input.val(v.toFixed(2));
-
-        request('PATCH', '/sprints/' + sprintId + '/workers/' + swId, { rtb: v })
-            .then(function () { flash('Saved'); })
-            .catch(function (e) { flash(e.message, true); });
-    });
-
-    // Drag reorder (requires jQuery UI)
-    if (typeof $.fn.sortable === 'function') {
-        $inSprint.sortable({
-            handle: '.handle',
-            axis: 'y',
-            placeholder: 'bg-slate-100 h-10',
-            update: function () {
-                const ordering = $inSprint.children('li').map(function (i, el) {
-                    return {
-                        sprint_worker_id: parseInt($(el).data('sw-id'), 10),
-                        sort_order: i + 1,
-                    };
-                }).get();
-
-                request('POST', '/sprints/' + sprintId + '/workers/reorder', ordering)
-                    .then(function (data) {
-                        flash(data.moved ? 'Order saved' : 'No changes');
-                    })
-                    .catch(function (e) { flash(e.message, true); });
-            },
-        });
-    } else {
-        // eslint-disable-next-line no-console
-        console.warn('[sprint-settings] jQuery UI not loaded — drag reorder disabled.');
     }
 
     function refreshEmptyStates() {
-        $root.find('[data-empty-available]').toggle($available.children('li').length === 0);
-        $root.find('[data-empty-sprint]').toggle($inSprint.children('li').length === 0);
+        const ea = qs('[data-empty-available]');
+        const es = qs('[data-empty-sprint]');
+        if (ea && available) { ea.style.display = available.querySelectorAll('li').length === 0 ? '' : 'none'; }
+        if (es && inSprint)  { es.style.display = inSprint.querySelectorAll('li').length === 0 ? '' : 'none'; }
     }
     refreshEmptyStates();
-
-})(jQuery);
+})();

+ 13 - 7
public/index.php

@@ -84,7 +84,11 @@ try {
 // ---------------------------------------------------------------------------
 // Shared services
 // ---------------------------------------------------------------------------
-$view          = new View(APP_ROOT . '/views');
+$twigCacheDir = APP_ROOT . '/data/twig-cache';
+if (!is_dir($twigCacheDir)) {
+    @mkdir($twigCacheDir, 0775, true);
+}
+$view          = new View(APP_ROOT . '/views', $twigCacheDir);
 $users         = new UserRepository($pdo);
 $workers       = new WorkerRepository($pdo);
 $sprints       = new SprintRepository($pdo);
@@ -194,15 +198,17 @@ $response = $router->dispatch($request);
 // Response::send) so the policy is visible + editable in one place.
 $isHttps = str_starts_with((string) (getenv('APP_BASE_URL') ?: ''), 'https://');
 
-// Strict CSP (Phase 11). With Tailwind pre-compiled at image-build time and
-// the last inline onclick replaced by `public/assets/js/app.js`, neither
-// 'unsafe-inline' nor the Tailwind CDN host are needed anymore.
+// Strict CSP (Phase 11 + Phase 19). Tailwind is pre-compiled at image-build
+// time, jQuery / jQuery UI are gone, and Alpine (CSP build), htmx, and
+// SortableJS are vendored under /assets/js/vendor/ — so script-src and
+// style-src are 'self' only, no 'unsafe-eval', no 'unsafe-inline', no CDN
+// hosts. font-src keeps `data:` for the few inline data-URL glyphs.
 $csp = implode('; ', [
     "default-src 'self'",
-    "script-src 'self' https://code.jquery.com",
-    "style-src 'self' https://code.jquery.com",
+    "script-src 'self'",
+    "style-src 'self'",
     "img-src 'self' data:",
-    "font-src 'self' data: https://code.jquery.com",
+    "font-src 'self' data:",
     "connect-src 'self'",
     "frame-ancestors 'none'",
     "base-uri 'self'",

+ 2 - 3
src/Controllers/SprintController.php

@@ -196,13 +196,12 @@ final class SprintController
             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.
+        // Present view extends layout-bare.twig instead of the shared layout.twig.
         return Response::html($this->view->render('sprints/present', [
             'title'       => $data['sprint']->name . ' — present',
             'currentUser' => $actor,
             'csrfToken'   => SessionGuard::csrfToken(),
-        ] + $data, null));
+        ] + $data));
     }
 
     /**

+ 87 - 37
src/Http/View.php

@@ -4,67 +4,117 @@ declare(strict_types=1);
 
 namespace App\Http;
 
+use Twig\Environment;
+use Twig\Loader\FilesystemLoader;
+use Twig\TwigFunction;
+
 /**
- * Minimal PHP template renderer. Templates live under `views/`, receive the
- * data array as extracted local variables, and are wrapped by `layout.php`
- * when `$layout` is provided.
+ * Twig 3 wrapper that keeps the historical View::render($name, $data, $layout)
+ * signature so controllers don't change. Templates live under views/ as
+ * *.twig and use {% extends "layout.twig" %} for inheritance — the $layout
+ * parameter is now vestigial (only `null` is honoured, used by the bare
+ * /sprints/{id}/present route to skip layout inheritance — but Twig's
+ * extends handles that just by picking the right base template).
  */
 final class View
 {
-    public function __construct(
-        private readonly string $viewsDir
-    ) {
+    private readonly Environment $twig;
+
+    public function __construct(string $viewsDir, ?string $cacheDir = null)
+    {
+        $loader = new FilesystemLoader($viewsDir);
+        $this->twig = new Environment($loader, [
+            'cache'            => $cacheDir ?? false,
+            'auto_reload'      => (getenv('APP_ENV') ?: 'production') !== 'production',
+            'strict_variables' => false,
+            'autoescape'       => 'html',
+        ]);
+
+        // fmt_days(x) — same shape used by the previous PHP templates and the
+        // CapacityCalculator: 0 → "0", whole numbers → integer, halves → x.5.
+        $this->twig->addFunction(new TwigFunction(
+            'fmt_days',
+            static function (mixed $x): string {
+                $n = (float) $x;
+                if (abs($n - round($n)) < 1e-9) {
+                    return (string) (int) round($n);
+                }
+                return number_format($n, 1);
+            }
+        ));
+
+        // fmt_rtb(x) — two decimals, e.g. 0.05.
+        $this->twig->addFunction(new TwigFunction(
+            'fmt_rtb',
+            static fn(mixed $x): string => number_format((float) $x, 2, '.', '')
+        ));
+
+        // pretty_json(raw) — used by audit/index.twig for the diff <pre> blocks.
+        $this->twig->addFunction(new TwigFunction(
+            'pretty_json',
+            static function (?string $raw): string {
+                if ($raw === null || $raw === '') {
+                    return '';
+                }
+                try {
+                    $v = json_decode($raw, true, 64, JSON_THROW_ON_ERROR);
+                } catch (\JsonException) {
+                    return $raw;
+                }
+                return (string) (json_encode(
+                    $v,
+                    JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
+                ) ?: $raw);
+            }
+        ));
+
+        // query_string(filters, drop, extra) — rebuilds a query string while
+        // dropping one key and merging extras. Used by /audit pagination.
+        $this->twig->addFunction(new TwigFunction(
+            'query_string',
+            /**
+             * @param array<string,scalar|null> $filters
+             * @param array<string,scalar|null> $extra
+             */
+            static function (array $filters, string $drop, array $extra = []): string {
+                $params = array_filter(
+                    array_merge($filters, $extra),
+                    static fn($v) => $v !== '' && $v !== null
+                );
+                unset($params[$drop]);
+                return $params === [] ? '' : '?' . http_build_query($params);
+            }
+        ));
     }
 
     /**
      * Render a view and return the HTML.
      *
-     * @param string $name   view name, e.g. 'home' → views/home.php
+     * @param string              $name view name; "home" → views/home.twig
      * @param array<string,mixed> $data
-     * @param string|null $layout e.g. 'layout' → views/layout.php wraps $content
+     * @param string|null         $layout retained for back-compat; ignored
+     *                                    by Twig (templates use {% extends %})
      */
     public function render(string $name, array $data = [], ?string $layout = 'layout'): string
     {
-        $content = $this->renderRaw($name, $data);
-
-        if ($layout === null) {
-            return $content;
-        }
-
-        return $this->renderRaw($layout, ['content' => $content] + $data);
+        unset($layout);
+        return $this->twig->render($name . '.twig', $data);
     }
 
     /** @param array<string,mixed> $data */
     public function renderRaw(string $name, array $data): string
     {
-        $file = $this->resolve($name);
-
-        $render = static function (string $__file, array $__data): string {
-            extract($__data, EXTR_SKIP);
-            ob_start();
-            try {
-                require $__file;
-            } catch (\Throwable $e) {
-                ob_end_clean();
-                throw $e;
-            }
-            return (string) ob_get_clean();
-        };
-
-        return $render($file, $data);
+        return $this->twig->render($name . '.twig', $data);
     }
 
-    private function resolve(string $name): string
+    /** Direct access to the Twig env (rarely needed; mostly for tests). */
+    public function twig(): Environment
     {
-        $path = $this->viewsDir . DIRECTORY_SEPARATOR . $name . '.php';
-        if (!is_file($path)) {
-            throw new \RuntimeException("View not found: {$name} ({$path})");
-        }
-        return $path;
+        return $this->twig;
     }
 }
 
-/** Escape for HTML. Intended to be imported as a function from templates. */
+/** Escape for HTML. Kept so legacy callers still resolve; Twig auto-escapes. */
 function e(mixed $v): string
 {
     if ($v === null) {

+ 2 - 2
tailwind.config.js

@@ -2,12 +2,12 @@
 module.exports = {
     darkMode: 'class',
     content: [
-        './views/**/*.php',
+        './views/**/*.twig',
         './src/**/*.php',
         './public/assets/js/**/*.js',
     ],
     // Phase 18 status classes are interpolated server-side
-    // (`assign-status-<?= $st ?>`), so Tailwind's content scanner can't see
+    // ("assign-status-{{ st }}"), so Tailwind's content scanner can't see
     // the literal class names and would otherwise prune the rules from
     // assets/css/input.css's @layer components block. The safelist keeps
     // them in the build.

+ 151 - 0
tests/Http/TwigViewTest.php

@@ -0,0 +1,151 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Tests\Http;
+
+use App\Http\View;
+use App\Tests\TestCase;
+use ReflectionClass;
+use ReflectionProperty;
+
+/**
+ * Smoke tests for the Twig-backed View. Phase 19 swapped the bare-PHP renderer
+ * for Twig 3 — these confirm that the controllers' (name, $data) call shape
+ * still produces HTML containing the markers each page is expected to carry.
+ */
+final class TwigViewTest extends TestCase
+{
+    private View $view;
+
+    protected function setUp(): void
+    {
+        parent::setUp();
+        $this->view = new View(__DIR__ . '/../../views');
+    }
+
+    private function makeUser(bool $isAdmin = true): object
+    {
+        $u = new \stdClass();
+        $u->id          = 1;
+        $u->email       = 'alice@example.com';
+        $u->displayName = 'Alice';
+        $u->isAdmin     = $isAdmin;
+        return $u;
+    }
+
+    public function testHomeRendersForSignedInAdmin(): void
+    {
+        $html = $this->view->render('home', [
+            'title'             => 'Sprint Planner',
+            'csrfToken'         => 'tok',
+            'currentUser'       => $this->makeUser(true),
+            'schemaVersion'     => 3,
+            'dbPath'            => '/tmp/x',
+            'appEnv'            => 'production',
+            'oidcConfigured'    => false,
+            'localAdminEnabled' => true,
+            'authError'         => false,
+            'sprintRows'        => [],
+        ]);
+
+        self::assertStringContainsString('<title>Sprint Planner</title>', $html);
+        self::assertStringContainsString('Sprints', $html);
+        self::assertStringContainsString('Alice', $html);     // displayName
+        self::assertStringContainsString('admin', $html);     // admin badge
+        self::assertStringContainsString('/auth/logout', $html);
+        // Phase 19: vendored Alpine + htmx + sortable scripts must be linked.
+        self::assertStringContainsString('/assets/js/vendor/alpine-csp.min.js', $html);
+        self::assertStringContainsString('/assets/js/vendor/htmx.min.js', $html);
+    }
+
+    public function testAuditRendersWithEmptyResults(): void
+    {
+        $html = $this->view->render('audit/index', [
+            'title'       => 'Audit',
+            'csrfToken'   => 'tok',
+            'currentUser' => $this->makeUser(true),
+            'filters'     => [
+                'user_email'  => '',
+                'action'      => '',
+                'entity_type' => '',
+                'entity_id'   => '',
+                'from_date'   => '',
+                'to_date'     => '',
+            ],
+            'page'        => 1,
+            'pages'       => 1,
+            'pageSize'    => 50,
+            'total'       => 0,
+            'rows'        => [],
+            'actions'     => ['CREATE', 'UPDATE', 'DELETE'],
+            'entityTypes' => ['worker', 'sprint'],
+            'users'       => [],
+        ]);
+
+        self::assertStringContainsString('Audit log', $html);
+        self::assertStringContainsString('No audit rows match', $html);
+        // hx-boost wired on the filter form (Phase 19).
+        self::assertStringContainsString('hx-boost="true"', $html);
+    }
+
+    public function testSprintsShowRendersTaskGridAndStatusFilter(): void
+    {
+        $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(true),
+            '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::assertStringContainsString('data-sprint-root', $html);
+        self::assertStringContainsString('data-sprint-id="1"', $html);
+        self::assertStringContainsString('data-task-status-enabled="1"', $html);
+        self::assertStringContainsString('Status', $html);    // status filter visible
+        self::assertStringContainsString('No tasks yet', $html);
+        // Phase 19: page-specific JS still loaded.
+        self::assertStringContainsString('/assets/js/sprint-planner.js', $html);
+    }
+}

+ 71 - 107
views/audit/index.php → views/audit/index.twig

@@ -1,58 +1,43 @@
-<?php
-/** @var array{user_email:string,action:string,entity_type:string,entity_id:string,from_date:string,to_date:string} $filters */
-/** @var int $page */
-/** @var int $pages */
-/** @var int $pageSize */
-/** @var int $total */
-/** @var list<array<string,mixed>> $rows */
-/** @var list<string> $actions */
-/** @var list<string> $entityTypes */
-/** @var list<string> $users */
-use function App\Http\e;
+{% extends "layout.twig" %}
 
-/** Pretty-print JSON for display; tolerate non-JSON values gracefully. */
-$prettyJson = static function (?string $raw): string {
-    if ($raw === null || $raw === '') { return ''; }
-    try {
-        $v = json_decode($raw, true, 64, JSON_THROW_ON_ERROR);
-    } catch (\JsonException) {
-        return $raw;
-    }
-    return json_encode($v, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: $raw;
-};
+{% set actionClasses = {
+    'CREATE':          'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
+    'UPDATE':          'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200',
+    'DELETE':          'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
+    'LOGIN':           'bg-slate-100 text-slate-700 dark:bg-slate-700 dark:text-slate-200',
+    'LOGOUT':          'bg-slate-100 text-slate-700 dark:bg-slate-700 dark:text-slate-200',
+    'LOGIN_FAILED':    'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200',
+    'BOOTSTRAP_ADMIN': 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200',
+} %}
+{% set defaultActionClass = 'bg-slate-100 text-slate-700 dark:bg-slate-700 dark:text-slate-200' %}
 
-/** Build the current query string minus one key (for pagination links). */
-$qsWithout = static function (array $filters, string $drop, array $extra = []): string {
-    $params = array_filter(array_merge($filters, $extra), fn($v) => $v !== '' && $v !== null);
-    unset($params[$drop]);
-    return $params === [] ? '' : '?' . http_build_query($params);
-};
+{% set anyFilter = false %}
+{% for v in filters %}{% if v != '' and v is not null %}{% set anyFilter = true %}{% endif %}{% endfor %}
 
-$anyFilter = array_filter($filters, fn($v) => $v !== '' && $v !== null);
-?>
+{% block content %}
 <section class="space-y-5">
     <header class="flex items-end justify-between gap-4">
         <div>
             <h1 class="text-2xl font-semibold tracking-tight">Audit log</h1>
             <p class="text-slate-600 text-sm mt-1 dark:text-slate-400">
-                <?= (int) $total ?> matching row<?= $total === 1 ? '' : 's' ?>
-                · page <?= (int) $page ?> / <?= (int) $pages ?>
-                · <?= (int) $pageSize ?> per page
+                {{ total }} matching row{{ total == 1 ? '' : 's' }}
+                · page {{ page }} / {{ pages }}
+                · {{ pageSize }} per page
             </p>
         </div>
     </header>
 
-    <!-- Filter form -->
     <form method="get" action="/audit"
+          hx-boost="true" hx-target="body" hx-push-url="true"
           class="rounded-lg border bg-white p-4 grid grid-cols-1 md:grid-cols-6 gap-3 dark:bg-slate-800 dark:border-slate-700">
         <label class="block">
             <span class="text-xs text-slate-600 dark:text-slate-400">User</span>
             <select name="user_email"
                     class="mt-1 block w-full rounded border border-slate-300 px-2 py-1.5 text-sm 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="">Any</option>
-                <?php foreach ($users as $u): ?>
-                    <option value="<?= e($u) ?>" <?= $filters['user_email'] === $u ? 'selected' : '' ?>><?= e($u) ?></option>
-                <?php endforeach; ?>
+                {% for u in users %}
+                    <option value="{{ u }}" {{ filters.user_email == u ? 'selected' : '' }}>{{ u }}</option>
+                {% endfor %}
             </select>
         </label>
 
@@ -61,9 +46,9 @@ $anyFilter = array_filter($filters, fn($v) => $v !== '' && $v !== null);
             <select name="action"
                     class="mt-1 block w-full rounded border border-slate-300 px-2 py-1.5 text-sm 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="">Any</option>
-                <?php foreach ($actions as $a): ?>
-                    <option value="<?= e($a) ?>" <?= $filters['action'] === $a ? 'selected' : '' ?>><?= e($a) ?></option>
-                <?php endforeach; ?>
+                {% for a in actions %}
+                    <option value="{{ a }}" {{ filters.action == a ? 'selected' : '' }}>{{ a }}</option>
+                {% endfor %}
             </select>
         </label>
 
@@ -72,37 +57,37 @@ $anyFilter = array_filter($filters, fn($v) => $v !== '' && $v !== null);
             <select name="entity_type"
                     class="mt-1 block w-full rounded border border-slate-300 px-2 py-1.5 text-sm 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="">Any</option>
-                <?php foreach ($entityTypes as $t): ?>
-                    <option value="<?= e($t) ?>" <?= $filters['entity_type'] === $t ? 'selected' : '' ?>><?= e($t) ?></option>
-                <?php endforeach; ?>
+                {% for t in entityTypes %}
+                    <option value="{{ t }}" {{ filters.entity_type == t ? 'selected' : '' }}>{{ t }}</option>
+                {% endfor %}
             </select>
         </label>
 
         <label class="block">
             <span class="text-xs text-slate-600 dark:text-slate-400">Entity ID (contains)</span>
             <input name="entity_id" type="text"
-                   value="<?= e($filters['entity_id']) ?>"
+                   value="{{ filters.entity_id }}"
                    class="mt-1 block w-full rounded border border-slate-300 px-2 py-1.5 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">
         </label>
 
         <label class="block">
             <span class="text-xs text-slate-600 dark:text-slate-400">From date</span>
             <input name="from_date" type="date"
-                   value="<?= e($filters['from_date']) ?>"
+                   value="{{ filters.from_date }}"
                    class="mt-1 block w-full rounded border border-slate-300 px-2 py-1.5 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">
         </label>
 
         <label class="block">
             <span class="text-xs text-slate-600 dark:text-slate-400">To date</span>
             <input name="to_date" type="date"
-                   value="<?= e($filters['to_date']) ?>"
+                   value="{{ filters.to_date }}"
                    class="mt-1 block w-full rounded border border-slate-300 px-2 py-1.5 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">
         </label>
 
         <div class="md:col-span-6 flex items-center justify-end gap-2">
-            <?php if ($anyFilter): ?>
+            {% if anyFilter %}
                 <a href="/audit" class="text-sm text-slate-600 hover:underline dark:text-slate-400 dark:hover:text-slate-200">Clear</a>
-            <?php endif; ?>
+            {% endif %}
             <button type="submit"
                     class="rounded bg-slate-900 text-white px-4 py-1.5 text-sm font-medium hover:bg-slate-800 dark:bg-slate-700 dark:hover:bg-slate-600">
                 Apply
@@ -110,11 +95,10 @@ $anyFilter = array_filter($filters, fn($v) => $v !== '' && $v !== null);
         </div>
     </form>
 
-    <!-- Rows -->
     <div class="rounded-lg border bg-white overflow-hidden dark:bg-slate-800 dark:border-slate-700">
-        <?php if ($rows === []): ?>
+        {% if rows is empty %}
             <div class="p-8 text-center text-slate-500 text-sm dark:text-slate-400">No audit rows match.</div>
-        <?php else: ?>
+        {% else %}
             <table class="min-w-full text-sm">
                 <thead class="bg-slate-50 text-slate-600 text-xs uppercase tracking-wider dark:bg-slate-700 dark:text-slate-300">
                     <tr>
@@ -127,92 +111,72 @@ $anyFilter = array_filter($filters, fn($v) => $v !== '' && $v !== null);
                     </tr>
                 </thead>
                 <tbody class="divide-y divide-slate-100 align-top dark:divide-slate-700">
-                    <?php foreach ($rows as $r): ?>
+                    {% for r in rows %}
                         <tr>
-                            <td class="px-3 py-2 font-mono text-xs whitespace-nowrap">
-                                <?= e((string) $r['occurred_at']) ?>
-                            </td>
+                            <td class="px-3 py-2 font-mono text-xs whitespace-nowrap">{{ r.occurred_at }}</td>
                             <td class="px-3 py-2">
-                                <?= $r['user_email'] !== null && $r['user_email'] !== ''
-                                    ? e((string) $r['user_email'])
-                                    : '<span class="text-slate-400 dark:text-slate-500">—</span>' ?>
+                                {% if r.user_email is not null and r.user_email != '' %}{{ r.user_email }}{% else %}<span class="text-slate-400 dark:text-slate-500">—</span>{% endif %}
                             </td>
                             <td class="px-3 py-2">
-                                <span class="inline-block px-1.5 py-0.5 rounded text-xs font-mono
-                                    <?php
-                                    $action = (string) $r['action'];
-                                    echo match ($action) {
-                                        'CREATE'          => 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
-                                        'UPDATE'          => 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200',
-                                        'DELETE'          => 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
-                                        'LOGIN'           => 'bg-slate-100 text-slate-700 dark:bg-slate-700 dark:text-slate-200',
-                                        'LOGOUT'          => 'bg-slate-100 text-slate-700 dark:bg-slate-700 dark:text-slate-200',
-                                        'LOGIN_FAILED'    => 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200',
-                                        'BOOTSTRAP_ADMIN' => 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200',
-                                        default           => 'bg-slate-100 text-slate-700 dark:bg-slate-700 dark:text-slate-200',
-                                    };
-                                    ?>">
-                                    <?= e($action) ?>
+                                <span class="inline-block px-1.5 py-0.5 rounded text-xs font-mono {{ actionClasses[r.action]|default(defaultActionClass) }}">
+                                    {{ r.action }}
                                 </span>
                             </td>
                             <td class="px-3 py-2 font-mono text-xs whitespace-nowrap">
-                                <?= e((string) $r['entity_type']) ?>
-                                <?php if ($r['entity_id'] !== null): ?>
+                                {{ r.entity_type }}
+                                {% if r.entity_id is not null %}
                                     <span class="text-slate-500 dark:text-slate-400">/</span>
-                                    <?= e((string) $r['entity_id']) ?>
-                                <?php endif; ?>
+                                    {{ r.entity_id }}
+                                {% endif %}
                             </td>
                             <td class="px-3 py-2">
-                                <?php $b = $prettyJson($r['before_json'] ?? null); $a = $prettyJson($r['after_json'] ?? null); ?>
-                                <?php if ($b === '' && $a === ''): ?>
+                                {% set b = pretty_json(r.before_json|default(null)) %}
+                                {% set a = pretty_json(r.after_json|default(null)) %}
+                                {% if b == '' and a == '' %}
                                     <span class="text-slate-400 text-xs dark:text-slate-500">—</span>
-                                <?php else: ?>
+                                {% else %}
                                     <details class="text-xs">
                                         <summary class="cursor-pointer text-slate-600 hover:text-slate-900 dark:text-slate-300 dark:hover:text-slate-100">
-                                            <?= $b !== '' && $a !== '' ? 'before / after'
-                                                : ($b !== '' ? 'before only' : 'after only') ?>
+                                            {% if b != '' and a != '' %}before / after{% elseif b != '' %}before only{% else %}after only{% endif %}
                                         </summary>
-                                        <?php if ($b !== ''): ?>
+                                        {% if b != '' %}
                                             <div class="mt-1 text-[11px] text-slate-500 dark:text-slate-400">before</div>
-                                            <pre class="mt-1 p-2 bg-slate-50 rounded overflow-x-auto dark:bg-slate-900 dark:text-slate-200"><?= e($b) ?></pre>
-                                        <?php endif; ?>
-                                        <?php if ($a !== ''): ?>
+                                            <pre class="mt-1 p-2 bg-slate-50 rounded overflow-x-auto dark:bg-slate-900 dark:text-slate-200">{{ b }}</pre>
+                                        {% endif %}
+                                        {% if a != '' %}
                                             <div class="mt-1 text-[11px] text-slate-500 dark:text-slate-400">after</div>
-                                            <pre class="mt-1 p-2 bg-slate-50 rounded overflow-x-auto dark:bg-slate-900 dark:text-slate-200"><?= e($a) ?></pre>
-                                        <?php endif; ?>
+                                            <pre class="mt-1 p-2 bg-slate-50 rounded overflow-x-auto dark:bg-slate-900 dark:text-slate-200">{{ a }}</pre>
+                                        {% endif %}
                                     </details>
-                                <?php endif; ?>
+                                {% endif %}
                             </td>
                             <td class="px-3 py-2 text-xs text-slate-500 whitespace-nowrap dark:text-slate-400">
-                                <?= e((string) ($r['ip_address'] ?? '')) ?>
-                                <?php if (!empty($r['user_agent'])): ?>
-                                    <span class="text-slate-300 dark:text-slate-500"
-                                          title="<?= e((string) $r['user_agent']) ?>">(UA)</span>
-                                <?php endif; ?>
+                                {{ r.ip_address|default('') }}
+                                {% if r.user_agent is defined and r.user_agent %}
+                                    <span class="text-slate-300 dark:text-slate-500" title="{{ r.user_agent }}">(UA)</span>
+                                {% endif %}
                             </td>
                         </tr>
-                    <?php endforeach; ?>
+                    {% endfor %}
                 </tbody>
             </table>
-        <?php endif; ?>
+        {% endif %}
     </div>
 
-    <!-- Pagination -->
-    <?php if ($pages > 1): ?>
+    {% if pages > 1 %}
         <nav class="flex items-center justify-between text-sm">
-            <?php
-            $prevQs = $qsWithout($filters, 'page', ['page' => max(1, $page - 1)]);
-            $nextQs = $qsWithout($filters, 'page', ['page' => min($pages, $page + 1)]);
-            ?>
-            <a href="/audit<?= e($prevQs) ?>"
-               class="<?= $page <= 1 ? 'pointer-events-none text-slate-300 dark:text-slate-600' : 'text-blue-700 hover:underline dark:text-blue-400 dark:hover:text-blue-300' ?>">
+            {% set prevQs = query_string(filters, 'page', { page: max(1, page - 1) }) %}
+            {% set nextQs = query_string(filters, 'page', { page: min(pages, page + 1) }) %}
+            <a href="/audit{{ prevQs }}"
+               class="{{ page <= 1 ? 'pointer-events-none text-slate-300 dark:text-slate-600' : 'text-blue-700 hover:underline dark:text-blue-400 dark:hover:text-blue-300' }}">
                 ← Previous
             </a>
-            <span class="text-slate-600 dark:text-slate-400">Page <?= (int) $page ?> of <?= (int) $pages ?></span>
-            <a href="/audit<?= e($nextQs) ?>"
-               class="<?= $page >= $pages ? 'pointer-events-none text-slate-300 dark:text-slate-600' : 'text-blue-700 hover:underline dark:text-blue-400 dark:hover:text-blue-300' ?>">
+            <span class="text-slate-600 dark:text-slate-400">Page {{ page }} of {{ pages }}</span>
+            <a href="/audit{{ nextQs }}"
+               class="{{ page >= pages ? 'pointer-events-none text-slate-300 dark:text-slate-600' : 'text-blue-700 hover:underline dark:text-blue-400 dark:hover:text-blue-300' }}">
                 Next →
             </a>
         </nav>
-    <?php endif; ?>
+    {% endif %}
 </section>
+{% endblock %}

+ 10 - 12
views/auth/local.php → views/auth/local.twig

@@ -1,9 +1,6 @@
-<?php
-/** @var string $csrfToken */
-/** @var string $email */
-/** @var bool   $error */
-use function App\Http\e;
-?>
+{% extends "layout.twig" %}
+
+{% block content %}
 <section class="max-w-md mx-auto mt-6">
     <div class="rounded-lg border bg-white p-6 dark:bg-slate-800 dark:border-slate-700">
         <h1 class="text-xl font-semibold tracking-tight">Local admin sign-in</h1>
@@ -12,20 +9,20 @@ use function App\Http\e;
             come from the <code>LOCAL_ADMIN_*</code> environment variables.
         </p>
 
-        <?php if ($error): ?>
+        {% if error %}
             <div class="mt-4 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-800 dark:bg-red-900 dark:border-red-800 dark:text-red-200">
                 Email or password did not match.
             </div>
-        <?php endif; ?>
+        {% endif %}
 
-        <form method="post" action="/auth/local" class="mt-4 space-y-3"
-              autocomplete="off">
-            <input type="hidden" name="_csrf" value="<?= e($csrfToken) ?>">
+        <form method="post" action="/auth/local" hx-boost="true" hx-target="body"
+              class="mt-4 space-y-3" autocomplete="off">
+            <input type="hidden" name="_csrf" value="{{ csrfToken }}">
 
             <label class="block">
                 <span class="text-sm text-slate-700 dark:text-slate-300">Email</span>
                 <input type="email" name="email" required
-                       value="<?= e($email) ?>"
+                       value="{{ email }}"
                        class="mt-1 block w-full rounded-md border-slate-300 shadow-sm
                               px-3 py-2 border 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">
@@ -50,3 +47,4 @@ use function App\Http\e;
         </p>
     </div>
 </section>
+{% endblock %}

+ 43 - 52
views/home.php → views/home.twig

@@ -1,23 +1,14 @@
-<?php
-/** @var int    $schemaVersion */
-/** @var string $dbPath */
-/** @var string $appEnv */
-/** @var \App\Domain\User|null $currentUser */
-/** @var bool   $oidcConfigured */
-/** @var bool   $localAdminEnabled */
-/** @var bool   $authError */
-/** @var list<array{sprint: \App\Domain\Sprint, nWorkers:int, nTasks:int}> $sprintRows */
-use function App\Http\e;
-$sprintRows = $sprintRows ?? [];
-?>
+{% extends "layout.twig" %}
+
+{% block content %}
 <section class="space-y-6">
-    <?php if ($authError ?? false): ?>
+    {% if authError|default(false) %}
         <div class="rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800 dark:bg-red-900 dark:border-red-800 dark:text-red-200">
             Sign-in failed. Check the server logs or the audit log for details.
         </div>
-    <?php endif; ?>
+    {% endif %}
 
-    <?php if ($currentUser === null): ?>
+    {% if currentUser is null %}
         <div class="rounded-lg border bg-white p-6 dark:bg-slate-800 dark:border-slate-700">
             <h1 class="text-2xl font-semibold tracking-tight">Sprint Planner</h1>
             <p class="text-slate-600 mt-2 max-w-prose dark:text-slate-400">
@@ -25,51 +16,51 @@ $sprintRows = $sprintRows ?? [];
                 to sign in becomes the admin automatically.
             </p>
             <div class="mt-4 flex flex-wrap items-center gap-3">
-                <?php if ($oidcConfigured): ?>
+                {% if oidcConfigured %}
                     <a href="/auth/login"
                        class="inline-flex items-center gap-2 rounded-md bg-slate-900 text-white px-4 py-2 text-sm font-medium hover:bg-slate-800 dark:bg-slate-700 dark:hover:bg-slate-600">
                         Sign in with Microsoft
                     </a>
-                <?php endif; ?>
-                <?php if ($localAdminEnabled): ?>
+                {% endif %}
+                {% if localAdminEnabled %}
                     <a href="/auth/local"
                        class="inline-flex items-center gap-2 rounded-md border border-slate-300 bg-white text-slate-700 px-4 py-2 text-sm font-medium hover:bg-slate-100 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-200 dark:hover:bg-slate-700">
                         Sign in as local admin
                     </a>
-                <?php endif; ?>
-                <?php if (!$oidcConfigured && !$localAdminEnabled): ?>
+                {% endif %}
+                {% if not oidcConfigured and not localAdminEnabled %}
                     <span class="inline-block rounded-md bg-slate-100 text-slate-600 px-3 py-2 text-sm dark:bg-slate-700 dark:text-slate-300">
                         No sign-in method configured. Set <code>ENTRA_*</code> or
                         <code>LOCAL_ADMIN_*</code> in <code>.env</code>.
                     </span>
-                <?php endif; ?>
+                {% endif %}
             </div>
         </div>
-    <?php else: ?>
+    {% else %}
         <div class="flex items-end justify-between gap-4">
             <div>
                 <h1 class="text-2xl font-semibold tracking-tight">Sprints</h1>
                 <p class="text-slate-600 mt-1 text-sm dark:text-slate-400">
-                    <?= count($sprintRows) ?> sprint<?= count($sprintRows) === 1 ? '' : 's' ?>.
+                    {{ sprintRows|length }} sprint{{ sprintRows|length == 1 ? '' : 's' }}.
                 </p>
             </div>
-            <?php if ($currentUser->isAdmin): ?>
+            {% if currentUser.isAdmin %}
                 <a href="/sprints/new"
                    class="inline-flex items-center gap-2 rounded-md bg-slate-900 text-white px-4 py-2 text-sm font-medium hover:bg-slate-800 dark:bg-slate-700 dark:hover:bg-slate-600">
                     New sprint
                 </a>
-            <?php endif; ?>
+            {% endif %}
         </div>
 
         <div class="rounded-lg border bg-white overflow-hidden dark:bg-slate-800 dark:border-slate-700">
-            <?php if ($sprintRows === []): ?>
+            {% if sprintRows is empty %}
                 <div class="p-8 text-center text-slate-500 text-sm dark:text-slate-400">
                     No sprints yet.
-                    <?php if ($currentUser->isAdmin): ?>
+                    {% if currentUser.isAdmin %}
                         <a href="/sprints/new" class="text-blue-700 hover:underline dark:text-blue-400 dark:hover:text-blue-300">Create the first one</a>.
-                    <?php endif; ?>
+                    {% endif %}
                 </div>
-            <?php else: ?>
+            {% else %}
                 <table class="min-w-full text-sm">
                     <thead class="bg-slate-50 text-slate-600 text-xs uppercase tracking-wider dark:bg-slate-700 dark:text-slate-300">
                         <tr>
@@ -82,62 +73,62 @@ $sprintRows = $sprintRows ?? [];
                         </tr>
                     </thead>
                     <tbody class="divide-y divide-slate-100 dark:divide-slate-700">
-                        <?php foreach ($sprintRows as $row): $s = $row['sprint']; ?>
+                        {% for row in sprintRows %}
+                            {% set s = row.sprint %}
                             <tr class="hover:bg-slate-50 cursor-pointer dark:hover:bg-slate-700"
-                                data-href="/sprints/<?= (int) $s->id ?>">
+                                data-href="/sprints/{{ s.id }}">
                                 <td class="px-4 py-2 font-medium">
-                                    <a href="/sprints/<?= (int) $s->id ?>" class="hover:underline">
-                                        <?= e($s->name) ?>
-                                    </a>
+                                    <a href="/sprints/{{ s.id }}" class="hover:underline">{{ s.name }}</a>
                                 </td>
                                 <td class="px-4 py-2 text-slate-600 dark:text-slate-400">
-                                    <?= e($s->startDate) ?> – <?= e($s->endDate) ?>
+                                    {{ s.startDate }} – {{ s.endDate }}
                                 </td>
-                                <td class="px-4 py-2 text-right font-mono"><?= (int) $row['nWorkers'] ?></td>
-                                <td class="px-4 py-2 text-right font-mono"><?= (int) $row['nTasks'] ?></td>
+                                <td class="px-4 py-2 text-right font-mono">{{ row.nWorkers }}</td>
+                                <td class="px-4 py-2 text-right font-mono">{{ row.nTasks }}</td>
                                 <td class="px-4 py-2 text-right font-mono">
-                                    <?= e(number_format($s->reserveFraction * 100, 0)) ?>%
+                                    {{ (s.reserveFraction * 100)|number_format(0) }}%
                                 </td>
                                 <td class="px-4 py-2">
-                                    <?php if ($s->isArchived): ?>
+                                    {% if s.isArchived %}
                                         <span class="inline-block px-2 py-0.5 text-xs bg-slate-100 text-slate-600 rounded dark:bg-slate-700 dark:text-slate-300">archived</span>
-                                    <?php else: ?>
+                                    {% else %}
                                         <span class="inline-block px-2 py-0.5 text-xs bg-green-100 text-green-800 rounded dark:bg-green-900 dark:text-green-200">active</span>
-                                    <?php endif; ?>
+                                    {% endif %}
                                 </td>
                             </tr>
-                        <?php endforeach; ?>
+                        {% endfor %}
                     </tbody>
                 </table>
-            <?php endif; ?>
+            {% endif %}
         </div>
-    <?php endif; ?>
+    {% endif %}
 
-    <?php if ($currentUser === null || $currentUser->isAdmin): ?>
+    {% if currentUser is null or currentUser.isAdmin %}
     <details class="rounded-lg border bg-white p-4 dark:bg-slate-800 dark:border-slate-700">
         <summary class="text-sm font-semibold text-slate-700 uppercase tracking-wider cursor-pointer dark:text-slate-200">Runtime</summary>
         <dl class="mt-3 grid grid-cols-[max-content_1fr] gap-x-6 gap-y-1 text-sm">
             <dt class="text-slate-500 dark:text-slate-400">PHP</dt>
-            <dd class="font-mono"><?= e(PHP_VERSION) ?></dd>
+            <dd class="font-mono">{{ constant('PHP_VERSION') }}</dd>
 
             <dt class="text-slate-500 dark:text-slate-400">APP_ENV</dt>
-            <dd class="font-mono"><?= e($appEnv) ?></dd>
+            <dd class="font-mono">{{ appEnv }}</dd>
 
             <dt class="text-slate-500 dark:text-slate-400">SQLite file</dt>
-            <dd class="font-mono break-all"><?= e($dbPath) ?></dd>
+            <dd class="font-mono break-all">{{ dbPath }}</dd>
 
             <dt class="text-slate-500 dark:text-slate-400">Schema version</dt>
-            <dd class="font-mono"><?= e($schemaVersion) ?></dd>
+            <dd class="font-mono">{{ schemaVersion }}</dd>
 
             <dt class="text-slate-500 dark:text-slate-400">OIDC</dt>
-            <dd class="font-mono"><?= $oidcConfigured ? 'configured' : 'not configured' ?></dd>
+            <dd class="font-mono">{{ oidcConfigured ? 'configured' : 'not configured' }}</dd>
 
             <dt class="text-slate-500 dark:text-slate-400">Local admin</dt>
-            <dd class="font-mono"><?= $localAdminEnabled ? 'enabled' : 'disabled' ?></dd>
+            <dd class="font-mono">{{ localAdminEnabled ? 'enabled' : 'disabled' }}</dd>
         </dl>
         <p class="mt-4 text-xs text-slate-500 dark:text-slate-400">
             Liveness probe: <a class="text-blue-700 hover:underline dark:text-blue-400 dark:hover:text-blue-300" href="/healthz"><code>/healthz</code></a>
         </p>
     </details>
-    <?php endif; ?>
+    {% endif %}
 </section>
+{% endblock %}

+ 16 - 0
views/layout-bare.twig

@@ -0,0 +1,16 @@
+<!doctype html>
+<html lang="en">
+<head>
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width,initial-scale=1">
+    <title>{{ title|default(sprint.name ~ ' — present') }}</title>
+    <script src="/assets/js/theme-init.js"></script>
+    <link rel="stylesheet" href="/assets/css/app.css">
+    <script src="/assets/js/vendor/sortable.min.js" defer></script>
+    <script src="/assets/js/sprint-planner.js" defer></script>
+    <script src="/assets/js/vendor/alpine-csp.min.js" defer></script>
+</head>
+<body class="bg-white text-slate-900 antialiased dark:bg-slate-900 dark:text-slate-100">
+    {% block content %}{% endblock %}
+</body>
+</html>

+ 28 - 33
views/layout.php → views/layout.twig

@@ -1,25 +1,16 @@
-<?php
-/** @var string $content */
-/** @var string $title */
-/** @var \App\Domain\User|null $currentUser */
-/** @var string $csrfToken */
-use function App\Http\e;
-$currentUser = $currentUser ?? null;
-$csrfToken   = $csrfToken   ?? '';
-?>
 <!doctype html>
 <html lang="en">
 <head>
     <meta charset="utf-8">
     <meta name="viewport" content="width=device-width,initial-scale=1">
-    <title><?= e($title ?? 'Sprint Planner') ?></title>
+    <title>{{ title|default('Sprint Planner') }}</title>
     <script src="/assets/js/theme-init.js"></script>
     <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/vendor/htmx.min.js" defer></script>
+    <script src="/assets/js/vendor/sortable.min.js" defer></script>
     <script src="/assets/js/app.js" defer></script>
+    <script src="/assets/js/vendor/alpine-csp.min.js" defer></script>
+    {% block head_extra %}{% endblock %}
 </head>
 <body class="bg-slate-100 text-slate-900 antialiased dark:bg-slate-900 dark:text-slate-100">
     <header class="border-b bg-white dark:bg-slate-800 dark:border-slate-700">
@@ -27,22 +18,23 @@ $csrfToken   = $csrfToken   ?? '';
             <a href="/" class="font-semibold tracking-tight">Sprint Planner</a>
 
             <nav class="ml-auto flex items-center gap-4 text-sm">
-                <?php if ($currentUser !== null): ?>
+                {% if currentUser is not null %}
                     <a href="/" class="text-slate-600 hover:text-slate-900 hover:underline dark:text-slate-300 dark:hover:text-slate-100">Sprints</a>
-                    <?php if ($currentUser->isAdmin): ?>
+                    {% if currentUser.isAdmin %}
                         <a href="/sprints/new" class="text-slate-600 hover:text-slate-900 hover:underline dark:text-slate-300 dark:hover:text-slate-100">New sprint</a>
-                    <?php endif; ?>
+                    {% endif %}
                     <span class="text-slate-400 dark:text-slate-600">·</span>
                     <span class="text-slate-600 dark:text-slate-300">
-                        <?= e($currentUser->displayName) ?>
-                        <?php if ($currentUser->isAdmin): ?>
+                        {{ currentUser.displayName }}
+                        {% if currentUser.isAdmin %}
                             <span class="ml-1 inline-block px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider bg-amber-100 text-amber-800 rounded dark:bg-amber-900 dark:text-amber-200">admin</span>
-                        <?php endif; ?>
+                        {% endif %}
                     </span>
-                    <div class="relative">
+                    <div class="relative" x-data="appMenu">
                         <button type="button"
-                                data-menu-trigger
-                                aria-expanded="false"
+                                x-ref="trigger"
+                                x-on:click="toggle()"
+                                x-bind:aria-expanded="open"
                                 aria-haspopup="true"
                                 aria-controls="app-menu"
                                 aria-label="Open menu"
@@ -54,11 +46,14 @@ $csrfToken   = $csrfToken   ?? '';
                             </svg>
                         </button>
                         <div id="app-menu"
-                             data-menu
                              role="menu"
-                             hidden
+                             x-show="open"
+                             x-on:click.outside="close()"
+                             x-on:keydown.escape.window="closeAndFocus()"
+                             x-on:click="closeOnItem($event)"
+                             x-cloak
                              class="absolute right-0 mt-2 min-w-[12rem] rounded-md border border-slate-200 bg-white shadow-lg py-1 z-10 dark:bg-slate-800 dark:border-slate-700">
-                            <?php if ($currentUser->isAdmin): ?>
+                            {% if currentUser.isAdmin %}
                                 <a href="/workers" role="menuitem"
                                    class="block px-3 py-2 text-sm text-slate-700 hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-slate-400 dark:text-slate-200 dark:hover:bg-slate-700">Workers</a>
                                 <a href="/users" role="menuitem"
@@ -67,15 +62,15 @@ $csrfToken   = $csrfToken   ?? '';
                                    class="block px-3 py-2 text-sm text-slate-700 hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-slate-400 dark:text-slate-200 dark:hover:bg-slate-700">Audit log</a>
                                 <a href="/settings" role="menuitem"
                                    class="block px-3 py-2 text-sm text-slate-700 hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-slate-400 dark:text-slate-200 dark:hover:bg-slate-700">Settings</a>
-                            <?php endif; ?>
-                            <button type="button" role="menuitem" data-theme-toggle
+                            {% endif %}
+                            <button type="button" role="menuitem" x-data="themeToggle" x-on:click="flip()"
                                     class="w-full text-left px-3 py-2 text-sm text-slate-700 hover:bg-slate-50 flex items-center justify-between focus:outline-none focus:ring-2 focus:ring-slate-400 dark:text-slate-200 dark:hover:bg-slate-700">
                                 <span>Theme</span>
-                                <span data-theme-label class="text-slate-500 dark:text-slate-400">Light</span>
+                                <span x-text="label" class="text-slate-500 dark:text-slate-400">Light</span>
                             </button>
                             <hr class="my-1 border-slate-200 dark:border-slate-700">
                             <form method="post" action="/auth/logout">
-                                <input type="hidden" name="_csrf" value="<?= e($csrfToken) ?>">
+                                <input type="hidden" name="_csrf" value="{{ csrfToken }}">
                                 <button type="submit" role="menuitem"
                                         class="block w-full text-left px-3 py-2 text-sm text-slate-700 hover:bg-slate-50 font-[inherit] focus:outline-none focus:ring-2 focus:ring-slate-400 dark:text-slate-200 dark:hover:bg-slate-700">
                                     Sign out
@@ -83,16 +78,16 @@ $csrfToken   = $csrfToken   ?? '';
                             </form>
                         </div>
                     </div>
-                <?php else: ?>
+                {% else %}
                     <a href="/auth/login"
                        class="text-blue-700 hover:underline dark:text-blue-400 dark:hover:text-blue-300">Sign in</a>
-                <?php endif; ?>
+                {% endif %}
             </nav>
         </div>
     </header>
 
     <main class="max-w-7xl mx-auto px-4 py-6">
-        <?= $content ?>
+        {% block content %}{% endblock %}
     </main>
 </body>
 </html>

+ 12 - 19
views/settings/index.php → views/settings/index.twig

@@ -1,15 +1,6 @@
-<?php
-/**
- * /settings — admin-only global flags.
- *
- * @var \App\Domain\User $currentUser
- * @var string           $csrfToken
- * @var array<string,bool>   $values     key => current bool
- * @var array<string,string> $keyLabels  key => human label
- * @var ?string          $flash
- */
-use function App\Http\e;
-?>
+{% extends "layout.twig" %}
+
+{% block content %}
 <section class="space-y-6">
     <header class="flex items-end justify-between gap-4">
         <div>
@@ -23,23 +14,24 @@ use function App\Http\e;
         </div>
     </header>
 
-    <?php if ($flash !== null): ?>
+    {% if flash is not null %}
         <div class="rounded-md border border-green-200 bg-green-50 px-4 py-2 text-sm text-green-800 dark:bg-green-900 dark:border-green-800 dark:text-green-200">
-            <?= e($flash) ?>
+            {{ flash }}
         </div>
-    <?php endif; ?>
+    {% endif %}
 
-    <form method="post" action="/settings" class="rounded-lg border bg-white p-5 space-y-4 dark:bg-slate-800 dark:border-slate-700">
-        <input type="hidden" name="_csrf" value="<?= e($csrfToken) ?>">
+    <form method="post" action="/settings" hx-boost="true" hx-target="body"
+          class="rounded-lg border bg-white p-5 space-y-4 dark:bg-slate-800 dark:border-slate-700">
+        <input type="hidden" name="_csrf" value="{{ csrfToken }}">
 
         <h2 class="text-sm font-semibold text-slate-700 uppercase tracking-wider dark:text-slate-200">Features</h2>
 
         <label class="flex items-start gap-3">
             <input type="checkbox" name="task_status_enabled" value="1"
-                   <?= !empty($values['task_status_enabled']) ? 'checked' : '' ?>
+                   {{ values.task_status_enabled|default(false) ? 'checked' : '' }}
                    class="mt-1 rounded border-slate-300 focus:ring-slate-400 dark:border-slate-600 dark:focus:ring-slate-500">
             <span>
-                <span class="font-medium"><?= e($keyLabels['task_status_enabled'] ?? 'Task status colors') ?></span>
+                <span class="font-medium">{{ keyLabels.task_status_enabled|default('Task status colors') }}</span>
                 <span class="block text-xs text-slate-500 mt-0.5 dark:text-slate-400">
                     Show a status selector next to each task-cell day input on every
                     sprint plan. States: <em>zugewiesen</em> (transparent),
@@ -59,3 +51,4 @@ use function App\Http\e;
         </div>
     </form>
 </section>
+{% endblock %}

+ 275 - 0
views/sprints/_task_list.twig

@@ -0,0 +1,275 @@
+{# Shared task list section used by both views/sprints/show.twig and views/sprints/present.twig.
+   The owning template provides: sprint, currentUser, sprintWorkers, tasks, taskGrid,
+   statusGrid, ownerChoices, taskStatusEnabled. #}
+{% set TaskAssignment_STATUSES = constant('App\\Domain\\TaskAssignment::STATUSES') %}
+{% set STATUS_ZUGEWIESEN = constant('App\\Domain\\TaskAssignment::STATUS_ZUGEWIESEN') %}
+
+<section class="rounded-lg border bg-white overflow-hidden {% 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' }}">
+    <div class="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>
+
+        <div class="ml-auto flex flex-wrap items-center gap-2">
+            <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 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-200 dark:hover:bg-slate-700">
+                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 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-100 dark:focus:ring-slate-500">
+
+            <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 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-100 dark:focus:ring-slate-500">
+                <option value="">All prios</option>
+                <option value="1">Prio 1 only</option>
+                <option value="2">Prio 2 only</option>
+            </select>
+
+            <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 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-200 dark:hover:bg-slate-700">
+                    Owners <span data-owner-filter-count class="text-slate-500 dark:text-slate-400"></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 dark:bg-slate-800 dark:border-slate-700">
+                    <div class="px-3 py-2 text-xs text-slate-500 flex items-center justify-between dark:text-slate-400">
+                        <span>Owner</span>
+                        <button type="button" data-owner-filter-clear
+                                class="text-blue-700 hover:underline dark:text-blue-400 dark:hover:text-blue-300">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 dark:hover:bg-slate-700">
+                            <input type="checkbox" data-owner-filter-opt value="__none__"
+                                   class="rounded border-slate-300 dark:border-slate-600">
+                            <span class="text-slate-500 italic dark:text-slate-400">No owner</span>
+                        </label>
+                        {% for ow in ownerChoices %}
+                            <label class="flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-slate-50 dark:hover:bg-slate-700">
+                                <input type="checkbox" data-owner-filter-opt value="{{ ow.id }}"
+                                       class="rounded border-slate-300 dark:border-slate-600">
+                                <span>{{ ow.name }}</span>
+                            </label>
+                        {% endfor %}
+                    </div>
+                </div>
+            </div>
+
+            {% if taskStatusEnabled %}
+            <div class="relative" data-status-filter-root>
+                <button type="button" data-status-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 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-200 dark:hover:bg-slate-700">
+                    Status <span data-status-filter-count class="text-slate-500 dark:text-slate-400"></span>
+                </button>
+                <div data-status-filter-dropdown
+                     class="hidden absolute right-0 z-20 mt-1 w-56 rounded-md border border-slate-200 bg-white shadow-lg dark:bg-slate-800 dark:border-slate-700">
+                    <div class="px-3 py-2 text-xs text-slate-500 flex items-center justify-between dark:text-slate-400">
+                        <span>Status</span>
+                        <button type="button" data-status-filter-clear
+                                class="text-blue-700 hover:underline dark:text-blue-400 dark:hover:text-blue-300">Clear</button>
+                    </div>
+                    <div class="max-h-64 overflow-y-auto">
+                        {% for key, meta in {
+                            'zugewiesen':    ['Zugewiesen',    'border border-slate-300 dark:border-slate-600'],
+                            'gestartet':     ['Gestartet',     'bg-yellow-300 dark:bg-yellow-500'],
+                            'abgeschlossen': ['Abgeschlossen', 'bg-green-300 dark:bg-green-500'],
+                            'abgebrochen':   ['Abgebrochen',   'bg-red-300 dark:bg-red-500'],
+                        } %}
+                            <label class="flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-slate-50 dark:hover:bg-slate-700">
+                                <input type="checkbox" data-status-filter-opt value="{{ key }}"
+                                       class="rounded border-slate-300 dark:border-slate-600">
+                                <span class="inline-block h-3 w-3 rounded {{ meta[1] }}"></span>
+                                <span>{{ meta[0] }}</span>
+                            </label>
+                        {% endfor %}
+                    </div>
+                </div>
+            </div>
+            {% endif %}
+
+            <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 dark:text-slate-400">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 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-100 dark:focus:ring-slate-500">
+                    <option value="">All workers</option>
+                    {% for sw in sprintWorkers %}
+                        <option value="{{ sw.id }}">{{ sw.workerName }}</option>
+                    {% endfor %}
+                </select>
+            </div>
+
+            <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 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-200 dark:hover:bg-slate-700">
+                    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 dark:bg-slate-800 dark:border-slate-700">
+                    <div class="px-3 py-2 text-xs text-slate-500 dark:text-slate-400">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 dark:hover:bg-slate-700">
+                            <input type="checkbox" data-column-opt value="owner" checked class="rounded border-slate-300 dark:border-slate-600">
+                            <span>Owner</span>
+                        </label>
+                        <label class="flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-slate-50 dark:hover:bg-slate-700">
+                            <input type="checkbox" data-column-opt value="prio"  checked class="rounded border-slate-300 dark:border-slate-600">
+                            <span>Prio</span>
+                        </label>
+                        <label class="flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-slate-50 dark:hover:bg-slate-700">
+                            <input type="checkbox" data-column-opt value="tot"   checked class="rounded border-slate-300 dark:border-slate-600">
+                            <span>Tot</span>
+                        </label>
+                        {% for sw in sprintWorkers %}
+                            <label class="flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-slate-50 dark:hover:bg-slate-700">
+                                <input type="checkbox" data-column-opt value="sw-{{ sw.id }}" checked class="rounded border-slate-300 dark:border-slate-600">
+                                <span>{{ sw.workerName }}</span>
+                            </label>
+                        {% endfor %}
+                    </div>
+                </div>
+            </div>
+
+            {% 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 dark:bg-slate-700 dark:hover:bg-slate-600">
+                    + Add task
+                </button>
+            {% 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 dark:bg-slate-700 dark:text-slate-300">
+                <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>
+                    {% for sw in sprintWorkers %}
+                        <th class="text-center px-2 py-2 font-semibold cursor-pointer select-none whitespace-nowrap"
+                            data-sort-col="sw-{{ sw.id }}" data-col="sw-{{ sw.id }}">
+                            {{ sw.workerName }}
+                            <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">
+                            No tasks yet.
+                            {% if currentUser.isAdmin %}
+                                Click <b>+ Add task</b> to start.
+                            {% endif %}
+                        </td>
+                    </tr>
+                {% else %}
+                    {% for t in tasks %}
+                        {% set assign = taskGrid[t.id]|default({}) %}
+                        {% set tot = 0 %}
+                        {% for v in assign %}{% set tot = tot + v %}{% endfor %}
+                        <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 }}">
+                            <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>
+                                {% 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>
+                                {% endif %}
+                            </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{{ 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">{{ fmt_days(d) }}</span>
+                                    {% endif %}
+                                    {% if taskStatusEnabled %}
+                                        <select data-assign-status
+                                                data-sw-id="{{ sw.id }}"
+                                                aria-label="Status"
+                                                class="assign-status-select">
+                                            {% for opt in TaskAssignment_STATUSES %}
+                                                <option value="{{ opt }}" {{ opt == st ? 'selected' : '' }}>{{ opt }}</option>
+                                            {% endfor %}
+                                        </select>
+                                    {% 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 %}
+            </tbody>
+        </table>
+    </div>
+    <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>
+</section>

+ 25 - 26
views/sprints/new.php → views/sprints/new.twig

@@ -1,20 +1,17 @@
-<?php
-/** @var string $csrfToken */
-/** @var string $error */
-/** @var array{name:string,start_date:string,end_date:string,reserve_fraction:string,n_weeks:string} $form */
-use function App\Http\e;
+{% extends "layout.twig" %}
 
-$errorMessages = [
-    'name_required'         => 'Sprint name is required.',
-    'dates_invalid'         => 'Start and end dates must both be valid dates (YYYY-MM-DD).',
-    'dates_order'           => 'End date must not be before start date.',
-    'reserve_invalid'       => 'Reserve must be a number (0–100).',
-    'reserve_out_of_range'  => 'Reserve must be between 0 and 100 percent.',
-    'n_weeks_invalid'       => 'Weeks must be an integer.',
-    'n_weeks_range'         => 'Weeks must be between 1 and 26.',
-    'db_error'              => 'Could not save. Try again.',
-];
-?>
+{% set errorMessages = {
+    'name_required':        'Sprint name is required.',
+    'dates_invalid':        'Start and end dates must both be valid dates (YYYY-MM-DD).',
+    'dates_order':          'End date must not be before start date.',
+    'reserve_invalid':      'Reserve must be a number (0–100).',
+    'reserve_out_of_range': 'Reserve must be between 0 and 100 percent.',
+    'n_weeks_invalid':      'Weeks must be an integer.',
+    'n_weeks_range':        'Weeks must be between 1 and 26.',
+    'db_error':             'Could not save. Try again.',
+} %}
+
+{% block content %}
 <section class="max-w-xl">
     <h1 class="text-2xl font-semibold tracking-tight">New sprint</h1>
     <p class="text-slate-600 mt-1 text-sm dark:text-slate-400">
@@ -22,19 +19,20 @@ $errorMessages = [
         sprint page after creation.
     </p>
 
-    <?php if ($error !== '' && isset($errorMessages[$error])): ?>
+    {% if error != '' and errorMessages[error] is defined %}
         <div class="mt-4 rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800 dark:bg-red-900 dark:border-red-800 dark:text-red-200">
-            <?= e($errorMessages[$error]) ?>
+            {{ errorMessages[error] }}
         </div>
-    <?php endif; ?>
+    {% endif %}
 
-    <form method="post" action="/sprints" class="mt-6 space-y-4 rounded-lg border bg-white p-5 dark:bg-slate-800 dark:border-slate-700">
-        <input type="hidden" name="_csrf" value="<?= e($csrfToken) ?>">
+    <form method="post" action="/sprints" hx-boost="true" hx-target="body"
+          class="mt-6 space-y-4 rounded-lg border bg-white p-5 dark:bg-slate-800 dark:border-slate-700">
+        <input type="hidden" name="_csrf" value="{{ csrfToken }}">
 
         <label class="block">
             <span class="text-sm text-slate-700 dark:text-slate-300">Name</span>
             <input name="name" type="text" required
-                   value="<?= e($form['name']) ?>"
+                   value="{{ form.name }}"
                    placeholder="e.g. Sprint 12"
                    class="mt-1 block w-full rounded-md border-slate-300 border shadow-sm px-3 py-2 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">
         </label>
@@ -43,13 +41,13 @@ $errorMessages = [
             <label class="block">
                 <span class="text-sm text-slate-700 dark:text-slate-300">Start date</span>
                 <input name="start_date" type="date" required
-                       value="<?= e($form['start_date']) ?>"
+                       value="{{ form.start_date }}"
                        class="mt-1 block w-full rounded-md border-slate-300 border shadow-sm px-3 py-2 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">
             </label>
             <label class="block">
                 <span class="text-sm text-slate-700 dark:text-slate-300">End date</span>
                 <input name="end_date" type="date" required
-                       value="<?= e($form['end_date']) ?>"
+                       value="{{ form.end_date }}"
                        class="mt-1 block w-full rounded-md border-slate-300 border shadow-sm px-3 py-2 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">
             </label>
         </div>
@@ -58,14 +56,14 @@ $errorMessages = [
             <label class="block">
                 <span class="text-sm text-slate-700 dark:text-slate-300">Reserve (%)</span>
                 <input name="reserve_fraction" type="number" min="0" max="100" step="1" required
-                       value="<?= e($form['reserve_fraction']) ?>"
+                       value="{{ form.reserve_fraction }}"
                        class="mt-1 block w-full rounded-md border-slate-300 border shadow-sm px-3 py-2 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">
                 <span class="text-xs text-slate-500 dark:text-slate-400">Reduction from raw capacity. The Excel uses 20%.</span>
             </label>
             <label class="block">
                 <span class="text-sm text-slate-700 dark:text-slate-300">Weeks</span>
                 <input name="n_weeks" type="number" min="1" max="26" step="1" required
-                       value="<?= e($form['n_weeks']) ?>"
+                       value="{{ form.n_weeks }}"
                        class="mt-1 block w-full rounded-md border-slate-300 border shadow-sm px-3 py-2 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">
                 <span class="text-xs text-slate-500 dark:text-slate-400">Week rows get 5 days/week by default; edit on the sprint page.</span>
             </label>
@@ -82,3 +80,4 @@ $errorMessages = [
         </div>
     </form>
 </section>
+{% endblock %}

+ 0 - 380
views/sprints/present.php

@@ -1,380 +0,0 @@
-<?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 array<int, array<int, string>> $statusGrid  task_id => sw_id => status
- * @var list<\App\Domain\Worker>       $ownerChoices
- * @var bool                           $taskStatusEnabled
- */
-use App\Domain\TaskAssignment;
-use function App\Http\e;
-$tasks             = $tasks             ?? [];
-$taskGrid          = $taskGrid          ?? [];
-$statusGrid        = $statusGrid        ?? [];
-$ownerChoices      = $ownerChoices      ?? [];
-$sprintWorkers     = $sprintWorkers     ?? [];
-$taskStatusEnabled = $taskStatusEnabled ?? false;
-
-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>
-    <script src="/assets/js/theme-init.js"></script>
-    <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 dark:bg-slate-900 dark:text-slate-100">
-<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 dark:bg-slate-800 dark:border-slate-700">
-        <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 dark:text-slate-400">
-                <?= 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 dark:bg-slate-700 dark:text-slate-300">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 dark:bg-slate-800 dark:border-slate-700 dark:text-slate-300">
-            </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 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-200 dark:hover:bg-slate-700">
-                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 dark:bg-amber-900 dark:border-amber-800 dark:text-amber-200">
-            <?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 dark:bg-slate-800 dark:border-slate-700"
-             data-task-section
-             data-task-status-enabled="<?= $taskStatusEnabled ? '1' : '0' ?>">
-        <div class="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>
-
-            <!-- 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 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-200 dark:hover:bg-slate-700">
-                    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 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-100 dark:focus:ring-slate-500">
-
-                <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 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-100 dark:focus:ring-slate-500">
-                    <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 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-200 dark:hover:bg-slate-700">
-                        Owners <span data-owner-filter-count class="text-slate-500 dark:text-slate-400"></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 dark:bg-slate-800 dark:border-slate-700">
-                        <div class="px-3 py-2 text-xs text-slate-500 flex items-center justify-between dark:text-slate-400">
-                            <span>Owner</span>
-                            <button type="button" data-owner-filter-clear
-                                    class="text-blue-700 hover:underline dark:text-blue-400 dark:hover:text-blue-300">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 dark:hover:bg-slate-700">
-                                <input type="checkbox" data-owner-filter-opt value="__none__"
-                                       class="rounded border-slate-300 dark:border-slate-600">
-                                <span class="text-slate-500 italic dark:text-slate-400">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 dark:hover:bg-slate-700">
-                                    <input type="checkbox" data-owner-filter-opt value="<?= (int) $ow->id ?>"
-                                           class="rounded border-slate-300 dark:border-slate-600">
-                                    <span><?= e($ow->name) ?></span>
-                                </label>
-                            <?php endforeach; ?>
-                        </div>
-                    </div>
-                </div>
-
-                <?php if ($taskStatusEnabled): ?>
-                <!-- Status filter (Phase 18) -->
-                <div class="relative" data-status-filter-root>
-                    <button type="button" data-status-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 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-200 dark:hover:bg-slate-700">
-                        Status <span data-status-filter-count class="text-slate-500 dark:text-slate-400"></span>
-                    </button>
-                    <div data-status-filter-dropdown
-                         class="hidden absolute right-0 z-20 mt-1 w-56 rounded-md border border-slate-200 bg-white shadow-lg dark:bg-slate-800 dark:border-slate-700">
-                        <div class="px-3 py-2 text-xs text-slate-500 flex items-center justify-between dark:text-slate-400">
-                            <span>Status</span>
-                            <button type="button" data-status-filter-clear
-                                    class="text-blue-700 hover:underline dark:text-blue-400 dark:hover:text-blue-300">Clear</button>
-                        </div>
-                        <div class="max-h-64 overflow-y-auto">
-                            <?php foreach ([
-                                'zugewiesen'    => ['Zugewiesen',    'border border-slate-300 dark:border-slate-600'],
-                                'gestartet'     => ['Gestartet',     'bg-yellow-300 dark:bg-yellow-500'],
-                                'abgeschlossen' => ['Abgeschlossen', 'bg-green-300 dark:bg-green-500'],
-                                'abgebrochen'   => ['Abgebrochen',   'bg-red-300 dark:bg-red-500'],
-                            ] as $key => $meta): ?>
-                                <label class="flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-slate-50 dark:hover:bg-slate-700">
-                                    <input type="checkbox" data-status-filter-opt value="<?= e($key) ?>"
-                                           class="rounded border-slate-300 dark:border-slate-600">
-                                    <span class="inline-block h-3 w-3 rounded <?= e($meta[1]) ?>"></span>
-                                    <span><?= e($meta[0]) ?></span>
-                                </label>
-                            <?php endforeach; ?>
-                        </div>
-                    </div>
-                </div>
-                <?php endif; ?>
-
-                <!-- 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 dark:text-slate-400">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 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-100 dark:focus:ring-slate-500">
-                        <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 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-200 dark:hover:bg-slate-700">
-                        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 dark:bg-slate-800 dark:border-slate-700">
-                        <div class="px-3 py-2 text-xs text-slate-500 dark:text-slate-400">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 dark:hover:bg-slate-700">
-                                <input type="checkbox" data-column-opt value="owner" checked class="rounded border-slate-300 dark:border-slate-600">
-                                <span>Owner</span>
-                            </label>
-                            <label class="flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-slate-50 dark:hover:bg-slate-700">
-                                <input type="checkbox" data-column-opt value="prio"  checked class="rounded border-slate-300 dark:border-slate-600">
-                                <span>Prio</span>
-                            </label>
-                            <label class="flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-slate-50 dark:hover:bg-slate-700">
-                                <input type="checkbox" data-column-opt value="tot"   checked class="rounded border-slate-300 dark:border-slate-600">
-                                <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 dark:hover:bg-slate-700">
-                                    <input type="checkbox" data-column-opt value="sw-<?= (int) $sw->id ?>" checked class="rounded border-slate-300 dark:border-slate-600">
-                                    <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 dark:bg-slate-700 dark:hover:bg-slate-600">
-                        + 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 dark:bg-slate-700 dark:text-slate-300">
-                    <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 dark:divide-slate-700" 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 dark:text-slate-400">
-                                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 dark:text-slate-500">&#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 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-100 dark:focus:ring-slate-500">
-                                    <?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 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-100 dark:focus:ring-slate-500">
-                                            <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 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>
-                                    <?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);
-                                    $st = (string) ($statusGrid[$t->id][$sw->id] ?? TaskAssignment::STATUS_ZUGEWIESEN);
-                                    $tdExtraClass = $taskStatusEnabled ? ' assign-status-' . $st : '';
-                                ?>
-                                    <td class="px-1 py-1 text-center<?= e($tdExtraClass) ?>"
-                                        data-col="sw-<?= (int) $sw->id ?>"
-                                        <?php if ($taskStatusEnabled): ?>data-assign-cell data-status="<?= e($st) ?>" data-sw-id="<?= (int) $sw->id ?>"<?php endif; ?>
-                                        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 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-100 dark:focus:ring-slate-500">
-                                        <?php else: ?>
-                                            <span class="font-mono"><?= e(fmt_days($d)) ?></span>
-                                        <?php endif; ?>
-                                        <?php if ($taskStatusEnabled): ?>
-                                            <select data-assign-status
-                                                    data-sw-id="<?= (int) $sw->id ?>"
-                                                    aria-label="Status"
-                                                    class="assign-status-select">
-                                                <?php foreach (TaskAssignment::STATUSES as $opt): ?>
-                                                    <option value="<?= e($opt) ?>" <?= $opt === $st ? 'selected' : '' ?>><?= e($opt) ?></option>
-                                                <?php endforeach; ?>
-                                            </select>
-                                        <?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 dark:text-red-400">×</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 dark:text-slate-400">
-            No tasks match the current filters.
-        </div>
-    </section>
-    <?php endif; ?>
-</main>
-</body>
-</html>

+ 44 - 0
views/sprints/present.twig

@@ -0,0 +1,44 @@
+{% extends "layout-bare.twig" %}
+
+{% block content %}
+<main class="min-h-screen w-screen overflow-hidden beamer-root"
+      data-sprint-root
+      data-sprint-id="{{ sprint.id }}"
+      data-csrf="{{ csrfToken }}"
+      data-reserve-fraction="{{ sprint.reserveFraction|number_format(4, '.', '') }}"
+      data-beamer="1">
+
+    <header class="flex items-center justify-between gap-4 px-4 py-2 border-b bg-slate-50 dark:bg-slate-800 dark:border-slate-700">
+        <div class="flex items-baseline gap-3">
+            <h1 class="text-lg font-semibold tracking-tight">{{ sprint.name }}</h1>
+            <p class="text-slate-600 text-xs dark:text-slate-400">
+                {{ sprint.startDate }} – {{ sprint.endDate }}
+                {% if sprint.isArchived %}
+                    · <span class="inline-block px-1.5 py-0.5 text-[10px] bg-slate-200 text-slate-600 rounded dark:bg-slate-700 dark:text-slate-300">archived</span>
+                {% 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 dark:bg-slate-800 dark:border-slate-700 dark:text-slate-300">
+            </div>
+            <a href="/sprints/{{ 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 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-200 dark:hover:bg-slate-700">
+                Close
+            </a>
+        </div>
+    </header>
+
+    {% if sprintWorkers is empty or weeks is empty %}
+        <div class="m-4 rounded-md border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:bg-amber-900 dark:border-amber-800 dark:text-amber-200">
+            {% if weeks is empty %}
+                No weeks yet. <a href="/sprints/{{ sprint.id }}/settings" class="underline">Open settings</a> to add some.
+            {% elseif sprintWorkers is empty %}
+                No workers on this sprint yet. <a href="/sprints/{{ sprint.id }}/settings" class="underline">Open settings</a> to add some.
+            {% endif %}
+        </div>
+    {% else %}
+        {% include "sprints/_task_list.twig" with { isBeamer: true } %}
+    {% endif %}
+</main>
+{% endblock %}

+ 43 - 50
views/sprints/settings.php → views/sprints/settings.twig

@@ -1,23 +1,18 @@
-<?php
-/** @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\Worker>       $availableWorkers */
-use App\Domain\SprintWeek;
-use function App\Http\e;
-?>
+{% extends "layout.twig" %}
+
+{% set dayLabels = constant('App\\Domain\\SprintWeek::DAY_LABELS') %}
+
+{% block content %}
 <section class="space-y-6"
          data-sprint-root
-         data-sprint-id="<?= (int) $sprint->id ?>"
-         data-csrf="<?= e($csrfToken) ?>">
+         data-sprint-id="{{ sprint.id }}"
+         data-csrf="{{ csrfToken }}">
 
     <header class="flex items-end justify-between gap-4">
         <div>
             <nav class="text-xs text-slate-500 dark:text-slate-400">
                 <a href="/" class="hover:underline">Sprints</a> /
-                <a href="/sprints/<?= (int) $sprint->id ?>" class="hover:underline"><?= e($sprint->name) ?></a> /
+                <a href="/sprints/{{ sprint.id }}" class="hover:underline">{{ sprint.name }}</a> /
             </nav>
             <h1 class="text-2xl font-semibold tracking-tight">Settings</h1>
         </div>
@@ -26,42 +21,40 @@ use function App\Http\e;
         </div>
     </header>
 
-    <!-- Sprint meta -->
     <section class="rounded-lg border bg-white p-5 space-y-4 dark:bg-slate-800 dark:border-slate-700">
         <h2 class="text-sm font-semibold text-slate-700 uppercase tracking-wider dark:text-slate-200">Sprint</h2>
         <div class="grid grid-cols-1 md:grid-cols-4 gap-4">
             <label class="md:col-span-2 block">
                 <span class="text-sm text-slate-700 dark:text-slate-300">Name</span>
-                <input data-meta name="name" type="text" value="<?= e($sprint->name) ?>"
+                <input data-meta name="name" type="text" value="{{ sprint.name }}"
                        class="mt-1 block w-full rounded-md border border-slate-300 shadow-sm px-3 py-2 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">
             </label>
             <label class="block">
                 <span class="text-sm text-slate-700 dark:text-slate-300">Start date</span>
-                <input data-meta name="start_date" type="date" value="<?= e($sprint->startDate) ?>"
+                <input data-meta name="start_date" type="date" value="{{ sprint.startDate }}"
                        class="mt-1 block w-full rounded-md border border-slate-300 shadow-sm px-3 py-2 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">
             </label>
             <label class="block">
                 <span class="text-sm text-slate-700 dark:text-slate-300">End date</span>
-                <input data-meta name="end_date" type="date" value="<?= e($sprint->endDate) ?>"
+                <input data-meta name="end_date" type="date" value="{{ sprint.endDate }}"
                        class="mt-1 block w-full rounded-md border border-slate-300 shadow-sm px-3 py-2 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">
             </label>
             <label class="block">
                 <span class="text-sm text-slate-700 dark:text-slate-300">Reserve (%)</span>
                 <input data-meta name="reserve_fraction" type="number" min="0" max="100" step="1"
-                       value="<?= e(number_format($sprint->reserveFraction * 100, 0)) ?>"
+                       value="{{ (sprint.reserveFraction * 100)|number_format(0) }}"
                        class="mt-1 block w-full rounded-md border border-slate-300 shadow-sm px-3 py-2 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">
             </label>
         </div>
         <p class="text-xs text-slate-500 dark:text-slate-400">Changes save automatically.</p>
     </section>
 
-    <!-- Weeks -->
     <section class="rounded-lg border bg-white p-5 space-y-4 dark:bg-slate-800 dark:border-slate-700">
         <div class="flex items-end justify-between gap-4">
             <div>
                 <h2 class="text-sm font-semibold text-slate-700 uppercase tracking-wider dark:text-slate-200">Weeks</h2>
                 <p class="text-xs text-slate-500 mt-1 dark:text-slate-400">
-                    Current: <?= count($weeks) ?> week<?= count($weeks) === 1 ? '' : 's' ?>.
+                    Current: {{ weeks|length }} week{{ weeks|length == 1 ? '' : 's' }}.
                     Tick the weekdays that are workdays for each week; the count feeds
                     the Arbeitstage header on the sprint page.
                 </p>
@@ -70,7 +63,7 @@ use function App\Http\e;
                 <label class="block">
                     <span class="text-xs text-slate-600 dark:text-slate-400">Set to</span>
                     <input name="n_weeks" type="number" min="1" max="26" step="1"
-                           value="<?= count($weeks) ?>"
+                           value="{{ weeks|length }}"
                            class="mt-1 w-24 rounded-md border border-slate-300 shadow-sm px-3 py-2 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">
                 </label>
                 <button type="submit"
@@ -86,34 +79,34 @@ use function App\Http\e;
                         <th class="text-left px-3 py-2 font-semibold">#</th>
                         <th class="text-left px-3 py-2 font-semibold">KW</th>
                         <th class="text-left px-3 py-2 font-semibold">Start</th>
-                        <?php foreach (SprintWeek::DAY_LABELS as $label): ?>
-                            <th class="text-center px-2 py-2 font-semibold w-10"><?= e($label) ?></th>
-                        <?php endforeach; ?>
+                        {% for label in dayLabels %}
+                            <th class="text-center px-2 py-2 font-semibold w-10">{{ label }}</th>
+                        {% endfor %}
                         <th class="text-right px-3 py-2 font-semibold">Arbeitstage</th>
                     </tr>
                 </thead>
                 <tbody class="divide-y divide-slate-100 dark:divide-slate-700">
-                    <?php foreach ($weeks as $w): ?>
-                        <tr data-week-row data-week-id="<?= (int) $w->id ?>">
-                            <td class="px-3 py-2 font-mono"><?= (int) $w->sortOrder ?></td>
-                            <td class="px-3 py-2 font-mono">KW<?= (int) $w->isoWeek ?></td>
-                            <td class="px-3 py-2 font-mono"><?= e($w->startDate) ?></td>
-                            <?php foreach (SprintWeek::DAY_LABELS as $bit => $label): ?>
+                    {% for w in weeks %}
+                        <tr data-week-row data-week-id="{{ w.id }}">
+                            <td class="px-3 py-2 font-mono">{{ w.sortOrder }}</td>
+                            <td class="px-3 py-2 font-mono">KW{{ w.isoWeek }}</td>
+                            <td class="px-3 py-2 font-mono">{{ w.startDate }}</td>
+                            {% for bit, label in dayLabels %}
                                 <td class="px-2 py-2 text-center">
                                     <input type="checkbox"
                                            data-day-toggle
-                                           data-bit="<?= (int) $bit ?>"
-                                           data-label="<?= e($label) ?>"
-                                           aria-label="<?= e($label) ?>"
-                                           <?= $w->hasDay($label) ? 'checked' : '' ?>
+                                           data-bit="{{ bit }}"
+                                           data-label="{{ label }}"
+                                           aria-label="{{ label }}"
+                                           {{ w.hasDay(label) ? 'checked' : '' }}
                                            class="rounded border-slate-300 focus:ring-slate-400 dark:border-slate-600 dark:focus:ring-slate-500">
                                 </td>
-                            <?php endforeach; ?>
+                            {% endfor %}
                             <td class="px-3 py-2 font-mono text-right" data-week-count>
-                                <?= (int) $w->maxWorkingDays ?>
+                                {{ w.maxWorkingDays }}
                             </td>
                         </tr>
-                    <?php endforeach; ?>
+                    {% endfor %}
                 </tbody>
             </table>
         </div>
@@ -122,7 +115,6 @@ use function App\Http\e;
         </p>
     </section>
 
-    <!-- Worker picker -->
     <section class="rounded-lg border bg-white p-5 space-y-4 dark:bg-slate-800 dark:border-slate-700">
         <h2 class="text-sm font-semibold text-slate-700 uppercase tracking-wider dark:text-slate-200">Workers</h2>
         <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
@@ -130,17 +122,17 @@ use function App\Http\e;
                 <h3 class="text-xs font-semibold text-slate-500 uppercase mb-2 dark:text-slate-400">Available</h3>
                 <div class="rounded border border-slate-200 dark:border-slate-700">
                     <ul data-available class="divide-y divide-slate-100 dark:divide-slate-700">
-                        <?php foreach ($availableWorkers as $w): ?>
+                        {% for w in availableWorkers %}
                             <li class="flex items-center gap-2 px-3 py-2 border-b last:border-b-0 dark:border-slate-700"
-                                data-worker-id="<?= (int) $w->id ?>">
-                                <span class="flex-1"><?= e($w->name) ?></span>
+                                data-worker-id="{{ w.id }}">
+                                <span class="flex-1">{{ w.name }}</span>
                                 <button type="button" data-add class="text-sm text-blue-700 hover:underline dark:text-blue-400 dark:hover:text-blue-300">Add →</button>
                             </li>
-                        <?php endforeach; ?>
+                        {% endfor %}
                     </ul>
                     <div data-empty-available
                          class="p-3 text-center text-xs text-slate-500 dark:text-slate-400"
-                         <?= $availableWorkers === [] ? '' : 'style="display:none"' ?>>
+                         {% if availableWorkers is not empty %}style="display:none"{% endif %}>
                         No other active workers.
                     </div>
                 </div>
@@ -149,23 +141,23 @@ use function App\Http\e;
                 <h3 class="text-xs font-semibold text-slate-500 uppercase mb-2 dark:text-slate-400">In sprint (drag to reorder)</h3>
                 <div class="rounded border border-slate-200 dark:border-slate-700">
                     <ul data-in-sprint class="divide-y divide-slate-100 dark:divide-slate-700">
-                        <?php foreach ($sprintWorkers as $sw): ?>
+                        {% for sw in sprintWorkers %}
                             <li class="flex items-center gap-2 px-3 py-2 border-b bg-white last:border-b-0 dark:bg-slate-800 dark:border-slate-700"
-                                data-sw-id="<?= (int) $sw->id ?>"
-                                data-worker-id="<?= (int) $sw->workerId ?>">
+                                data-sw-id="{{ sw.id }}"
+                                data-worker-id="{{ sw.workerId }}">
                                 <span class="handle cursor-grab text-slate-400 select-none dark:text-slate-500">&#8801;</span>
-                                <span class="flex-1"><?= e($sw->workerName) ?></span>
+                                <span class="flex-1">{{ sw.workerName }}</span>
                                 <input type="number" step="0.05" min="0" max="1"
-                                       value="<?= e(number_format($sw->rtb, 2, '.', '')) ?>"
+                                       value="{{ fmt_rtb(sw.rtb) }}"
                                        data-rtb
                                        class="w-20 rounded border border-slate-300 px-2 py-1 font-mono 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">
                                 <button type="button" data-remove class="text-sm text-red-600 hover:underline dark:text-red-400">Remove</button>
                             </li>
-                        <?php endforeach; ?>
+                        {% endfor %}
                     </ul>
                     <div data-empty-sprint
                          class="p-3 text-center text-xs text-slate-500 dark:text-slate-400"
-                         <?= $sprintWorkers === [] ? '' : 'style="display:none"' ?>>
+                         {% if sprintWorkers is not empty %}style="display:none"{% endif %}>
                         No workers assigned yet.
                     </div>
                 </div>
@@ -178,3 +170,4 @@ use function App\Http\e;
 </section>
 
 <script src="/assets/js/sprint-settings.js" defer></script>
+{% endblock %}

+ 0 - 515
views/sprints/show.php

@@ -1,515 +0,0 @@
-<?php
-/** @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 array<int, array<int, float>> $grid        sw_id => week_id => days */
-/** @var array<int, array{ressourcen:float, after_reserves:float, committed_prio1:float, available:float}> $capacity */
-/** @var list<\App\Domain\Task>        $tasks */
-/** @var array<int, array<int, float>> $taskGrid    task_id => sw_id => days */
-/** @var array<int, array<int, string>> $statusGrid task_id => sw_id => status */
-/** @var list<\App\Domain\Worker>      $ownerChoices */
-/** @var bool $taskStatusEnabled */
-use App\Domain\SprintWeek;
-use App\Domain\TaskAssignment;
-use function App\Http\e;
-$tasks             = $tasks             ?? [];
-$taskGrid          = $taskGrid          ?? [];
-$statusGrid        = $statusGrid        ?? [];
-$ownerChoices      = $ownerChoices      ?? [];
-$taskStatusEnabled = $taskStatusEnabled ?? false;
-
-if (!function_exists('fmt_days')) {
-    function fmt_days(float $x): string
-    {
-        // Show 0 as "0", whole numbers as integer, halves as x.5
-        if (abs($x - round($x)) < 1e-9) {
-            return (string) (int) round($x);
-        }
-        return number_format($x, 1);
-    }
-}
-?>
-<section class="space-y-6"
-         data-sprint-root
-         data-sprint-id="<?= (int) $sprint->id ?>"
-         data-csrf="<?= e($csrfToken) ?>"
-         data-reserve-fraction="<?= e(number_format($sprint->reserveFraction, 4, '.', '')) ?>">
-
-    <header class="flex items-end justify-between gap-4">
-        <div>
-            <nav class="text-xs text-slate-500 dark:text-slate-400">
-                <a href="/" class="hover:underline">Sprints</a> /
-            </nav>
-            <h1 class="text-2xl font-semibold tracking-tight"><?= e($sprint->name) ?></h1>
-            <p class="text-slate-600 mt-1 text-sm dark:text-slate-400">
-                <?= e($sprint->startDate) ?> – <?= e($sprint->endDate) ?>
-                · Reserve <?= e(number_format($sprint->reserveFraction * 100, 0)) ?>%
-                <?php if ($sprint->isArchived): ?>
-                    · <span class="inline-block px-2 py-0.5 text-xs bg-slate-100 text-slate-600 rounded dark:bg-slate-700 dark:text-slate-300">archived</span>
-                <?php endif; ?>
-            </p>
-        </div>
-        <div class="flex items-center gap-3">
-            <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 dark:bg-slate-800 dark:border-slate-700 dark:text-slate-300">
-            </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 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-200 dark:hover:bg-slate-700">
-                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 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-200 dark:hover:bg-slate-700">
-                    Settings
-                </a>
-            <?php endif; ?>
-        </div>
-    </header>
-
-    <?php if ($sprintWorkers === [] || $weeks === []): ?>
-        <div class="rounded-md border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:bg-amber-900 dark:border-amber-800 dark:text-amber-200">
-            <?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: ?>
-
-        <!-- Arbeitstage grid -->
-        <section class="rounded-lg border bg-white overflow-x-auto dark:bg-slate-800 dark:border-slate-700">
-            <table class="min-w-full text-sm" data-arbeitstage>
-                <thead class="bg-slate-50 text-slate-600 text-xs uppercase tracking-wider dark:bg-slate-700 dark:text-slate-300">
-                    <tr>
-                        <th class="text-left px-3 py-2 font-semibold sticky left-0 bg-slate-50 z-10 dark:bg-slate-700">&nbsp;</th>
-                        <?php foreach ($weeks as $w): ?>
-                            <th class="text-center px-2 py-2 font-semibold whitespace-nowrap">
-                                <div class="font-mono">KW<?= (int) $w->isoWeek ?></div>
-                                <div class="text-[10px] text-slate-500 font-normal dark:text-slate-400"><?= e($w->startDate) ?></div>
-                            </th>
-                        <?php endforeach; ?>
-                        <th class="text-center px-2 py-2 font-semibold">Σ</th>
-                        <th class="text-center px-2 py-2 font-semibold">RTB</th>
-                    </tr>
-                </thead>
-                <tbody class="divide-y divide-slate-100 dark:divide-slate-700" data-tbody>
-                    <!-- Arbeitstage row — derived from weekday selection in Sprint Settings. -->
-                    <tr class="bg-slate-50 dark:bg-slate-700">
-                        <th class="text-left px-3 py-2 font-semibold text-slate-700 sticky left-0 bg-slate-50 dark:bg-slate-700 dark:text-slate-200">
-                            Arbeitstage
-                            <?php if ($currentUser->isAdmin): ?>
-                                <a href="/sprints/<?= (int) $sprint->id ?>/settings"
-                                   class="ml-1 text-[10px] font-normal text-slate-500 hover:underline dark:text-slate-400"
-                                   title="Pick weekdays in Settings">(edit)</a>
-                            <?php endif; ?>
-                        </th>
-                        <?php $sumMax = 0.0; foreach ($weeks as $w): $sumMax += $w->maxWorkingDays; ?>
-                            <td class="px-1 py-1">
-                                <div class="flex items-center justify-center gap-1"
-                                     data-week-arbeitstage data-week-id="<?= (int) $w->id ?>"
-                                     title="<?= e(implode(' ', $w->activeDays())) ?: '—' ?>">
-                                    <?php foreach (SprintWeek::DAY_LABELS as $bit => $_label): ?>
-                                        <span class="inline-block h-2.5 w-2.5 rounded-full
-                                            <?= $w->hasDay($_label) ? 'bg-green-500 dark:bg-green-400' : 'bg-slate-300 dark:bg-slate-600' ?>"></span>
-                                    <?php endforeach; ?>
-                                </div>
-                            </td>
-                        <?php endforeach; ?>
-                        <td class="px-2 py-1 text-center font-mono font-semibold" data-sum-max>
-                            <?= e(fmt_days($sumMax)) ?>
-                        </td>
-                        <td>&nbsp;</td>
-                    </tr>
-
-                    <!-- One row per sprint worker -->
-                    <?php foreach ($sprintWorkers as $sw): ?>
-                        <?php $rowDays = $grid[$sw->id] ?? []; $rowSum = array_sum($rowDays); ?>
-                        <tr data-sw-row data-sw-id="<?= (int) $sw->id ?>">
-                            <th class="text-left px-3 py-2 font-medium sticky left-0 bg-white z-10 dark:bg-slate-800">
-                                <span class="flex items-center gap-2">
-                                    <?php if ($currentUser->isAdmin): ?>
-                                        <span class="handle cursor-grab text-slate-400 select-none dark:text-slate-500">&#8801;</span>
-                                    <?php endif; ?>
-                                    <?= e($sw->workerName) ?>
-                                </span>
-                            </th>
-                            <?php foreach ($weeks as $w): $v = (float) ($rowDays[$w->id] ?? 0.0); ?>
-                                <td class="px-1 py-1 text-center">
-                                    <?php if ($currentUser->isAdmin): ?>
-                                        <input type="number" min="0" max="5" step="0.5"
-                                               value="<?= e(fmt_days($v)) ?>"
-                                               data-day data-sw-id="<?= (int) $sw->id ?>"
-                                               data-week-id="<?= (int) $w->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">
-                                    <?php else: ?>
-                                        <span class="font-mono"><?= e(fmt_days($v)) ?></span>
-                                    <?php endif; ?>
-                                </td>
-                            <?php endforeach; ?>
-                            <td class="px-2 py-1 text-center font-mono font-semibold"
-                                data-sum-days data-sw-id="<?= (int) $sw->id ?>">
-                                <?= e(fmt_days($rowSum)) ?>
-                            </td>
-                            <td class="px-1 py-1 text-center">
-                                <?php if ($currentUser->isAdmin): ?>
-                                    <input type="number" min="0" max="1" step="0.05"
-                                           value="<?= e(number_format($sw->rtb, 2, '.', '')) ?>"
-                                           data-rtb data-sw-id="<?= (int) $sw->id ?>"
-                                           class="w-16 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">
-                                <?php else: ?>
-                                    <span class="font-mono"><?= e(number_format($sw->rtb, 2, '.', '')) ?></span>
-                                <?php endif; ?>
-                            </td>
-                        </tr>
-                    <?php endforeach; ?>
-                </tbody>
-            </table>
-        </section>
-
-        <!-- Capacity summary — one column per worker, aligned with task columns in Phase 6 -->
-        <section class="rounded-lg border bg-white overflow-x-auto dark:bg-slate-800 dark:border-slate-700">
-            <div class="px-4 py-2 border-b bg-slate-50 text-xs uppercase tracking-wider text-slate-600 font-semibold dark:bg-slate-700 dark:border-slate-700 dark:text-slate-300">
-                Capacity
-            </div>
-            <table class="min-w-full text-sm">
-                <thead>
-                    <tr class="bg-slate-50 text-slate-600 text-xs dark:bg-slate-700 dark:text-slate-300">
-                        <th class="text-left px-3 py-2 font-semibold sticky left-0 bg-slate-50 z-10 dark:bg-slate-700">&nbsp;</th>
-                        <?php foreach ($sprintWorkers as $sw): ?>
-                            <th class="text-center px-2 py-2 font-semibold whitespace-nowrap">
-                                <?= e($sw->workerName) ?>
-                            </th>
-                        <?php endforeach; ?>
-                    </tr>
-                </thead>
-                <tbody class="divide-y divide-slate-100 dark:divide-slate-700">
-                    <tr>
-                        <th class="text-left px-3 py-2 text-slate-700 font-medium sticky left-0 bg-white dark:bg-slate-800 dark:text-slate-200">Ressourcen</th>
-                        <?php foreach ($sprintWorkers as $sw): $c = $capacity[$sw->id] ?? null; ?>
-                            <td class="px-2 py-2 text-center font-mono"
-                                data-cap-ressourcen data-sw-id="<?= (int) $sw->id ?>">
-                                <?= e(fmt_days($c['ressourcen'] ?? 0.0)) ?>
-                            </td>
-                        <?php endforeach; ?>
-                    </tr>
-                    <tr>
-                        <th class="text-left px-3 py-2 text-slate-700 font-medium sticky left-0 bg-white dark:bg-slate-800 dark:text-slate-200">− Reserven</th>
-                        <?php foreach ($sprintWorkers as $sw): $c = $capacity[$sw->id] ?? null; ?>
-                            <td class="px-2 py-2 text-center font-mono text-slate-600 dark:text-slate-400"
-                                data-cap-after-reserves data-sw-id="<?= (int) $sw->id ?>">
-                                <?= e(fmt_days($c['after_reserves'] ?? 0.0)) ?>
-                            </td>
-                        <?php endforeach; ?>
-                    </tr>
-                    <tr>
-                        <th class="text-left px-3 py-2 text-slate-700 font-semibold sticky left-0 bg-white dark:bg-slate-800 dark:text-slate-200">Available</th>
-                        <?php foreach ($sprintWorkers as $sw): $c = $capacity[$sw->id] ?? null; $av = (float) ($c['available'] ?? 0.0); ?>
-                            <td class="px-2 py-2 text-center font-mono font-semibold <?= $av < 0 ? 'text-red-700 dark:text-red-400' : 'text-slate-900 dark:text-slate-100' ?>"
-                                data-cap-available data-sw-id="<?= (int) $sw->id ?>">
-                                <?= e(fmt_days($av)) ?>
-                            </td>
-                        <?php endforeach; ?>
-                    </tr>
-                </tbody>
-            </table>
-        </section>
-
-        <p class="text-xs text-slate-500 dark:text-slate-400">
-            Numeric inputs snap to 0.5 (days) or 0.05 (RTB) on blur. Edits save automatically
-            with a 400&nbsp;ms debounce; Available turns red if a worker is overcommitted.
-        </p>
-
-    <!-- Section B: Task list -->
-    <section class="rounded-lg border bg-white overflow-hidden dark:bg-slate-800 dark:border-slate-700"
-             data-task-section
-             data-task-status-enabled="<?= $taskStatusEnabled ? '1' : '0' ?>">
-        <div class="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>
-
-            <!-- 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 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-200 dark:hover:bg-slate-700">
-                    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 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-100 dark:focus:ring-slate-500">
-
-                <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 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-100 dark:focus:ring-slate-500">
-                    <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 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-200 dark:hover:bg-slate-700">
-                        Owners <span data-owner-filter-count class="text-slate-500 dark:text-slate-400"></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 dark:bg-slate-800 dark:border-slate-700">
-                        <div class="px-3 py-2 text-xs text-slate-500 flex items-center justify-between dark:text-slate-400">
-                            <span>Owner</span>
-                            <button type="button" data-owner-filter-clear
-                                    class="text-blue-700 hover:underline dark:text-blue-400 dark:hover:text-blue-300">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 dark:hover:bg-slate-700">
-                                <input type="checkbox" data-owner-filter-opt value="__none__"
-                                       class="rounded border-slate-300 dark:border-slate-600">
-                                <span class="text-slate-500 italic dark:text-slate-400">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 dark:hover:bg-slate-700">
-                                    <input type="checkbox" data-owner-filter-opt value="<?= (int) $ow->id ?>"
-                                           class="rounded border-slate-300 dark:border-slate-600">
-                                    <span><?= e($ow->name) ?></span>
-                                </label>
-                            <?php endforeach; ?>
-                        </div>
-                    </div>
-                </div>
-
-                <?php if ($taskStatusEnabled): ?>
-                <!-- Status filter (Phase 18) — multi-select; hides tasks
-                     whose cells are not in any of the picked states. The
-                     'zugewiesen' (default) variant only matches cells with
-                     days > 0 so the legend doubles as a sanity check. -->
-                <div class="relative" data-status-filter-root>
-                    <button type="button" data-status-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 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-200 dark:hover:bg-slate-700">
-                        Status <span data-status-filter-count class="text-slate-500 dark:text-slate-400"></span>
-                    </button>
-                    <div data-status-filter-dropdown
-                         class="hidden absolute right-0 z-20 mt-1 w-56 rounded-md border border-slate-200 bg-white shadow-lg dark:bg-slate-800 dark:border-slate-700">
-                        <div class="px-3 py-2 text-xs text-slate-500 flex items-center justify-between dark:text-slate-400">
-                            <span>Status</span>
-                            <button type="button" data-status-filter-clear
-                                    class="text-blue-700 hover:underline dark:text-blue-400 dark:hover:text-blue-300">Clear</button>
-                        </div>
-                        <div class="max-h-64 overflow-y-auto">
-                            <?php foreach ([
-                                'zugewiesen'    => ['Zugewiesen',    'border border-slate-300 dark:border-slate-600'],
-                                'gestartet'     => ['Gestartet',     'bg-yellow-300 dark:bg-yellow-500'],
-                                'abgeschlossen' => ['Abgeschlossen', 'bg-green-300 dark:bg-green-500'],
-                                'abgebrochen'   => ['Abgebrochen',   'bg-red-300 dark:bg-red-500'],
-                            ] as $key => $meta): ?>
-                                <label class="flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-slate-50 dark:hover:bg-slate-700">
-                                    <input type="checkbox" data-status-filter-opt value="<?= e($key) ?>"
-                                           class="rounded border-slate-300 dark:border-slate-600">
-                                    <span class="inline-block h-3 w-3 rounded <?= e($meta[1]) ?>"></span>
-                                    <span><?= e($meta[0]) ?></span>
-                                </label>
-                            <?php endforeach; ?>
-                        </div>
-                    </div>
-                </div>
-                <?php endif; ?>
-
-                <!-- 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 dark:text-slate-400">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 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-100 dark:focus:ring-slate-500">
-                        <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 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-200 dark:hover:bg-slate-700">
-                        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 dark:bg-slate-800 dark:border-slate-700">
-                        <div class="px-3 py-2 text-xs text-slate-500 dark:text-slate-400">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 dark:hover:bg-slate-700">
-                                <input type="checkbox" data-column-opt value="owner" checked class="rounded border-slate-300 dark:border-slate-600">
-                                <span>Owner</span>
-                            </label>
-                            <label class="flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-slate-50 dark:hover:bg-slate-700">
-                                <input type="checkbox" data-column-opt value="prio"  checked class="rounded border-slate-300 dark:border-slate-600">
-                                <span>Prio</span>
-                            </label>
-                            <label class="flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-slate-50 dark:hover:bg-slate-700">
-                                <input type="checkbox" data-column-opt value="tot"   checked class="rounded border-slate-300 dark:border-slate-600">
-                                <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 dark:hover:bg-slate-700">
-                                    <input type="checkbox" data-column-opt value="sw-<?= (int) $sw->id ?>" checked class="rounded border-slate-300 dark:border-slate-600">
-                                    <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 dark:bg-slate-700 dark:hover:bg-slate-600">
-                        + 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 dark:bg-slate-700 dark:text-slate-300">
-                    <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 dark:divide-slate-700" 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 dark:text-slate-400">
-                                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 dark:text-slate-500">&#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 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-100 dark:focus:ring-slate-500">
-                                    <?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 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-100 dark:focus:ring-slate-500">
-                                            <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 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>
-                                    <?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);
-                                    $st = (string) ($statusGrid[$t->id][$sw->id] ?? TaskAssignment::STATUS_ZUGEWIESEN);
-                                    // Phase 18: when the global flag is on, the status colour class
-                                    // and data-* attributes live on the <td> itself — no nested
-                                    // wrapper. Keeps the cell's table-layout intact and the days
-                                    // input renders exactly as it did pre-Phase-18.
-                                    $tdExtraClass = $taskStatusEnabled ? ' assign-status-' . $st : '';
-                                ?>
-                                    <td class="px-1 py-1 text-center<?= e($tdExtraClass) ?>"
-                                        data-col="sw-<?= (int) $sw->id ?>"
-                                        <?php if ($taskStatusEnabled): ?>data-assign-cell data-status="<?= e($st) ?>" data-sw-id="<?= (int) $sw->id ?>"<?php endif; ?>
-                                        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 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-100 dark:focus:ring-slate-500">
-                                        <?php else: ?>
-                                            <span class="font-mono"><?= e(fmt_days($d)) ?></span>
-                                        <?php endif; ?>
-                                        <?php if ($taskStatusEnabled): ?>
-                                            <select data-assign-status
-                                                    data-sw-id="<?= (int) $sw->id ?>"
-                                                    aria-label="Status"
-                                                    class="assign-status-select">
-                                                <?php foreach (TaskAssignment::STATUSES as $opt): ?>
-                                                    <option value="<?= e($opt) ?>" <?= $opt === $st ? 'selected' : '' ?>><?= e($opt) ?></option>
-                                                <?php endforeach; ?>
-                                            </select>
-                                        <?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 dark:text-red-400">×</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 dark:text-slate-400">
-            No tasks match the current filters.
-        </div>
-    </section>
-    <?php endif; ?>
-</section>
-
-<script src="/assets/js/sprint-planner.js" defer></script>

+ 207 - 0
views/sprints/show.twig

@@ -0,0 +1,207 @@
+{% extends "layout.twig" %}
+
+{% set dayLabels = constant('App\\Domain\\SprintWeek::DAY_LABELS') %}
+
+{% block content %}
+<section class="space-y-6"
+         data-sprint-root
+         data-sprint-id="{{ sprint.id }}"
+         data-csrf="{{ csrfToken }}"
+         data-reserve-fraction="{{ sprint.reserveFraction|number_format(4, '.', '') }}">
+
+    <header class="flex items-end justify-between gap-4">
+        <div>
+            <nav class="text-xs text-slate-500 dark:text-slate-400">
+                <a href="/" class="hover:underline">Sprints</a> /
+            </nav>
+            <h1 class="text-2xl font-semibold tracking-tight">{{ sprint.name }}</h1>
+            <p class="text-slate-600 mt-1 text-sm dark:text-slate-400">
+                {{ sprint.startDate }} – {{ sprint.endDate }}
+                · Reserve {{ (sprint.reserveFraction * 100)|number_format(0) }}%
+                {% if sprint.isArchived %}
+                    · <span class="inline-block px-2 py-0.5 text-xs bg-slate-100 text-slate-600 rounded dark:bg-slate-700 dark:text-slate-300">archived</span>
+                {% endif %}
+            </p>
+        </div>
+        <div class="flex items-center gap-3">
+            <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 dark:bg-slate-800 dark:border-slate-700 dark:text-slate-300">
+            </div>
+            <a href="/sprints/{{ 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 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-200 dark:hover:bg-slate-700">
+                Present
+            </a>
+            {% if currentUser.isAdmin %}
+                <a href="/sprints/{{ 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 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-200 dark:hover:bg-slate-700">
+                    Settings
+                </a>
+            {% endif %}
+        </div>
+    </header>
+
+    {% if sprintWorkers is empty or weeks is empty %}
+        <div class="rounded-md border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:bg-amber-900 dark:border-amber-800 dark:text-amber-200">
+            {% if weeks is empty %}
+                No weeks yet. <a href="/sprints/{{ sprint.id }}/settings" class="underline">Open settings</a> to add some.
+            {% elseif sprintWorkers is empty %}
+                No workers on this sprint yet. <a href="/sprints/{{ sprint.id }}/settings" class="underline">Open settings</a> to add some.
+            {% endif %}
+        </div>
+    {% else %}
+
+        <section class="rounded-lg border bg-white overflow-x-auto dark:bg-slate-800 dark:border-slate-700">
+            <table class="min-w-full text-sm" data-arbeitstage>
+                <thead class="bg-slate-50 text-slate-600 text-xs uppercase tracking-wider dark:bg-slate-700 dark:text-slate-300">
+                    <tr>
+                        <th class="text-left px-3 py-2 font-semibold sticky left-0 bg-slate-50 z-10 dark:bg-slate-700">&nbsp;</th>
+                        {% for w in weeks %}
+                            <th class="text-center px-2 py-2 font-semibold whitespace-nowrap">
+                                <div class="font-mono">KW{{ w.isoWeek }}</div>
+                                <div class="text-[10px] text-slate-500 font-normal dark:text-slate-400">{{ w.startDate }}</div>
+                            </th>
+                        {% endfor %}
+                        <th class="text-center px-2 py-2 font-semibold">Σ</th>
+                        <th class="text-center px-2 py-2 font-semibold">RTB</th>
+                    </tr>
+                </thead>
+                <tbody class="divide-y divide-slate-100 dark:divide-slate-700" data-tbody>
+                    <tr class="bg-slate-50 dark:bg-slate-700">
+                        <th class="text-left px-3 py-2 font-semibold text-slate-700 sticky left-0 bg-slate-50 dark:bg-slate-700 dark:text-slate-200">
+                            Arbeitstage
+                            {% if currentUser.isAdmin %}
+                                <a href="/sprints/{{ sprint.id }}/settings"
+                                   class="ml-1 text-[10px] font-normal text-slate-500 hover:underline dark:text-slate-400"
+                                   title="Pick weekdays in Settings">(edit)</a>
+                            {% endif %}
+                        </th>
+                        {% set sumMax = 0 %}
+                        {% for w in weeks %}
+                            {% set sumMax = sumMax + w.maxWorkingDays %}
+                            <td class="px-1 py-1">
+                                <div class="flex items-center justify-center gap-1"
+                                     data-week-arbeitstage data-week-id="{{ w.id }}"
+                                     title="{{ w.activeDays|join(' ') ?: '—' }}">
+                                    {% for bit, label in dayLabels %}
+                                        <span class="inline-block h-2.5 w-2.5 rounded-full {{ w.hasDay(label) ? 'bg-green-500 dark:bg-green-400' : 'bg-slate-300 dark:bg-slate-600' }}"></span>
+                                    {% endfor %}
+                                </div>
+                            </td>
+                        {% endfor %}
+                        <td class="px-2 py-1 text-center font-mono font-semibold" data-sum-max>
+                            {{ fmt_days(sumMax) }}
+                        </td>
+                        <td>&nbsp;</td>
+                    </tr>
+
+                    {% for sw in sprintWorkers %}
+                        {% set rowDays = grid[sw.id]|default({}) %}
+                        {% set rowSum = 0 %}
+                        {% for v in rowDays %}{% set rowSum = rowSum + v %}{% endfor %}
+                        <tr data-sw-row data-sw-id="{{ sw.id }}">
+                            <th class="text-left px-3 py-2 font-medium sticky left-0 bg-white z-10 dark:bg-slate-800">
+                                <span class="flex items-center gap-2">
+                                    {% if currentUser.isAdmin %}
+                                        <span class="handle cursor-grab text-slate-400 select-none dark:text-slate-500">&#8801;</span>
+                                    {% endif %}
+                                    {{ sw.workerName }}
+                                </span>
+                            </th>
+                            {% for w in weeks %}
+                                {% set v = rowDays[w.id]|default(0.0) %}
+                                <td class="px-1 py-1 text-center">
+                                    {% if currentUser.isAdmin %}
+                                        <input type="number" min="0" max="5" step="0.5"
+                                               value="{{ fmt_days(v) }}"
+                                               data-day data-sw-id="{{ sw.id }}"
+                                               data-week-id="{{ w.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">{{ fmt_days(v) }}</span>
+                                    {% endif %}
+                                </td>
+                            {% endfor %}
+                            <td class="px-2 py-1 text-center font-mono font-semibold"
+                                data-sum-days data-sw-id="{{ sw.id }}">
+                                {{ fmt_days(rowSum) }}
+                            </td>
+                            <td class="px-1 py-1 text-center">
+                                {% if currentUser.isAdmin %}
+                                    <input type="number" min="0" max="1" step="0.05"
+                                           value="{{ fmt_rtb(sw.rtb) }}"
+                                           data-rtb data-sw-id="{{ sw.id }}"
+                                           class="w-16 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">{{ fmt_rtb(sw.rtb) }}</span>
+                                {% endif %}
+                            </td>
+                        </tr>
+                    {% endfor %}
+                </tbody>
+            </table>
+        </section>
+
+        <section class="rounded-lg border bg-white overflow-x-auto dark:bg-slate-800 dark:border-slate-700">
+            <div class="px-4 py-2 border-b bg-slate-50 text-xs uppercase tracking-wider text-slate-600 font-semibold dark:bg-slate-700 dark:border-slate-700 dark:text-slate-300">
+                Capacity
+            </div>
+            <table class="min-w-full text-sm">
+                <thead>
+                    <tr class="bg-slate-50 text-slate-600 text-xs dark:bg-slate-700 dark:text-slate-300">
+                        <th class="text-left px-3 py-2 font-semibold sticky left-0 bg-slate-50 z-10 dark:bg-slate-700">&nbsp;</th>
+                        {% for sw in sprintWorkers %}
+                            <th class="text-center px-2 py-2 font-semibold whitespace-nowrap">
+                                {{ sw.workerName }}
+                            </th>
+                        {% endfor %}
+                    </tr>
+                </thead>
+                <tbody class="divide-y divide-slate-100 dark:divide-slate-700">
+                    <tr>
+                        <th class="text-left px-3 py-2 text-slate-700 font-medium sticky left-0 bg-white dark:bg-slate-800 dark:text-slate-200">Ressourcen</th>
+                        {% for sw in sprintWorkers %}
+                            {% set c = capacity[sw.id]|default(null) %}
+                            <td class="px-2 py-2 text-center font-mono"
+                                data-cap-ressourcen data-sw-id="{{ sw.id }}">
+                                {{ fmt_days(c.ressourcen|default(0.0)) }}
+                            </td>
+                        {% endfor %}
+                    </tr>
+                    <tr>
+                        <th class="text-left px-3 py-2 text-slate-700 font-medium sticky left-0 bg-white dark:bg-slate-800 dark:text-slate-200">− Reserven</th>
+                        {% for sw in sprintWorkers %}
+                            {% set c = capacity[sw.id]|default(null) %}
+                            <td class="px-2 py-2 text-center font-mono text-slate-600 dark:text-slate-400"
+                                data-cap-after-reserves data-sw-id="{{ sw.id }}">
+                                {{ fmt_days(c.after_reserves|default(0.0)) }}
+                            </td>
+                        {% endfor %}
+                    </tr>
+                    <tr>
+                        <th class="text-left px-3 py-2 text-slate-700 font-semibold sticky left-0 bg-white dark:bg-slate-800 dark:text-slate-200">Available</th>
+                        {% for sw in sprintWorkers %}
+                            {% set c = capacity[sw.id]|default(null) %}
+                            {% set av = c.available|default(0.0) %}
+                            <td class="px-2 py-2 text-center font-mono font-semibold {{ av < 0 ? 'text-red-700 dark:text-red-400' : 'text-slate-900 dark:text-slate-100' }}"
+                                data-cap-available data-sw-id="{{ sw.id }}">
+                                {{ fmt_days(av) }}
+                            </td>
+                        {% endfor %}
+                    </tr>
+                </tbody>
+            </table>
+        </section>
+
+        <p class="text-xs text-slate-500 dark:text-slate-400">
+            Numeric inputs snap to 0.5 (days) or 0.05 (RTB) on blur. Edits save automatically
+            with a 400&nbsp;ms debounce; Available turns red if a worker is overcommitted.
+        </p>
+
+        {% include "sprints/_task_list.twig" %}
+
+    {% endif %}
+</section>
+
+<script src="/assets/js/sprint-planner.js" defer></script>
+{% endblock %}

+ 36 - 39
views/users/index.php → views/users/index.twig

@@ -1,23 +1,18 @@
-<?php
-/** @var list<\App\Domain\User> $users */
-/** @var \App\Domain\User        $currentUser */
-/** @var string                  $csrfToken */
-/** @var string                  $flash */
-/** @var string                  $error */
-use function App\Http\e;
+{% extends "layout.twig" %}
 
-$errorMessages = [
-    'self_demote' => 'You cannot demote yourself — ask another admin.',
-    'last_admin'  => 'Cannot demote the last remaining admin.',
-    'not_found'   => 'User not found.',
-    'db_error'    => 'Could not save. Try again.',
-];
-$flashMessages = [
-    'promoted' => 'Admin granted.',
-    'demoted'  => 'Admin revoked.',
-    'noop'     => 'Nothing changed.',
-];
-?>
+{% set errorMessages = {
+    'self_demote': 'You cannot demote yourself — ask another admin.',
+    'last_admin':  'Cannot demote the last remaining admin.',
+    'not_found':   'User not found.',
+    'db_error':    'Could not save. Try again.',
+} %}
+{% set flashMessages = {
+    'promoted': 'Admin granted.',
+    'demoted':  'Admin revoked.',
+    'noop':     'Nothing changed.',
+} %}
+
+{% block content %}
 <section class="space-y-6">
     <div>
         <h1 class="text-2xl font-semibold tracking-tight">Users</h1>
@@ -28,21 +23,21 @@ $flashMessages = [
         </p>
     </div>
 
-    <?php if ($error !== '' && isset($errorMessages[$error])): ?>
+    {% if error and errorMessages[error] is defined %}
         <div class="rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800 dark:bg-red-900 dark:border-red-800 dark:text-red-200">
-            <?= e($errorMessages[$error]) ?>
+            {{ errorMessages[error] }}
         </div>
-    <?php endif; ?>
-    <?php if ($flash !== '' && isset($flashMessages[$flash])): ?>
+    {% endif %}
+    {% if flash and flashMessages[flash] is defined %}
         <div class="rounded-md border border-green-200 bg-green-50 px-4 py-3 text-sm text-green-800 dark:bg-green-900 dark:border-green-800 dark:text-green-200">
-            <?= e($flashMessages[$flash]) ?>
+            {{ flashMessages[flash] }}
         </div>
-    <?php endif; ?>
+    {% endif %}
 
     <div class="rounded-lg border bg-white overflow-hidden dark:bg-slate-800 dark:border-slate-700">
-        <?php if ($users === []): ?>
+        {% if users is empty %}
             <div class="p-8 text-center text-slate-500 text-sm dark:text-slate-400">No users yet.</div>
-        <?php else: ?>
+        {% else %}
             <table class="min-w-full text-sm">
                 <thead class="bg-slate-50 text-slate-600 text-xs uppercase tracking-wider dark:bg-slate-700 dark:text-slate-300">
                     <tr>
@@ -54,25 +49,26 @@ $flashMessages = [
                     </tr>
                 </thead>
                 <tbody class="divide-y divide-slate-100 dark:divide-slate-700">
-                    <?php foreach ($users as $u): $isSelf = $u->id === $currentUser->id; ?>
+                    {% for u in users %}
+                        {% set isSelf = u.id == currentUser.id %}
                         <tr>
-                            <form method="post" action="/users/<?= (int) $u->id ?>" class="contents">
-                                <input type="hidden" name="_csrf" value="<?= e($csrfToken) ?>">
+                            <form method="post" action="/users/{{ u.id }}" hx-boost="true" hx-target="body" class="contents">
+                                <input type="hidden" name="_csrf" value="{{ csrfToken }}">
                                 <td class="px-4 py-2 font-mono text-xs">
-                                    <?= e($u->email) ?>
-                                    <?php if ($isSelf): ?>
+                                    {{ u.email }}
+                                    {% if isSelf %}
                                         <span class="ml-1 inline-block px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider bg-slate-100 text-slate-700 rounded dark:bg-slate-700 dark:text-slate-200">you</span>
-                                    <?php endif; ?>
+                                    {% endif %}
                                 </td>
-                                <td class="px-4 py-2"><?= e($u->displayName) ?></td>
+                                <td class="px-4 py-2">{{ u.displayName }}</td>
                                 <td class="px-4 py-2 text-slate-500 font-mono text-xs dark:text-slate-400">
-                                    <?= $u->lastLoginAt !== null ? e($u->lastLoginAt) : '<span class="text-slate-400 dark:text-slate-500">—</span>' ?>
+                                    {% if u.lastLoginAt is not null %}{{ u.lastLoginAt }}{% else %}<span class="text-slate-400 dark:text-slate-500">—</span>{% endif %}
                                 </td>
                                 <td class="px-4 py-2">
                                     <label class="inline-flex items-center gap-2">
                                         <input name="is_admin" type="checkbox" value="1"
-                                               <?= $u->isAdmin ? 'checked' : '' ?>
-                                               <?= $isSelf && $u->isAdmin ? 'disabled title="You cannot demote yourself"' : '' ?>
+                                               {{ u.isAdmin ? 'checked' : '' }}
+                                               {% if isSelf and u.isAdmin %}disabled title="You cannot demote yourself"{% endif %}
                                                class="rounded border-slate-300 dark:border-slate-600">
                                         <span class="text-slate-600 dark:text-slate-400">admin</span>
                                     </label>
@@ -85,9 +81,10 @@ $flashMessages = [
                                 </td>
                             </form>
                         </tr>
-                    <?php endforeach; ?>
+                    {% endfor %}
                 </tbody>
             </table>
-        <?php endif; ?>
+        {% endif %}
     </div>
 </section>
+{% endblock %}

+ 36 - 41
views/workers/index.php → views/workers/index.twig

@@ -1,48 +1,44 @@
-<?php
-/** @var list<\App\Domain\Worker> $workers */
-/** @var string $csrfToken */
-/** @var string $flash */
-/** @var string $error */
-use function App\Http\e;
+{% extends "layout.twig" %}
 
-$errorMessages = [
-    'name_required'     => 'Worker name is required.',
-    'name_taken'        => 'That name is already in use.',
-    'rtb_out_of_range'  => 'RTB must be between 0.0 and 1.0.',
-    'db_error'          => 'Could not save. Try again.',
-];
-$flashMessages = [
-    'created' => 'Worker created.',
-    'updated' => 'Saved.',
-    'noop'    => 'Nothing changed.',
-];
-?>
+{% set errorMessages = {
+    'name_required':    'Worker name is required.',
+    'name_taken':       'That name is already in use.',
+    'rtb_out_of_range': 'RTB must be between 0.0 and 1.0.',
+    'db_error':         'Could not save. Try again.',
+} %}
+{% set flashMessages = {
+    'created': 'Worker created.',
+    'updated': 'Saved.',
+    'noop':    'Nothing changed.',
+} %}
+
+{% block content %}
 <section class="space-y-6">
     <div>
         <h1 class="text-2xl font-semibold tracking-tight">Workers</h1>
         <p class="text-slate-600 mt-1 text-sm max-w-prose dark:text-slate-400">
             Master data for the people tasks get assigned to. Workers are not the
-            same as users &mdash; a worker doesn't have to ever sign in. To remove
+            same as users  a worker doesn't have to ever sign in. To remove
             someone, toggle them inactive rather than deleting.
         </p>
     </div>
 
-    <?php if ($error !== '' && isset($errorMessages[$error])): ?>
+    {% if error and errorMessages[error] is defined %}
         <div class="rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800 dark:bg-red-900 dark:border-red-800 dark:text-red-200">
-            <?= e($errorMessages[$error]) ?>
+            {{ errorMessages[error] }}
         </div>
-    <?php endif; ?>
-    <?php if ($flash !== '' && isset($flashMessages[$flash])): ?>
+    {% endif %}
+    {% if flash and flashMessages[flash] is defined %}
         <div class="rounded-md border border-green-200 bg-green-50 px-4 py-3 text-sm text-green-800 dark:bg-green-900 dark:border-green-800 dark:text-green-200">
-            <?= e($flashMessages[$flash]) ?>
+            {{ flashMessages[flash] }}
         </div>
-    <?php endif; ?>
+    {% endif %}
 
-    <!-- Add worker -->
     <div class="rounded-lg border bg-white p-4 dark:bg-slate-800 dark:border-slate-700">
         <h2 class="text-sm font-semibold text-slate-700 uppercase tracking-wider dark:text-slate-200">Add worker</h2>
-        <form method="post" action="/workers" class="mt-3 flex flex-wrap items-end gap-3">
-            <input type="hidden" name="_csrf" value="<?= e($csrfToken) ?>">
+        <form method="post" action="/workers" hx-boost="true" hx-target="body"
+              class="mt-3 flex flex-wrap items-end gap-3">
+            <input type="hidden" name="_csrf" value="{{ csrfToken }}">
             <label class="flex-1 min-w-[12rem]">
                 <span class="text-xs text-slate-600 dark:text-slate-400">Name</span>
                 <input name="name" type="text" required
@@ -60,11 +56,10 @@ $flashMessages = [
         </form>
     </div>
 
-    <!-- Workers table -->
     <div class="rounded-lg border bg-white overflow-hidden dark:bg-slate-800 dark:border-slate-700">
-        <?php if ($workers === []): ?>
+        {% if workers is empty %}
             <div class="p-8 text-center text-slate-500 text-sm dark:text-slate-400">No workers yet.</div>
-        <?php else: ?>
+        {% else %}
             <table class="min-w-full text-sm">
                 <thead class="bg-slate-50 text-slate-600 text-xs uppercase tracking-wider dark:bg-slate-700 dark:text-slate-300">
                     <tr>
@@ -75,23 +70,22 @@ $flashMessages = [
                     </tr>
                 </thead>
                 <tbody class="divide-y divide-slate-100 dark:divide-slate-700">
-                    <?php foreach ($workers as $w): ?>
-                        <tr class="<?= $w->isActive ? '' : 'opacity-60' ?>">
-                            <form method="post" action="/workers/<?= (int) $w->id ?>" class="contents">
-                                <input type="hidden" name="_csrf" value="<?= e($csrfToken) ?>">
+                    {% for w in workers %}
+                        <tr class="{{ w.isActive ? '' : 'opacity-60' }}">
+                            <form method="post" action="/workers/{{ w.id }}" hx-boost="true" hx-target="body" class="contents">
+                                <input type="hidden" name="_csrf" value="{{ csrfToken }}">
                                 <td class="px-4 py-2">
-                                    <input name="name" type="text" required
-                                           value="<?= e($w->name) ?>"
+                                    <input name="name" type="text" required value="{{ w.name }}"
                                            class="w-full rounded-md border-slate-300 border shadow-sm 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">
                                 </td>
                                 <td class="px-4 py-2 w-32">
                                     <input name="default_rtb" type="number" min="0" max="1" step="0.05"
-                                           value="<?= e(number_format($w->defaultRtb, 2, '.', '')) ?>"
+                                           value="{{ fmt_rtb(w.defaultRtb) }}"
                                            class="w-full rounded-md border-slate-300 border shadow-sm px-2 py-1 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>
                                 <td class="px-4 py-2">
                                     <label class="inline-flex items-center gap-2">
-                                        <input name="is_active" type="checkbox" value="1" <?= $w->isActive ? 'checked' : '' ?>
+                                        <input name="is_active" type="checkbox" value="1" {{ w.isActive ? 'checked' : '' }}
                                                class="rounded border-slate-300 dark:border-slate-600">
                                         <span class="text-slate-600 dark:text-slate-400">active</span>
                                     </label>
@@ -104,9 +98,10 @@ $flashMessages = [
                                 </td>
                             </form>
                         </tr>
-                    <?php endforeach; ?>
+                    {% endfor %}
                 </tbody>
             </table>
-        <?php endif; ?>
+        {% endif %}
     </div>
 </section>
+{% endblock %}

Some files were not shown because too many files changed in this diff