1
0

HANDOFF.md 39 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.

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.

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, two-stage build, node:20-alpine for CSS + php:8.3-apache for runtime.
  • 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 CSS (vendored, compiled by the Node stage) + jQuery 3.x + jQuery UI 1.13 (from code.jquery.com CDN).
  • 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).
  • npm deps: tailwindcss (build-time only).

3. Directory layout

├── 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/
│           ├── theme-init.js       # Phase 16: synchronous dark-class set from localStorage (no FOUC)
│           ├── app.js              # site-wide; data-href click handler + hamburger menu + theme toggle
│           ├── 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,present}.php,
│                        audit/index.php
├── tests/               TestCase + Services/ + Repositories/ + Controllers/ + Cascade/
│                                 + Domain/ + Db/
└── data/                SQLite + sessions directory (volume-mounted, gitignored)

4. Schema (migrations/001_init.sql + 002_sprint_week_active_days.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.

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

  • All 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 CASCADE
  • sprint_workers.sprint_id → sprints(id) ON DELETE CASCADE
  • sprint_workers.worker_id → workers(id) ON DELETE RESTRICT
  • sprint_worker_days.sprint_worker_id → sprint_workers(id) ON DELETE CASCADE
  • sprint_worker_days.sprint_week_id → sprint_weeks(id) ON DELETE CASCADE
  • tasks.sprint_id → sprints(id) ON DELETE CASCADE
  • tasks.owner_worker_id → workers(id) ON DELETE SET NULL
  • task_assignments.task_id → tasks(id) ON DELETE CASCADE
  • task_assignments.sprint_worker_id → sprint_workers(id) ON DELETE CASCADE

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 | /users | admin | | POST | /users/{id} | admin | | GET | /sprints/new | admin | | POST | /sprints | admin | | GET | /sprints/{id} | signed-in | | GET | /sprints/{id}/present | 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:

  • 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. References:
    • TaskController::delete() — task → task_assignments
    • SprintController::removeWorker() — sprint_worker → sprint_worker_days + task_assignments
    • SprintController::replaceWeeks() — sprint_week → sprint_worker_days (on shrink)
  • 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

Shipped

  • Phase 1 — Skeleton (58a6b30)
  • Phase 2 — Auth (be193d2, hotfix 83493d0): Entra OIDC with PKCE, session + CSRF, first-user-is-admin bootstrap, local-admin fallback.
  • Apache routing fix (82ddc98): FallbackResource /index.php.
  • Phase 3 — Workers + sprints + audit (f189e7d).
  • Phase 4 — Sprint settings (38ba151): meta edit, weeks resize, worker membership add/remove/reorder, per-row RTB.
  • Phase 5 — Arbeitstage grid (515d7d0): editable matrix, capacity calc, per-cell persistence with audit.
  • Phase 6 — Task list (ad78283): CRUD, assignments grid, sort/filter/search, drag-reorder.
  • SRI hotfix (927b708): guarded sortable() calls.
  • Phase 7 — Audit viewer + polish (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.
  • Phase 8 — Cascade audit integrity (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.
  • Phase 9 — Users management (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.
  • Phase 10 — Task list polish (c35a934, hotfix 7c298d3): multi-select owner filter (checkbox dropdown) and column-visibility toggle, both pure client-side with localStorage persistence per sprint. Hotfix 7c298d3 repairs a regression c35a934 left in sprint-planner.js: ownerChoices() still scraped the pre-Phase-10 [data-owner-filter] option selector (replaced by [data-owner-filter-opt] checkboxes in this phase), so every client-built task row (admin clicks "+ Add task") had an empty owner dropdown until a page reload re-rendered it server-side. Also affects the Phase 15 /sprints/{id}/present view since it reuses the same toolbar markup + JS.
  • Phase 11 — CSP hardening (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.
  • Phase 12 — Per-week weekday selection (Mo–Fr) drives Arbeitstage (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.

  • [x] Phase 15 — Big-screen (beamer) task viewer at /sprints/{id}/present (d1dda4f). New signed-in route renders a stripped-down view: no shared layout chrome, no Arbeitstage matrix, no capacity summary — just a thin top bar (sprint name + dates + Close) and the task-list toolbar

    • table. SprintController::show() keeps its behaviour; the shared data fan-out is extracted into a private loadSprintPage(int $id): ?array helper that both show() and the new present() method call, returning null for a missing sprint so each caller renders its own 404. views/sprints/present.php emits its own <!doctype html> (rendered with layout=null) reusing /assets/css/app.css
    • the jQuery / jQuery UI CDN tags from layout.php + /assets/js/sprint-planner.js defer. The root <main> carries beamer-root + data-sprint-root + data-sprint- id + data-csrf + data-reserve-fraction + data- beamer="1". sprint-planner.js detects the beamer flag, namespaces its three localStorage keys with a :beamer suffix (so presentation filters don't clobber the user's /sprints/{id} workflow), seeds ["owner","prio","tot"] into hiddenCols:beamer on first load (before the first applyColumnVisibility() so nothing flashes), and after the boot applyFilters() measures table.scrollWidth > container.clientWidth; if it overflows, adds .beamer-vertical-headers (rotates sw column headers 90°); if it still overflows, console.warns and falls through to horizontal scroll — never a hang. Strict CSP unchanged. CSS scoping block lives under @layer components in assets/css/input.css (.beamer-root table typography + padding, .handle + [data-delete-task] hidden, vertical- header rule). Entry point is a new "Present" anchor next to Settings in views/sprints/show.php, target="_blank" for all signed-in users. Tests unchanged at 88 / 208 — refactor is a pure extraction and the sanity test the plan allowed was skipped because the existing tests/ Controllers/ harness only runs pure statics; a full controller integration test would need PDO + session wiring out of scope for this phase. ACCEPTANCE.md gains a "Phase 15 — Big-screen viewer" section with the six manual scenarios from the plan.
  • [x] Phase 16 — Dark-mode toggle + light-mode contrast cleanup (94b2841). Two small palette issues addressed at once: (a) both body and table-header bands used bg-slate-50, so table headers blended into the page — body bumps one shade cooler to bg-slate-100 (the user's explicit ask), <thead> bands stay at bg-slate-50 and now read as a distinct lighter strip; (b) no dark palette existed at all, painful for the Phase 15 beamer view in dim conference rooms. Manual toggle only — no prefers-color-scheme auto-detect. tailwind.config.js gains darkMode: 'class'. New public/assets/js/theme-init.js (8 lines, synchronous <script src> in <head> before the stylesheet) reads localStorage['sp:theme'] inside a try/catch and sets <html class="dark"> if the value is 'dark' — no FOUC. views/layout.php and views/sprints/present.php both include the init script; the present route emits its own <!doctype html>, hence two tags. public/assets/js/app.js grows a third vanilla-JS IIFE (~17 lines) wiring [data-theme-toggle]: toggle the class on <html>, write sp:theme, stamp the [data-theme-label] text; writes wrapped in try/catch so private-window denials no-op. The toggle lives in the hamburger menu in views/layout.php as a new "Theme" row above the <hr> divider, visible to admins and non-admins alike (theme is a personal preference, not an admin action); the divider now always renders (previously only admins saw one) because the Theme row always renders. Every view file (views/layout, views/home, views/auth/local, views/workers/index, views/users/index, views/sprints/{new,settings,show, present}, views/audit/index) gets a systematic dark: sweep: body/card/header surfaces on the slate-900/800/700 ramp, borders on slate-700 (600 for inputs), primary text slate-100, secondary slate-400, inputs dark:bg-slate-800 dark:border-slate-600 dark:text-slate-100 dark:focus:ring-slate-500, links dark:text-blue-400 dark:hover:text-blue-300, success / error / amber flash chips on *-900 backgrounds with *-200 text and *-800 borders, admin badge dark:bg-amber-900 dark:text-amber-200, Phase 12 weekday dots active dark:bg-green-400 / off dark:bg-slate-600, capacity "available" red dark:text-red-400, audit action chips similarly remapped. assets/css/input.css needed no edits — it carries no colour classes (only the .focus-auto-hidden utility and the .beamer-root typography block, both colour-free). Strict CSP stays intact (theme-init.js is a standard <script src> under script-src 'self'). Sign-out form block untouched — still a native POST with the _csrf hidden input. Zero PHPUnit — same pattern as Phases 10, 13, 14, 15; the change is CSS class additions plus ~25 lines of vanilla JS without a unit surface the existing harness can reach. Tests at 88 / 208. ACCEPTANCE.md gains a "Phase 16 — Dark mode + light contrast" section with the six scenarios from the plan (light-band separation, toggle flip, reload persistence under Network throttling + no FOUC, present view inherits dark, admin-pages contrast sweep, private-window localStorage denial fallback).

Upcoming

  • [ ] Phase 17 — Hide native number spinners + custom 0.5-step stepper popover

    Problem. Three classes of number input in the app all deal in half-day increments:

    1. the Arbeitstage per-worker day cells on `/sprints/{id}`
       (`[data-day]`, 0..5 step 0.5);
    2. the task assignment cells on `/sprints/{id}` and
       `/sprints/{id}/present` (`[data-assign]`, ≥ 0 step 0.5);
    3. the per-worker RTB cells (`[data-rtb]`, 0..1 step 0.05).
    

    Browsers render these as <input type="number"> with native up/down spinner arrows attached — the arrows are small, visually noisy inside a dense table, and behave inconsistently across Chrome / Firefox / Safari. On touch devices they often do nothing. The team would rather see a clean number on the page and get a tap-friendly stepper on click.

    Goal. Hide every native spinner across the app, and for the three half-step (or 0.05-step for RTB) cell types, show a small popover stepper when the input is clicked/focused. The popover has a large current-value readout, / + buttons that step by the input's own step attribute, and — when min and max are both set — a range slider for quick gross moves. Typing numerically still works; the popover is an alternative input method, not a replacement.

    Scope (plan).

    1. CSS — assets/css/input.css. Hide the native spinner on every input[type="number"]. Two rules under @layer base:

       input[type="number"]::-webkit-outer-spin-button,
       input[type="number"]::-webkit-inner-spin-button {
           -webkit-appearance: none;
           margin: 0;
       }
       input[type="number"] {
           -moz-appearance: textfield;
           appearance: textfield;
       }
      

      Applies app-wide — the week-count and reserve-percent inputs on Settings / New-sprint also lose their arrows, which is fine; they're edited rarely and keyboard typing is the usual path.

    2. Opt-in attribute — data-stepper. Only the half-day cells get the popover. Stamp data-stepper on:

      • [data-day] in views/sprints/show.php (Arbeitstage per-worker day cells; admin-editable).
      • [data-rtb] in views/sprints/show.php and views/sprints/settings.php (admin-editable).
      • [data-assign] in views/sprints/show.php and the JS-built row in public/assets/js/sprint-planner.js (buildTaskRow); same data-stepper on the template. Don't stamp it on n_weeks / reserve_fraction — keyboard-only is right there.
    3. New JS module — public/assets/js/number-stepper.js. Vanilla JS, ≤ 120 lines, loaded with defer from views/layout.php and views/sprints/present.php after sprint-planner.js. Exposes no globals; single IIFE.

      • On document, delegate click + focus on input[data-stepper] → open the popover anchored to that input. Only one popover open at a time; clicking another stepper input moves it.
      • Popover DOM (built once, reused):

        <div class="stepper-popover" hidden role="dialog"
            aria-label="Set value">
         <button data-stepper-dec aria-label="Decrease">−</button>
         <output data-stepper-value>0</output>
         <button data-stepper-inc aria-label="Increase">+</button>
         <input type="range" data-stepper-range hidden>
        </div>
        

        Styled with Tailwind utility classes in the template (or a @layer components block in input.css so they compile). Rounded card, shadow-lg, 1px border; dark:bg-slate-800 etc. for Phase 16 compatibility.

      • Reads min, max, step from the bound input. When both min and max are finite numbers, un-hide the <input type="range"> and wire it to set the value on input event. Otherwise only the +/− buttons show.

      • + / mutate input.value via a small pure helper clampToStep(current, delta, step, min, max) — step up or down by exactly step, clamp into [min, max] when defined, and quantise with Math.round(v / step) * step (with a tiny float-epsilon tolerance to avoid 0.30000000000000004 artefacts).

      • After every value mutation, dispatch a synthetic input event (for live recompute in sprint-planner.js) and — only on popover close — dispatch change (to fire the existing debounced save pipeline).

      • Close on: outside-click, Escape (focus returns to the input), Tab that leaves the popover + input, and a second click on a different stepper input (auto-reopens there).

      • Position: below the input unless the input sits in the bottom ~25% of the viewport, then above. Horizontal clamp to viewport with a 4px margin.

      • Keyboard while focused on the input: ArrowUp / ArrowDown also step by step (replaces the native spinner's behaviour that we just disabled). That way keyboard users don't lose a shortcut.

    4. Integration with existing save logic. sprint-planner.js already listens for blur change on [data-day] / [data-rtb] / [data-assign]. The stepper dispatches change on popover-close (and mirrors the value into input.value before dispatching). No edits required in sprint-planner.js except verifying that it reads .value every time the handler fires — spot-check current code: recomputeRow() and queueCell() both do Number($el.val()), so we're fine.

    5. Dark mode. The popover uses Tailwind utility classes with dark: siblings matching Phase 16 conventions (bg-white dark:bg-slate-800, border slate-200 / slate-600, text slate-900 / slate-100, hover slate-100 / slate-700). Range slider uses accent-slate-600 dark:accent-slate-400 so the Tailwind accent-color picks up theme-appropriate thumb colour.

    6. Edge cases.

      • Read-only cells (non-admin users). Non-admin users see <span class="font-mono"> rather than an <input> for these cells (see show.php). No stepper needed; the attribute simply doesn't get stamped.
      • Firefox range-input support. <input type="range"> with float step=0.5 is well-supported; quantisation already handled in clampToStep().
      • Safari momentum scroll mid-popover. Outside-click handler uses pointerdown so a scroll gesture that starts inside the popover doesn't immediately close it.
      • iOS tap-then-type. Tapping a number input opens the popover; the native keyboard does NOT auto-appear. To type, focus the input and start typing — still works.
      • Batch paste of non-numeric text. Input remains a native type="number", so browsers reject non-numeric pastes as today.
      • Concurrent popover + jQuery UI drag. The worker / task row drag handlers (handle span) are separate from number inputs — no conflict.
    7. Tests. No PHPUnit — pure CSS + vanilla JS over existing markup. Add a handful of cheap pure-JS unit assertions as a sanity matrix only if clampToStep is extracted to be testable without the DOM. Practically: skip, same pattern as Phases 10 / 13 / 14 / 15 / 16. ACCEPTANCE.md gains a "Phase 17 — Number stepper popover" section with five scenarios: (a) no native spinner arrows visible on any number input; (b) clicking a day cell pops the stepper; +/−

         increments by 0.5; value persists after blur;
      

      (c) RTB cell pops the stepper; +/− increments by 0.05; (d) task assignment cell in both /sprints/{id} and

         `/sprints/{id}/present` pops the stepper; no min
         no range slider shown;
      

      (e) Escape closes the popover and returns focus;

         outside-click closes too; dark mode matches the
         theme.
      

    Out of scope.

    • Touching non-half-step inputs (n_weeks, reserve_fraction) beyond hiding the native arrows.
    • Keyboard-shortcut stepping from anywhere on the page — ArrowUp/Down is scoped to the focused input only.
    • Replacing <input type="number"> with a contenteditable div or a third-party picker library.
    • Undo/redo stack per cell.
    • Long-press or drag-to-scroll on the buttons for fast stepping. +/− buttons only step by one step per click.

    Spec alignment.

    • §3 layout: add number-stepper.js to the public/assets/js/ block.
    • §4 / §5 / §7: no changes.
    • §6 Routes: unchanged.
    • §11 phpunit count: stays 88.
    • Strict CSP (Phase 11) stays intact — standard <script src>, no inline handlers.
    • Dark mode (Phase 16): popover uses dark: variants throughout.

10. Residual known gaps / deferred items

  • 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). Not scheduled; the reload is fast and the alternative (live-reordering columns in JS) is complex for little win.
  • OIDC library raises PHP 8.4 deprecations. 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.
  • jQuery UI CDN CSS is not dark-aware. The base theme on code.jquery.com/ui/1.13.3/themes/base/jquery-ui.css is a light-only stylesheet. It only shows up during drag operations (worker reorder on /sprints/{id}/settings, sprint-worker / task reorder on /sprints/{id}); the sortable ghost element reads slightly out of place on a dark:bg-slate-900 body. Accepted for now — the alternative is self-hosting a custom jQuery UI theme inside the Docker css-builder stage, which is a larger chunk of work than the cosmetic mismatch warrants.
  • Manual acceptance walkthrough (ACCEPTANCE.md) hasn't been executed end-to-end by a human yet — it's a documentary follow-up that should happen in the running container.

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

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, the git log, and ACCEPTANCE.md. Phases 1–16 are shipped (see §9); Phase 17 (hide native number spinners + add a custom 0.5-step stepper popover on day / RTB / assignment cells) is planned but not implemented — scope in §9 Upcoming. Other outstanding items are in §10 (human-run acceptance walkthrough, jQuery UI dark-mode cosmetic gap). If I ask you to work Phase 17, 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.

13. Git history (as of this handoff)

94b2841 Phase 16: dark-mode toggle + light-mode contrast cleanup
0d7124a HANDOFF.md: add Phase 16 plan (dark-mode toggle + light-mode contrast)
d4738d7 HANDOFF.md: note buildTaskRow owner-dropdown hotfix on Phase 10
7c298d3 Fix: buildTaskRow owner dropdown was empty until a page refresh
c70e442 HANDOFF.md: mark Phase 15 shipped
d1dda4f Phase 15: big-screen (beamer) task viewer at /sprints/{id}/present
48c56b7 HANDOFF.md: add Phase 15 plan (big-screen task viewer)
d59120c HANDOFF.md: mark Phase 14 shipped
101cc57 Phase 14: hamburger menu groups admin utilities + Sign out
15695ab HANDOFF.md: add Phase 14 plan (hamburger menu)
bfb93fc gitignore: exclude .claude/ (Claude Code agent runtime scratch)
a30cb0b HANDOFF.md: note buildTaskRow data-col hotfix on Phase 13
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.


14. Maintenance contract

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:

  1. Commit the code first. A commit message that captures what changed, why, and how it was verified is the canonical record.
  2. Update §9. If the work matched a planned phase, move it from Upcoming → Shipped with the SHA. If it didn't match a planned phase (hotfix, infra fix, etc.), insert it inline with the SHA.
  3. Update §13. Append the new SHA to the git history block.
  4. If new files / directories were added or moved, refresh §3.
  5. If the test count changed, update §11's expected count.
  6. Commit the HANDOFF.md update as its own commit. Keeping it separate means a git revert of the code revert leaves the doc honest by reverting alongside.
  7. If the change affects the resume prompt in §12 (e.g. a new "next phase" or a deferred-work item gets resolved), update that too.

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.