# Sprint Planner — Specification 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. ## 1. What this is 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: - **Arbeitstage matrix** (top): max working days per week + per-worker availability per week. - **Task list** (bottom): one row per task with priority, owner, total days, and a per-worker days-allocated cell for each sprint worker. The web app reproduces that workflow with proper auth, database, and per-cell audit trail. ## 2. Tech stack (non-negotiable) - Runtime: Docker, two-stage build, `node:20-alpine` for CSS + JS-vendor copy + `php:8.3-apache` for runtime. The runtime stage installs `pdo_sqlite`, plus `zip` and `gd` (Phase 20 — required by PhpSpreadsheet); `dom`, `xml`, `xmlreader`, `xmlwriter`, `simplexml`, `mbstring`, `fileinfo` ship with the base image. - Language: PHP 8.3, strict types, PSR-12. - Database: SQLite via PDO, file at `/var/www/data/app.sqlite` (mounted volume). - 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: `twig/twig`, `jumbojett/openid-connect-php`, `vlucas/phpdotenv`, `phpoffice/phpspreadsheet` (Phase 20 — XLSX import wizard), `phpunit/phpunit` (dev). - npm deps (build-time only): `tailwindcss`, `alpinejs`, `@alpinejs/csp`, `htmx.org`, `sortablejs`. ## 3. Directory layout ``` ├── 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 │ └── Tool_Sprint Planning.xlsx # Phase 20 — sample workbook (parser fixture) ├── 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, ImportController (Phase 20) │ ├── Db/ Connection, Migrator │ ├── Domain/ User, Worker, Sprint, SprintWeek, SprintWorker, │ │ SprintWorkerDay, Task, TaskAssignment │ │ └── Import/ (Phase 20) ParsedSheet, ParsedWeek, ParsedWorker, │ │ ParsedTask, ParsedAssignment, ImportResult │ ├── Http/ Request, Response, Router, View (+ e() helper) │ ├── Repositories/ UserRepository, WorkerRepository, SprintRepository, │ │ SprintWeekRepository, SprintWorkerRepository, │ │ SprintWorkerDayRepository, TaskRepository, │ │ TaskAssignmentRepository, AuditRepository, │ │ AppSettingsRepository, AuthThrottleRepository │ └── Services/ AuditLogger, CapacityCalculator │ └── Import/ (Phase 20) XlsxColorClassifier, XlsxSprintImporter, │ SprintImporter ├── 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) │ 004_task_metadata_and_links.sql (Phase 22 — task description/url + linked_task_id) │ 005_auth_throttle.sql (R01-N06 — local-admin login throttle) ├── 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), │ sprints/import_upload.twig (Phase 20), │ sprints/import_preview.twig (Phase 20) ├── 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..005) 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), `auth_throttle` (R01-N06 — local-admin login throttle), plus the `schema_version` tracking table. Phase 22 (migration 004) adds three columns to `tasks`: `description TEXT NOT NULL DEFAULT ''`, `url TEXT NOT NULL DEFAULT ''`, and `linked_task_id INTEGER REFERENCES tasks(id) ON DELETE SET NULL` — set on a copy and pointed at the source. Plus index `idx_tasks_linked`. R01-N06 (migration 005) adds `auth_throttle(ip_address, email, attempts, first_failure_at, last_failure_at, locked_until)` with PK `(ip_address, email)` plus index `idx_auth_throttle_locked`. `AuthThrottleRepository` owns the policy: 5 failures in a 15-minute window → 5-min lock, 10 → 30-min, 20+ → 1-hour. A successful sign-in deletes the row. `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): - All `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 CASCADE - `sprint_workers.sprint_id → sprints(id)` ON DELETE CASCADE - `sprint_workers.worker_id → workers(id)` ON DELETE RESTRICT - `sprint_worker_days.sprint_worker_id → sprint_workers(id)` ON DELETE CASCADE - `sprint_worker_days.sprint_week_id → sprint_weeks(id)` ON DELETE CASCADE - `tasks.sprint_id → sprints(id)` ON DELETE CASCADE - `tasks.owner_worker_id → workers(id)` ON DELETE SET NULL - `task_assignments.task_id → tasks(id)` ON DELETE CASCADE - `task_assignments.sprint_worker_id → sprint_workers(id)` ON DELETE CASCADE ## 5. Capacity math (spec §6.5) Runs 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. ## 6. Routes 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/import` | admin | | POST | `/sprints/import` | admin (multipart, `_csrf`) | | GET | `/sprints/import/{token}` | admin | | POST | `/sprints/import/{token}` | admin (form `_csrf`) | | GET | `/sprints/{id}` | signed-in | | GET | `/sprints/{id}/present` | signed-in | | GET | `/sprints/{id}/settings` | admin | | POST | `/sprints/{id}/delete` | admin (form `_csrf` + `confirm_name` must match sprint name verbatim) | | 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 — when start_date or end_date changes, week rows are auto-resynced (count = ⌊(end−start)/7⌋+1, capped at 26; existing rows realign + audit) | | POST | `/sprints/{id}/weeks` | resize week set (legacy; UI no longer calls it — kept for back-compat) | | 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) | | POST | `/tasks/{id}/move` | move task to another sprint (drops assignments, audited) — Phase 22 | | POST | `/tasks/{id}/copy` | clone task into another sprint with `linked_task_id = source.id` — Phase 22 | Response envelope: - Success: `{"ok": true, "data": …}` - Failure: `{"ok": false, "error": {"code", "message", "details?"}}` - Validation errors: HTTP 422. ## 7. Audit logging rules (cross-cutting) `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. - Every CREATE / UPDATE / DELETE on a domain table → exactly one row. - Bulk operations (batch cell save) → one row per changed cell. - A no-op UPDATE (canonical-JSON-equal before/after) → no row. - FK-cascading deletes must be audited by the controller BEFORE calling the cascading delete. References: - `TaskController::delete()` — task → task_assignments - `SprintController::removeWorker()` — sprint_worker → sprint_worker_days + task_assignments - `SprintController::replaceWeeks()` — sprint_week → sprint_worker_days (on shrink) - Non-mutation events (LOGIN, LOGOUT, LOGIN_FAILED, BOOTSTRAP_ADMIN) → always one row. R01-N06 adds a second `LOGIN_FAILED` reason on the local-admin path: `local_admin_throttled_until_` is written when the `(ip, email)` bucket is currently locked, separate from the existing `local_admin_credential_mismatch` row written when the password itself was wrong. ## 8. Env (.env.example) ``` ENTRA_TENANT_ID= ENTRA_CLIENT_ID= ENTRA_CLIENT_SECRET= APP_BASE_URL=http://localhost:8080 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 verified with PHP's password_verify() against the bcrypt hash # stored in LOCAL_ADMIN_PASSWORD_HASH; the plaintext password never lands on # disk. Generate the hash via `password_hash($pw, PASSWORD_DEFAULT)` (see # README's Quick setup). The resulting user row has # entra_oid="local:", is_admin=1. LOCAL_ADMIN_EMAIL= LOCAL_ADMIN_PASSWORD_HASH= 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. ## 9. Build phases — status ### Shipped - [x] **Phase 1 — Skeleton** (`58a6b30`) - [x] **Phase 2 — Auth** (`be193d2`, hotfix `83493d0`): Entra OIDC with PKCE, session + CSRF, first-user-is-admin bootstrap, local-admin fallback. - [x] **Apache routing fix** (`82ddc98`): FallbackResource /index.php. - [x] **Phase 3 — Workers + sprints + audit** (`f189e7d`). - [x] **Phase 4 — Sprint settings** (`38ba151`): meta edit, weeks resize, worker membership add/remove/reorder, per-row RTB. - [x] **Phase 5 — Arbeitstage grid** (`515d7d0`): editable matrix, capacity calc, per-cell persistence with audit. - [x] **Phase 6 — Task list** (`ad78283`): CRUD, assignments grid, sort/filter/search, drag-reorder. - [x] **SRI hotfix** (`927b708`): guarded sortable() calls. - [x] **Phase 7 — Audit viewer + polish** (`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](ACCEPTANCE.md) captures the spec §10 manual walkthrough. - [x] **Phase 8 — Cascade audit integrity** (`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. - [x] **Phase 9 — Users management** (`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. - [x] **Phase 10 — Task list polish** (`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. - [x] **Phase 11 — CSP hardening** (`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. - [x] **Phase 12 — Per-week weekday selection (Mo–Fr) drives Arbeitstage** (`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 `