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.
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.
php:8.3-apache base./var/www/data/app.sqlite (mounted volume).jumbojett/openid-connect-php, vlucas/phpdotenv,
phpunit/phpunit (dev).├── Dockerfile
├── docker-compose.yml
├── .env.example
├── composer.json / composer.lock
├── public/
│ ├── index.php # front controller + router wiring
│ ├── .htaccess # belt-and-suspenders rewrite
│ └── assets/
│ └── js/
│ ├── sprint-planner.js # main planning view (/sprints/{id})
│ └── sprint-settings.js # settings page
├── src/
│ ├── Auth/ LocalAdmin, OidcClient, SessionGuard
│ ├── Controllers/ AuthController, WorkerController, SprintController, TaskController
│ ├── 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
│ └── Services/ AuditLogger, CapacityCalculator
├── migrations/ 001_init.sql (full schema per spec §3)
├── views/ layout.php, home.php, auth/local.php,
│ workers/index.php, sprints/{new,show,settings}.php
├── data/ SQLite + sessions directory (volume-mounted, gitignored)
└── tests/ (empty; PHPUnit setup pending in Phase 7)
Tables (already applied): users, workers, sprints, sprint_weeks,
sprint_workers, sprint_worker_days, tasks, task_assignments,
audit_log, plus the schema_version tracking table.
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.max_working_days, sprint_worker_days.days ∈ {0, 0.5, …, 5}.task_assignments.days ≥ 0, no hard upper bound.reserve_fraction, rtb ∈ [0, 1].Runs 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 | /sprints/new | admin |
| POST | /sprints | admin |
| GET | /sprints/{id} | signed-in |
| GET | /sprints/{id}/settings | 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 |
| 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 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() as the reference.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): Dockerfile, docker-compose, composer,
front controller, router, migrator, hello home, /healthz.be193d2, hotfix 83493d0): Entra OIDC with PKCE,
session + CSRF, first-user-is-admin bootstrap, login/logout/LOGIN_FAILED/
BOOTSTRAP_ADMIN audit, local-admin fallback, scalar-safe Request.82ddc98): replaced sed-based docroot hack with
explicit site config using FallbackResource /index.php.f189e7d): workers CRUD,
sprints create, generic AuditLogger wired into repositories, home shows
sprint list.38ba151): meta edit, weeks resize,
worker membership add/remove/reorder (jQuery UI sortable), per-row RTB.515d7d0): editable matrix,
CapacityCalculator (server + client), per-cell persistence with audit.ad78283): task CRUD, assignments grid,
client-side sort/filter/search, drag-reorder, capacity updates with
committed prio-1.927b708): dropped hand-typed SRI hashes, guarded
.sortable() calls so missing jQuery UI fails soft./audit admin page: reverse-chronological, paginated (50/page),
filters (user / action / entity_type / date range / entity_id search),
pretty-printed collapsible before/after JSON.X-Content-Type-Options: nosniff,
X-Frame-Options: DENY, Referrer-Policy: strict-origin-when-cross-origin,
a strict-ish CSP allowing cdn.tailwindcss.com and code.jquery.com.CapacityCalculator: half-step rounding, prio-2 exclusion, empty-sprint edges.AuditLogger: no-op update → 0 rows; real update → 1 row; DELETE
carries before_json + null after_json.sprint_worker_days cascade-deletes rows that are
NOT reflected in audit_log. TaskController::delete does the correct
snapshot-then-delete; SprintController::removeWorker should be brought
to parity. Fix sketch: in a tx, pre-read all sprint_worker_days rows
for that sprint_worker, write DELETE audit for each, then call
SprintWorkerRepository::remove().task_assignments (via FK on sprint_worker)
— same audit gap.phpunit.xml yet, tests/ is
empty). Phase 7 work.forceAdmin=true)./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).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 changes:
docker compose build --no-cache && docker compose up
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 in-session smoke tests (used throughout to verify each phase):
these live as ad-hoc php -r '…' scripts in the commit history and the
handoff conversation. There's no formal test runner until Phase 7 lands
PHPUnit.
Tell Claude:
Working on
/Users/achiappa/Development/claude_code_private/sprint_planer_web. ReadHANDOFF.md,CLAUDE.mdif present, the git log, and.planning/*if present. Phases 1–6 are complete (including hotfixes). Start Phase 7 — Audit viewer + polish: /audit page with filters and pagination, security headers, strict-ish CSP allowing the two CDNs, PHPUnit test harness with the four test groups listed in spec §10, and walk through the manual acceptance checklist. Do it one deliverable at a time, commit between each, keep the existing audit-logging conventions.
Claude should verify what's described here against actual repo state before acting — nothing here is load-bearing once it grows stale.
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.