Răsfoiți Sursa

Phase 3: workers + sprints + generic audit wiring

Domain + repositories:
- Domain\Worker, Domain\Sprint value objects with toAuditSnapshot().
- WorkerRepository: all(), find(), findByName(), create(), update().
  update() takes a whitelisted subset {name, is_active, default_rtb}
  and returns before/after Worker snapshots for auditing.
- SprintRepository: allWithCounts() (correlated subqueries for n_workers
  and n_tasks), find(), create(), materializeWeeks() — given a start
  date and N, inserts N sprint_weeks rows with ISO week numbers derived
  from the date and max_working_days defaulting to 5.

Audit:
- AuditLogger gains recordForRequest(action, type, id, before, after,
  Request, ?User) so controllers stop repeating the ip/user-agent/
  user_id plumbing. record() is still the primitive.

Auth guards:
- SessionGuard::requireAuth($users): User|Response and
  requireAdmin($users): User|Response. Controllers use
  `if ($u instanceof Response) return $u;` at the top of each handler.

Controllers:
- WorkerController (admin-only): GET /workers list, POST /workers
  create, POST /workers/{id} inline-row update. All mutations run in a
  single tx with a matching audit row. UNIQUE(name) violations and
  out-of-range RTBs surface as redirect-with-error-query; no-op
  updates flash "noop" and write no audit row (the generic no-op rule
  catches them).
- SprintController: GET /sprints/new form (admin), POST /sprints
  creates the sprint row AND materialises N week rows in one tx,
  auditing CREATE for the sprint and each sprint_week. GET
  /sprints/{id} shows a minimal detail page (settings/Arbeitstage/
  tasks land in later phases).

UI:
- Layout navigation gains Sprints / Workers / New-sprint links, the
  last two admin-only.
- Home page now renders the sprint list (name, dates, # workers,
  # tasks, reserve %, status chip) for signed-in users, with a
  prominent "New sprint" CTA for admins. The runtime/diagnostics
  block is now an <details> behind admin-only visibility.
- /workers page has an "Add worker" form plus an inline-editable
  table (name, RTB, active toggle, Save-per-row).
- /sprints/new form mirrors §6.3 fields (name, start, end,
  reserve %, weeks) and surfaces validation errors via ?error= codes.
- /sprints/{id} shows a breadcrumb, sprint metadata, a TODO banner
  for features coming in later phases.

Verified:
- php -l clean across all changed files.
- End-to-end tx smoke: create/rename/toggle worker + duplicate-name
  rejection + no-op-update-writes-zero-rows; create sprint with 4
  weeks materialised at the right ISO week offsets; 9 audit rows
  covering every mutation with actor email.
- Render tests for workers/index, sprints/new (error banner), sprints/
  show, home (auth + rows / auth + empty / anonymous), layout (admin
  and non-admin nav visibility).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
achiappa 2 săptămâni în urmă
părinte
comite
f189e7de5a

+ 33 - 14
public/index.php

@@ -6,13 +6,17 @@ use App\Auth\LocalAdmin;
 use App\Auth\OidcClient;
 use App\Auth\SessionGuard;
 use App\Controllers\AuthController;
+use App\Controllers\SprintController;
+use App\Controllers\WorkerController;
 use App\Db\Connection;
 use App\Db\Migrator;
 use App\Http\Request;
 use App\Http\Response;
 use App\Http\Router;
 use App\Http\View;
+use App\Repositories\SprintRepository;
 use App\Repositories\UserRepository;
+use App\Repositories\WorkerRepository;
 use App\Services\AuditLogger;
 
 // Buffer output so a stray warning/notice can't send headers before
@@ -69,32 +73,39 @@ try {
 // ---------------------------------------------------------------------------
 // Shared services
 // ---------------------------------------------------------------------------
-$view   = new View(APP_ROOT . '/views');
-$users  = new UserRepository($pdo);
-$audit  = new AuditLogger($pdo);
-$auth   = new AuthController($pdo, $users, $audit, $view);
+$view          = new View(APP_ROOT . '/views');
+$users         = new UserRepository($pdo);
+$workers       = new WorkerRepository($pdo);
+$sprints       = new SprintRepository($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, $audit, $view);
 
 // ---------------------------------------------------------------------------
 // Routing
 // ---------------------------------------------------------------------------
 $router = new Router();
 
-$router->get('/', function (Request $req) use ($view, $pdo, $users, $appEnv): Response {
-    $currentUser = SessionGuard::currentUser($users);
+$router->get('/', function (Request $req) use ($view, $pdo, $users, $sprints, $appEnv): Response {
+    $currentUser   = SessionGuard::currentUser($users);
     $schemaVersion = (int) $pdo->query(
         'SELECT COALESCE(MAX(version), 0) FROM schema_version'
     )->fetchColumn();
 
+    $sprintRows = $currentUser === null ? [] : $sprints->allWithCounts();
+
     return Response::html($view->render('home', [
-        'title'            => 'Sprint Planner',
-        'currentUser'      => $currentUser,
-        'schemaVersion'    => $schemaVersion,
-        'dbPath'           => Connection::path(),
-        'appEnv'           => $appEnv,
-        'oidcConfigured'   => OidcClient::isConfigured(),
+        'title'             => 'Sprint Planner',
+        'currentUser'       => $currentUser,
+        'schemaVersion'     => $schemaVersion,
+        'dbPath'            => Connection::path(),
+        'appEnv'            => $appEnv,
+        'oidcConfigured'    => OidcClient::isConfigured(),
         'localAdminEnabled' => LocalAdmin::isEnabled(),
-        'authError'        => isset($req->query['auth_error']),
-        'csrfToken'        => SessionGuard::csrfToken(),
+        'authError'         => isset($req->query['auth_error']),
+        'csrfToken'         => SessionGuard::csrfToken(),
+        'sprintRows'        => $sprintRows,
     ]));
 });
 
@@ -106,6 +117,14 @@ $router->post('/auth/logout',   $auth->logout(...));
 $router->get('/auth/local',     $auth->loginLocalForm(...));
 $router->post('/auth/local',    $auth->loginLocal(...));
 
+$router->get('/workers',         $workerCtrl->index(...));
+$router->post('/workers',        $workerCtrl->create(...));
+$router->post('/workers/{id}',   $workerCtrl->update(...));
+
+$router->get('/sprints/new',     $sprintCtrl->newForm(...));
+$router->post('/sprints',        $sprintCtrl->create(...));
+$router->get('/sprints/{id}',    $sprintCtrl->show(...));
+
 // ---------------------------------------------------------------------------
 // Dispatch
 // ---------------------------------------------------------------------------

+ 30 - 0
src/Auth/SessionGuard.php

@@ -6,6 +6,7 @@ namespace App\Auth;
 
 use App\Domain\User;
 use App\Http\Request;
+use App\Http\Response;
 use App\Repositories\UserRepository;
 
 /**
@@ -129,4 +130,33 @@ final class SessionGuard
         }
         return hash_equals(self::csrfToken(), $token);
     }
+
+    /**
+     * Return the signed-in User, or a redirect Response to /auth/login if not
+     * authenticated. Controllers use:
+     *
+     *     $user = SessionGuard::requireAuth($this->users);
+     *     if ($user instanceof Response) return $user;
+     */
+    public static function requireAuth(UserRepository $users): User|Response
+    {
+        $user = self::currentUser($users);
+        return $user ?? Response::redirect('/auth/login');
+    }
+
+    /**
+     * Same as requireAuth but also requires is_admin. Returns 403 when signed
+     * in as a non-admin; a redirect to /auth/login when anonymous.
+     */
+    public static function requireAdmin(UserRepository $users): User|Response
+    {
+        $user = self::currentUser($users);
+        if ($user === null) {
+            return Response::redirect('/auth/login');
+        }
+        if (!$user->isAdmin) {
+            return Response::text('Forbidden — admin access required', 403);
+        }
+        return $user;
+    }
 }

+ 162 - 0
src/Controllers/SprintController.php

@@ -0,0 +1,162 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Controllers;
+
+use App\Auth\SessionGuard;
+use App\Http\Request;
+use App\Http\Response;
+use App\Http\View;
+use App\Repositories\SprintRepository;
+use App\Repositories\UserRepository;
+use App\Services\AuditLogger;
+use DateTimeImmutable;
+use PDO;
+use Throwable;
+
+final class SprintController
+{
+    public function __construct(
+        private readonly PDO              $pdo,
+        private readonly UserRepository   $users,
+        private readonly SprintRepository $sprints,
+        private readonly AuditLogger      $audit,
+        private readonly View             $view,
+    ) {
+    }
+
+    /** GET /sprints/new — admin-only form. */
+    public function newForm(Request $req): Response
+    {
+        $actor = SessionGuard::requireAdmin($this->users);
+        if ($actor instanceof Response) {
+            return $actor;
+        }
+
+        return Response::html($this->view->render('sprints/new', [
+            'title'       => 'New sprint',
+            'currentUser' => $actor,
+            'csrfToken'   => SessionGuard::csrfToken(),
+            'error'       => $req->queryString('error'),
+            'form'        => [
+                'name'             => '',
+                'start_date'       => '',
+                'end_date'         => '',
+                'reserve_fraction' => '20',
+                'n_weeks'          => '4',
+            ],
+        ]));
+    }
+
+    /** POST /sprints — create sprint + materialise weeks in one tx. */
+    public function create(Request $req): Response
+    {
+        $actor = SessionGuard::requireAdmin($this->users);
+        if ($actor instanceof Response) {
+            return $actor;
+        }
+        if (!SessionGuard::verifyCsrf($req)) {
+            return Response::text('CSRF token invalid', 403);
+        }
+
+        $name     = trim($req->postString('name'));
+        $start    = $req->postString('start_date');
+        $end      = $req->postString('end_date');
+        // reserve_fraction submitted as a percentage (0..100) from the form.
+        $reservePct = $req->postString('reserve_fraction');
+        $nWeeksStr  = $req->postString('n_weeks');
+
+        if ($name === '') {
+            return Response::redirect('/sprints/new?error=name_required');
+        }
+        $startD = DateTimeImmutable::createFromFormat('Y-m-d', $start);
+        $endD   = DateTimeImmutable::createFromFormat('Y-m-d', $end);
+        if ($startD === false || $endD === false) {
+            return Response::redirect('/sprints/new?error=dates_invalid');
+        }
+        if ($endD < $startD) {
+            return Response::redirect('/sprints/new?error=dates_order');
+        }
+        if (!is_numeric($reservePct)) {
+            return Response::redirect('/sprints/new?error=reserve_invalid');
+        }
+        $reserve = ((float) $reservePct) / 100.0;
+        if ($reserve < 0.0 || $reserve > 1.0) {
+            return Response::redirect('/sprints/new?error=reserve_out_of_range');
+        }
+        if (!ctype_digit($nWeeksStr)) {
+            return Response::redirect('/sprints/new?error=n_weeks_invalid');
+        }
+        $nWeeks = (int) $nWeeksStr;
+        if ($nWeeks < 1 || $nWeeks > 26) {
+            return Response::redirect('/sprints/new?error=n_weeks_range');
+        }
+
+        $this->pdo->beginTransaction();
+        try {
+            $sprint = $this->sprints->create(
+                name:            $name,
+                startDate:       $startD->format('Y-m-d'),
+                endDate:         $endD->format('Y-m-d'),
+                reserveFraction: $reserve,
+            );
+
+            $this->audit->recordForRequest(
+                action:     'CREATE',
+                entityType: 'sprint',
+                entityId:   $sprint->id,
+                before:     null,
+                after:      $sprint->toAuditSnapshot(),
+                req:        $req,
+                actor:      $actor,
+            );
+
+            $weeks = $this->sprints->materializeWeeks(
+                $sprint->id,
+                $startD->format('Y-m-d'),
+                $nWeeks,
+            );
+            foreach ($weeks as $w) {
+                $this->audit->recordForRequest(
+                    action:     'CREATE',
+                    entityType: 'sprint_week',
+                    entityId:   $w['id'],
+                    before:     null,
+                    after:      ['sprint_id' => $sprint->id] + $w,
+                    req:        $req,
+                    actor:      $actor,
+                );
+            }
+
+            $this->pdo->commit();
+        } catch (Throwable) {
+            $this->pdo->rollBack();
+            return Response::redirect('/sprints/new?error=db_error');
+        }
+
+        return Response::redirect('/sprints/' . $sprint->id);
+    }
+
+    /** GET /sprints/{id} — minimal detail (settings/tasks follow in later phases). */
+    public function show(Request $req, array $params): Response
+    {
+        $actor = SessionGuard::requireAuth($this->users);
+        if ($actor instanceof Response) {
+            return $actor;
+        }
+
+        $id = (int) $params['id'];
+        $sprint = $this->sprints->find($id);
+        if ($sprint === null) {
+            return Response::text('Not Found', 404);
+        }
+
+        return Response::html($this->view->render('sprints/show', [
+            'title'       => $sprint->name,
+            'currentUser' => $actor,
+            'csrfToken'   => SessionGuard::csrfToken(),
+            'sprint'      => $sprint,
+        ]));
+    }
+}

+ 173 - 0
src/Controllers/WorkerController.php

@@ -0,0 +1,173 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Controllers;
+
+use App\Auth\SessionGuard;
+use App\Http\Request;
+use App\Http\Response;
+use App\Http\View;
+use App\Repositories\UserRepository;
+use App\Repositories\WorkerRepository;
+use App\Services\AuditLogger;
+use PDO;
+use PDOException;
+use Throwable;
+
+/**
+ * /workers — master data for the people to whom sprint tasks are assigned.
+ *
+ * Access: admin only (all methods). Workers are independent from users: a
+ * worker is not required to ever log in.
+ */
+final class WorkerController
+{
+    public function __construct(
+        private readonly PDO               $pdo,
+        private readonly UserRepository    $users,
+        private readonly WorkerRepository  $workers,
+        private readonly AuditLogger       $audit,
+        private readonly View              $view,
+    ) {
+    }
+
+    /** GET /workers */
+    public function index(Request $req): Response
+    {
+        $actor = SessionGuard::requireAdmin($this->users);
+        if ($actor instanceof Response) {
+            return $actor;
+        }
+
+        return Response::html($this->view->render('workers/index', [
+            'title'       => 'Workers',
+            'currentUser' => $actor,
+            'csrfToken'   => SessionGuard::csrfToken(),
+            'workers'     => $this->workers->all(),
+            'flash'       => $req->queryString('flash'),
+            'error'       => $req->queryString('error'),
+        ]));
+    }
+
+    /** POST /workers — create a worker. */
+    public function create(Request $req): Response
+    {
+        $actor = SessionGuard::requireAdmin($this->users);
+        if ($actor instanceof Response) {
+            return $actor;
+        }
+        if (!SessionGuard::verifyCsrf($req)) {
+            return Response::text('CSRF token invalid', 403);
+        }
+
+        $name    = trim($req->postString('name'));
+        $rtbRaw  = $req->postString('default_rtb');
+        $rtb     = $rtbRaw === '' ? 0.0 : (float) $rtbRaw;
+
+        if ($name === '') {
+            return Response::redirect('/workers?error=name_required');
+        }
+        if ($rtb < 0.0 || $rtb > 1.0) {
+            return Response::redirect('/workers?error=rtb_out_of_range');
+        }
+
+        $this->pdo->beginTransaction();
+        try {
+            $worker = $this->workers->create($name, true, $rtb);
+            $this->audit->recordForRequest(
+                action:     'CREATE',
+                entityType: 'worker',
+                entityId:   $worker->id,
+                before:     null,
+                after:      $worker->toAuditSnapshot(),
+                req:        $req,
+                actor:      $actor,
+            );
+            $this->pdo->commit();
+        } catch (PDOException $e) {
+            $this->pdo->rollBack();
+            // UNIQUE(name) violation or similar.
+            return Response::redirect('/workers?error=' . urlencode(
+                str_contains(strtolower($e->getMessage()), 'unique') ? 'name_taken' : 'db_error'
+            ));
+        } catch (Throwable) {
+            $this->pdo->rollBack();
+            return Response::redirect('/workers?error=db_error');
+        }
+
+        return Response::redirect('/workers?flash=created');
+    }
+
+    /** POST /workers/{id} (form) — update a worker's editable fields. */
+    public function update(Request $req, array $params): Response
+    {
+        $actor = SessionGuard::requireAdmin($this->users);
+        if ($actor instanceof Response) {
+            return $actor;
+        }
+        if (!SessionGuard::verifyCsrf($req)) {
+            return Response::text('CSRF token invalid', 403);
+        }
+
+        $id = (int) $params['id'];
+        $existing = $this->workers->find($id);
+        if ($existing === null) {
+            return Response::text('Not Found', 404);
+        }
+
+        $changes = [];
+
+        $name = trim($req->postString('name'));
+        if ($name !== '' && $name !== $existing->name) {
+            $changes['name'] = $name;
+        }
+
+        $rtbRaw = $req->postString('default_rtb');
+        if ($rtbRaw !== '') {
+            $rtb = (float) $rtbRaw;
+            if ($rtb < 0.0 || $rtb > 1.0) {
+                return Response::redirect('/workers?error=rtb_out_of_range');
+            }
+            if (abs($rtb - $existing->defaultRtb) > 1e-9) {
+                $changes['default_rtb'] = $rtb;
+            }
+        }
+
+        // is_active comes via checkbox — absent means "off".
+        $isActive = isset($req->post['is_active'])
+            && (string) $req->post['is_active'] !== '0';
+        if ($isActive !== $existing->isActive) {
+            $changes['is_active'] = $isActive;
+        }
+
+        if ($changes === []) {
+            return Response::redirect('/workers?flash=noop');
+        }
+
+        $this->pdo->beginTransaction();
+        try {
+            $result = $this->workers->update($id, $changes);
+            $this->audit->recordForRequest(
+                action:     'UPDATE',
+                entityType: 'worker',
+                entityId:   $id,
+                before:     $result['before']->toAuditSnapshot(),
+                after:      $result['after']->toAuditSnapshot(),
+                req:        $req,
+                actor:      $actor,
+            );
+            $this->pdo->commit();
+        } catch (PDOException $e) {
+            $this->pdo->rollBack();
+            return Response::redirect('/workers?error=' . urlencode(
+                str_contains(strtolower($e->getMessage()), 'unique') ? 'name_taken' : 'db_error'
+            ));
+        } catch (Throwable) {
+            $this->pdo->rollBack();
+            return Response::redirect('/workers?error=db_error');
+        }
+
+        return Response::redirect('/workers?flash=updated');
+    }
+}

+ 34 - 0
src/Domain/Sprint.php

@@ -0,0 +1,34 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Domain;
+
+final class Sprint
+{
+    public function __construct(
+        public readonly int     $id,
+        public readonly string  $name,
+        public readonly string  $startDate,         // ISO Y-m-d
+        public readonly string  $endDate,           // ISO Y-m-d
+        public readonly float   $reserveFraction,   // 0..1
+        public readonly bool    $isArchived,
+        public readonly string  $createdAt,
+        public readonly string  $updatedAt,
+    ) {
+    }
+
+    public function toAuditSnapshot(): array
+    {
+        return [
+            'id'               => $this->id,
+            'name'             => $this->name,
+            'start_date'       => $this->startDate,
+            'end_date'         => $this->endDate,
+            'reserve_fraction' => $this->reserveFraction,
+            'is_archived'      => $this->isArchived ? 1 : 0,
+            'created_at'       => $this->createdAt,
+            'updated_at'       => $this->updatedAt,
+        ];
+    }
+}

+ 31 - 0
src/Domain/Worker.php

@@ -0,0 +1,31 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Domain;
+
+final class Worker
+{
+    public function __construct(
+        public readonly int     $id,
+        public readonly string  $name,
+        public readonly bool    $isActive,
+        public readonly float   $defaultRtb,
+        public readonly string  $createdAt,
+        public readonly string  $updatedAt,
+    ) {
+    }
+
+    /** Row snapshot for audit JSON. */
+    public function toAuditSnapshot(): array
+    {
+        return [
+            'id'          => $this->id,
+            'name'        => $this->name,
+            'is_active'   => $this->isActive ? 1 : 0,
+            'default_rtb' => $this->defaultRtb,
+            'created_at'  => $this->createdAt,
+            'updated_at'  => $this->updatedAt,
+        ];
+    }
+}

+ 134 - 0
src/Repositories/SprintRepository.php

@@ -0,0 +1,134 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Repositories;
+
+use App\Domain\Sprint;
+use DateTimeImmutable;
+use PDO;
+use RuntimeException;
+
+final class SprintRepository
+{
+    public function __construct(private readonly PDO $pdo)
+    {
+    }
+
+    /**
+     * List sprints with worker + task counts. Newest start first.
+     *
+     * @return list<array{sprint: Sprint, nWorkers: int, nTasks: int}>
+     */
+    public function allWithCounts(): array
+    {
+        $stmt = $this->pdo->query(
+            'SELECT s.*,
+                    (SELECT COUNT(*) FROM sprint_workers WHERE sprint_id = s.id) AS n_workers,
+                    (SELECT COUNT(*) FROM tasks          WHERE sprint_id = s.id) AS n_tasks
+             FROM sprints s
+             ORDER BY s.start_date DESC, s.id DESC'
+        );
+        $out = [];
+        foreach ($stmt as $row) {
+            $out[] = [
+                'sprint'   => self::hydrate($row),
+                'nWorkers' => (int) $row['n_workers'],
+                'nTasks'   => (int) $row['n_tasks'],
+            ];
+        }
+        return $out;
+    }
+
+    public function find(int $id): ?Sprint
+    {
+        $stmt = $this->pdo->prepare('SELECT * FROM sprints WHERE id = ?');
+        $stmt->execute([$id]);
+        $row = $stmt->fetch();
+        return is_array($row) ? self::hydrate($row) : null;
+    }
+
+    /**
+     * Create a sprint row. Caller is responsible for the enclosing transaction
+     * (e.g. to also audit the change and materialise week rows atomically).
+     */
+    public function create(
+        string $name,
+        string $startDate,
+        string $endDate,
+        float $reserveFraction,
+    ): Sprint {
+        $now = gmdate('Y-m-d\TH:i:s\Z');
+        $stmt = $this->pdo->prepare(
+            'INSERT INTO sprints (name, start_date, end_date, reserve_fraction, is_archived, created_at, updated_at)
+             VALUES (?, ?, ?, ?, 0, ?, ?)'
+        );
+        $stmt->execute([$name, $startDate, $endDate, $reserveFraction, $now, $now]);
+
+        $id = (int) $this->pdo->lastInsertId();
+        $sprint = $this->find($id);
+        if ($sprint === null) {
+            throw new RuntimeException('Inserted sprint not found');
+        }
+        return $sprint;
+    }
+
+    /**
+     * Materialise N week rows for a sprint with sensible defaults.
+     *
+     * Returns the inserted rows (before=null, after=row-snapshot) so the caller
+     * can audit each CREATE.
+     *
+     * @return list<array{id:int, sort_order:int, iso_week:int, start_date:string, max_working_days:float}>
+     */
+    public function materializeWeeks(int $sprintId, string $startDate, int $nWeeks): array
+    {
+        if ($nWeeks < 1) {
+            return [];
+        }
+
+        $d0 = DateTimeImmutable::createFromFormat('Y-m-d', $startDate);
+        if ($d0 === false) {
+            throw new RuntimeException("Invalid start_date: {$startDate}");
+        }
+
+        $insert = $this->pdo->prepare(
+            'INSERT INTO sprint_weeks (sprint_id, sort_order, iso_week, start_date, max_working_days)
+             VALUES (?, ?, ?, ?, ?)'
+        );
+
+        $out = [];
+        for ($i = 1; $i <= $nWeeks; $i++) {
+            $weekStart = $d0->modify('+' . ($i - 1) . ' weeks');
+            $iso       = (int) $weekStart->format('W');
+            $ymd       = $weekStart->format('Y-m-d');
+            $insert->execute([$sprintId, $i, $iso, $ymd, 5.0]);
+
+            $out[] = [
+                'id'               => (int) $this->pdo->lastInsertId(),
+                'sort_order'       => $i,
+                'iso_week'         => $iso,
+                'start_date'       => $ymd,
+                'max_working_days' => 5.0,
+            ];
+        }
+        return $out;
+    }
+
+    /**
+     * @param array<string,mixed> $row
+     */
+    private static function hydrate(array $row): Sprint
+    {
+        return new Sprint(
+            id:              (int) $row['id'],
+            name:            (string) $row['name'],
+            startDate:       (string) $row['start_date'],
+            endDate:         (string) $row['end_date'],
+            reserveFraction: (float) $row['reserve_fraction'],
+            isArchived:      ((int) $row['is_archived']) === 1,
+            createdAt:       (string) $row['created_at'],
+            updatedAt:       (string) $row['updated_at'],
+        );
+    }
+}

+ 127 - 0
src/Repositories/WorkerRepository.php

@@ -0,0 +1,127 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Repositories;
+
+use App\Domain\Worker;
+use PDO;
+use RuntimeException;
+
+final class WorkerRepository
+{
+    /** Whitelisted updatable columns. Do not extend without also auditing intent. */
+    private const UPDATABLE = ['name', 'is_active', 'default_rtb'];
+
+    public function __construct(private readonly PDO $pdo)
+    {
+    }
+
+    /** @return list<Worker> */
+    public function all(): array
+    {
+        $stmt = $this->pdo->query(
+            'SELECT * FROM workers ORDER BY is_active DESC, LOWER(name) ASC'
+        );
+        $out = [];
+        foreach ($stmt as $row) {
+            $out[] = self::hydrate($row);
+        }
+        return $out;
+    }
+
+    public function find(int $id): ?Worker
+    {
+        $stmt = $this->pdo->prepare('SELECT * FROM workers WHERE id = ?');
+        $stmt->execute([$id]);
+        $row = $stmt->fetch();
+        return is_array($row) ? self::hydrate($row) : null;
+    }
+
+    public function findByName(string $name): ?Worker
+    {
+        $stmt = $this->pdo->prepare('SELECT * FROM workers WHERE name = ?');
+        $stmt->execute([$name]);
+        $row = $stmt->fetch();
+        return is_array($row) ? self::hydrate($row) : null;
+    }
+
+    /**
+     * Insert a new worker. Throws if the unique name constraint is violated.
+     */
+    public function create(string $name, bool $isActive, float $defaultRtb): Worker
+    {
+        $now = gmdate('Y-m-d\TH:i:s\Z');
+        $stmt = $this->pdo->prepare(
+            'INSERT INTO workers (name, is_active, default_rtb, created_at, updated_at)
+             VALUES (?, ?, ?, ?, ?)'
+        );
+        $stmt->execute([$name, $isActive ? 1 : 0, $defaultRtb, $now, $now]);
+
+        $id = (int) $this->pdo->lastInsertId();
+        $worker = $this->find($id);
+        if ($worker === null) {
+            throw new RuntimeException('Inserted worker not found');
+        }
+        return $worker;
+    }
+
+    /**
+     * Apply the given changes (only whitelisted columns). Returns before/after
+     * snapshots so the caller can drive the AuditLogger.
+     *
+     * @param array<string,mixed> $changes subset of {name, is_active, default_rtb}
+     * @return array{before: Worker, after: Worker}
+     */
+    public function update(int $id, array $changes): array
+    {
+        $before = $this->find($id);
+        if ($before === null) {
+            throw new RuntimeException("Worker {$id} not found");
+        }
+
+        $changes = array_intersect_key($changes, array_flip(self::UPDATABLE));
+        if ($changes === []) {
+            return ['before' => $before, 'after' => $before];
+        }
+
+        $sets = [];
+        $vals = [];
+        foreach ($changes as $col => $v) {
+            $sets[] = "{$col} = ?";
+            if ($col === 'is_active') {
+                $vals[] = (bool) $v ? 1 : 0;
+            } elseif ($col === 'default_rtb') {
+                $vals[] = (float) $v;
+            } else {
+                $vals[] = (string) $v;
+            }
+        }
+        $sets[] = 'updated_at = ?';
+        $vals[] = gmdate('Y-m-d\TH:i:s\Z');
+        $vals[] = $id;
+
+        $stmt = $this->pdo->prepare(
+            'UPDATE workers SET ' . implode(', ', $sets) . ' WHERE id = ?'
+        );
+        $stmt->execute($vals);
+
+        $after = $this->find($id) ?? $before;
+        return ['before' => $before, 'after' => $after];
+    }
+
+    /**
+     * @param array<string,mixed> $row
+     */
+    private static function hydrate(array $row): Worker
+    {
+        return new Worker(
+            id:         (int) $row['id'],
+            name:       (string) $row['name'],
+            isActive:   ((int) $row['is_active']) === 1,
+            defaultRtb: (float) $row['default_rtb'],
+            createdAt:  (string) $row['created_at'],
+            updatedAt:  (string) $row['updated_at'],
+        );
+    }
+}

+ 32 - 0
src/Services/AuditLogger.php

@@ -4,6 +4,8 @@ declare(strict_types=1);
 
 namespace App\Services;
 
+use App\Domain\User;
+use App\Http\Request;
 use JsonException;
 use PDO;
 use RuntimeException;
@@ -65,6 +67,36 @@ final class AuditLogger
         return (int) $this->pdo->lastInsertId();
     }
 
+    /**
+     * Thin wrapper that fills userId / userEmail / ip / user-agent from the
+     * Request + User. Controllers should prefer this over record() to avoid
+     * forgetting request-level metadata.
+     *
+     * @param array<string,mixed>|null $before
+     * @param array<string,mixed>|null $after
+     */
+    public function recordForRequest(
+        string $action,
+        string $entityType,
+        ?int $entityId,
+        ?array $before,
+        ?array $after,
+        Request $req,
+        ?User $actor,
+    ): ?int {
+        return $this->record(
+            action:     $action,
+            entityType: $entityType,
+            entityId:   $entityId,
+            before:     $before,
+            after:      $after,
+            userId:     $actor?->id,
+            userEmail:  $actor?->email,
+            ipAddress:  $req->ip(),
+            userAgent:  $req->userAgent(),
+        );
+    }
+
     private static function isNoOp(string $action, ?array $before, ?array $after): bool
     {
         if ($action !== 'UPDATE') {

+ 74 - 20
views/home.php

@@ -6,7 +6,9 @@
 /** @var bool   $oidcConfigured */
 /** @var bool   $localAdminEnabled */
 /** @var bool   $authError */
+/** @var list<array{sprint: \App\Domain\Sprint, nWorkers:int, nTasks:int}> $sprintRows */
 use function App\Http\e;
+$sprintRows = $sprintRows ?? [];
 ?>
 <section class="space-y-6">
     <?php if ($authError ?? false): ?>
@@ -44,21 +46,76 @@ use function App\Http\e;
             </div>
         </div>
     <?php else: ?>
-        <div>
-            <h1 class="text-2xl font-semibold tracking-tight">
-                Welcome, <?= e($currentUser->displayName) ?>.
-            </h1>
-            <p class="text-slate-600 mt-1">
-                <?= $currentUser->isAdmin
-                    ? 'You have admin rights.'
-                    : 'You are signed in.' ?>
-                Sprints and tasks land in the next phases.
-            </p>
+        <div class="flex items-end justify-between gap-4">
+            <div>
+                <h1 class="text-2xl font-semibold tracking-tight">Sprints</h1>
+                <p class="text-slate-600 mt-1 text-sm">
+                    <?= count($sprintRows) ?> sprint<?= count($sprintRows) === 1 ? '' : 's' ?>.
+                </p>
+            </div>
+            <?php if ($currentUser->isAdmin): ?>
+                <a href="/sprints/new"
+                   class="inline-flex items-center gap-2 rounded-md bg-slate-900 text-white px-4 py-2 text-sm font-medium hover:bg-slate-800">
+                    New sprint
+                </a>
+            <?php endif; ?>
+        </div>
+
+        <div class="rounded-lg border bg-white overflow-hidden">
+            <?php if ($sprintRows === []): ?>
+                <div class="p-8 text-center text-slate-500 text-sm">
+                    No sprints yet.
+                    <?php if ($currentUser->isAdmin): ?>
+                        <a href="/sprints/new" class="text-blue-700 hover:underline">Create the first one</a>.
+                    <?php endif; ?>
+                </div>
+            <?php else: ?>
+                <table class="min-w-full text-sm">
+                    <thead class="bg-slate-50 text-slate-600 text-xs uppercase tracking-wider">
+                        <tr>
+                            <th class="text-left px-4 py-2 font-semibold">Name</th>
+                            <th class="text-left px-4 py-2 font-semibold">Dates</th>
+                            <th class="text-right px-4 py-2 font-semibold">Workers</th>
+                            <th class="text-right px-4 py-2 font-semibold">Tasks</th>
+                            <th class="text-right px-4 py-2 font-semibold">Reserve</th>
+                            <th class="text-left px-4 py-2 font-semibold">Status</th>
+                        </tr>
+                    </thead>
+                    <tbody class="divide-y divide-slate-100">
+                        <?php foreach ($sprintRows as $row): $s = $row['sprint']; ?>
+                            <tr class="hover:bg-slate-50 cursor-pointer"
+                                onclick="location.href='/sprints/<?= (int) $s->id ?>'">
+                                <td class="px-4 py-2 font-medium">
+                                    <a href="/sprints/<?= (int) $s->id ?>" class="hover:underline">
+                                        <?= e($s->name) ?>
+                                    </a>
+                                </td>
+                                <td class="px-4 py-2 text-slate-600">
+                                    <?= e($s->startDate) ?> – <?= e($s->endDate) ?>
+                                </td>
+                                <td class="px-4 py-2 text-right font-mono"><?= (int) $row['nWorkers'] ?></td>
+                                <td class="px-4 py-2 text-right font-mono"><?= (int) $row['nTasks'] ?></td>
+                                <td class="px-4 py-2 text-right font-mono">
+                                    <?= e(number_format($s->reserveFraction * 100, 0)) ?>%
+                                </td>
+                                <td class="px-4 py-2">
+                                    <?php if ($s->isArchived): ?>
+                                        <span class="inline-block px-2 py-0.5 text-xs bg-slate-100 text-slate-600 rounded">archived</span>
+                                    <?php else: ?>
+                                        <span class="inline-block px-2 py-0.5 text-xs bg-green-100 text-green-800 rounded">active</span>
+                                    <?php endif; ?>
+                                </td>
+                            </tr>
+                        <?php endforeach; ?>
+                    </tbody>
+                </table>
+            <?php endif; ?>
         </div>
     <?php endif; ?>
 
-    <div class="rounded-lg border bg-white p-4">
-        <h2 class="text-sm font-semibold text-slate-700 uppercase tracking-wider">Runtime</h2>
+    <?php if ($currentUser === null || $currentUser->isAdmin): ?>
+    <details class="rounded-lg border bg-white p-4">
+        <summary class="text-sm font-semibold text-slate-700 uppercase tracking-wider cursor-pointer">Runtime</summary>
         <dl class="mt-3 grid grid-cols-[max-content_1fr] gap-x-6 gap-y-1 text-sm">
             <dt class="text-slate-500">PHP</dt>
             <dd class="font-mono"><?= e(PHP_VERSION) ?></dd>
@@ -78,12 +135,9 @@ use function App\Http\e;
             <dt class="text-slate-500">Local admin</dt>
             <dd class="font-mono"><?= $localAdminEnabled ? 'enabled' : 'disabled' ?></dd>
         </dl>
-    </div>
-
-    <div class="rounded-lg border bg-white p-4">
-        <h2 class="text-sm font-semibold text-slate-700 uppercase tracking-wider">Health checks</h2>
-        <ul class="mt-3 text-sm space-y-1">
-            <li><a class="text-blue-700 hover:underline" href="/healthz"><code>/healthz</code></a> — liveness probe</li>
-        </ul>
-    </div>
+        <p class="mt-4 text-xs text-slate-500">
+            Liveness probe: <a class="text-blue-700 hover:underline" href="/healthz"><code>/healthz</code></a>
+        </p>
+    </details>
+    <?php endif; ?>
 </section>

+ 6 - 0
views/layout.php

@@ -30,6 +30,12 @@ $csrfToken   = $csrfToken   ?? '';
 
             <nav class="ml-auto flex items-center gap-4 text-sm">
                 <?php if ($currentUser !== null): ?>
+                    <a href="/" class="text-slate-600 hover:text-slate-900 hover:underline">Sprints</a>
+                    <?php if ($currentUser->isAdmin): ?>
+                        <a href="/workers" class="text-slate-600 hover:text-slate-900 hover:underline">Workers</a>
+                        <a href="/sprints/new" class="text-slate-600 hover:text-slate-900 hover:underline">New sprint</a>
+                    <?php endif; ?>
+                    <span class="text-slate-400">·</span>
                     <span class="text-slate-600">
                         <?= e($currentUser->displayName) ?>
                         <?php if ($currentUser->isAdmin): ?>

+ 84 - 0
views/sprints/new.php

@@ -0,0 +1,84 @@
+<?php
+/** @var string $csrfToken */
+/** @var string $error */
+/** @var array{name:string,start_date:string,end_date:string,reserve_fraction:string,n_weeks:string} $form */
+use function App\Http\e;
+
+$errorMessages = [
+    'name_required'         => 'Sprint name is required.',
+    'dates_invalid'         => 'Start and end dates must both be valid dates (YYYY-MM-DD).',
+    'dates_order'           => 'End date must not be before start date.',
+    'reserve_invalid'       => 'Reserve must be a number (0–100).',
+    'reserve_out_of_range'  => 'Reserve must be between 0 and 100 percent.',
+    'n_weeks_invalid'       => 'Weeks must be an integer.',
+    'n_weeks_range'         => 'Weeks must be between 1 and 26.',
+    'db_error'              => 'Could not save. Try again.',
+];
+?>
+<section class="max-w-xl">
+    <h1 class="text-2xl font-semibold tracking-tight">New sprint</h1>
+    <p class="text-slate-600 mt-1 text-sm">
+        Worker membership, weekly availability and tasks are configured on the
+        sprint page after creation.
+    </p>
+
+    <?php if ($error !== '' && isset($errorMessages[$error])): ?>
+        <div class="mt-4 rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
+            <?= e($errorMessages[$error]) ?>
+        </div>
+    <?php endif; ?>
+
+    <form method="post" action="/sprints" class="mt-6 space-y-4 rounded-lg border bg-white p-5">
+        <input type="hidden" name="_csrf" value="<?= e($csrfToken) ?>">
+
+        <label class="block">
+            <span class="text-sm text-slate-700">Name</span>
+            <input name="name" type="text" required
+                   value="<?= e($form['name']) ?>"
+                   placeholder="e.g. Sprint 12"
+                   class="mt-1 block w-full rounded-md border-slate-300 border shadow-sm px-3 py-2 focus:outline-none focus:ring-2 focus:ring-slate-400">
+        </label>
+
+        <div class="grid grid-cols-2 gap-3">
+            <label class="block">
+                <span class="text-sm text-slate-700">Start date</span>
+                <input name="start_date" type="date" required
+                       value="<?= e($form['start_date']) ?>"
+                       class="mt-1 block w-full rounded-md border-slate-300 border shadow-sm px-3 py-2 focus:outline-none focus:ring-2 focus:ring-slate-400">
+            </label>
+            <label class="block">
+                <span class="text-sm text-slate-700">End date</span>
+                <input name="end_date" type="date" required
+                       value="<?= e($form['end_date']) ?>"
+                       class="mt-1 block w-full rounded-md border-slate-300 border shadow-sm px-3 py-2 focus:outline-none focus:ring-2 focus:ring-slate-400">
+            </label>
+        </div>
+
+        <div class="grid grid-cols-2 gap-3">
+            <label class="block">
+                <span class="text-sm text-slate-700">Reserve (%)</span>
+                <input name="reserve_fraction" type="number" min="0" max="100" step="1" required
+                       value="<?= e($form['reserve_fraction']) ?>"
+                       class="mt-1 block w-full rounded-md border-slate-300 border shadow-sm px-3 py-2 font-mono focus:outline-none focus:ring-2 focus:ring-slate-400">
+                <span class="text-xs text-slate-500">Reduction from raw capacity. The Excel uses 20%.</span>
+            </label>
+            <label class="block">
+                <span class="text-sm text-slate-700">Weeks</span>
+                <input name="n_weeks" type="number" min="1" max="26" step="1" required
+                       value="<?= e($form['n_weeks']) ?>"
+                       class="mt-1 block w-full rounded-md border-slate-300 border shadow-sm px-3 py-2 font-mono focus:outline-none focus:ring-2 focus:ring-slate-400">
+                <span class="text-xs text-slate-500">Week rows get 5 days/week by default; edit on the sprint page.</span>
+            </label>
+        </div>
+
+        <div class="flex gap-3 pt-2">
+            <button type="submit"
+                    class="rounded-md bg-slate-900 text-white px-4 py-2 text-sm font-medium hover:bg-slate-800">
+                Create sprint
+            </button>
+            <a href="/" class="inline-flex items-center rounded-md border border-slate-300 bg-white text-slate-700 px-4 py-2 text-sm hover:bg-slate-100">
+                Cancel
+            </a>
+        </div>
+    </form>
+</section>

+ 34 - 0
views/sprints/show.php

@@ -0,0 +1,34 @@
+<?php
+/** @var \App\Domain\Sprint $sprint */
+/** @var \App\Domain\User $currentUser */
+use function App\Http\e;
+?>
+<section class="space-y-6">
+    <div class="flex items-end justify-between gap-4">
+        <div>
+            <nav class="text-xs text-slate-500">
+                <a href="/" class="hover:underline">Sprints</a> /
+            </nav>
+            <h1 class="text-2xl font-semibold tracking-tight"><?= e($sprint->name) ?></h1>
+            <p class="text-slate-600 mt-1 text-sm">
+                <?= e($sprint->startDate) ?> – <?= e($sprint->endDate) ?>
+                · Reserve <?= e(number_format($sprint->reserveFraction * 100, 0)) ?>%
+                <?php if ($sprint->isArchived): ?>
+                    · <span class="inline-block px-2 py-0.5 text-xs bg-slate-100 text-slate-600 rounded">archived</span>
+                <?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="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.
+    </div>
+</section>

+ 112 - 0
views/workers/index.php

@@ -0,0 +1,112 @@
+<?php
+/** @var list<\App\Domain\Worker> $workers */
+/** @var string $csrfToken */
+/** @var string $flash */
+/** @var string $error */
+use function App\Http\e;
+
+$errorMessages = [
+    'name_required'     => 'Worker name is required.',
+    'name_taken'        => 'That name is already in use.',
+    'rtb_out_of_range'  => 'RTB must be between 0.0 and 1.0.',
+    'db_error'          => 'Could not save. Try again.',
+];
+$flashMessages = [
+    'created' => 'Worker created.',
+    'updated' => 'Saved.',
+    'noop'    => 'Nothing changed.',
+];
+?>
+<section class="space-y-6">
+    <div>
+        <h1 class="text-2xl font-semibold tracking-tight">Workers</h1>
+        <p class="text-slate-600 mt-1 text-sm max-w-prose">
+            Master data for the people tasks get assigned to. Workers are not the
+            same as users &mdash; a worker doesn't have to ever sign in. To remove
+            someone, toggle them inactive rather than deleting.
+        </p>
+    </div>
+
+    <?php if ($error !== '' && isset($errorMessages[$error])): ?>
+        <div class="rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
+            <?= e($errorMessages[$error]) ?>
+        </div>
+    <?php endif; ?>
+    <?php if ($flash !== '' && isset($flashMessages[$flash])): ?>
+        <div class="rounded-md border border-green-200 bg-green-50 px-4 py-3 text-sm text-green-800">
+            <?= e($flashMessages[$flash]) ?>
+        </div>
+    <?php endif; ?>
+
+    <!-- Add worker -->
+    <div class="rounded-lg border bg-white p-4">
+        <h2 class="text-sm font-semibold text-slate-700 uppercase tracking-wider">Add worker</h2>
+        <form method="post" action="/workers" class="mt-3 flex flex-wrap items-end gap-3">
+            <input type="hidden" name="_csrf" value="<?= e($csrfToken) ?>">
+            <label class="flex-1 min-w-[12rem]">
+                <span class="text-xs text-slate-600">Name</span>
+                <input name="name" type="text" required
+                       class="mt-1 block w-full rounded-md border-slate-300 border shadow-sm px-3 py-2 focus:outline-none focus:ring-2 focus:ring-slate-400">
+            </label>
+            <label class="w-36">
+                <span class="text-xs text-slate-600">Default RTB (0–1)</span>
+                <input name="default_rtb" type="number" min="0" max="1" step="0.05" value="0.00"
+                       class="mt-1 block w-full rounded-md border-slate-300 border shadow-sm px-3 py-2 focus:outline-none focus:ring-2 focus:ring-slate-400">
+            </label>
+            <button type="submit"
+                    class="rounded-md bg-slate-900 text-white px-4 py-2 text-sm font-medium hover:bg-slate-800">
+                Add
+            </button>
+        </form>
+    </div>
+
+    <!-- Workers table -->
+    <div class="rounded-lg border bg-white overflow-hidden">
+        <?php if ($workers === []): ?>
+            <div class="p-8 text-center text-slate-500 text-sm">No workers yet.</div>
+        <?php else: ?>
+            <table class="min-w-full text-sm">
+                <thead class="bg-slate-50 text-slate-600 text-xs uppercase tracking-wider">
+                    <tr>
+                        <th class="text-left px-4 py-2 font-semibold">Name</th>
+                        <th class="text-left px-4 py-2 font-semibold">Default RTB</th>
+                        <th class="text-left px-4 py-2 font-semibold">Active</th>
+                        <th class="text-right px-4 py-2 font-semibold">&nbsp;</th>
+                    </tr>
+                </thead>
+                <tbody class="divide-y divide-slate-100">
+                    <?php foreach ($workers as $w): ?>
+                        <tr class="<?= $w->isActive ? '' : 'opacity-60' ?>">
+                            <form method="post" action="/workers/<?= (int) $w->id ?>" class="contents">
+                                <input type="hidden" name="_csrf" value="<?= e($csrfToken) ?>">
+                                <td class="px-4 py-2">
+                                    <input name="name" type="text" required
+                                           value="<?= e($w->name) ?>"
+                                           class="w-full rounded-md border-slate-300 border shadow-sm px-2 py-1 focus:outline-none focus:ring-2 focus:ring-slate-400">
+                                </td>
+                                <td class="px-4 py-2 w-32">
+                                    <input name="default_rtb" type="number" min="0" max="1" step="0.05"
+                                           value="<?= e(number_format($w->defaultRtb, 2, '.', '')) ?>"
+                                           class="w-full rounded-md border-slate-300 border shadow-sm px-2 py-1 font-mono focus:outline-none focus:ring-2 focus:ring-slate-400">
+                                </td>
+                                <td class="px-4 py-2">
+                                    <label class="inline-flex items-center gap-2">
+                                        <input name="is_active" type="checkbox" value="1" <?= $w->isActive ? 'checked' : '' ?>
+                                               class="rounded border-slate-300">
+                                        <span class="text-slate-600">active</span>
+                                    </label>
+                                </td>
+                                <td class="px-4 py-2 text-right">
+                                    <button type="submit"
+                                            class="rounded-md border border-slate-300 bg-white text-slate-700 px-3 py-1 text-sm hover:bg-slate-100">
+                                        Save
+                                    </button>
+                                </td>
+                            </form>
+                        </tr>
+                    <?php endforeach; ?>
+                </tbody>
+            </table>
+        <?php endif; ?>
+    </div>
+</section>