HANDOFF.md 14 KB

Sprint Planner — Handoff

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.

1. What this is

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:

  • Arbeitstage matrix (top): max working days per week + per-worker availability per week.
  • Task list (bottom): one row per task with priority, owner, total days, and a per-worker days-allocated cell for each sprint worker.

The web app reproduces that workflow with proper auth, database, and per-cell audit trail.

2. Tech stack (non-negotiable)

  • Runtime: Docker, single container, php:8.3-apache base.
  • Language: PHP 8.3, strict types, PSR-12.
  • Database: SQLite via PDO, file at /var/www/data/app.sqlite (mounted volume).
  • Front end: server-rendered PHP templates + Tailwind (Play CDN) + jQuery 3.x
    • jQuery UI 1.13 (CDN). No build step.
  • Auth: Microsoft Entra ID via OpenID Connect (Authorization Code + PKCE), plus an optional env-configured "local admin" fallback for dev / on-prem.
  • Composer deps: jumbojett/openid-connect-php, vlucas/phpdotenv, phpunit/phpunit (dev).

3. Directory layout

├── 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)

4. Schema (migrations/001_init.sql)

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):

  • All 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].

5. Capacity math (spec §6.5)

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.

6. Routes

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:

  • Success: {"ok": true, "data": …}
  • Failure: {"ok": false, "error": {"code", "message", "details?"}}
  • Validation errors: HTTP 422.

7. Audit logging rules (cross-cutting)

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.

  • Every CREATE / UPDATE / DELETE on a domain table → exactly one row.
  • Bulk operations (batch cell save) → one row per changed cell.
  • A no-op UPDATE (canonical-JSON-equal before/after) → no row.
  • FK-cascading deletes must be audited by the controller BEFORE calling the cascading delete. See TaskController::delete() as the reference.
  • Non-mutation events (LOGIN, LOGOUT, LOGIN_FAILED, BOOTSTRAP_ADMIN) → always one row.

8. Env (.env.example)

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.

9. Build phases — status

  • Phase 1 — Skeleton (58a6b30): Dockerfile, docker-compose, composer, front controller, router, migrator, hello home, /healthz.
  • Phase 2 — Auth (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.
  • Apache routing fix (82ddc98): replaced sed-based docroot hack with explicit site config using FallbackResource /index.php.
  • Phase 3 — Workers + sprints + audit (f189e7d): workers CRUD, sprints create, generic AuditLogger wired into repositories, home shows sprint list.
  • Phase 4 — Sprint settings (38ba151): meta edit, weeks resize, worker membership add/remove/reorder (jQuery UI sortable), per-row RTB.
  • Phase 5 — Arbeitstage grid (515d7d0): editable matrix, CapacityCalculator (server + client), per-cell persistence with audit.
  • Phase 6 — Task list (ad78283): task CRUD, assignments grid, client-side sort/filter/search, drag-reorder, capacity updates with committed prio-1.
  • SRI fix (927b708): dropped hand-typed SRI hashes, guarded .sortable() calls so missing jQuery UI fails soft.
  • Phase 7 — Audit viewer + polish (pending). Required deliverables:
    • /audit admin page: reverse-chronological, paginated (50/page), filters (user / action / entity_type / date range / entity_id search), pretty-printed collapsible before/after JSON.
    • Security headers: 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.
    • Double-check CSRF on every mutation (mostly in place — verify).
    • PHPUnit tests from spec §10:
      • CapacityCalculator: half-step rounding, prio-2 exclusion, empty-sprint edges.
      • Day-value validation: rejects 0.3, 1.7, negative.
      • AuditLogger: no-op update → 0 rows; real update → 1 row; DELETE carries before_json + null after_json.
      • OIDC bootstrap: first user becomes admin; subsequent users do not.
    • Walk through the manual acceptance checklist in spec §10.

10. Known gaps / deferred items

  • Phase 4 removeWorker cascade is un-audited. When a sprint_worker is removed, the FK on 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().
  • removeWorker also cascades task_assignments (via FK on sprint_worker) — same audit gap.
  • PHPUnit test harness not set up (no phpunit.xml yet, tests/ is empty). Phase 7 work.
  • No users management page. Admin bootstrap on first login is the only way to gain admin rights. If you demote yourself, there's no UI to fix it — you'd need to hand-edit the DB (or re-log-in via local admin with forceAdmin=true).
  • Column-visibility toggle on the task list (spec §6.4 mentions it) was deferred; not implemented. Horizontal scroll handles wide worker counts for now.
  • Multi-select owner filter (spec §6.4) is a single-select in the UI.
  • Worker reorder on /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).

11. Running locally

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.

12. How to resume in a fresh Claude session

Tell Claude:

Working on /Users/achiappa/Development/claude_code_private/sprint_planer_web. Read HANDOFF.md, CLAUDE.md if 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.

13. Git history (as of this handoff)

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.