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 HANDOFF update separately. See §14.
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:
The web app reproduces that workflow with proper auth, database, and per-cell audit trail.
node:20-alpine for CSS + php:8.3-apache for runtime./var/www/data/app.sqlite (mounted volume).jumbojett/openid-connect-php, vlucas/phpdotenv,
phpunit/phpunit (dev).tailwindcss (build-time only).├── 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
├── HANDOFF.md # this file
├── 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/
│ ├── app.js # site-wide; data-href click handler
│ ├── sprint-planner.js # /sprints/{id} Arbeitstage + task list
│ └── sprint-settings.js # /sprints/{id}/settings
├── src/
│ ├── Auth/ LocalAdmin, OidcClient, SessionGuard
│ ├── Controllers/ AuthController, WorkerController, SprintController,
│ │ TaskController, AuditController, UserController
│ ├── Db/ Connection, Migrator
│ ├── Domain/ User, Worker, Sprint, SprintWeek, SprintWorker,
│ │ SprintWorkerDay, Task, TaskAssignment
│ ├── Http/ Request, Response, Router, View (+ e() helper)
│ ├── Repositories/ UserRepository, WorkerRepository, SprintRepository,
│ │ SprintWeekRepository, SprintWorkerRepository,
│ │ SprintWorkerDayRepository, TaskRepository,
│ │ TaskAssignmentRepository, AuditRepository
│ └── Services/ AuditLogger, CapacityCalculator
├── migrations/ 001_init.sql (full schema per spec §3)
│ 002_sprint_week_active_days.sql (Phase 12 — mask column)
├── views/ layout.php, home.php, auth/local.php,
│ workers/index.php, users/index.php,
│ sprints/{new,show,settings}.php,
│ audit/index.php
├── tests/ TestCase + Services/ + Repositories/ + Controllers/ + Cascade/
│ + Domain/ + Db/
└── data/ SQLite + sessions directory (volume-mounted, gitignored)
Tables (already applied): users, workers, sprints, sprint_weeks,
sprint_workers, sprint_worker_days, tasks, task_assignments,
audit_log, plus the schema_version tracking table.
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):
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.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 CASCADEsprint_workers.sprint_id → sprints(id) ON DELETE CASCADEsprint_workers.worker_id → workers(id) ON DELETE RESTRICTsprint_worker_days.sprint_worker_id → sprint_workers(id) ON DELETE CASCADEsprint_worker_days.sprint_week_id → sprint_weeks(id) ON DELETE CASCADEtasks.sprint_id → sprints(id) ON DELETE CASCADEtasks.owner_worker_id → workers(id) ON DELETE SET NULLtask_assignments.task_id → tasks(id) ON DELETE CASCADEtask_assignments.sprint_worker_id → sprint_workers(id) ON DELETE CASCADERuns 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.
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/{id} | signed-in |
| GET | /sprints/{id}/settings | admin |
| GET | /audit | admin |
JSON (admin-only, CSRF via X-CSRF-Token header; envelope per spec §7):
| Method | Path | What |
|--------|----------------------------------------------|---------------|
| PATCH | /sprints/{id} | name/dates/reserve |
| POST | /sprints/{id}/weeks | resize week set |
| 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 |
Response envelope:
{"ok": true, "data": …}{"ok": false, "error": {"code", "message", "details?"}}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.
TaskController::delete() — task → task_assignmentsSprintController::removeWorker() — sprint_worker → sprint_worker_days + task_assignmentsSprintController::replaceWeeks() — sprint_week → sprint_worker_days (on shrink)ENTRA_TENANT_ID=
ENTRA_CLIENT_ID=
ENTRA_CLIENT_SECRET=
APP_BASE_URL=http://localhost:8080
SESSION_SECRET=
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 compared verbatim (not hashed) — .env must be file-permissions
# protected. The resulting user is entra_oid="local:<email>", is_admin=1.
LOCAL_ADMIN_EMAIL=
LOCAL_ADMIN_PASSWORD=
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.
58a6b30)be193d2, hotfix 83493d0): Entra OIDC with PKCE,
session + CSRF, first-user-is-admin bootstrap, local-admin fallback.82ddc98): FallbackResource /index.php.f189e7d).38ba151): meta edit, weeks resize,
worker membership add/remove/reorder, per-row RTB.515d7d0): editable matrix, capacity
calc, per-cell persistence with audit.ad78283): CRUD, assignments grid,
sort/filter/search, drag-reorder.927b708): guarded sortable() calls.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 captures the spec §10
manual walkthrough.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.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.c35a934): multi-select owner
filter (checkbox dropdown) and column-visibility toggle, both
pure client-side with localStorage persistence per sprint.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.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 <button
data-menu-trigger> with an inline-SVG hamburger (three
<line>s, stroke-current, no external asset). The <div
id="app-menu" data-menu role="menu" hidden> panel is
absolutely positioned with min-w-[12rem], rounded border,
bg-white shadow-lg, items px-3 py-2 text-sm
hover:bg-slate-50 plus a focus ring. Admins see Workers /
Users / Audit log / <hr> / Sign out; non-admins see just
Sign out (no divider). Sprints, New sprint (admin only), and
the user badge stay inline. Sign out remains a native
<form method="post" action="/auth/logout"> with the
_csrf hidden input — no JS-driven POST. public/assets/js/
app.js gains a ~30-line vanilla-JS IIFE (document.
querySelector + addEventListener, no jQuery) that toggles
hidden + aria-expanded on click, closes on outside-click
/ Escape (returning focus to the trigger) / any role=
"menuitem" click. CSP stays strict. Zero PHPUnit changes —
88 / 208 holds. ACCEPTANCE.md gains a "Phase 14 — Hamburger
menu" section with the four manual scenarios from the plan.
[ ] Phase 15 — Big-screen (beamer) task viewer
Problem. Sprint-planning stand-ups and retro discussions happen
around a projector / TV at roughly 1920×1080 or 3840×2160. The
regular /sprints/{id} page is tuned for editing from a laptop:
max-w-7xl centred on the page, full header chrome, the
Arbeitstage matrix and capacity summary stacked on top of the
task list. On a beamer this wastes 40%+ of the screen width
and forces horizontal scroll once a sprint has more than ~7
workers — exactly the moment when everyone needs to see every
column at once.
Goal. A dedicated presentation route that strips the chrome, dumps the planning sections the group isn't discussing, and renders only the task list at full-viewport width with all worker columns visible without horizontal scroll. Filters, focus, and column-visibility reuse the existing Phase 10/13 plumbing as-is. Admins can still edit cells in-place (discussion edits persist instantly); viewers see read-only text, same as today.
Scope (plan).
Route + controller.
public/index.php:
GET /sprints/{id}/present → SprintController::present(...).
Auth: SessionGuard::requireAuth($this->users) — anyone
signed-in can view, same as show().SprintController::present(Request $req, array $params)
method. Its body is ~90% a copy of show() — load the
sprint, weeks, sprint workers, day grid, tasks, task grid,
capacity, owner choices — but renders a different view.
To avoid drift with show(), extract the data-loading
fan-out into a private loadSprintPage(int $id): ?array
helper that both methods call. Return null when the
sprint is missing; let each caller render the appropriate
View — views/sprints/present.php.
views/layout.php. Instead
the view emits its own <!doctype html> with a minimal
<head> that reuses the same compiled
/assets/css/app.css, the same jQuery + jQuery UI CDN
tags from layout.php, and /assets/js/sprint-planner.js
with defer. No nav header, no page padding — the entire
viewport belongs to the task table.<body class="bg-white"><main class="min-h-screen w-screen overflow-hidden beamer-root">....
.beamer-root is a new scoping class (see §3 below).[data-sprint-root] + data-sprint-id
data-csrf + data-reserve-fraction so
sprint-planner.js auto-wires filters, focus, reset,
column visibility, and cell-save debounce without a
single line of new JS.<section data-task-section> block
(toolbar + table). Drop the Arbeitstage matrix, the
capacity summary, and the footer hint. A thin top bar
shows the sprint name + date range + a "Close" link back
to /sprints/{id} (so a viewer can escape without
history.back). Include the CSRF meta and the status chip
so error flashes still surface.CSS — assets/css/input.css.
beamer-root scoping class under @layer
components (keeps Tailwind's JIT scan happy without
scattering one-off classes across the view). Inside the
scope:
table { table-layout: fixed; font-size: clamp(0.75rem, 0.95vw, 1.05rem); }td, th { padding: 0.25rem 0.35rem; }).data-col values on the toolbar's hidden-columns
set: on first page load, seed
localStorage['sp:{sprintId}:hiddenCols'] with
["owner","prio","tot"] if the key has never been
set in presentation mode. Use a second key —
sp:{sprintId}:hiddenCols:beamer — so the regular
/sprints/{id} view's hidden-columns pick isn't
clobbered. Implementing this cleanly needs one tiny
JS touch: see §4..handle { display: none; } — drag-reorder is wrong
for a shared screen; also hides the delete buttons
(pattern: add .px-1.py-1.text-right button[data-delete-task] { display: none; })..beamer-root section[data-task-section]
(@container (max-width: calc(100vw))). Too clever;
fall back to a JS-side switch that measures
table.scrollWidth > containerWidth once on boot and
toggles a .beamer-vertical-headers class on the root.
In the CSS, .beamer-vertical-headers thead th[data-sort-col^="sw-"] { writing-mode: vertical-rl; transform: rotate(180deg); padding: 0.5rem 0.25rem; }.
See §4 — 10 lines of new JS.JS — public/assets/js/sprint-planner.js (minimal touch).
$root.closest('.beamer-root').length > 0 (or a new
$root.data('beamer') === 1), use the :beamer
localStorage namespace for hiddenCols (and optionally
focusWorker + ownerFilterSet). Cleanest: take the
storage-key prefix as a variable derived from a single
boolean isBeamer.localStorage[beamerHiddenColsKey] is missing, seed with
["owner","prio","tot"], persist, then call
applyColumnVisibility(). Respects the user's later
overrides.applyFilters() completes for the first time in
beamer mode, measure table.scrollWidth vs
container.clientWidth. If it still overflows, add
.beamer-vertical-headers to the root and re-measure; if
it still overflows (very wide sprint), log a console
warning but leave horizontal scroll available — hard
visual failure is worse than a small scroll.Entry point — views/sprints/show.php.
<a href="/sprints/{id}/present" target="_blank" rel="noopener"
class="inline-flex items-center gap-2 rounded-md border
border-slate-300 bg-white text-slate-700 px-3 py-2 text-sm
hover:bg-slate-100">Present</a>.
Visible to every signed-in user, not just admins.A11y / polish.
aria-label stays the
plain text, so screen readers read it horizontally.Edge cases.
$currentUser->isAdmin gate.show()
uses.Ctrl+P if curious,
the CSS doesn't have @media print rules yet.Tests.
loadSprintPage() is done, add one controller-level
sanity test that present renders HTTP 200 for a seeded
sprint and 404 for an unknown one. This keeps the count
climbing only if refactoring benefits from a guard.
Target: 88 → 90 if the helper is extracted; stay at
88 if it isn't.ACCEPTANCE.md gains a "Phase 15 — Big-screen viewer"
section with six scenarios:
(a) open /sprints/{id}/present on an admin account:
no horizontal scroll at 1920×1080; all worker
columns visible;
(b) same, but at 3840×2160 (HiDPI beamer);
(c) apply focus + owner filter + search: all still
work and column auto-hide still fires;
(d) admin edits a cell in present mode; reload
/sprints/{id}; value persisted;
(e) non-admin user opens the URL: read-only, same
layout;
(f) very wide sprint (12+ workers): vertical header
rotation kicks in, table still fits.Out of scope.
/sprints/{id} is already the right
shape.Spec alignment.
views/sprints/present.php to the views/
line.GET /sprints/{id}/present row
(signed-in, any auth level)./sprints/{id} reloads the page after drag so the
task list's worker columns stay in sync. Acceptable, but noisy if the
user has a lot of edits in flight (they're all saved by then). Not
scheduled; the reload is fast and the alternative (live-reordering
columns in JS) is complex for little win.jumbojett/openid-connect-php
1.0.2 uses implicitly-nullable params. The container runs 8.3 where these
are E_DEPRECATED but still emit — harmless, and silenced by
ini_set('display_errors','0') in production. Upstream library needs a
release.cp .env.example .env
# Fill Entra vars, OR set LOCAL_ADMIN_EMAIL + LOCAL_ADMIN_PASSWORD
docker compose up --build
# open http://localhost:8080
Rebuild when the Dockerfile / composer manifest / Tailwind sources change:
docker compose build --no-cache && docker compose up
For local CSS dev without Docker:
npm install
npm run watch:css # rebuilds public/assets/css/app.css on change
The SQLite file lives at ./data/app.sqlite on the host; nuking it resets
the app to a blank slate (migrations run on the next request).
Syntax-check PHP without Docker:
for f in $(git ls-files '*.php'); do php -l "$f" | tail -1 | sed "s|^|$f: |"; done
Run the test suite:
vendor/bin/phpunit
# → OK (88 tests, 208 assertions)
Tell Claude:
Working on
/Users/achiappa/Development/claude_code_private/sprint_planer_web. ReadHANDOFF.md, the git log, andACCEPTANCE.md. Phases 1–14 are shipped (see §9); Phase 15 (big-screen / beamer task viewer at/sprints/{id}/present) is planned but not implemented — the scope is in §9 under Upcoming. Outstanding items are in §10 (mostly a human-run acceptance walkthrough in the running container). If I ask you to work Phase 15, follow the maintenance rule in §14 — commit code, then commit a HANDOFF.md update separately that moves the entry from Upcoming → Shipped with its SHA.
Claude should verify what's described here against actual repo state before acting — nothing here is load-bearing once it grows stale.
101cc57 Phase 14: hamburger menu groups admin utilities + Sign out
15695ab HANDOFF.md: add Phase 14 plan (hamburger menu)
23ab365 Fix: stamp data-col on JS-built task row cells
d0fdf53 HANDOFF.md: mark Phase 13 shipped
b027c5d Phase 13: Focus filter + Reset in the task list
e23cfac HANDOFF.md: add Phase 13 plan (Focus filter + reset)
815e232 HANDOFF.md: note 5-dot Arbeitstage indicator follow-up
1aca417 Sprint view Arbeitstage: 5-dot weekday indicator instead of a number
59eb1d7 HANDOFF.md: mark Phase 12 shipped
a634582 Phase 12: per-week weekday selection (Mo–Fr) drives Arbeitstage
a1a1266 HANDOFF.md: mark Phases 8–11 shipped + codify the maintenance rule
ab9430b Phase 11: vendor Tailwind + drop inline onclick + tighten CSP
c35a934 Phase 10: multi-select owner filter + column visibility toggle
f7f5db5 Phase 9: users management page (promote / demote admin)
dd158f3 Phase 8: audit rows for FK-cascaded deletes
8537fc2 Plan Phases 8–11 in HANDOFF.md
afa9e4f Ignore PHPUnit cache directory
21d0c4a Phase 7: audit viewer + security headers + PHPUnit
09b67f3 Add HANDOFF.md for resuming work in a fresh session
927b708 Fix: drop unreliable SRI hashes + guard sortable() calls
ad78283 Phase 6: task list, assignments, client-side sort/filter/search
515d7d0 Phase 5: Arbeitstage grid + capacity calculator + cell persistence
38ba151 Phase 4: sprint settings — meta, weeks, workers, reorder, RTB
f189e7d Phase 3: workers + sprints + generic audit wiring
82ddc98 Route all URLs to the front controller via FallbackResource
83493d0 Phase 2 hotfix: scalar-safe Request + local admin login
be193d2 Phase 2: Entra OIDC auth + session + audit log
58a6b30 Phase 1: skeleton
Each commit message captures the deliverables, rationale, and the smoke tests that were run to validate it. Read those in preference to any summary here.
The previous §14 was the plan for Phases 8–11. All four shipped, so the plan moved into §9. This section now codifies the rule that produced this file in the first place — don't lose it on a context reset.
After every commit that completes a phase or substantive change:
git revert of the code revert leaves the doc
honest by reverting alongside.Why: a fresh Claude session starts by reading this file. Stale status here is the single biggest source of duplicated or wrong work. Keeping the file truthful costs ~2 minutes per phase; recovering from a stale file costs more.
If you skip these steps, the next session will eventually notice and
have to rebuild the picture from git log and the codebase. That's
recoverable, but a regression from why this file exists.