ACCEPTANCE.md 17 KB

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

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 set. 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).