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.
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.
Fresh container, empty DB → sign in → you're the admin.
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.Create a sprint with 4 weeks.
/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.Add 6 workers, reorder by dragging — reload persists order.
/sprints/1/settings → add all 6 to the sprint via Add →
buttons.audit_log shows UPDATE sprint_worker rows for every moved row
(unchanged rows emit nothing thanks to the no-op rule).Fill Arbeitstage; Ressourcen / Reserven / Available update live.
/sprints/1 → edit a day cell (0.5-step input).Ressourcen, − Reserven, Available). Status pill flashes "Saved N
cell(s)". audit_log has one CREATE sprint_worker_days row per edited
cell.Add tasks, assign days — Tot updates; over-commit turns Available red but still saves.
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.Sort task table by Owner, then by a worker column; clear → original drag order returns.
Owner header — rows sort asc, header shows "↑".data-sort-order (the drag-persisted order).Filter tasks: Prio=1, Owner=X, free-text "report".
Rename a worker in /workers — reflected everywhere.
/sprints/1 in another tab.audit_log has an UPDATE
worker row./audit shows one row per change with diffs visible.
<details> opens
to show pretty-printed JSON snapshots.Sign out; unauthenticated → redirect to /auth/login.
audit_log has a LOGOUT user row./workers or /sprints/1/settings while anonymous →
redirected to /auth/login (and then Entra, or the local form).If you have Tool_Sprint_PlanningSample.xlsx handy:
Ressourcen should match Excel: sum across weeks.− Reserven should match: round_to_0.5(Ressourcen * 0.8) with reserve = 20 %.Available should be
(Ressourcen − Reserven) − N, and turn red when negative.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://.
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).
Focus on a worker with assignments → only their tasks + only their non-zero columns remain.
sp:{sprintId}:focusWorker).Focus on a worker with no assignments → empty-filter banner + every sw column collapses.
Stacked filters AND together.
Reset clears everything and the button hides.
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.
Signed-in admin: dropdown contains Workers / Users / Audit log / Sign out.
<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.Signed-in non-admin: dropdown contains only Sign out.
/users
while logged in as a second admin, or seed a user with
is_admin=0 directly).<hr> divider. The primary
New sprint link is already hidden for non-admins (admin
gate lives on the anchor itself).Outside-click and Escape close the menu; focus returns to trigger on Escape.
aria-expanded="false".Sign out from the menu still posts with CSRF and logs out.
/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.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).
Admin opens /sprints/{id}/present at 1920×1080 — all worker
columns fit without horizontal scroll.
/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).Same URL at 3840×2160 (HiDPI beamer).
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.Filters + focus stack on the present view.
/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.Admin edits a task cell in present mode; /sprints/{id}
reload shows the new value.
/sprints/{id} and reload.audit_log row (CREATE or UPDATE task_assignment)
is present.Non-admin opens the same URL — read-only, same layout.
/sprints/{id}/present.Very wide sprint (12+ workers) — vertical header rotation kicks in.
/sprints/{id}/present.[sprint-planner] beamer: table still overflows … and
horizontal scroll is enabled — the page does not spin.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.
Fresh page load in light mode: body is cooler than the table headers (visible band separation).
sp:theme from localStorage first).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.Click Theme in the hamburger menu → whole app flips to dark; label reads "Dark".
<hr> /
Sign out; non-admin: Theme (Light) / <hr> / Sign out).<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.Reload the page → dark persists, no flash of light before styles apply.
<script src> in <head> before the
stylesheet, so the dark class is on <html> the first
time the stylesheet resolves). No white flash.Open /sprints/{id}/present in a new tab → picks up dark
too (theme-init.js in its head).
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>.Back to light mode, open Workers / Users / Audit / Settings pages: no stray white-on-white or unreadable text anywhere.
/workers, /users, /audit, /sprints/new,
/sprints/{id}, /sprints/{id}/settings,
/sprints/{id}/present.Private-window (localStorage denied) → defaults to light, toggle no-ops without throwing.
<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).