# Changelog

All notable changes to this project are documented here. The format is based
on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Changed

- **Tooling: `make` → `appctl`.** The `Makefile` is gone; a hand-written
  bash wrapper at `bin/appctl` (with a top-level `./appctl` symlink)
  takes over. Subcommand surface follows the verb-then-action pattern:
  `./appctl dev start|stop|build|shell|logs`,
  `./appctl prod start|stop|build`, and the unchanged check trio
  `./appctl lint|test|check`. `HOST_UID` / `HOST_GID` are still exported
  for the css-watcher's bind-mount ownership; the long
  `docker compose -f docker-compose.yml -f docker-compose.dev.yml …`
  invocation is still the one being wrapped. Bash completion ships at
  `bin/appctl-completion.bash`; the first interactive `./appctl`
  invocation offers to add a `source` line to `~/.bashrc`, with a
  marker file under `~/.config/appctl/` so the prompt only appears
  once. Updated SPEC §3 / §11, README, `doc/admin-manual.md`, the
  `/check` Claude Code skill, and the `container-tester` agent so
  `make check` no longer appears anywhere.

## [0.25.0] — 2026-05-07

Small UX-polish release on top of `v0.24.0`. The anonymous welcome
card on `/` is rebuilt to share the local-login mask's silhouette —
narrow `max-w-md` box centred under a 144×144 cycle logo that lives
inside the card, with full-width stacked sign-in buttons. The home
sprint table loses its "Status" column: `is_archived` had no
functional effect anywhere in the app (no list filter, no edit
gate, no permission split) so the active/archived chip was pulled
until the flag actually means something.

### Changed

- **Welcome page matches the local-login mask.** `views/home.twig`'s
  anonymous branch now mirrors `views/auth/local.twig`: a single
  `max-w-md mx-auto` card with the brand cycle logo (144×144,
  `id="brand-cycle-glow-card"` to avoid colliding with the header
  logo's gradient id) centred at the top of the card, then the
  "Sprint Planner" heading, the description text, and the two
  sign-in buttons stacked full-width (`flex items-center
  justify-center w-full`) rather than wrapped inline. The same
  logo treatment is applied to `auth/local.twig` so both entry
  points feel like one product.

- **Drop the Status column from the home sprint table.** Removed the
  `<th>Status</th>` header and the per-row chip `<td>` from
  `views/home.twig`. The chip was the only consumer of `s.isArchived`
  in the list, and grepping the codebase confirmed `is_archived` /
  `isArchived` is only referenced for display (`home.twig`,
  `sprints/show.twig`, `sprints/present.twig`); no controller, no
  list query, no permission gate cares about it. Pulling the column
  avoids advertising a state that does nothing yet.

## [0.24.0] — 2026-05-07

UX-focused release on top of `v0.23.0`: the per-row task hamburger is
rebuilt as a single right-anchored two-pane popup that carries the
menu *and* a read-only info pane (title / description / URL /
linked-task chips) plus a sprint-chooser flyout. Non-admins now see
the hamburger and can run every action it offers
(`Edit task` / `Move task to sprint` / `Copy task to sprint` /
`Move up/down` / `Delete task`); the trigger also stays visible in
the `/present` beamer view so presenters can read description / URL
/ refs during a discussion. Inline title / owner / priority editing
and `+ Add task` remain admin-only.

### Changed

- **Hamburger popup visible in beamer / `/present` view.** Drops
  `[data-task-menu-trigger]` from the `.beamer-root` hide rule in
  `assets/css/input.css`. Now that all the per-row task info
  (description / URL / linked-task chips) lives inside the
  hamburger popup, presenters need the trigger reachable during
  discussions; the existing `.handle` and `[data-delete-task]` hides
  stay in place.

- **Task hamburger: two-pane popup with right-anchored sprint flyout;
  actions opened up to non-admins.** The per-row hamburger trigger now
  renders for every signed-in user (no more `{% if currentUser.isAdmin %}`
  on the title cell), and clicking it opens a single body-attached
  popup positioned to the right of the icon and vertically centred on
  it (cellPopover positioning model). Three panes:
    1. *Left* — vertical menu: **Edit task** / **Move task to sprint ▸** /
       **Copy task to sprint ▸** / **Move up/down** / divider /
       **Delete task**. The two `▸` items toggle the third pane.
    2. *Right* — read-only task info: title, description (preformatted,
       scrollable), URL as a clickable link, and the Phase-22
       bidirectional reference chips (`← Sprint X` / `→ Sprint Y`,
       hover title = "Copied from / to: <task title> (<sprint>)") at
       the bottom.
    3. *Flyout (third column, hidden by default)* — sprint chooser for
       Move / Copy. Opens on the right of the info pane; clicking a
       sprint button fires the existing `POST /tasks/{id}/move` /
       `/copy` endpoints and closes the popup.
  Closing mirrors the Phase-18 cell popover: outside-pointerdown,
  Escape, scroll / resize, and a 250 ms mouseleave grace.
  Server-side, the five hamburger-action endpoints
  (`PATCH /tasks/{id}`, `DELETE /tasks/{id}`, `POST /tasks/{id}/move`,
  `POST /tasks/{id}/copy`, `POST /sprints/{id}/tasks/reorder`) now
  use `SessionGuard::requireAuthJson` instead of `requireAdminJson` —
  any signed-in user can edit / move / copy / pick up / delete a task,
  matching the explicit operator decision to give non-admins the same
  hamburger affordances. Inline title / owner / priority editing and
  `+ Add task` stay admin-only as today (still gated by
  `{% if currentUser.isAdmin %}` in `_task_list.twig` and by
  `requireAdminJson` on `POST /sprints/{id}/tasks` and
  `PATCH /tasks/{id}/assignments`). Touchpoints:
  `src/Controllers/TaskController.php` (5 guard swaps),
  `views/sprints/_task_list.twig` (drop the admin gate around the
  hamburger button; revert the title cell to the simple
  `flex` layout that ships the URL link affordance only),
  `public/assets/js/sprint-planner.js` (replaces the old single-column
  `task-menu` with the wider `task-menu-inner` flex container —
  `buildTaskMenu`, `fillTaskMenuFlyout`, `fillTaskMenuInfo`,
  `positionTaskMenu` (right + vcenter, falls back to left flip),
  `openTaskMenu` / `closeTaskMenu` with `cancelTaskMenuGrace` /
  `scheduleTaskMenuGrace`; retires the brief info-popover from the
  prior commit and its `renderTaskRefs` helper); and
  `assets/css/input.css` (new `.task-menu-list`, `.task-menu-info`,
  `.task-menu-flyout`, `.task-menu-info-ref-chip`, `.task-menu-divider`;
  retires the unused `.task-title-grid`, `.task-info-popover`,
  `.task-desc-popover`). No migration / new endpoint — the existing
  `data-description`, `data-url`, and `linkedMap` keep driving the
  popup; new `data-task-title` / `data-links` row attributes feed the
  info pane without an extra round-trip.

## [0.23.0] — 2026-05-07

Quality-of-life release on top of `v0.22.0`: explicit OIDC kill-switch
for dev / testing, a production-bootstrap guard against shipping an
unreachable instance, and a refreshed admin-only Runtime panel that
surfaces app version + creator instead of the PHP version.

### Added

- **`OIDC_ENABLED` kill-switch for dev / testing.** New env var (default
  `true`) that, when set to `false` / `0` / `no` / `off` (case-insensitive,
  trimmed), forces `OidcClient::isConfigured()` to return false even when
  every `ENTRA_*` var is populated. `/auth/login` and `/auth/callback` both
  short-circuit to the same operator-facing 503 config page, with copy
  that distinguishes "disabled by flag" from "not configured". Lets dev /
  on-prem deployments route everyone through `LOCAL_ADMIN_*` without
  unsetting the Entra creds in `.env`. New `OidcClient::isExplicitlyDisabled()`
  helper and 6 lock-in tests in `tests/Auth/OidcClientTest.php`.
- **`App\Meta` — single source of truth for app version + creator.**
  New class exposes `Meta::VERSION` (`0.23.0`) and `Meta::CREATOR`
  (`Alessandro Chiapparini`); bump alongside the release commit so the
  CHANGELOG heading, the git tag, and the in-app Runtime panel stay
  aligned.

### Changed

- **Production bootstrap refuses to start with no sign-in method.**
  `public/index.php` now aborts with a 503 + `Retry-After: 30` and an
  `error_log` line when `APP_ENV=production` and neither OIDC nor
  `LOCAL_ADMIN_*` is enabled. Stops a fully unreachable instance from
  shipping silently after a misconfigured deploy.
- **Admin-only Runtime panel on `/` swaps contents.** Drops the `PHP`
  row; adds `App version` and `Creator` (sourced from `App\Meta`). The
  OIDC row's value vocabulary changes from `configured` / `not configured`
  to `enabled` / `disabled`, matching the Local admin row, so
  `OIDC_ENABLED=false` reads naturally. No leak-surface change — the gate
  (`currentUser is not null and currentUser.isAdmin` from R01-N02) is
  unchanged, and `TwigViewTest::testHomeForAnonymousUserHidesRuntimePanel`
  was updated to assert that neither the new `appVersion` nor
  `appCreator` strings render for anonymous visitors.

## [0.22.0] — 2026-05-07

First tagged release. The minor version mirrors the latest build phase
shipped (Phase 22). Section grouping below reflects the build-phase history
captured in `SPEC.md` §9 plus the security-review hardening pass tracked in
`doc/REVIEW_01.md` (R01-N01..R01-N34).

### Added

- **Phase 1 — Skeleton.** Front controller, router, request/response
  primitives, view layer.
- **Phase 2 — Auth.** Microsoft Entra ID via OpenID Connect (Authorization
  Code + PKCE), session + CSRF, first-user-is-admin bootstrap, optional
  local-admin fallback for dev / on-prem deployments.
- **Phase 3 — Workers, sprints, audit.** Worker CRUD, sprint create,
  per-cell audit log.
- **Phase 4 — Sprint settings.** Sprint metadata edit, weeks resize,
  worker membership add/remove/reorder, per-row reserve-time-buffer (RTB).
- **Phase 5 — Arbeitstage grid.** Editable working-days matrix, capacity
  calculation, per-cell persistence with audit.
- **Phase 6 — Task list.** CRUD, assignments grid, sort/filter/search,
  drag-reorder.
- **Phase 7 — Audit viewer + polish.** `/audit` admin page with filters,
  pagination, collapsible diffs; security headers + strict-ish CSP; CSRF
  audit (18/18 mutations); PHPUnit harness with 59 tests.
- **Phase 8 — Cascade audit integrity.** Three FK cascade paths
  (sprint_worker → sprint_worker_days, sprint_worker → task_assignments,
  sprint_week → sprint_worker_days on shrink) snapshot-audit before the
  parent delete fires.
- **Phase 9 — Users management.** `GET /users` + `POST /users/{id}` with
  self-demote and last-admin guardrails.
- **Phase 10 — Task list polish.** Multi-select owner filter and column
  visibility toggle, both pure client-side with localStorage persistence.
- **Phase 11 — CSP hardening.** 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.
- **Phase 12 — Per-week weekday selection (Mo–Fr) drives Arbeitstage.**
  `sprint_weeks.active_days_mask` becomes the source of truth;
  `max_working_days` is a cached `popcount(mask)` projection. Five
  checkboxes per week in Sprint Settings; row of five dots per week in
  the sprint view (green = active, gray = off). Migration 002 backfills
  legacy rows.
- **Phase 13 — Focus filter + Reset in the task list.** New
  `[data-focus-select]` picks one sprint worker; `applyFocusColumnVisibility`
  hides all-zero columns; `[data-reset-filters]` wipes search, prio,
  ownerFilterSet, focusWorker, and hiddenCols in one click. State persists
  in localStorage.
- **Phase 14 — Hamburger menu.** Workers / Users / Audit log / Sign out
  collapsed into a dropdown behind a `data-menu-trigger` button with an
  inline-SVG icon; vanilla-JS toggle (~30 lines) honours outside-click,
  Escape, and focus return.
- **Phase 15 — Big-screen (beamer) task viewer.** New signed-in route
  `/sprints/{id}/present` renders a stripped-down view for projection;
  shared partial with `/sprints/{id}` via a `loadSprintPage` helper.
  Auto vertical column headers when overflow detected.
- **Phase 16 — Dark-mode toggle + light-mode contrast cleanup.** Manual
  toggle (no `prefers-color-scheme` auto-detect); `theme-init.js`
  synchronously sets `<html class="dark">` from localStorage to prevent
  FOUC; comprehensive `dark:` class sweep across every view.
- **Phase 17 — Number-stepper popover (later replaced).** Hidden native
  number-input spinners (`@layer base` reset); per-cell click-to-open
  vertical-slider popover on day, RTB, and task-assignment inputs. After
  seven iterations the implementation was removed in favour of plain
  typed entry; ArrowUp/ArrowDown stepping retained via browser default.
- **Phase 18 — Per-cell task-status colours + filter + global toggle.**
  `task_assignments.status` ∈ {`zugewiesen`, `gestartet`, `abgeschlossen`,
  `abgebrochen`}; new `app_settings` KV table with opt-in
  `task_status_enabled` flag; admin `/settings` page; Status multi-select
  filter; first non-admin write surface (`PATCH
  /tasks/{id}/assignments/status`). Migration 003.
- **Phase 19 — Twig 3 + Tailwind 3 + Alpine CSP + htmx + SortableJS;
  jQuery removed.** Stack-shift of the entire UI layer with zero changes
  to controllers, repositories, schema, capacity math, or audit
  semantics. All 11 `views/*.php` rewritten as `*.twig`. Strict CSP:
  `script-src 'self'` and `style-src 'self'` only — no third-party
  hosts.
- **Phase 20 — XLSX import wizard.** Two-step admin-only wizard at
  `/sprints/import`; multipart upload (≤ 5 MB, ZIP magic-byte check),
  preview screen with target picker (Create new / Merge into empty
  existing), per-sheet skip toggle, transactional commit; per-sprint
  `IMPORTED_FROM_XLSX` audit row. New service trio:
  `XlsxColorClassifier`, `XlsxSprintImporter`, `SprintImporter`.
  Composer dep `phpoffice/phpspreadsheet ^3.4`; runtime stage adds
  `zip` + `gd` extensions.
- **Phase 21 — Auto-derived week count in sprint settings.** `PATCH
  /sprints/{id}` resyncs `sprint_weeks` whenever `start_date` or
  `end_date` changes (target = `floor((end − start)/7) + 1`, capped at
  26). Existing rows realign; appended rows default to `MASK_ALL`;
  trailing rows shrink with the same audit-cascaded-days flow as the
  legacy `replaceWeeks`.
- **Phase 22 — Per-task hamburger menu: move / copy / edit / reorder.**
  Per-row admin button gathers Edit details / Move to sprint / Copy to
  sprint / Move (pick up) / Delete. Tasks gain `description` (≤ 8000
  chars), `url` (`http(s)://`, ≤ 2048 chars), and `linked_task_id` for
  bidirectional cross-sprint copy chips. Two new admin JSON endpoints
  (`POST /tasks/{id}/move`, `POST /tasks/{id}/copy`). Migration 004.
- **Sprint settings: secured Delete sprint action.** Danger-zone
  `POST /sprints/{id}/delete` form gated by `requireAdmin` + CSRF +
  typed-confirmation match; full-cascade audit (every descendant
  snapshotted) plus an UPDATE audit for cross-sprint linked rows whose
  `linked_task_id` was nulled out.

### Changed

- **Header cleanup: Import moved into the admin dropdown.** The
  `/sprints/import` link moved from inline in the header to the top of
  the admin section in the hamburger menu.
- **New sprint form: drop weeks input.** `/sprints/new` no longer
  collects `n_weeks`; the count is derived from `start_date` / `end_date`
  (capped at 26; above that redirects with `?error=dates_too_long`).
- **Task table polish.** Sortable headers gain `whitespace-nowrap`;
  per-row assignment cells gain `whitespace-nowrap`; `.assign-status-*`
  tint moved off the `<td>` onto the inner field.
- **Filter dropdown close polish.** Owners / Status / Columns dropdowns
  no longer get cropped on a single-row task table; close on
  `mouseleave` with a 250 ms grace timer.
- **Sprint view tabs + smart Close on present.** `/sprints/{id}` is
  split into "Arbeitstage and capacity" + "Capacity and tasks" tabs,
  persisted in localStorage. The present view's Close button calls
  `history.back()` when possible, falling back to `window.close()` then
  to navigation.
- **Cell popover replaces per-cell status select.** Single body-attached
  `.cell-popover` panel anchored 8 px right of the cell; left column is
  a slider whose max comes from `assignment_slider_max` (1..100,
  default 10), right column is four status pills.

### Removed

- **Number-stepper slider popover (Phase 17).** Removed after seven
  iterations failed to land reliable behaviour; the team prefers plain
  typed entry. The `@layer base` rule that hides native number-spinner
  arrows app-wide stays.

### Security (R01-N* — `doc/REVIEW_01.md`)

- **R01-N01 — Hash-only local-admin password.** `LOCAL_ADMIN_PASSWORD`
  → `LOCAL_ADMIN_PASSWORD_HASH`; `password_verify()` against a bcrypt
  hash; no plaintext fallback.
- **R01-N02 / R01-N31 — Runtime panel on `/` is admin-only.**
  `views/home.twig`'s "Runtime" `<details>` block was leaking PHP
  version, env, SQLite path, schema version, and OIDC flags to anonymous
  visitors; tightened to `currentUser is not null and currentUser.isAdmin`.
  In-page `/healthz` hint also removed (the route stays public for
  liveness probes).
- **R01-N03 — Explicit env-bootstrap for the first OIDC admin.** OIDC
  no longer auto-promotes the first user to land on `/auth/callback`;
  `BOOTSTRAP_ADMIN_OID` / `BOOTSTRAP_ADMIN_EMAIL` name the principal up
  front. Without one of them set, OIDC never auto-promotes anyone.
- **R01-N04 — `SESSION_SECRET` removed.** The env var was documented but
  unused; deployments that rotated it got a false sense of security.
- **R01-N05 + R01-N07 — Trusted-proxy aware HTTPS detection & client
  IP.** `TrustedProxies` honours a comma-separated `TRUSTED_PROXIES=`
  env var and walks the XFF chain right-to-left; with the env blank,
  forwarded headers are ignored. Session cookie marked `Secure` when
  either `APP_BASE_URL` is HTTPS or the live request is effectively
  HTTPS via a trusted proxy. One-shot HTTP→HTTPS redirect (308) when
  the proxy explicitly reports `X-Forwarded-Proto: http`.
- **R01-N06 — Throttle local-admin login by (ip, email).** Migration
  005 adds `auth_throttle`. Policy in `computeLockout(attempts, now)`:
  1–4 → no lock; 5–9 → +5 min; 10–19 → +30 min; 20+ → +1 hour. Counter
  rolls over after a 15-min idle window; success deletes the row.
  `LOGIN_FAILED` audit row distinguishes throttled hits from
  credential mismatches.
- **R01-N08 — Idle session timeout + CSRF rotation on login.** 30-min
  idle window (`IDLE_TIMEOUT_SECONDS = 1800`); `last_active` stamp
  drops auth keys + regenerates the session id past the threshold.
  Login now `unset()`s `csrf_token` so a pre-login token can't be
  replayed against the authenticated session.
- **R01-N10 — Bind `sprint_id` with placeholders in `MAX(sort_order)`
  lookups.** Three repo-level read paths previously interpolated the
  integer parameter directly into SQL (not exploitable today; route
  layer int-casts) — switched to prepared statements.
- **R01-N11 — Whitelist column in
  `AuditRepository::distinctColumn`.** Private helper interpolated its
  `$col` argument; added explicit `in_array($col, ['action',
  'entity_type'], true)` guard.
- **R01-N12 — Validate `/audit` date filters server-side.** Strict
  `Y-m-d` parse via `DateTimeImmutable::createFromFormat` round-trip
  equality; bad input drops the filter and surfaces a per-field error.
- **R01-N13 — Fatal-error safety net.** New `FatalErrorHandler`
  registers both `set_exception_handler` and a fatal-mask
  `register_shutdown_function`. On fire, every output buffer is drained
  and a minimal 500 page is written with the same security headers a
  normal response carries (CSP / HSTS / X-Frame-Options /
  X-Content-Type-Options / Referrer-Policy). `public/index.php` now
  sources its security headers from
  `FatalErrorHandler::securityHeaders()` — single source of truth.
- **R01-N14 — XLSX session payload cap.** The full parsed workbook
  was JSON-stashed in the session file with no upper bound on parsed
  expansion; capped at 2 MiB via `MAX_SESSION_PAYLOAD_BYTES`. Pruning
  paths emit an `IMPORT_PREVIEW_ABANDONED` audit row.
- **R01-N15 — `noreferrer` on external task URL link.** The
  user-controlled `t.url` link in `views/sprints/_task_list.twig` now
  carries `rel="noopener noreferrer"`; sequential sprint IDs no longer
  leak via Referer.
- **R01-N16 — `bin/audit.sh` composer-audit helper.** Wraps `composer
  audit --locked --no-interaction` inside the runtime image so the
  audit reflects the live container's dependency tree (host PHP often
  lacks the right ext-* set). Honours `SPRINT_PLANER_IMAGE` for
  non-default tags. Admin manual §5.5 documents a recommended cadence.
- **R01-N18 — OIDC email trust path.** `OidcClaims::resolveEmail()` is
  the single decision point: drops the `preferred_username` fallback;
  honours `claims.email` only when `email_verified !== false`; falls
  back to `entra:<oid>` when no trusted email is available.
- **R01-N19 — CSP `report-uri /csp-report` + audit endpoint.** Strict
  CSP gains a report URI; new public `POST /csp-report` route
  (`CspReportController`) writes one audit row per browser-fired
  report. Bodies that fail to decode or exceed the 16 KiB cap return
  204 with no row written.
- **R01-N20 — `Response::redirect` rejects non-path locations.** Refuses
  anything that is not a single `/` followed by a non-`/`, plus
  CR/LF/NUL guard (header injection). The HTTP→HTTPS canonical
  redirect moves to a new `Response::external($url, $status)` helper
  that scheme-restricts to `http`/`https`.
- **R01-N21 — Twig auto-escape pinned by tests.** Two regression tests
  in `tests/Http/TwigAutoescapeTest.php`: a behaviour pin renders a
  known XSS payload through the wired-up `View` env and asserts HTML
  escaping; a static guard walks every `.twig` file under `views/` and
  fails on any `|raw`, `|safe`, or `{% autoescape` directive.
- **R01-N22 — Migrations move to deploy time.** `bin/docker-entrypoint.sh`
  → `php bin/migrate.php` runs BEFORE Apache binds the port; a failed
  migration aborts container start. The web request path only checks
  `schema_version` and returns 503 when something is pending.
- **R01-N23 — `users.tombstoned_at` for soft erasure.** Privacy-request
  path that overwrites email / OID with placeholder values, retains
  the row for FK integrity, and stops the user from signing back in.
- **R01-N24 — 1 MiB body cap + 5000-item batch cap on JSON endpoints.**
  Defends against memory-exhaustion via oversized POST bodies.
- **R01-N25 — `X-Permitted-Cross-Domain-Policies: none` header.** Emit
  alongside the other security headers.
- **R01-N26 — One-shot session flash for post-delete chip.** Replaces
  the query-string-driven flash so the message survives a cookie path
  but doesn't bookmark.
- **R01-N27 — Backgrounded session-file GC loop in entrypoint.** Long
  running cleanup decoupled from the request path.
- **R01-N28 — Drop dead `$changed[]` in `SettingsController::update`.**
  Code-quality cleanup; no behaviour change.

### Notes on accepted-by-design findings

The following review findings were closed without code change after
analysis; see `doc/REVIEW_01.md` for the rationale on each:
R01-N09 (`SameSite=Lax` retained — `Strict` would block the OIDC
callback), R01-N17 (concurrent-tab OIDC clobber is correct
RFC behaviour), R01-N29, R01-N30, R01-N32, R01-N33, R01-N34.

[Unreleased]: https://github.com/chiappa/sprint_planer_web/compare/v0.23.0...HEAD
[0.23.0]: https://github.com/chiappa/sprint_planer_web/compare/v0.22.0...v0.23.0
[0.22.0]: https://github.com/chiappa/sprint_planer_web/releases/tag/v0.22.0
