Single source of truth to resume work in a fresh session. Keep this file in sync with the code; if something here disagrees with the repo, trust the repo.
Maintenance rule (read first, then keep doing it). After every commit that completes a phase or substantive change, update §9 (move the entry from Upcoming → Shipped with the SHA) and §13 (git history). If new files were added, refresh §3. Commit the SPEC update separately. See §14.
A web replacement for an Excel sprint-planning workbook used by a ~15-person ops/dev team. One sheet per sprint in the original; per sheet:
The web app reproduces that workflow with proper auth, database, and per-cell audit trail.
node:20-alpine for CSS + JS-vendor
copy + php:8.3-apache for runtime./var/www/data/app.sqlite (mounted volume).*.twig under views/, {% extends %}
inheritance, auto-escape ON, compiled cache in data/twig-cache/).assets/css/input.css → public/assets/css/app.css). No CDN.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.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/.twig/twig, jumbojett/openid-connect-php,
vlucas/phpdotenv, phpunit/phpunit (dev).tailwindcss, alpinejs, @alpinejs/csp,
htmx.org, sortablejs.├── Dockerfile # multi-stage: css-builder + php:8.3-apache
├── docker-compose.yml
├── .dockerignore
├── .env.example
├── composer.json / composer.lock
├── package.json / package-lock.json
├── tailwind.config.js
├── phpunit.xml
├── ACCEPTANCE.md # spec §10 manual checklist walkthrough
├── SPEC.md # this file
├── doc/
│ └── admin-manual.md # operator-facing setup + run guide
├── assets/
│ └── css/input.css # Tailwind entry, compiled into public/assets/css/app.css
├── public/
│ ├── index.php # front controller + router wiring + security headers
│ ├── .htaccess # belt-and-suspenders rewrite
│ └── assets/
│ ├── 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 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,
│ │ TaskController, AuditController, UserController,
│ │ SettingsController
│ ├── Db/ Connection, Migrator
│ ├── Domain/ User, Worker, Sprint, SprintWeek, SprintWorker,
│ │ SprintWorkerDay, Task, TaskAssignment
│ ├── Http/ Request, Response, Router, View (+ e() helper)
│ ├── Repositories/ UserRepository, WorkerRepository, SprintRepository,
│ │ SprintWeekRepository, SprintWorkerRepository,
│ │ SprintWorkerDayRepository, TaskRepository,
│ │ TaskAssignmentRepository, AuditRepository,
│ │ AppSettingsRepository
│ └── Services/ AuditLogger, CapacityCalculator
├── 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/ (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)
Tables (already applied): users, workers, sprints, sprint_weeks,
sprint_workers, sprint_worker_days, tasks, task_assignments,
audit_log, app_settings (Phase 18 — KV store for global flags),
plus the schema_version tracking table.
sprint_weeks.active_days_mask INTEGER NOT NULL DEFAULT 31 (Phase 12) is
a 5-bit mask — bit0=Mo, bit1=Di, bit2=Mi, bit3=Do, bit4=Fr — and is the
source of truth for "is this a workday this week." max_working_days
lives on as a cached popcount(mask) projection, so the two columns
are always in sync.
Indexes: idx_audit_occurred_at, idx_audit_entity, idx_tasks_sprint,
idx_sw_sprint.
Value constraints enforced in PHP (not SQL):
days fields: non-negative multiple of 0.5.sprint_weeks.max_working_days ∈ {0, 1, 2, 3, 4, 5} — derived from the
weekday mask, so half-days are gone at the week level (Phase 12).sprint_weeks.active_days_mask ∈ 0..31 (bits Mo..Fr).sprint_worker_days.days ∈ {0, 0.5, …, 5}.task_assignments.days ≥ 0, no hard upper bound.task_assignments.status ∈ {zugewiesen, gestartet, abgeschlossen,
abgebrochen} (Phase 18). DB CHECK constraint enforces this.reserve_fraction, rtb ∈ [0, 1].FK cascades (every cascade path now snapshot-audits before the parent delete runs — Phase 8):
sprint_weeks.sprint_id → sprints(id) ON DELETE CASCADEsprint_workers.sprint_id → sprints(id) ON DELETE CASCADEsprint_workers.worker_id → workers(id) ON DELETE RESTRICTsprint_worker_days.sprint_worker_id → sprint_workers(id) ON DELETE CASCADEsprint_worker_days.sprint_week_id → sprint_weeks(id) ON DELETE CASCADEtasks.sprint_id → sprints(id) ON DELETE CASCADEtasks.owner_worker_id → workers(id) ON DELETE SET NULLtask_assignments.task_id → tasks(id) ON DELETE CASCADEtask_assignments.sprint_worker_id → sprint_workers(id) ON DELETE CASCADERuns identically in App\Services\CapacityCalculator (PHP) and in
sprint-planner.js (JS). Any edit must touch both.
round_half(x) = round(x * 2) / 2
ressourcen = Σ sprint_worker_days.days per sprint worker
after_reserves = round_half(ressourcen * (1 − sprint.reserve_fraction))
committed_p1 = Σ task_assignments.days where task.priority = 1
available = after_reserves − committed_p1
Priority-2 assignments do NOT consume capacity (they're "nice to have").
A negative available turns the cell red but is not blocked.
Pages (HTML):
| Method | Path | Auth |
|--------|-----------------------------|----------------|
| GET | / | any (anon → sign-in CTA) |
| GET | /healthz | — |
| GET | /auth/login | — |
| GET | /auth/callback | — |
| GET | /auth/local | — (404 if disabled) |
| POST | /auth/local | — (404 if disabled) |
| POST | /auth/logout | signed-in |
| GET | /workers | admin |
| POST | /workers | admin |
| POST | /workers/{id} | admin |
| GET | /users | admin |
| POST | /users/{id} | admin |
| GET | /sprints/new | admin |
| POST | /sprints | admin |
| GET | /sprints/{id} | signed-in |
| GET | /sprints/{id}/present | signed-in |
| GET | /sprints/{id}/settings | admin |
| GET | /audit | admin |
| GET | /settings | admin |
| POST | /settings | admin (form CSRF via _csrf) |
JSON (admin-only, CSRF via X-CSRF-Token header; envelope per spec §7):
| Method | Path | What |
|--------|----------------------------------------------|---------------|
| PATCH | /sprints/{id} | name/dates/reserve |
| POST | /sprints/{id}/weeks | resize week set |
| POST | /sprints/{id}/workers | add sprint worker |
| DELETE | /sprints/{id}/workers/{sw_id} | remove sprint worker (audits cascaded children) |
| POST | /sprints/{id}/workers/reorder | reorder sprint workers |
| PATCH | /sprints/{id}/workers/{sw_id} | set rtb |
| PATCH | /sprints/{id}/week-cells | batch day cells |
| PATCH | /sprints/{id}/week/{week_id} | set active_days_mask or active_days (derives max_working_days) |
| POST | /sprints/{id}/tasks | create task |
| POST | /sprints/{id}/tasks/reorder | reorder tasks |
| PATCH | /tasks/{id} | title/owner/priority |
| DELETE | /tasks/{id} | delete task (audits cascaded assignments) |
| PATCH | /tasks/{id}/assignments | batch assignment cells (days only) |
| PATCH | /tasks/{id}/assignments/status | batch cell status — any signed-in user; gated by app_settings.task_status_enabled (403 when off) |
Response envelope:
{"ok": true, "data": …}{"ok": false, "error": {"code", "message", "details?"}}App\Services\AuditLogger::record(action, entityType, entityId, before, after, userId, userEmail, ip, ua)
is called inside the same transaction as the DB change. Controllers prefer
recordForRequest(..., Request, ?User) to drop the repeated plumbing.
TaskController::delete() — task → task_assignmentsSprintController::removeWorker() — sprint_worker → sprint_worker_days + task_assignmentsSprintController::replaceWeeks() — sprint_week → sprint_worker_days (on shrink)ENTRA_TENANT_ID=
ENTRA_CLIENT_ID=
ENTRA_CLIENT_SECRET=
APP_BASE_URL=http://localhost:8080
SESSION_SECRET=
DB_PATH=/var/www/data/app.sqlite
SESSION_PATH=/var/www/data/sessions
APP_ENV=production
# Optional local admin fallback (disables when blank).
# Password is compared verbatim (not hashed) — .env must be file-permissions
# protected. The resulting user is entra_oid="local:<email>", is_admin=1.
LOCAL_ADMIN_EMAIL=
LOCAL_ADMIN_PASSWORD=
LOCAL_ADMIN_NAME=Local Admin
First-login bootstrap: when the users table is empty at the moment of
successful login (either OIDC or local), that user is promoted to is_admin=1
with a BOOTSTRAP_ADMIN audit row.
58a6b30)be193d2, hotfix 83493d0): Entra OIDC with PKCE,
session + CSRF, first-user-is-admin bootstrap, local-admin fallback.82ddc98): FallbackResource /index.php.f189e7d).38ba151): meta edit, weeks resize,
worker membership add/remove/reorder, per-row RTB.515d7d0): editable matrix, capacity
calc, per-cell persistence with audit.ad78283): CRUD, assignments grid,
sort/filter/search, drag-reorder.927b708): guarded sortable() calls.21d0c4a): /audit admin page
with filters + pagination + collapsible diffs, security headers +
strict-ish CSP, CSRF audit (18/18 mutations), PHPUnit harness with
59 tests. ACCEPTANCE.md captures the spec §10
manual walkthrough.dd158f3): three FK cascade
paths (sprint_worker → sprint_worker_days, sprint_worker →
task_assignments, sprint_week → sprint_worker_days on shrink) now
snapshot-audit before the parent delete fires. +4 tests, +2 repo
lookup methods.f7f5db5): GET /users + POST
/users/{id} with self-demote and last-admin guardrails. Pure static
UserController::demoteGuardrail is testable with no PDO/session
setup. +6 tests.c35a934, hotfix 7c298d3):
multi-select owner filter (checkbox dropdown) and column-visibility
toggle, both pure client-side with localStorage persistence per
sprint. Hotfix 7c298d3 repairs a regression c35a934 left in
sprint-planner.js: ownerChoices() still scraped the pre-Phase-10
[data-owner-filter] option selector (replaced by
[data-owner-filter-opt] checkboxes in this phase), so every
client-built task row (admin clicks "+ Add task") had an empty
owner dropdown until a page reload re-rendered it server-side.
Also affects the Phase 15 /sprints/{id}/present view since it
reuses the same toolbar markup + JS.ab9430b): vendored Tailwind via
a Node css-builder Docker stage; inline onclick replaced by
data-href + app.js; CSP dropped 'unsafe-inline' and the
Tailwind CDN host. Strict CSP now in effect.a634582, follow-up UI 1aca417): sprint_weeks.active_days_mask
is the new source of truth; max_working_days is a cached
popcount(mask) projection. Sprint Settings exposes five checkboxes
(Mo Di Mi Do Fr) per week. The sprint view's Arbeitstage row shows
a row of five dots per week (green = active, gray = off) — no
labels, tooltip carries the day names. PATCH
/sprints/{id}/week/{week_id} now accepts active_days_mask or
active_days; max_working_days in the body is rejected. Migration
002 backfills legacy rows (half-days round up, clamped to 0..5).
+14 tests, 88 total (was 74).[x] Phase 13 — Focus filter + Reset in the task list (b027c5d, hotfix 23ab365):
new [data-focus-select] in the task-list toolbar picks one sprint
worker; applyFilters() grows a fourth AND predicate hiding rows
whose [data-assign][data-sw-id="{focus}"] is not > 0, and a new
applyFocusColumnVisibility() tags every sw column that is
all-zero across the remaining visible rows with .focus-auto-hidden
(a one-line utility added to assets/css/input.css — does NOT
touch hiddenCols, so clearing focus restores the user's manual
Columns picks). [data-reset-filters] wipes search, prio,
ownerFilterSet, focusWorker, and hiddenCols in one click and
re-hides itself. All state lives in localStorage
(sp:{sprintId}:focusWorker joins the existing namespace). Pure
client-side; no schema, route, or audit changes. Tests unchanged
at 88 (the phase is 100% JS over existing HTML, same pattern as
Phase 10). Hotfix 23ab365 stamps data-col on JS-built task
cells in buildTaskRow, which was a pre-existing gap exposed by
both the Columns dropdown (Phase 10) and this phase's focus
auto-hide — new-task cells are now recognised by both systems.
[x] Phase 14 — Hamburger menu groups admin utilities + Sign out
(101cc57): views/layout.php moves Workers / Users / Audit
log / Sign out into a dropdown behind a <button
data-menu-trigger> with an inline-SVG hamburger (three
<line>s, stroke-current, no external asset). The <div
id="app-menu" data-menu role="menu" hidden> panel is
absolutely positioned with min-w-[12rem], rounded border,
bg-white shadow-lg, items px-3 py-2 text-sm
hover:bg-slate-50 plus a focus ring. Admins see Workers /
Users / Audit log / <hr> / Sign out; non-admins see just
Sign out (no divider). Sprints, New sprint (admin only), and
the user badge stay inline. Sign out remains a native
<form method="post" action="/auth/logout"> with the
_csrf hidden input — no JS-driven POST. public/assets/js/
app.js gains a ~30-line vanilla-JS IIFE (document.
querySelector + addEventListener, no jQuery) that toggles
hidden + aria-expanded on click, closes on outside-click
/ Escape (returning focus to the trigger) / any role=
"menuitem" click. CSP stays strict. Zero PHPUnit changes —
88 / 208 holds. ACCEPTANCE.md gains a "Phase 14 — Hamburger
menu" section with the four manual scenarios from the plan.
[x] Phase 15 — Big-screen (beamer) task viewer at
/sprints/{id}/present (d1dda4f). New signed-in route
renders a stripped-down view: no shared layout chrome, no
Arbeitstage matrix, no capacity summary — just a thin top
bar (sprint name + dates + Close) and the task-list toolbar
SprintController::show() keeps its behaviour; the
shared data fan-out is extracted into a private
loadSprintPage(int $id): ?array helper that both show()
and the new present() method call, returning null for a
missing sprint so each caller renders its own 404.
views/sprints/present.php emits its own <!doctype html>
(rendered with layout=null) reusing /assets/css/app.css/assets/js/sprint-planner.js defer. The root <main>
carries beamer-root + data-sprint-root + data-sprint-
id + data-csrf + data-reserve-fraction + data-
beamer="1". sprint-planner.js detects the beamer flag,
namespaces its three localStorage keys with a :beamer
suffix (so presentation filters don't clobber the user's
/sprints/{id} workflow), seeds ["owner","prio","tot"]
into hiddenCols:beamer on first load (before the first
applyColumnVisibility() so nothing flashes), and after
the boot applyFilters() measures table.scrollWidth >
container.clientWidth; if it overflows, adds
.beamer-vertical-headers (rotates sw column headers 90°);
if it still overflows, console.warns and falls through to
horizontal scroll — never a hang. Strict CSP unchanged.
CSS scoping block lives under @layer components in
assets/css/input.css (.beamer-root table typography +
padding, .handle + [data-delete-task] hidden, vertical-
header rule). Entry point is a new "Present" anchor next to
Settings in views/sprints/show.php, target="_blank" for
all signed-in users. Tests unchanged at 88 / 208 —
refactor is a pure extraction and the sanity test the plan
allowed was skipped because the existing tests/
Controllers/ harness only runs pure statics; a full
controller integration test would need PDO + session wiring
out of scope for this phase. ACCEPTANCE.md gains a
"Phase 15 — Big-screen viewer" section with the six manual
scenarios from the plan.[x] Phase 16 — Dark-mode toggle + light-mode contrast
cleanup (94b2841). Two small palette issues addressed
at once: (a) both body and table-header bands used
bg-slate-50, so table headers blended into the page —
body bumps one shade cooler to bg-slate-100 (the
user's explicit ask), <thead> bands stay at bg-slate-50
and now read as a distinct lighter strip; (b) no dark
palette existed at all, painful for the Phase 15 beamer
view in dim conference rooms. Manual toggle only — no
prefers-color-scheme auto-detect. tailwind.config.js
gains darkMode: 'class'. New
public/assets/js/theme-init.js (8 lines, synchronous
<script src> in <head> before the stylesheet) reads
localStorage['sp:theme'] inside a try/catch and sets
<html class="dark"> if the value is 'dark' — no FOUC.
views/layout.php and views/sprints/present.php both
include the init script; the present route emits its own
<!doctype html>, hence two tags. public/assets/js/app.js
grows a third vanilla-JS IIFE (~17 lines) wiring
[data-theme-toggle]: toggle the class on <html>, write
sp:theme, stamp the [data-theme-label] text; writes
wrapped in try/catch so private-window denials no-op. The
toggle lives in the hamburger menu in views/layout.php
as a new "Theme" row above the <hr> divider, visible to
admins and non-admins alike (theme is a personal
preference, not an admin action); the divider now always
renders (previously only admins saw one) because the
Theme row always renders. Every view file (views/layout,
views/home, views/auth/local, views/workers/index,
views/users/index, views/sprints/{new,settings,show,
present}, views/audit/index) gets a systematic
dark: sweep: body/card/header surfaces on the
slate-900/800/700 ramp, borders on slate-700 (600 for
inputs), primary text slate-100, secondary slate-400,
inputs dark:bg-slate-800 dark:border-slate-600
dark:text-slate-100 dark:focus:ring-slate-500, links
dark:text-blue-400 dark:hover:text-blue-300, success /
error / amber flash chips on *-900 backgrounds with
*-200 text and *-800 borders, admin badge
dark:bg-amber-900 dark:text-amber-200, Phase 12 weekday
dots active dark:bg-green-400 / off dark:bg-slate-600,
capacity "available" red dark:text-red-400, audit action
chips similarly remapped. assets/css/input.css needed no
edits — it carries no colour classes (only the
.focus-auto-hidden utility and the .beamer-root
typography block, both colour-free). Strict CSP stays
intact (theme-init.js is a standard <script src> under
script-src 'self'). Sign-out form block untouched — still
a native POST with the _csrf hidden input. Zero PHPUnit
— same pattern as Phases 10, 13, 14, 15; the change is CSS
class additions plus ~25 lines of vanilla JS without a unit
surface the existing harness can reach. Tests at 88 / 208.
ACCEPTANCE.md gains a "Phase 16 — Dark mode + light
contrast" section with the six scenarios from the plan
(light-band separation, toggle flip, reload persistence
under Network throttling + no FOUC, present view inherits
dark, admin-pages contrast sweep, private-window
localStorage denial fallback).
[x] Phase 17 — Hide native number spinners + custom slider popover
for number inputs (b457896, UX tweak c07af1c, hotfix 832b256, rewrite 15b2d24, blur-fix f189ef7, close-fix 8d79f96, rebuild ff807c2, direct-listeners e93df6b). Three classes of number input —
day cells, RTB cells, task assignment cells — deal in half-day
increments (or 0.05 for RTB). Browsers rendered each as
<input type="number"> with tiny native up/down spinner
arrows: visually noisy in a dense table, inconsistent across
Chrome / Firefox / Safari, useless on touch. Hidden app-wide
via a two-rule @layer base block in assets/css/input.css
(-webkit-*-spin-button { appearance: none; } +
-moz-appearance: textfield). Week-count and reserve-percent
inputs lose arrows too — fine, keyboard typing is their usual
path. For the three opt-in cell types, a new
public/assets/js/number-stepper.js (~180 lines, single IIFE,
vanilla JS, no jQuery dep) delegates click + focusin on
document to input[data-stepper]; on match, lazily builds a
single .stepper-popover DOM node (role="dialog", − /
<output> / + / hidden <input type="range">) and anchors
it next to the bound input. Reads step / min / max off
the input (default step=1); when both min and max parse as
finite, un-hides the range slider and wires it; otherwise
[data-assign] gets just the +/− buttons. clampToStep(current,
delta, step, min, max) pure helper — adds delta, clamps to
[min,max] when finite, quantises via Math.round(next/step) *
step with a ~1e-9 epsilon tolerance so 0.6 + 0.05 = 0.65
lands cleanly. Every mutation mirrors into input.value and
dispatches a bubbling synthetic input event, so
sprint-planner.js's existing recomputeRow / row-total handlers
fire live. On popover close (outside pointerdown, Escape,
Tab-away, or clicking a different stepper input) the helper
dispatches change — the existing debounced save pipeline
(PATCH /sprints/{id}/week-cells, /workers/{sw_id}, or
/tasks/{id}/assignments) fires once, same audit semantics as
typed edits. ArrowUp / ArrowDown while focused on the input
step by step (restores the shortcut the CSS reset just
disabled). Position: below the input with a 4px gap unless
the input sits in the lower 25% of the viewport, then above;
horizontal clamp to viewport with 4px margins. Outside-click
uses pointerdown (not click) so a Safari scroll gesture
starting inside the popover doesn't dismiss it. data-stepper
stamped on the admin-branch [data-day] / [data-rtb] /
[data-assign] inputs in views/sprints/show.php, the
[data-rtb] input in views/sprints/settings.php, the
[data-assign] input in views/sprints/present.php, and the
JS-built assignment cells in sprint-planner.js::buildTaskRow
— non-admin <span> branches stay clean. Popover styled in
assets/css/input.css under @layer components with
Tailwind @apply on the Phase 16 palette (bg-white /
dark:bg-slate-800 + slate-100/200/600/700 hover/border/text
siblings + accent-slate-600/400 on the range). Both
views/layout.php and views/sprints/present.php load the
new module via <script src="/assets/js/number-stepper.js"
defer> after sprint-planner.js — strict CSP stays intact, no
inline handlers, no new external hosts. Zero PHPUnit — pure
CSS + vanilla JS over existing markup, same pattern as Phases
10 / 13 / 14 / 15 / 16. Tests at 88 / 208. ACCEPTANCE.md
gains a "Phase 17 — Number stepper popover" section with the
five scenarios from the plan (no native arrows on any number
input, day-cell stepper at 0.5 step, RTB stepper at 0.05
step, task-assignment stepper on both show and present views
with no range slider because no max, Escape + outside-click +
dark-mode polish). UX tweak c07af1c: the popover is
now hover-to-open (pointerover / pointerout with a 200 ms
grace timer so transit between input and popover is safe)
instead of click-to-open, and the optional range slider
renders vertically (writing-mode: vertical-lr + direction:
rtl, with slider-vertical / orient="vertical" fallbacks).
Focus and pointerdown triggers remain for keyboard and
touch users respectively. Hotfix 832b256: readBounds()
was using the IDL boundInput.max property which coerces a
missing max attribute to the empty string and then to 0 via
Number("") — the result was that task-assignment cells
(which set min="0" but have no max) were clamped to
[0, 0] and the +/− buttons appeared to do nothing on any
table but the Arbeitstage grid. Switched to getAttribute()
NaN.
Same commit centres the popover on the input's horizontal
midpoint instead of aligning to its left edge — looks
balanced over narrow table cells. Rewrite 15b2d24
lands the final interaction model: the popover is now slider
only — no +/− buttons, no numeric output — opens on click
(not hover) of any input[type="number"] app-wide (no
data-stepper opt-in needed; the attribute stays harmless on
the existing markup), anchors to the right of the input at
its vertical midpoint, flips to the left when the right edge
would clip the viewport, and closes as soon as the pointer
leaves the popover after entering it at least once (a
popoverEntered latch forgives click-to-open cursors that
already sit inside the popover rectangle). Task-assignment
inputs, which declare min="0" but no max, get an adaptive
slider max of Math.max(current + 5, 10) so the slider is
always useful. Dragging fires change on the bound input on
every tick; sprint-planner.js's 400 ms debounce coalesces the
flurry into one server write while its capacity recompute
runs on every tick so Ressourcen / Available / ≤ reserves
update smoothly during the drag. Escape, outside-pointerdown
and Tab-out fallbacks remain for keyboard / touch paths.
Blur-fix f189ef7 plugs a gap the rewrite left open: the
popover now closes whenever focus leaves either the bound input
or a focusable element inside the popover, unless focus is
moving into another eligible number input (seamless rebind, no
flicker). Catches the case where a click elsewhere blurred
the input but never landed on a registered "outside" target.
Replaces the Tab-specific keydown handler, which is now a
subset of the generic focusout behaviour.
Close-fix 8d79f96: the "mouse leaves" and outside-click
paths were unreliable in real use — the dismissal required the
cursor to physically enter the popover rectangle at least once
(popoverEntered latch), so a user who clicked a cell and
moved the cursor anywhere the popover wasn't would see the
popup linger indefinitely. Replaced the latch with a
document-level pointermove tracker: while the popover is
open, if the cursor is over neither the bound input nor the
popover for more than 150 ms, close. A 250 ms initial grace
after open() covers the first few frames where the cursor
may briefly be in the gap. Outside-click close is now
registered in both bubble (pointerdown) and capture
(click, {capture:true}) phases via a shared handler so a
downstream stopPropagation can't strand the popup. The
viewport-exit pointerleave on document is also a close
trigger — the pointer heading for the browser chrome
shouldn't leave the popup behind.
Rebuild ff807c2: after seven iterations the file had
accumulated contradictory event plumbing (hover-to-open
co-existing with click-to-open, two close latches, two outside-
click paths, stopPropagation on the popover's own pointerdown)
and still failed in practice — the popup wouldn't close on
drift or outside click, slider clicks sometimes stole focus
without mutating the value, and the popup didn't follow the
input on page scroll. Threw the file out and rebuilt it from
scratch as a single ~250-line IIFE with exactly four concerns:
open (click / focusin → configure slider + position),
slider-to-input sync (one input listener mirrors
elRange.value → boundInput.value and dispatches bubbling
input+change events), close (pointer off both rects > 200 ms
past a 300 ms open-grace window / viewport exit / capture-phase
outside-pointerdown / Escape — all testing live
getBoundingClientRect so repositioned popovers don't leave
stale hit rects), and scroll anchoring
(window.addEventListener('scroll', fn, {capture:true}) plus
resize, rAF-throttled reposition; closes if the bound input
collapses to 0×0). The CSS was untouched — the visual style is
identical. Kept the ArrowUp/Down keyboard-nudge bonus that
replaces the spinner-arrow shortcut.
Direct-listeners e93df6b: even after the rebuild the
close logic still didn't fire reliably — every close path was
routed through document-level delegation (pointermove tracker,
capture-phase pointerdown, pointerleave on document), and
whatever was silently suppressing those events on the user's
page suppressed all of them at once, leaving the popup
stranded. Swapped the mouse-tracking close paths to
element-local pointerenter / pointerleave listeners
attached directly to the two elements that matter — the
popover (once, in build()) and the bound input (per-open
via bindInput(), detached via unbindInput() on close or
rebind). pointerleave on either schedules close; pointerenter
on either cancels it; the 300 ms open-grace keeps the timer
rescheduling itself during the first 300 ms after click-to-
open. Outside-click keeps the capture-phase document
pointerdown, now with a 50 ms OPEN_IGNORE_MS guard so the
opening click can't be misread as an outside click. Removed
the document-level pointermove tracker, the document-level
pointerleave viewport-exit handler, and the focusout handler
entirely — superseded.[x] Remove number-stepper slider popover (e551705):
after seven iterations the click-to-open vertical-slider
popover added in Phase 17 still didn't behave reliably, and
the team prefers plain typed entry. Deleted
public/assets/js/number-stepper.js, dropped the
<script> tags from views/layout.php +
views/sprints/present.php, removed the .stepper-popover
CSS block from assets/css/input.css, and stripped
data-stepper attributes from views/sprints/{show,
settings,present}.php and the JS-built cell in
sprint-planner.js::buildTaskRow. Kept the @layer base
rule that hides native number-spinner arrows app-wide —
typing is now the only edit path on every number input;
ArrowUp/ArrowDown still steps via browser default. No
schema / route / PHP changes; tests untouched at 88 / 208.
[x] Phase 18 — Per-cell task-status colours + filter +
global toggle (9cb7669, hotfix 3e115f5). Each task-
assignment cell on both /sprints/{id} and
/sprints/{id}/present now carries a workflow status —
zugewiesen (transparent, default), gestartet (yellow),
abgeschlossen (green), abgebrochen (red) — picked from a
chevron-only <select data-assign-status> next to the day
input/span. The status colour class (assign-status-{state})
and data-assign-cell / data-status / data-sw-id attrs
live directly on the <td> — the original commit's
<span class="assign-cell"> wrapper turned out to be a
layout trap inside table cells (display:inline-flex on a
span around an input with a sibling select made the day
inputs collapse on blur in production), so the hotfix
flattened the structure: the day input is now a direct
child of the <td>, the status select sits as a sibling,
no wrapper. sprint-planner.js
mirrors the chosen value into the wrapper class + data-
status and queues a save through a new
pendingStatus/flushStatus debounced pipeline that hits
PATCH /tasks/{id}/assignments/status (400 ms, same
semantics as the days pipeline). New "Status" multi-select
filter sits between Owners and Focus in the toolbar; a row
passes when at least one cell is in the picked set, with a
special-case rule that the default zugewiesen only matches
when days > 0 (so picking it doesn't include every task).
State persists in localStorage
(sp:{sprintId}:statusFilter, :beamer-namespaced for the
present view) and is wiped by the existing Reset button.
Schema: migrations/003_task_status_and_app_settings.sql
adds task_assignments.status TEXT NOT NULL DEFAULT
'zugewiesen' with a CHECK constraint, and creates a new
app_settings(key TEXT PK, value TEXT NOT NULL, updated_at
TEXT NOT NULL) KV table seeded with
('task_status_enabled', '0') so the feature is opt-in.
App\Repositories\AppSettingsRepository (get/getBool/set)
reads it; SprintController::loadSprintPage() passes
taskStatusEnabled + statusGrid into both views, which
conditionally render the per-cell selectors and the toolbar
Status filter. New admin-only /settings page (linked from
the hamburger menu, App\Controllers\SettingsController)
flips the toggle via a native form POST with _csrf;
audit row entity_type='app_setting'. New
PATCH /tasks/{id}/assignments/status is the first non-
admin write surface in the app — gated by
SessionGuard::requireAuthJson (auth + CSRF, no admin) plus
app_settings.task_status_enabled (403 when off). Existing
PATCH /tasks/{id}/assignments stays admin-only and days-
only; TaskAssignmentRepository::upsert preserves status,
::upsertStatus preserves days and inserts a days=0 row
when the cell didn't exist (so a state can be tracked before
any work is assigned). Per-cell audit semantics unchanged
— one row per changed cell. The four .assign-status-*
class names are interpolated server-side, so
tailwind.config.js gains a safelist keeping them in the
build (Tailwind was silently dropping them otherwise — the
:not() reference happened to keep zugewiesen in but the
other three vanished). Strict CSP unchanged. Tests:
105 / 265 (was 88 / 208) — +TaskAssignmentTest (status
enum + audit-snapshot shape), +AppSettingsRepositoryTest
(seeded flag, get/set roundtrip, no-op equality, default
fallback), +TaskAssignmentRepositoryTest (upsertStatus's
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 COPYs 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.
Nothing scheduled.
/sprints/{id} reloads the page after drag so the
task list's worker columns stay in sync. Acceptable, but noisy if the
user has a lot of edits in flight (they're all saved by then). Not
scheduled; the reload is fast and the alternative (live-reordering
columns in JS) is complex for little win.jumbojett/openid-connect-php
1.0.2 uses implicitly-nullable params. The container runs 8.3 where these
are E_DEPRECATED but still emit — harmless, and silenced by
ini_set('display_errors','0') in production. Upstream library needs a
release.cp .env.example .env
# Fill Entra vars, OR set LOCAL_ADMIN_EMAIL + LOCAL_ADMIN_PASSWORD
docker compose up --build
# open http://localhost:8080
Rebuild when the Dockerfile / composer manifest / Tailwind sources change:
docker compose build --no-cache && docker compose up
For local CSS dev without Docker:
npm install
npm run watch:css # rebuilds public/assets/css/app.css on change
The SQLite file lives at ./data/app.sqlite on the host; nuking it resets
the app to a blank slate (migrations run on the next request).
Syntax-check PHP without Docker:
for f in $(git ls-files '*.php'); do php -l "$f" | tail -1 | sed "s|^|$f: |"; done
Run the test suite:
vendor/bin/phpunit
# → OK (108 tests, 281 assertions)
Tell Claude:
Working on
/Users/achiappa/Development/claude_code_private/sprint_planer_web. ReadSPEC.md, the git log, andACCEPTANCE.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.
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
da726bd SPEC.md: note number-stepper popover removal
e551705 Remove number-stepper slider popover
c5eef6a Docs: rename HANDOFF.md to SPEC.md, add admin manual, refresh README
fd2f0df changed docker compose port
761c4dd HANDOFF.md: note stepper close via direct listeners on Phase 17
e93df6b Fix: stepper close via direct element listeners (not doc delegation)
ac6ae73 HANDOFF.md: note number-stepper rebuild on Phase 17
ff807c2 Rewrite: number-stepper popover from scratch
729617d HANDOFF.md: note stepper close-fix (pointer-drift + capture-click)
8d79f96 Fix: stepper popover closes on mouse-drift + outside-click (belt-and-braces)
9de4bf2 HANDOFF.md: note stepper blur-close fix on Phase 17
f189ef7 Fix: stepper popover now closes when the bound input loses focus
27eea76 HANDOFF.md: note slider-only stepper rewrite on Phase 17
15b2d24 Stepper popover: slider-only, click-to-open, close on leave-popup
515f9ec HANDOFF.md: note stepper positioning + bounds-parsing hotfix
832b256 Fix: stepper popover broken on task-assignment cells + not centred
14a41b1 HANDOFF.md: note stepper hover + vertical-slider UX tweak on Phase 17
c07af1c Stepper popover: hover-to-open + vertical range slider
735aa4f HANDOFF.md: mark Phase 17 shipped
b457896 Phase 17: hide native number spinners + custom 0.5-step stepper popover
712bcc5 HANDOFF.md: add Phase 17 plan (number-stepper popover + hide native spinners)
0d738b2 HANDOFF.md: mark Phase 16 shipped
94b2841 Phase 16: dark-mode toggle + light-mode contrast cleanup
0d7124a HANDOFF.md: add Phase 16 plan (dark-mode toggle + light-mode contrast)
d4738d7 HANDOFF.md: note buildTaskRow owner-dropdown hotfix on Phase 10
7c298d3 Fix: buildTaskRow owner dropdown was empty until a page refresh
c70e442 HANDOFF.md: mark Phase 15 shipped
d1dda4f Phase 15: big-screen (beamer) task viewer at /sprints/{id}/present
48c56b7 HANDOFF.md: add Phase 15 plan (big-screen task viewer)
d59120c HANDOFF.md: mark Phase 14 shipped
101cc57 Phase 14: hamburger menu groups admin utilities + Sign out
15695ab HANDOFF.md: add Phase 14 plan (hamburger menu)
bfb93fc gitignore: exclude .claude/ (Claude Code agent runtime scratch)
a30cb0b HANDOFF.md: note buildTaskRow data-col hotfix on Phase 13
23ab365 Fix: stamp data-col on JS-built task row cells
d0fdf53 HANDOFF.md: mark Phase 13 shipped
b027c5d Phase 13: Focus filter + Reset in the task list
e23cfac HANDOFF.md: add Phase 13 plan (Focus filter + reset)
815e232 HANDOFF.md: note 5-dot Arbeitstage indicator follow-up
1aca417 Sprint view Arbeitstage: 5-dot weekday indicator instead of a number
59eb1d7 HANDOFF.md: mark Phase 12 shipped
a634582 Phase 12: per-week weekday selection (Mo–Fr) drives Arbeitstage
a1a1266 HANDOFF.md: mark Phases 8–11 shipped + codify the maintenance rule
ab9430b Phase 11: vendor Tailwind + drop inline onclick + tighten CSP
c35a934 Phase 10: multi-select owner filter + column visibility toggle
f7f5db5 Phase 9: users management page (promote / demote admin)
dd158f3 Phase 8: audit rows for FK-cascaded deletes
8537fc2 Plan Phases 8–11 in HANDOFF.md
afa9e4f Ignore PHPUnit cache directory
21d0c4a Phase 7: audit viewer + security headers + PHPUnit
09b67f3 Add HANDOFF.md for resuming work in a fresh session
927b708 Fix: drop unreliable SRI hashes + guard sortable() calls
ad78283 Phase 6: task list, assignments, client-side sort/filter/search
515d7d0 Phase 5: Arbeitstage grid + capacity calculator + cell persistence
38ba151 Phase 4: sprint settings — meta, weeks, workers, reorder, RTB
f189e7d Phase 3: workers + sprints + generic audit wiring
82ddc98 Route all URLs to the front controller via FallbackResource
83493d0 Phase 2 hotfix: scalar-safe Request + local admin login
be193d2 Phase 2: Entra OIDC auth + session + audit log
58a6b30 Phase 1: skeleton
Each commit message captures the deliverables, rationale, and the smoke tests that were run to validate it. Read those in preference to any summary here.
The previous §14 was the plan for Phases 8–11. All four shipped, so the plan moved into §9. This section now codifies the rule that produced this file in the first place — don't lose it on a context reset.
After every commit that completes a phase or substantive change:
git revert of the code revert leaves the doc
honest by reverting alongside.Why: a fresh Claude session starts by reading this file. Stale status here is the single biggest source of duplicated or wrong work. Keeping the file truthful costs ~2 minutes per phase; recovering from a stale file costs more.
If you skip these steps, the next session will eventually notice and
have to rebuild the picture from git log and the codebase. That's
recoverable, but a regression from why this file exists.