# Acceptance checklist (spec §10) — walkthrough

This is the checklist from the spec. Run it against a fresh container after
Phase 7 lands. Expected outcome is noted next to each step; edit this file
(and open a bug) if any step deviates.

## Setup

```bash
docker compose down && rm -rf data/app.sqlite
docker compose up --build
```

`.env` should have EITHER valid `ENTRA_*` values OR `LOCAL_ADMIN_EMAIL` +
`LOCAL_ADMIN_PASSWORD_HASH` set (the hash is the output of PHP's
`password_hash()` — see README's Quick setup). Below, "sign in" means
whichever flow you configured.

## Steps

1. **Fresh container, empty DB → sign in → you're the admin.**
   - Open http://localhost:8080.
   - If OIDC configured, click **Sign in with Microsoft** → complete Entra
     round-trip. If local admin configured, click **Sign in as local admin**
     and enter the env credentials.
   - Expected: header shows your display name + `admin` badge.
     `SELECT is_admin FROM users LIMIT 1` in `data/app.sqlite` returns 1.
     `audit_log` has rows: `CREATE user / BOOTSTRAP_ADMIN user / LOGIN user`.

2. **Create a sprint with 4 weeks.**
   - Header → **New sprint** → name "Sprint 1", reasonable dates, reserve 20%,
     weeks = 4 → Create sprint.
   - Expected: redirect to `/sprints/1`; empty-state banner says "No workers".
     Clicking **Settings** opens the settings page. The weeks table shows
     4 rows with max_working_days 5.0 each and ISO week numbers computed
     from the start date.

3. **Add 6 workers, reorder by dragging — reload persists order.**
   - Header → **Workers** → add Alice, Bob, Carol, Dan, Eve, Frank.
   - Back to `/sprints/1/settings` → add all 6 to the sprint via **Add →**
     buttons.
   - Drag the "≡" handles to reorder (e.g. reverse order).
   - Reload the page.
   - Expected: the reordered sequence persists.
     `audit_log` shows `UPDATE sprint_worker` rows for every moved row
     (unchanged rows emit nothing thanks to the no-op rule).

4. **Fill Arbeitstage; Ressourcen / Reserven / Available update live.**
   - Open `/sprints/1` → edit a day cell (0.5-step input).
   - Blur the input.
   - Expected: Σ column updates immediately, the capacity strip updates
     (`Ressourcen`, `− Reserven`, `Available`). Status pill flashes "Saved N
     cell(s)". `audit_log` has one `CREATE sprint_worker_days` row per edited
     cell.

5. **Add tasks, assign days — Tot updates; over-commit turns Available red
   but still saves.**
   - In Section B: click **+ Add task** → focus jumps to the title input →
     type "Report for Q1", set priority 1, assign 10 days to one worker
     whose capacity is 8.
   - Expected: `Tot` cell shows 10; that worker's `Available` in the capacity
     strip goes negative and turns red. The assignment is persisted
     (reload still shows 10); `audit_log` has `CREATE task` + `CREATE
     task_assignment`.

6. **Sort task table by Owner, then by a worker column; clear → original
   drag order returns.**
   - Click the `Owner` header — rows sort asc, header shows "↑".
   - Click again — desc, "↓".
   - Click any worker column header — sorts by that cell's days.
   - Click the same header a third time (or any other until cleared) — the
     rows return to `data-sort-order` (the drag-persisted order).
   - Expected: no 500s, no layout shift, drag is disabled while a sort is
     active.

7. **Filter tasks: Prio=1, Owner=X, free-text "report".**
   - Set prio filter to "Prio 1 only", owner to a specific worker, type
     "report" into the search box.
   - Expected: only matching rows stay visible. The "No tasks match the
     current filters" banner appears when every row is hidden.

8. **Rename a worker in `/workers` — reflected everywhere.**
   - Header → **Workers** → change "Alice" to "Alice Cooper" → Save.
   - Open `/sprints/1` in another tab.
   - Expected: Arbeitstage row label, task list column header, and the
     Owner dropdowns all show the new name. `audit_log` has an `UPDATE
     worker` row.

9. **/audit shows one row per change with diffs visible.**
   - Header → **Audit log**.
   - Expected: reverse-chronological table with 50/page pagination. Every
     change you made is there. Each row's "before / after" `<details>` opens
     to show pretty-printed JSON snapshots.
   - Filter by user / action / entity type / date range / entity_id substring
     — narrow the list.

10. **Sign out; unauthenticated → redirect to /auth/login.**
    - Click **Sign out**.
    - Expected: session cleared, `audit_log` has a `LOGOUT user` row.
    - Try visiting `/workers` or `/sprints/1/settings` while anonymous →
      redirected to `/auth/login` (and then Entra, or the local form).

## Spot-check (spec §12)

If you have `Tool_Sprint_PlanningSample.xlsx` handy:
- Pick a worker with known weekly availability (e.g. 4-4-5-5-2).
  `Ressourcen` should match Excel: sum across weeks.
- `− Reserven` should match: `round_to_0.5(Ressourcen * 0.8)` with reserve = 20 %.
- For a prio-1 task totalling N days on that worker, `Available` should be
  `(Ressourcen − Reserven) − N`, and turn red when negative.

## Security headers (spec §9)

With the app running, `curl -I http://localhost:8080/` should report:

```
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Referrer-Policy: strict-origin-when-cross-origin
Content-Security-Policy: default-src 'self'; script-src 'self' …; …
```

`Strict-Transport-Security` appears only when `APP_BASE_URL` uses `https://`.

## Phase 13 — Focus filter + reset

Runs against a sprint with at least 4 workers and a handful of tasks —
set up via steps 3–5 above if starting from scratch. All scenarios below
are pure client-side UI; no audit rows are written and no API requests
should fire (verify with the Network panel open).

1. **Focus on a worker with assignments → only their tasks + only their
   non-zero columns remain.**
   - Pick a sprint worker (say **Bob**) who is the assignee on two tasks
     out of five.
   - Toolbar → **Focus** select → choose **Bob**.
   - Expected:
     - Only the two tasks where Bob's cell is > 0 stay visible.
     - Any worker column where every visible row is 0 collapses. Bob's
       own column stays visible (by definition > 0).
     - The **Reset** button appears to the left of the search box.
     - Reloading the page preserves the focus (localStorage key
       `sp:{sprintId}:focusWorker`).

2. **Focus on a worker with no assignments → empty-filter banner + every
   sw column collapses.**
   - Add a new worker to the sprint who has no task assignments (or pick
     an existing one — e.g. **Frank** — and zero out all their cells
     first).
   - Toolbar → **Focus** → choose that worker.
   - Expected:
     - Task rows all hidden → the "No tasks match the current filters"
       banner shows (NOT the "No tasks yet" empty-state row).
     - Every worker column in the task-list header is collapsed (no sw
       column has a non-zero visible row).
     - Title / Owner / Prio / Tot columns stay visible.

3. **Stacked filters AND together.**
   - Type "report" in **Search**, set **Prio** to "Prio 1 only", check
     one owner in **Owners**, and pick a **Focus** worker.
   - Expected: only rows that satisfy *every* predicate show —
     title matches, prio = 1, owner matches, focus worker's assignment
     > 0. Flipping any one filter off broadens the set as expected.

4. **Reset clears everything and the button hides.**
   - With all four filters from (3) active (plus at least one column
     hidden via the **Columns** dropdown), click **Reset**.
   - Expected:
     - Search empties, Prio resets to "All prios", Owners count clears,
       Focus returns to "All workers", every column re-appears in the
     Columns dropdown (all checkboxes re-checked), every row visible.
     - The **Reset** button itself disappears.
     - Reload the page — nothing returns (all five localStorage keys
       cleared/reset to empty state).

## Phase 14 — Hamburger menu

Runs with any signed-in user. The header primary links (Sprints,
New sprint) and the user badge stay inline; Workers / Users / Audit
log / Sign out live behind the new hamburger button on the right.

1. **Signed-in admin: dropdown contains Workers / Users / Audit log /
   Sign out.**
   - Sign in as an admin (see Setup).
   - Click the hamburger button on the right of the header.
   - Expected: panel opens immediately below the button with four
     rows in order: **Workers**, **Users**, **Audit log**, a thin
     `<hr>` divider, **Sign out**. `aria-expanded="true"` on the
     trigger. Each row has `role="menuitem"`; the panel has
     `role="menu"`. Clicking **Workers** navigates to `/workers`
     and closes the menu.

2. **Signed-in non-admin: dropdown contains only Sign out.**
   - Sign in as a non-admin user (demote yourself from `/users`
     while logged in as a second admin, or seed a user with
     `is_admin=0` directly).
   - Click the hamburger.
   - Expected: panel contains a single **Sign out** row — no
     Workers / Users / Audit log, no `<hr>` divider. The primary
     **New sprint** link is already hidden for non-admins (admin
     gate lives on the anchor itself).

3. **Outside-click and Escape close the menu; focus returns to
   trigger on Escape.**
   - Open the menu (hamburger click).
   - Click anywhere in the page outside the panel (e.g. the
     Sprint Planner wordmark or the main content). Expected:
     panel closes, `aria-expanded="false"`.
   - Re-open the menu. Press **Escape**. Expected: panel closes
     AND the hamburger button regains keyboard focus (visible via
     the focus ring).

4. **Sign out from the menu still posts with CSRF and logs out.**
   - Open the menu → click **Sign out**.
   - Expected: browser POSTs to `/auth/logout` (Network panel
     shows a 302 with `_csrf` in the form payload), session is
     cleared, and the page redirects to `/auth/login` (or the
     public home with a "Sign in" CTA). `audit_log` has a new
     `LOGOUT user` row. No JS-driven POST — the native `<form>`
     carries the `_csrf` hidden input and submits the usual way.

## Phase 15 — Big-screen viewer

Runs against a sprint with at least 4 workers and a handful of tasks.
The presentation view lives at `/sprints/{id}/present`; open it from
the **Present** button in the sprint header (opens in a new tab).

1. **Admin opens `/sprints/{id}/present` at 1920×1080 — all worker
   columns fit without horizontal scroll.**
   - Sign in as admin. Open the sprint, click **Present**.
   - Expected: new tab at the `/present` URL. The page has no
     site nav/header chrome, no Arbeitstage matrix, and no
     capacity summary — just a thin top bar (sprint name +
     dates + Close) and the task-list toolbar + table.
     At 1920×1080 (resize the tab to roughly that if on a
     bigger display), every worker column is visible without
     horizontal scrolling. The Owner / Prio / Tot columns
     are hidden by default (un-check / re-check them in the
     **Columns** dropdown to verify the seed persists).

2. **Same URL at 3840×2160 (HiDPI beamer).**
   - Simulate by setting browser zoom to 50% on a 1920×1080
     display or use a 4K external screen.
   - Expected: the `clamp(0.75rem, 0.95vw, 1.05rem)` font-size
     scales up (cell text stays legible from across the room).
     No layout break; horizontal scroll remains absent.

3. **Filters + focus stack on the present view.**
   - Toolbar → type something in **Search**, set **Prio** to
     "Prio 1 only", check one **Owner**, and pick a **Focus**
     worker.
   - Expected: same client-side predicate AND'd together as on
     `/sprints/{id}`. The Focus-driven column auto-hide still
     fires (sw columns with all-zero visible rows collapse).
     Reloading the tab preserves all four filters — state is
     namespaced to the `:beamer` localStorage keys, so the
     regular `/sprints/{id}` tab's filters are untouched.

4. **Admin edits a task cell in present mode; `/sprints/{id}`
   reload shows the new value.**
   - In the present tab, change a worker's day count on a task
     (blur to trigger save). Status chip flashes "Saved 1 cell".
   - Switch to a tab at `/sprints/{id}` and reload.
   - Expected: the new value is persisted there as well, and a
     new `audit_log` row (`CREATE` or `UPDATE` task_assignment)
     is present.

5. **Non-admin opens the same URL — read-only, same layout.**
   - Demote yourself (or open an incognito window as a seeded
     non-admin user). Visit `/sprints/{id}/present`.
   - Expected: page renders with the same structure, but task
     title / owner / prio / per-cell inputs are replaced by
     plain-text spans. No drag handles, no **+ Add task**
     button, no per-row delete buttons. Filtering still works.

6. **Very wide sprint (12+ workers) — vertical header rotation
   kicks in.**
   - Seed or add enough workers to the sprint so the task table
     would otherwise overflow the viewport width at 1920×1080.
   - Load `/sprints/{id}/present`.
   - Expected: sw column headers rotate 90° (vertical-rl +
     180° flip so the text reads bottom-to-top). The table
     fits without horizontal scroll. If it still overflows
     (20+ workers), the browser console shows
     `[sprint-planner] beamer: table still overflows …` and
     horizontal scroll is enabled — the page does not spin.

## Phase 16 — Dark mode + light contrast

Runs with any signed-in user. The new Theme row lives in the
hamburger menu (visible for admins and non-admins alike,
sitting above the `<hr>` that separates it from Sign out).
Toggle state persists per browser in `localStorage['sp:theme']`
and is applied synchronously by `/assets/js/theme-init.js`
before the stylesheet resolves — zero FOUC.

1. **Fresh page load in light mode: body is cooler than the
   table headers (visible band separation).**
   - Open http://localhost:8080 in a fresh browser profile (or
     clear `sp:theme` from localStorage first).
   - Expected: body background is `bg-slate-100`, table
     `<thead>` bands stay at `bg-slate-50`. Looking at the
     home page Sprints table, the header band now reads as a
     distinctly lighter strip above rows, no longer blending
     into the page tint. Cards (`bg-white`) still pop above
     slate-100 as before.

2. **Click Theme in the hamburger menu → whole app flips to
   dark; label reads "Dark".**
   - Sign in. Click the hamburger. Panel shows (admin:
     Workers / Users / Audit log / **Theme (Light)** / `<hr>` /
     Sign out; non-admin: **Theme (Light)** / `<hr>` / Sign out).
   - Click **Theme**.
   - Expected: `<html class="dark">` is set. Body goes
     `dark:bg-slate-900`, header and cards go
     `dark:bg-slate-800`, table header bands go
     `dark:bg-slate-700`, borders go `dark:border-slate-700`.
     Primary text reads `dark:text-slate-100`. Re-open the
     menu — the right-hand label now reads **Dark**. Click
     again to flip back.

3. **Reload the page → dark persists, no flash of light
   before styles apply.**
   - With the app in dark mode, reload (F5). Watch the first
     paint carefully — in devtools, enable Network throttling
     ("Slow 3G") and reload so you can see the first paint
     before CSS applies.
   - Expected: the background is dark immediately (theme-init.js
     is a synchronous `<script src>` in `<head>` before the
     stylesheet, so the `dark` class is on `<html>` the first
     time the stylesheet resolves). No white flash.

4. **Open `/sprints/{id}/present` in a new tab → picks up dark
   too (theme-init.js in its head).**
   - With dark mode active in the main tab, open a sprint and
     click **Present** (new tab).
   - Expected: the presentation view loads already in dark mode
     — body `dark:bg-slate-900`, top bar
     `dark:bg-slate-800`, task table header
     `dark:bg-slate-700`, inputs `dark:bg-slate-800
     dark:text-slate-100`. Verifies the separate
     `<!doctype html>` emitted by `views/sprints/present.php`
     includes the same theme-init.js tag in its `<head>`.

5. **Back to light mode, open Workers / Users / Audit /
   Settings pages: no stray white-on-white or unreadable text
   anywhere.**
   - Toggle back to light mode. As admin, walk through:
     `/workers`, `/users`, `/audit`, `/sprints/new`,
     `/sprints/{id}`, `/sprints/{id}/settings`,
     `/sprints/{id}/present`.
   - Expected: every page reads cleanly in light mode (slate-100
     body, slate-50 header bands, white cards). Toggle to dark
     and do the same sweep — every flash chip (green/red/
     amber), admin badge, focus ring, and capacity-red cell
     still has legible contrast on the slate-800/900 surfaces.

6. **Private-window (localStorage denied) → defaults to light,
   toggle no-ops without throwing.**
   - Open a private / incognito window that blocks localStorage
     (or disable storage for the origin in devtools → Application
     → Storage → Local Storage).
   - Expected: the app loads in light mode (theme-init.js's
     try/catch silently swallows the read denial). Opening the
     hamburger and clicking **Theme** still flips the class on
     `<html>` for the current page (in-memory), label updates
     to "Dark"/"Light"; the write to localStorage is caught by
     try/catch and the page does not throw. Reloading resets to
     light (no persistence possible).

## Phase 17 — Number stepper popover

Runs against a sprint with at least a couple of workers and a
handful of tasks. All numeric inputs in the app now have the
native up/down spinners hidden; the day / RTB / assignment cells
additionally open a small `.stepper-popover` anchored to the
input on click/focus. Non-admin users see plain `<span>`s, so
the popover only appears for admins (and only on the opt-in
`[data-stepper]` inputs — `n_weeks` and `reserve_fraction` stay
bare).

1. **No native spinner arrows on any number input across the
   app.**
   - Sign in as admin. Walk through `/sprints/new`,
     `/sprints/{id}`, `/sprints/{id}/settings`, and
     `/sprints/{id}/present`. Hover every `<input
     type="number">` (week count, reserve %, day cells, RTB
     cells, task assignment cells).
   - Expected: neither Chrome / Safari (webkit spin buttons)
     nor Firefox (MozAppearance spinner) renders the up/down
     arrows — every number input reads as plain text. Manual
     keyboard typing still works.

2. **Clicking a day cell on `/sprints/{id}` opens the stepper;
   +/− step by 0.5; value persists after blur.**
   - Open `/sprints/{id}` as admin. Click (or tab into) any
     Arbeitstage per-worker day cell.
   - Expected: a `.stepper-popover` appears just below (or
     above if the cell is in the lower 25% of the viewport)
     the input, with −, the current value as a big
     `<output>`, +, and — because min=0 / max=5 are both set
     — a horizontal range slider. Clicking **+** increments
     the input by 0.5 (clamped to 5); the Σ column and the
     capacity strip update live as each tick lands (synthetic
     `input` events wired into `sprint-planner.js`). Click
     outside to close — the debounced save (PATCH
     `/sprints/{id}/week-cells`) fires exactly once, `audit_log`
     gains one row per changed cell. Reload to confirm
     persistence.

3. **RTB cell on `/sprints/{id}` (and `/sprints/{id}/settings`)
   pops the stepper; +/− step by 0.05.**
   - Click an RTB cell next to a sprint worker.
   - Expected: same popover, but `step=0.05`, `min=0`,
     `max=1`. +/− clicks nudge the value by 0.05; the range
     slider covers 0..1. Clicking outside triggers the save
     (PATCH `/sprints/{id}/workers/{sw_id}`). Same behaviour
     on `/sprints/{id}/settings`'s worker list.

4. **Task assignment cell on both `/sprints/{id}` and
   `/sprints/{id}/present` pops the stepper; no range slider.**
   - Click a task's per-worker assignment cell.
   - Expected: `min=0`, no `max` → the popover shows −, the
     value, +, and **no** range slider (the `<input
     type="range">` stays hidden). +/− steps by 0.5. On
     `/sprints/{id}/present`, the same behaviour — the
     presentation view gets its own `<script src="/assets/js/
     number-stepper.js" defer>` tag, so the popover works
     there too. Newly-added tasks (admin clicks + Add task)
     inherit the stepper too — `buildTaskRow` stamps the
     `data-stepper` attribute on its JS-built `[data-assign]`
     cells.

5. **Escape + outside-click + dark-mode polish.**
   - Open a day cell's popover. Press **Escape** → popover
     closes, focus returns to the input (visible focus ring).
   - Re-open the popover. Click anywhere else on the page
     (white space, the Close / Settings buttons) → popover
     closes. Clicking another `[data-stepper]` input instead
     moves the popover to the new target (no flash of two).
   - Use ArrowUp / ArrowDown while focused on the input →
     value steps by `step` (replaces the native spinner
     shortcut the CSS reset just disabled).
   - Flip the app into dark mode via the hamburger Theme row
     → re-open the popover. Card reads `bg-slate-800`
     with a `border-slate-600` ring and a `shadow-lg`; the
     value output reads `text-slate-100`, −/+ buttons
     `hover:bg-slate-700`, and the range slider thumb picks
     up `accent-slate-400`. No white rectangle, no stray
     light borders.
