Forráskód Böngészése

Phase 5: Arbeitstage grid + capacity calculator + cell persistence

Domain:
- Domain\SprintWorkerDay — value object for sprint_worker_days rows
  with toAuditSnapshot().

Repositories:
- SprintWorkerDayRepository: grid(sprintId) returns a nested
  sw_id->week_id->days map for fast row-render; upsert() is the
  write path with four cases:
    * empty cell, days=0           -> NOOP (no insert)
    * empty cell, days>0           -> CREATE
    * existing row, same days      -> NOOP (no update)
    * existing row, different days -> UPDATE (row kept at days=0 when
      zeroed out, preserving audit history)
  All return action + before + after for the controller to audit.
- SprintWeekRepository gains setMaxWorkingDays(weekId, days) returning
  before/after for the matching UPDATE audit.

Services:
- CapacityCalculator: roundHalf, forWorker (ressourcen / after_reserves
  / committed_prio1 / available), isHalfStep and isRtbStep validators.
  Same formula runs client-side in sprint-planner.js. Prio-1 slot
  lands in Phase 6.

Controller:
- SprintController::show now renders the main planning view — pulls
  weeks + sprint_workers + the cell grid, runs CapacityCalculator
  server-side for initial display.
- PATCH /sprints/{id}/week-cells — batch upsert with validation per
  cell (half-step, 0..5, belongs-to-sprint). Invalid cell fails the
  whole batch with 422. One audit row per non-NOOP cell; touched
  workers receive recomputed capacity in the response so the client
  doesn't have to re-sum.
- PATCH /sprints/{id}/week/{week_id} — single-value update to
  max_working_days with half-step validation.

View:
- views/sprints/show.php rewritten as the Arbeitstage grid: a week-
  header row, the Arbeitstage (max-days) row with editable cells for
  admins, one row per sprint worker with drag handle + day inputs +
  Σ + RTB. Below: a capacity table (one column per worker) with
  Ressourcen / −Reserven / Available; Available cells go red when
  negative. Non-admins see read-only values and no handles or
  Settings link. Empty-state banner for sprints with no workers/weeks.

JS (public/assets/js/sprint-planner.js):
- Day cells snap to 0.5 on blur, debounced 400 ms batch-save via
  PATCH /week-cells; server response drives final capacity numbers.
- Arbeitstage row posts per-week PATCH /week/{week_id} on blur.
- RTB inputs snap to 0.05 on blur, PATCH /workers/{sw_id}.
- jQuery UI sortable on the tbody, drop posts /workers/reorder.
- Status pill flashes success/error for each action.
- Capacity formula mirrors CapacityCalculator exactly.

Routing: /sprints/{id}/week-cells and /sprints/{id}/week/{week_id}
wired into public/index.php alongside the existing admin JSON endpoints.

Verified:
- php -l on all changed/new files.
- CapacityCalculator unit cases including spec example (20 days,
  20% reserve -> 16 available) and fractional case (21 * 0.8 = 16.8,
  round_half = 17.0).
- End-to-end: all four upsert branches behave correctly, grid read
  matches writes, capacity maths match (Alice 18 -> 14.5; Bob 17 ->
  13.5), week-max update audited. 4 audit rows for a minimal flow.
- Show view renders for admin (inputs present), non-admin (read-only,
  no handles), and empty-sprint (banner) states.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
achiappa 2 hete
szülő
commit
515d7d07b9

+ 286 - 0
public/assets/js/sprint-planner.js

@@ -0,0 +1,286 @@
+/* global jQuery */
+/**
+ * Main planning view (/sprints/{id}) — Section A: Arbeitstage grid.
+ *
+ * Behaviours:
+ * - Day cells (per worker, per week) snap to 0.5 on blur, batch-saved via
+ *   PATCH /sprints/{id}/week-cells with 400 ms debounce.
+ * - Max-working-days cells (the "Arbeitstage" row) snap to 0.5 on blur,
+ *   saved via PATCH /sprints/{id}/week/{week_id}.
+ * - RTB inputs snap to 0.05 on blur, saved via PATCH /sprints/{id}/workers/{sw_id}.
+ * - Worker rows are sortable (jQuery UI). Drop posts to
+ *   POST /sprints/{id}/workers/reorder.
+ *
+ * All capacity values are recomputed client-side with the same formula as
+ * `App\Services\CapacityCalculator` so the UI stays in sync without waiting
+ * for the server response.
+ */
+(function ($) {
+    'use strict';
+
+    const $root = $('[data-sprint-root]');
+    if ($root.length === 0) { return; }
+
+    const sprintId        = parseInt($root.data('sprint-id'), 10);
+    const csrf            = String($root.data('csrf') || '');
+    const reserveFraction = Number($root.data('reserve-fraction') || 0);
+
+    // ---------------------------------------------------------------------
+    // Capacity math — MUST match App\Services\CapacityCalculator
+    // ---------------------------------------------------------------------
+
+    function roundHalf(x) { return Math.round(x * 2) / 2; }
+    function snap05(x)    { return roundHalf(x); }
+    function snap005(x)   { return Math.round(x * 20) / 20; }
+
+    function fmtDays(x) {
+        const n = Number(x);
+        if (Math.abs(n - Math.round(n)) < 1e-9) { return String(Math.round(n)); }
+        return n.toFixed(1);
+    }
+
+    function fmtRtb(x) { return Number(x).toFixed(2); }
+
+    function capacity(ressourcen) {
+        const afterReserves = roundHalf(ressourcen * (1 - reserveFraction));
+        const available     = afterReserves; // committed prio-1 lands in Phase 6
+        return { ressourcen, afterReserves, available };
+    }
+
+    // ---------------------------------------------------------------------
+    // HTTP helper — spec §7 envelopes
+    // ---------------------------------------------------------------------
+
+    function request(method, url, body) {
+        const opts = {
+            method,
+            headers: {
+                Accept:         'application/json',
+                'X-CSRF-Token': csrf,
+            },
+            credentials: 'same-origin',
+        };
+        if (body !== undefined) {
+            opts.headers['Content-Type'] = 'application/json';
+            opts.body = JSON.stringify(body);
+        }
+        return fetch(url, opts).then(async function (res) {
+            let payload = null;
+            try { payload = await res.json(); } catch (_) { /* ignore */ }
+            if (!res.ok || !payload || payload.ok !== true) {
+                const msg = (payload && payload.error && payload.error.message)
+                    ? payload.error.message
+                    : res.statusText || 'Request failed';
+                const err = new Error(msg);
+                err.status = res.status;
+                err.payload = payload;
+                throw err;
+            }
+            return payload.data;
+        });
+    }
+
+    // ---------------------------------------------------------------------
+    // Status line (shared with settings page styling)
+    // ---------------------------------------------------------------------
+
+    const $status = $root.find('[data-status]');
+    let statusTimer = null;
+
+    function flash(text, isError) {
+        $status
+            .text(text)
+            .removeClass('text-green-700 text-red-700 bg-green-50 bg-red-50 border-green-200 border-red-200')
+            .addClass(isError ? 'text-red-700 bg-red-50 border-red-200' : 'text-green-700 bg-green-50 border-green-200')
+            .removeClass('opacity-0').addClass('opacity-100');
+        clearTimeout(statusTimer);
+        statusTimer = setTimeout(function () {
+            $status.removeClass('opacity-100').addClass('opacity-0');
+        }, 2500);
+    }
+
+    // ---------------------------------------------------------------------
+    // Recompute worker row sum + capacity summary locally
+    // ---------------------------------------------------------------------
+
+    function recomputeRow(swId) {
+        const $row = $root.find('[data-sw-row][data-sw-id="' + swId + '"]');
+        let sum = 0;
+        $row.find('[data-day]').each(function () {
+            const v = Number($(this).val());
+            if (!Number.isNaN(v)) { sum += v; }
+        });
+        const cap = capacity(sum);
+
+        $row.find('[data-sum-days]').text(fmtDays(cap.ressourcen));
+        $root.find('[data-cap-ressourcen][data-sw-id="' + swId + '"]').text(fmtDays(cap.ressourcen));
+        $root.find('[data-cap-after-reserves][data-sw-id="' + swId + '"]').text(fmtDays(cap.afterReserves));
+
+        const $avail = $root.find('[data-cap-available][data-sw-id="' + swId + '"]');
+        $avail.text(fmtDays(cap.available));
+        if (cap.available < 0) {
+            $avail.removeClass('text-slate-900').addClass('text-red-700');
+        } else {
+            $avail.removeClass('text-red-700').addClass('text-slate-900');
+        }
+    }
+
+    function recomputeSumMax() {
+        let sum = 0;
+        $root.find('[data-week-max]').each(function () {
+            const v = Number($(this).val());
+            if (!Number.isNaN(v)) { sum += v; }
+        });
+        $root.find('[data-sum-max]').text(fmtDays(sum));
+    }
+
+    // ---------------------------------------------------------------------
+    // Pending-cell queue, debounced batch save
+    // ---------------------------------------------------------------------
+
+    // key = "swId:weekId" → { sw_id, week_id, days }
+    const pendingCells = new Map();
+    let cellDebounce = null;
+
+    function queueCell(swId, weekId, days) {
+        pendingCells.set(swId + ':' + weekId, {
+            sprint_worker_id: swId,
+            sprint_week_id:   weekId,
+            days:             days,
+        });
+        clearTimeout(cellDebounce);
+        cellDebounce = setTimeout(flushCells, 400);
+    }
+
+    function flushCells() {
+        if (pendingCells.size === 0) { return; }
+        const cells = Array.from(pendingCells.values());
+        pendingCells.clear();
+
+        request('PATCH', '/sprints/' + sprintId + '/week-cells', cells)
+            .then(function (data) {
+                if (data.applied === 0 && data.noop > 0) {
+                    flash('No changes');
+                } else {
+                    flash('Saved ' + data.applied + (data.applied === 1 ? ' cell' : ' cells'));
+                }
+                // Trust the server's capacity numbers — same formula, but a
+                // safety net if an input was tampered with.
+                if (data.per_worker && typeof data.per_worker === 'object') {
+                    Object.keys(data.per_worker).forEach(function (swIdStr) {
+                        const c = data.per_worker[swIdStr];
+                        $root.find('[data-cap-ressourcen][data-sw-id="' + swIdStr + '"]').text(fmtDays(c.ressourcen));
+                        $root.find('[data-cap-after-reserves][data-sw-id="' + swIdStr + '"]').text(fmtDays(c.after_reserves));
+                        const $av = $root.find('[data-cap-available][data-sw-id="' + swIdStr + '"]');
+                        $av.text(fmtDays(c.available));
+                        if (c.available < 0) {
+                            $av.removeClass('text-slate-900').addClass('text-red-700');
+                        } else {
+                            $av.removeClass('text-red-700').addClass('text-slate-900');
+                        }
+                        $root.find('[data-sw-row][data-sw-id="' + swIdStr + '"] [data-sum-days]').text(fmtDays(c.ressourcen));
+                    });
+                }
+            })
+            .catch(function (e) { flash(e.message, true); });
+    }
+
+    // ---------------------------------------------------------------------
+    // Day cells
+    // ---------------------------------------------------------------------
+
+    $root.on('blur change', '[data-day]', function () {
+        const $el = $(this);
+        let v = Number($el.val());
+        if (Number.isNaN(v)) { v = 0; }
+        if (v < 0) { v = 0; }
+        if (v > 5) { v = 5; }
+        v = snap05(v);
+        $el.val(fmtDays(v));
+
+        const swId   = parseInt($el.data('sw-id'), 10);
+        const weekId = parseInt($el.data('week-id'), 10);
+        queueCell(swId, weekId, v);
+        recomputeRow(swId);
+    });
+
+    // ---------------------------------------------------------------------
+    // Max working days (Arbeitstage row)
+    // ---------------------------------------------------------------------
+
+    $root.on('blur change', '[data-week-max]', function () {
+        const $el = $(this);
+        let v = Number($el.val());
+        if (Number.isNaN(v)) { v = 0; }
+        if (v < 0) { v = 0; }
+        if (v > 5) { v = 5; }
+        v = snap05(v);
+        $el.val(fmtDays(v));
+
+        const weekId = parseInt($el.data('week-id'), 10);
+        recomputeSumMax();
+
+        request('PATCH', '/sprints/' + sprintId + '/week/' + weekId, { max_working_days: v })
+            .then(function () { flash('Saved'); })
+            .catch(function (e) { flash(e.message, true); });
+    });
+
+    // ---------------------------------------------------------------------
+    // Per-row RTB edit
+    // ---------------------------------------------------------------------
+
+    $root.on('blur change', '[data-rtb]', function () {
+        const $el = $(this);
+        let v = Number($el.val());
+        if (Number.isNaN(v)) { v = 0; }
+        if (v < 0) { v = 0; }
+        if (v > 1) { v = 1; }
+        v = snap005(v);
+        $el.val(fmtRtb(v));
+
+        const swId = parseInt($el.data('sw-id'), 10);
+        request('PATCH', '/sprints/' + sprintId + '/workers/' + swId, { rtb: v })
+            .then(function () { flash('Saved'); })
+            .catch(function (e) { flash(e.message, true); });
+    });
+
+    // ---------------------------------------------------------------------
+    // Worker row drag-reorder (admin only — tbody only exists with handles)
+    // ---------------------------------------------------------------------
+
+    const $tbody = $root.find('[data-tbody]');
+    if ($tbody.find('.handle').length > 0) {
+        $tbody.sortable({
+            handle: '.handle',
+            items:  'tr[data-sw-row]',
+            axis:   'y',
+            helper: function (e, tr) {
+                // Preserve the td widths so the row doesn't collapse while dragging.
+                const $cells = tr.children();
+                const $clone = tr.clone();
+                $clone.children().each(function (i) { $(this).width($cells.eq(i).width()); });
+                return $clone;
+            },
+            update: function () {
+                const ordering = $tbody.find('tr[data-sw-row]').map(function (i, el) {
+                    return {
+                        sprint_worker_id: parseInt($(el).data('sw-id'), 10),
+                        sort_order:       i + 1,
+                    };
+                }).get();
+
+                request('POST', '/sprints/' + sprintId + '/workers/reorder', ordering)
+                    .then(function (data) { flash(data.moved ? 'Order saved' : 'No changes'); })
+                    .catch(function (e) { flash(e.message, true); });
+            },
+        });
+    }
+
+    // Recompute once at boot in case the server-rendered sums drift from the
+    // JS formula (e.g. after a stale reload).
+    $root.find('[data-sw-row]').each(function () {
+        recomputeRow(parseInt($(this).data('sw-id'), 10));
+    });
+    recomputeSumMax();
+
+})(jQuery);

+ 7 - 1
public/index.php

@@ -16,6 +16,7 @@ use App\Http\Router;
 use App\Http\View;
 use App\Repositories\SprintRepository;
 use App\Repositories\SprintWeekRepository;
+use App\Repositories\SprintWorkerDayRepository;
 use App\Repositories\SprintWorkerRepository;
 use App\Repositories\UserRepository;
 use App\Repositories\WorkerRepository;
@@ -81,11 +82,12 @@ $workers       = new WorkerRepository($pdo);
 $sprints       = new SprintRepository($pdo);
 $sprintWeeks   = new SprintWeekRepository($pdo);
 $sprintWorkers = new SprintWorkerRepository($pdo);
+$swDays        = new SprintWorkerDayRepository($pdo);
 $audit         = new AuditLogger($pdo);
 $auth          = new AuthController($pdo, $users, $audit, $view);
 $workerCtrl    = new WorkerController($pdo, $users, $workers, $audit, $view);
 $sprintCtrl    = new SprintController(
-    $pdo, $users, $sprints, $sprintWeeks, $sprintWorkers, $workers, $audit, $view,
+    $pdo, $users, $sprints, $sprintWeeks, $sprintWorkers, $swDays, $workers, $audit, $view,
 );
 
 // ---------------------------------------------------------------------------
@@ -140,6 +142,10 @@ $router->delete('/sprints/{id}/workers/{sw_id}',      $sprintCtrl->removeWorker(
 $router->post('/sprints/{id}/workers/reorder',        $sprintCtrl->reorderWorkers(...));
 $router->patch('/sprints/{id}/workers/{sw_id}',       $sprintCtrl->updateWorker(...));
 
+// Phase 5 — Arbeitstage grid:
+$router->patch('/sprints/{id}/week-cells',            $sprintCtrl->updateWeekCells(...));
+$router->patch('/sprints/{id}/week/{week_id}',        $sprintCtrl->updateWeekMax(...));
+
 // ---------------------------------------------------------------------------
 // Dispatch
 // ---------------------------------------------------------------------------

+ 195 - 13
src/Controllers/SprintController.php

@@ -11,10 +11,12 @@ use App\Http\Response;
 use App\Http\View;
 use App\Repositories\SprintRepository;
 use App\Repositories\SprintWeekRepository;
+use App\Repositories\SprintWorkerDayRepository;
 use App\Repositories\SprintWorkerRepository;
 use App\Repositories\UserRepository;
 use App\Repositories\WorkerRepository;
 use App\Services\AuditLogger;
+use App\Services\CapacityCalculator;
 use DateTimeImmutable;
 use PDO;
 use PDOException;
@@ -23,14 +25,15 @@ use Throwable;
 final class SprintController
 {
     public function __construct(
-        private readonly PDO                    $pdo,
-        private readonly UserRepository         $users,
-        private readonly SprintRepository       $sprints,
-        private readonly SprintWeekRepository   $weeks,
-        private readonly SprintWorkerRepository $sprintWorkers,
-        private readonly WorkerRepository       $workers,
-        private readonly AuditLogger            $audit,
-        private readonly View                   $view,
+        private readonly PDO                       $pdo,
+        private readonly UserRepository            $users,
+        private readonly SprintRepository          $sprints,
+        private readonly SprintWeekRepository      $weeks,
+        private readonly SprintWorkerRepository    $sprintWorkers,
+        private readonly SprintWorkerDayRepository $days,
+        private readonly WorkerRepository          $workers,
+        private readonly AuditLogger               $audit,
+        private readonly View                      $view,
     ) {
     }
 
@@ -146,7 +149,7 @@ final class SprintController
         return Response::redirect('/sprints/' . $sprint->id);
     }
 
-    /** GET /sprints/{id} — minimal detail (settings/tasks follow in later phases). */
+    /** GET /sprints/{id} — main planning view (Section A Arbeitstage; tasks land in Phase 6). */
     public function show(Request $req, array $params): Response
     {
         $actor = SessionGuard::requireAuth($this->users);
@@ -160,11 +163,32 @@ final class SprintController
             return Response::text('Not Found', 404);
         }
 
+        $weeks         = $this->weeks->allForSprint($id);
+        $sprintWorkers = $this->sprintWorkers->allForSprint($id);
+        $grid          = $this->days->grid($id);
+
+        // Seed initial capacity server-side so the page is meaningful without JS
+        // and the JS has the same numbers to compare against.
+        $capacity = [];
+        foreach ($sprintWorkers as $sw) {
+            $wkDays     = $grid[$sw->id] ?? [];
+            $ressourcen = array_sum($wkDays);
+            $capacity[$sw->id] = CapacityCalculator::forWorker(
+                $ressourcen,
+                $sprint->reserveFraction,
+                0.0, // prio-1 commitments come with Phase 6
+            );
+        }
+
         return Response::html($this->view->render('sprints/show', [
-            'title'       => $sprint->name,
-            'currentUser' => $actor,
-            'csrfToken'   => SessionGuard::csrfToken(),
-            'sprint'      => $sprint,
+            'title'         => $sprint->name,
+            'currentUser'   => $actor,
+            'csrfToken'     => SessionGuard::csrfToken(),
+            'sprint'        => $sprint,
+            'weeks'         => $weeks,
+            'sprintWorkers' => $sprintWorkers,
+            'grid'          => $grid,
+            'capacity'      => $capacity,
         ]));
     }
 
@@ -528,6 +552,164 @@ final class SprintController
         return Response::ok(['sprint_worker' => $result['after']->toAuditSnapshot()]);
     }
 
+    /** PATCH /sprints/{id}/week-cells — JSON — batch upsert of sprint_worker_days. */
+    public function updateWeekCells(Request $req, array $params): Response
+    {
+        $gate = $this->gateJsonAdmin($req);
+        if ($gate instanceof Response) {
+            return $gate;
+        }
+        $actor = $gate;
+
+        $sprintId = (int) $params['id'];
+        $sprint   = $this->sprints->find($sprintId);
+        if ($sprint === null) {
+            return Response::err('not_found', 'Sprint not found', 404);
+        }
+
+        $body = $req->json();
+        if (!is_array($body) || !array_is_list($body)) {
+            return Response::err('validation', 'body must be a list of {sprint_worker_id, sprint_week_id, days}', 422);
+        }
+        if ($body === []) {
+            return Response::ok(['applied' => 0, 'noop' => 0, 'per_worker' => new \stdClass()]);
+        }
+
+        // Cross-check every cell belongs to this sprint.
+        $validSw = array_column(
+            array_map(fn($sw) => ['id' => $sw->id], $this->sprintWorkers->allForSprint($sprintId)),
+            'id',
+        );
+        $validSw = array_flip($validSw);
+
+        $validWk = array_column(
+            array_map(fn($w) => ['id' => $w->id], $this->weeks->allForSprint($sprintId)),
+            'id',
+        );
+        $validWk = array_flip($validWk);
+
+        $cells = [];
+        foreach ($body as $i => $row) {
+            if (!is_array($row) || !isset($row['sprint_worker_id'], $row['sprint_week_id'], $row['days'])) {
+                return Response::err('validation', "cell[{$i}] needs sprint_worker_id, sprint_week_id, days", 422);
+            }
+            $swId   = (int) $row['sprint_worker_id'];
+            $wkId   = (int) $row['sprint_week_id'];
+            $daysN  = $row['days'];
+            if (!is_numeric($daysN)) {
+                return Response::err('validation', "cell[{$i}] days must be numeric", 422);
+            }
+            $days = (float) $daysN;
+            if (!isset($validSw[$swId])) {
+                return Response::err('validation', "cell[{$i}] sprint_worker {$swId} not in sprint", 422);
+            }
+            if (!isset($validWk[$wkId])) {
+                return Response::err('validation', "cell[{$i}] sprint_week {$wkId} not in sprint", 422);
+            }
+            if (!CapacityCalculator::isHalfStep($days, 0.0, 5.0)) {
+                return Response::err('validation', "cell[{$i}] days must be 0..5 in 0.5 steps", 422);
+            }
+            $cells[] = ['sw_id' => $swId, 'week_id' => $wkId, 'days' => $days];
+        }
+
+        $applied = 0;
+        $noop    = 0;
+        $touchedWorkers = [];
+
+        $this->pdo->beginTransaction();
+        try {
+            foreach ($cells as $c) {
+                $result = $this->days->upsert($c['sw_id'], $c['week_id'], $c['days']);
+                if ($result['action'] === 'NOOP') {
+                    $noop++;
+                    continue;
+                }
+                $applied++;
+                $touchedWorkers[$c['sw_id']] = true;
+
+                $this->audit->recordForRequest(
+                    action:     $result['action'],
+                    entityType: 'sprint_worker_days',
+                    entityId:   $result['after']?->id ?? $result['before']?->id,
+                    before:     $result['before']?->toAuditSnapshot(),
+                    after:      $result['after']?->toAuditSnapshot(),
+                    req:        $req,
+                    actor:      $actor,
+                );
+            }
+            $this->pdo->commit();
+        } catch (Throwable) {
+            $this->pdo->rollBack();
+            return Response::err('db_error', 'Could not save cells', 500);
+        }
+
+        // Recompute capacity for every worker whose row changed.
+        $grid = $this->days->grid($sprintId);
+        $perWorker = [];
+        foreach (array_keys($touchedWorkers) as $swId) {
+            $ressourcen = array_sum($grid[$swId] ?? []);
+            $perWorker[(string) $swId] = CapacityCalculator::forWorker(
+                $ressourcen,
+                $sprint->reserveFraction,
+                0.0,
+            );
+        }
+
+        return Response::ok([
+            'applied'    => $applied,
+            'noop'       => $noop,
+            'per_worker' => $perWorker === [] ? new \stdClass() : $perWorker,
+        ]);
+    }
+
+    /** PATCH /sprints/{id}/week/{week_id} — JSON — edit max_working_days for one week. */
+    public function updateWeekMax(Request $req, array $params): Response
+    {
+        $gate = $this->gateJsonAdmin($req);
+        if ($gate instanceof Response) {
+            return $gate;
+        }
+        $actor = $gate;
+
+        $sprintId = (int) $params['id'];
+        $weekId   = (int) $params['week_id'];
+
+        $week = $this->weeks->find($weekId);
+        if ($week === null || $week->sprintId !== $sprintId) {
+            return Response::err('not_found', 'sprint_week not found in this sprint', 404);
+        }
+
+        $body = $req->json() ?? [];
+        if (!isset($body['max_working_days']) || !is_numeric($body['max_working_days'])) {
+            return Response::err('validation', 'max_working_days required', 422);
+        }
+        $maxDays = (float) $body['max_working_days'];
+        if (!CapacityCalculator::isHalfStep($maxDays, 0.0, 5.0)) {
+            return Response::err('validation', 'max_working_days must be 0..5 in 0.5 steps', 422);
+        }
+
+        if (abs($week->maxWorkingDays - $maxDays) < 1e-9) {
+            return Response::ok(['sprint_week' => $week->toAuditSnapshot()]);
+        }
+
+        $this->pdo->beginTransaction();
+        try {
+            $result = $this->weeks->setMaxWorkingDays($weekId, $maxDays);
+            $this->audit->recordForRequest(
+                'UPDATE', 'sprint_week', $weekId,
+                $result['before']->toAuditSnapshot(),
+                $result['after']->toAuditSnapshot(),
+                $req, $actor,
+            );
+            $this->pdo->commit();
+        } catch (Throwable) {
+            $this->pdo->rollBack();
+            return Response::err('db_error', 'Could not update week', 500);
+        }
+
+        return Response::ok(['sprint_week' => $result['after']->toAuditSnapshot()]);
+    }
+
     // ------------------------------------------------------------------
     // Shared helpers
     // ------------------------------------------------------------------

+ 26 - 0
src/Domain/SprintWorkerDay.php

@@ -0,0 +1,26 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Domain;
+
+final class SprintWorkerDay
+{
+    public function __construct(
+        public readonly int   $id,
+        public readonly int   $sprintWorkerId,
+        public readonly int   $sprintWeekId,
+        public readonly float $days,
+    ) {
+    }
+
+    public function toAuditSnapshot(): array
+    {
+        return [
+            'id'                => $this->id,
+            'sprint_worker_id'  => $this->sprintWorkerId,
+            'sprint_week_id'    => $this->sprintWeekId,
+            'days'              => $this->days,
+        ];
+    }
+}

+ 18 - 0
src/Repositories/SprintWeekRepository.php

@@ -37,6 +37,24 @@ final class SprintWeekRepository
         return is_array($row) ? self::hydrate($row) : null;
     }
 
+    /**
+     * Update max_working_days on one week. Returns before/after for auditing.
+     *
+     * @return array{before: SprintWeek, after: SprintWeek}
+     */
+    public function setMaxWorkingDays(int $weekId, float $maxDays): array
+    {
+        $before = $this->find($weekId);
+        if ($before === null) {
+            throw new \RuntimeException("sprint_week {$weekId} not found");
+        }
+        $this->pdo
+            ->prepare('UPDATE sprint_weeks SET max_working_days = ? WHERE id = ?')
+            ->execute([$maxDays, $weekId]);
+        $after = $this->find($weekId) ?? $before;
+        return ['before' => $before, 'after' => $after];
+    }
+
     /**
      * Resize the week set of a sprint to $targetCount weeks.
      *

+ 97 - 0
src/Repositories/SprintWorkerDayRepository.php

@@ -0,0 +1,97 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Repositories;
+
+use App\Domain\SprintWorkerDay;
+use PDO;
+
+final class SprintWorkerDayRepository
+{
+    public function __construct(private readonly PDO $pdo)
+    {
+    }
+
+    /**
+     * Build the full [sprint_worker_id][sprint_week_id] => days map for a sprint.
+     * Cells with no DB row are simply absent from the map (read as 0 by callers).
+     *
+     * @return array<int, array<int, float>>
+     */
+    public function grid(int $sprintId): array
+    {
+        $stmt = $this->pdo->prepare(
+            'SELECT swd.sprint_worker_id, swd.sprint_week_id, swd.days
+             FROM sprint_worker_days swd
+             JOIN sprint_workers sw ON sw.id = swd.sprint_worker_id
+             WHERE sw.sprint_id = ?'
+        );
+        $stmt->execute([$sprintId]);
+        $out = [];
+        foreach ($stmt as $row) {
+            $swId  = (int) $row['sprint_worker_id'];
+            $wkId  = (int) $row['sprint_week_id'];
+            $days  = (float) $row['days'];
+            $out[$swId][$wkId] = $days;
+        }
+        return $out;
+    }
+
+    public function find(int $swId, int $weekId): ?SprintWorkerDay
+    {
+        $stmt = $this->pdo->prepare(
+            'SELECT * FROM sprint_worker_days WHERE sprint_worker_id = ? AND sprint_week_id = ?'
+        );
+        $stmt->execute([$swId, $weekId]);
+        $row = $stmt->fetch();
+        if (!is_array($row)) {
+            return null;
+        }
+        return new SprintWorkerDay(
+            id:              (int)   $row['id'],
+            sprintWorkerId:  (int)   $row['sprint_worker_id'],
+            sprintWeekId:    (int)   $row['sprint_week_id'],
+            days:            (float) $row['days'],
+        );
+    }
+
+    /**
+     * Set days for a single (sprint_worker, sprint_week) cell.
+     *
+     * Rules:
+     *   - If the row exists and days are unchanged → no DB write, action=NOOP.
+     *   - If the row doesn't exist and days === 0 → don't insert, action=NOOP.
+     *   - If the row doesn't exist and days > 0 → INSERT, action=CREATE.
+     *   - Otherwise → UPDATE, action=UPDATE.
+     *
+     * @return array{action:string, before: ?SprintWorkerDay, after: ?SprintWorkerDay}
+     */
+    public function upsert(int $swId, int $weekId, float $days): array
+    {
+        $existing = $this->find($swId, $weekId);
+
+        if ($existing !== null && abs($existing->days - $days) < 1e-9) {
+            return ['action' => 'NOOP', 'before' => $existing, 'after' => $existing];
+        }
+
+        if ($existing === null) {
+            if (abs($days) < 1e-9) {
+                return ['action' => 'NOOP', 'before' => null, 'after' => null];
+            }
+            $stmt = $this->pdo->prepare(
+                'INSERT INTO sprint_worker_days (sprint_worker_id, sprint_week_id, days)
+                 VALUES (?, ?, ?)'
+            );
+            $stmt->execute([$swId, $weekId, $days]);
+            $id    = (int) $this->pdo->lastInsertId();
+            $after = new SprintWorkerDay($id, $swId, $weekId, $days);
+            return ['action' => 'CREATE', 'before' => null, 'after' => $after];
+        }
+
+        $stmt = $this->pdo->prepare('UPDATE sprint_worker_days SET days = ? WHERE id = ?');
+        $stmt->execute([$days, $existing->id]);
+        $after = new SprintWorkerDay($existing->id, $swId, $weekId, $days);
+        return ['action' => 'UPDATE', 'before' => $existing, 'after' => $after];
+    }
+}

+ 73 - 0
src/Services/CapacityCalculator.php

@@ -0,0 +1,73 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Services;
+
+/**
+ * Capacity math per spec §6.5.
+ *
+ * The same formula runs client-side in `sprint-planner.js` and server-side
+ * here; any edit here must land in both places together.
+ */
+final class CapacityCalculator
+{
+    /** round to the nearest 0.5. */
+    public static function roundHalf(float $x): float
+    {
+        return round($x * 2) / 2;
+    }
+
+    /**
+     * Compute capacity for a single sprint worker.
+     *
+     * @return array{
+     *   ressourcen: float,
+     *   after_reserves: float,
+     *   committed_prio1: float,
+     *   available: float,
+     * }
+     */
+    public static function forWorker(
+        float $ressourcen,
+        float $reserveFraction,
+        float $committedPrio1Days = 0.0,
+    ): array {
+        $afterReserves = self::roundHalf($ressourcen * (1.0 - $reserveFraction));
+        return [
+            'ressourcen'      => $ressourcen,
+            'after_reserves'  => $afterReserves,
+            'committed_prio1' => $committedPrio1Days,
+            'available'       => $afterReserves - $committedPrio1Days,
+        ];
+    }
+
+    /**
+     * Validate a 0.5-step numeric input: must be in [$min, $max] and land on a
+     * half-day grid. Returns false if not finite, out of range, or mis-stepped.
+     */
+    public static function isHalfStep(
+        float $v,
+        float $min = 0.0,
+        float $max = 5.0,
+    ): bool {
+        if (!is_finite($v)) {
+            return false;
+        }
+        if ($v < $min || $v > $max) {
+            return false;
+        }
+        $doubled = $v * 2;
+        return abs($doubled - round($doubled)) < 1e-9;
+    }
+
+    /** Validate RTB or reserve_fraction — 0..1 in 0.05 steps. */
+    public static function isRtbStep(float $v): bool
+    {
+        if (!is_finite($v) || $v < 0.0 || $v > 1.0) {
+            return false;
+        }
+        $twentieths = $v * 20.0;
+        return abs($twentieths - round($twentieths)) < 1e-9;
+    }
+}

+ 190 - 14
views/sprints/show.php

@@ -1,10 +1,31 @@
 <?php
 /** @var \App\Domain\Sprint $sprint */
-/** @var \App\Domain\User $currentUser */
+/** @var \App\Domain\User   $currentUser */
+/** @var string             $csrfToken */
+/** @var list<\App\Domain\SprintWeek>   $weeks */
+/** @var list<\App\Domain\SprintWorker> $sprintWorkers */
+/** @var array<int, array<int, float>> $grid        sw_id => week_id => days */
+/** @var array<int, array{ressourcen:float, after_reserves:float, committed_prio1:float, available:float}> $capacity */
 use function App\Http\e;
+
+if (!function_exists('fmt_days')) {
+    function fmt_days(float $x): string
+    {
+        // Show 0 as "0", whole numbers as integer, halves as x.5
+        if (abs($x - round($x)) < 1e-9) {
+            return (string) (int) round($x);
+        }
+        return number_format($x, 1);
+    }
+}
 ?>
-<section class="space-y-6">
-    <div class="flex items-end justify-between gap-4">
+<section class="space-y-6"
+         data-sprint-root
+         data-sprint-id="<?= (int) $sprint->id ?>"
+         data-csrf="<?= e($csrfToken) ?>"
+         data-reserve-fraction="<?= e(number_format($sprint->reserveFraction, 4, '.', '')) ?>">
+
+    <header class="flex items-end justify-between gap-4">
         <div>
             <nav class="text-xs text-slate-500">
                 <a href="/" class="hover:underline">Sprints</a> /
@@ -18,17 +39,172 @@ use function App\Http\e;
                 <?php endif; ?>
             </p>
         </div>
-        <?php if ($currentUser->isAdmin): ?>
-            <a href="/sprints/<?= (int) $sprint->id ?>/settings"
-               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">
-                Settings
-            </a>
-        <?php endif; ?>
-    </div>
+        <div class="flex items-center gap-3">
+            <div data-status
+                 class="text-sm border rounded px-3 py-1 opacity-0 transition-opacity duration-200 border-slate-200 bg-slate-50 text-slate-700">
+            </div>
+            <?php if ($currentUser->isAdmin): ?>
+                <a href="/sprints/<?= (int) $sprint->id ?>/settings"
+                   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">
+                    Settings
+                </a>
+            <?php endif; ?>
+        </div>
+    </header>
+
+    <?php if ($sprintWorkers === [] || $weeks === []): ?>
+        <div class="rounded-md border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900">
+            <?php if ($weeks === []): ?>
+                No weeks yet. <a href="/sprints/<?= (int) $sprint->id ?>/settings" class="underline">Open settings</a> to add some.
+            <?php elseif ($sprintWorkers === []): ?>
+                No workers on this sprint yet. <a href="/sprints/<?= (int) $sprint->id ?>/settings" class="underline">Open settings</a> to add some.
+            <?php endif; ?>
+        </div>
+    <?php else: ?>
 
-    <div class="rounded-lg border bg-amber-50 text-amber-900 px-4 py-3 text-sm">
-        Sprint settings, Arbeitstage grid and task list land in the next phases.
-        The weeks for this sprint are already materialised, but the editors
-        aren't wired up yet.
+        <!-- Arbeitstage grid -->
+        <section class="rounded-lg border bg-white overflow-x-auto">
+            <table class="min-w-full text-sm" data-arbeitstage>
+                <thead class="bg-slate-50 text-slate-600 text-xs uppercase tracking-wider">
+                    <tr>
+                        <th class="text-left px-3 py-2 font-semibold sticky left-0 bg-slate-50 z-10">&nbsp;</th>
+                        <?php foreach ($weeks as $w): ?>
+                            <th class="text-center px-2 py-2 font-semibold whitespace-nowrap">
+                                <div class="font-mono">KW<?= (int) $w->isoWeek ?></div>
+                                <div class="text-[10px] text-slate-500 font-normal"><?= e($w->startDate) ?></div>
+                            </th>
+                        <?php endforeach; ?>
+                        <th class="text-center px-2 py-2 font-semibold">Σ</th>
+                        <th class="text-center px-2 py-2 font-semibold">RTB</th>
+                    </tr>
+                </thead>
+                <tbody class="divide-y divide-slate-100" data-tbody>
+                    <!-- Arbeitstage (max working days) row -->
+                    <tr class="bg-slate-50">
+                        <th class="text-left px-3 py-2 font-semibold text-slate-700 sticky left-0 bg-slate-50">
+                            Arbeitstage
+                        </th>
+                        <?php $sumMax = 0.0; foreach ($weeks as $w): $sumMax += $w->maxWorkingDays; ?>
+                            <td class="px-1 py-1 text-center">
+                                <?php if ($currentUser->isAdmin): ?>
+                                    <input type="number" min="0" max="5" step="0.5"
+                                           value="<?= e(fmt_days($w->maxWorkingDays)) ?>"
+                                           data-week-max data-week-id="<?= (int) $w->id ?>"
+                                           class="w-14 rounded border border-slate-300 px-1 py-1 text-center font-mono focus:outline-none focus:ring-2 focus:ring-slate-400">
+                                <?php else: ?>
+                                    <span class="font-mono"><?= e(fmt_days($w->maxWorkingDays)) ?></span>
+                                <?php endif; ?>
+                            </td>
+                        <?php endforeach; ?>
+                        <td class="px-2 py-1 text-center font-mono font-semibold" data-sum-max>
+                            <?= e(fmt_days($sumMax)) ?>
+                        </td>
+                        <td>&nbsp;</td>
+                    </tr>
+
+                    <!-- One row per sprint worker -->
+                    <?php foreach ($sprintWorkers as $sw): ?>
+                        <?php $rowDays = $grid[$sw->id] ?? []; $rowSum = array_sum($rowDays); ?>
+                        <tr data-sw-row data-sw-id="<?= (int) $sw->id ?>">
+                            <th class="text-left px-3 py-2 font-medium sticky left-0 bg-white z-10">
+                                <span class="flex items-center gap-2">
+                                    <?php if ($currentUser->isAdmin): ?>
+                                        <span class="handle cursor-grab text-slate-400 select-none">&#8801;</span>
+                                    <?php endif; ?>
+                                    <?= e($sw->workerName) ?>
+                                </span>
+                            </th>
+                            <?php foreach ($weeks as $w): $v = (float) ($rowDays[$w->id] ?? 0.0); ?>
+                                <td class="px-1 py-1 text-center">
+                                    <?php if ($currentUser->isAdmin): ?>
+                                        <input type="number" min="0" max="5" step="0.5"
+                                               value="<?= e(fmt_days($v)) ?>"
+                                               data-day data-sw-id="<?= (int) $sw->id ?>"
+                                               data-week-id="<?= (int) $w->id ?>"
+                                               class="w-14 rounded border border-slate-200 px-1 py-1 text-center font-mono focus:outline-none focus:ring-2 focus:ring-slate-400">
+                                    <?php else: ?>
+                                        <span class="font-mono"><?= e(fmt_days($v)) ?></span>
+                                    <?php endif; ?>
+                                </td>
+                            <?php endforeach; ?>
+                            <td class="px-2 py-1 text-center font-mono font-semibold"
+                                data-sum-days data-sw-id="<?= (int) $sw->id ?>">
+                                <?= e(fmt_days($rowSum)) ?>
+                            </td>
+                            <td class="px-1 py-1 text-center">
+                                <?php if ($currentUser->isAdmin): ?>
+                                    <input type="number" min="0" max="1" step="0.05"
+                                           value="<?= e(number_format($sw->rtb, 2, '.', '')) ?>"
+                                           data-rtb data-sw-id="<?= (int) $sw->id ?>"
+                                           class="w-16 rounded border border-slate-200 px-1 py-1 text-center font-mono focus:outline-none focus:ring-2 focus:ring-slate-400">
+                                <?php else: ?>
+                                    <span class="font-mono"><?= e(number_format($sw->rtb, 2, '.', '')) ?></span>
+                                <?php endif; ?>
+                            </td>
+                        </tr>
+                    <?php endforeach; ?>
+                </tbody>
+            </table>
+        </section>
+
+        <!-- Capacity summary — one column per worker, aligned with task columns in Phase 6 -->
+        <section class="rounded-lg border bg-white overflow-x-auto">
+            <div class="px-4 py-2 border-b bg-slate-50 text-xs uppercase tracking-wider text-slate-600 font-semibold">
+                Capacity
+            </div>
+            <table class="min-w-full text-sm">
+                <thead>
+                    <tr class="bg-slate-50 text-slate-600 text-xs">
+                        <th class="text-left px-3 py-2 font-semibold sticky left-0 bg-slate-50 z-10">&nbsp;</th>
+                        <?php foreach ($sprintWorkers as $sw): ?>
+                            <th class="text-center px-2 py-2 font-semibold whitespace-nowrap">
+                                <?= e($sw->workerName) ?>
+                            </th>
+                        <?php endforeach; ?>
+                    </tr>
+                </thead>
+                <tbody class="divide-y divide-slate-100">
+                    <tr>
+                        <th class="text-left px-3 py-2 text-slate-700 font-medium sticky left-0 bg-white">Ressourcen</th>
+                        <?php foreach ($sprintWorkers as $sw): $c = $capacity[$sw->id] ?? null; ?>
+                            <td class="px-2 py-2 text-center font-mono"
+                                data-cap-ressourcen data-sw-id="<?= (int) $sw->id ?>">
+                                <?= e(fmt_days($c['ressourcen'] ?? 0.0)) ?>
+                            </td>
+                        <?php endforeach; ?>
+                    </tr>
+                    <tr>
+                        <th class="text-left px-3 py-2 text-slate-700 font-medium sticky left-0 bg-white">− Reserven</th>
+                        <?php foreach ($sprintWorkers as $sw): $c = $capacity[$sw->id] ?? null; ?>
+                            <td class="px-2 py-2 text-center font-mono text-slate-600"
+                                data-cap-after-reserves data-sw-id="<?= (int) $sw->id ?>">
+                                <?= e(fmt_days($c['after_reserves'] ?? 0.0)) ?>
+                            </td>
+                        <?php endforeach; ?>
+                    </tr>
+                    <tr>
+                        <th class="text-left px-3 py-2 text-slate-700 font-semibold sticky left-0 bg-white">Available</th>
+                        <?php foreach ($sprintWorkers as $sw): $c = $capacity[$sw->id] ?? null; $av = (float) ($c['available'] ?? 0.0); ?>
+                            <td class="px-2 py-2 text-center font-mono font-semibold <?= $av < 0 ? 'text-red-700' : 'text-slate-900' ?>"
+                                data-cap-available data-sw-id="<?= (int) $sw->id ?>">
+                                <?= e(fmt_days($av)) ?>
+                            </td>
+                        <?php endforeach; ?>
+                    </tr>
+                </tbody>
+            </table>
+        </section>
+
+        <p class="text-xs text-slate-500">
+            Numeric inputs snap to 0.5 (days) or 0.05 (RTB) on blur. Edits save automatically
+            with a 400&nbsp;ms debounce; Available turns red if a worker is overcommitted.
+        </p>
+
+    <?php endif; ?>
+
+    <div class="rounded-md border border-slate-200 bg-slate-50 px-4 py-3 text-xs text-slate-600">
+        Task list lands in Phase 6.
     </div>
 </section>
+
+<script src="/assets/js/sprint-planner.js" defer></script>