Prechádzať zdrojové kódy

Phase 12: per-week weekday selection (Mo–Fr) drives Arbeitstage

The Arbeitstage header on /sprints/{id} was a free-form numeric input per
week (0–5, step 0.5). In practice the team thinks "which weekdays are
workdays this week?" — a holiday on Friday means 4 workdays, but the
system had no record of *which* day was dropped, and 3.5-style inputs
were lossy between two admins looking at the same row.

This phase switches the source of truth to a 5-bit weekday selection
(Mo Di Mi Do Fr) that admins tick in Sprint Settings. The per-week
Arbeitstage shown on the sprint page is now the popcount of that mask —
read-only, not a text input.

Schema (migration 002):
- sprint_weeks gains active_days_mask INTEGER NOT NULL DEFAULT 31.
  Bit layout: bit0=Mo, bit1=Di, bit2=Mi, bit3=Do, bit4=Fr
  (31 = 0b11111 → Mon–Fri all active).
- Backfill clamps existing max_working_days to the nearest integer in
  0..5 (half-days round up) and lights up that many leading bits.
  max_working_days is rewritten to equal popcount(mask), so the two
  columns are never out of sync going forward.

Domain (SprintWeek):
- New activeDaysMask field and DAY_LABELS / MASK_ALL constants.
- Pure helpers: popcount, maskToDays, daysToMask (throws on unknown or
  duplicate labels), hasDay, withMask (used in tests for copies).
- toAuditSnapshot now emits both active_days_mask and max_working_days
  so audit diffs surface the mask change *and* its derived count.

Repository:
- SprintWeekRepository::setMaxWorkingDays replaced by updateActiveDays,
  which writes both columns in one UPDATE with popcount server-side —
  there's no caller that can desync them.
- Hydrate reads active_days_mask; defaults to MASK_ALL if the column is
  somehow missing (migration not yet run).
- New-week paths (materializeWeeks, syncCount) seed mask=31, days=5.

Controller + routes:
- PATCH /sprints/{id}/week/{week_id} now accepts either
  {"active_days_mask": 15} or {"active_days": ["Mo","Di","Mi","Do"]}.
  max_working_days in the body is rejected. 422 on mask > 31, unknown
  day labels, or duplicate days. Audit row emitted as before, through
  the usual recordForRequest path.
- Endpoint renamed updateWeekMax → updateWeekDays; the path is
  unchanged so there's no URL break.

Views:
- Settings /sprints/{id}/settings: the "Max days" column is replaced by
  five checkboxes per row (Mo Di Mi Do Fr) plus a derived Arbeitstage
  count. sprint-settings.js wires change handlers with a 250 ms per-row
  debounce so a burst of clicks batches into one PATCH.
- Sprint /sprints/{id}: the Arbeitstage row is now a read-only <span>
  with a title attribute showing the active day labels. Admins get an
  (edit) link to jump to settings. The sum column is computed
  server-side; there is no longer a JS path to recompute it live.

JS cleanup in sprint-planner.js:
- Removed the [data-week-max] change handler, recomputeSumMax, and its
  boot call — the Arbeitstage row can no longer be edited from this
  page, so there's nothing to recompute.

Tests: +14 over 3 new files.
- tests/Domain/SprintWeekTest.php — mask/days round-trip, popcount,
  duplicate/unknown day rejection, hasDay bit math, withMask recompute,
  audit snapshot contains mask.
- tests/Repositories/SprintWeekRepositoryTest.php — updateActiveDays
  writes both columns and clamps out-of-range bits; materializeWeeks
  and syncCount default to mask=31 / days=5.
- tests/Db/MigrationBackfillTest.php — DataProvider-style matrix over
  0.0 / 0.5 / 1.0 / 2.5 / 3.0 / 4.5 / 5.0 legacy values, confirming
  each maps to the right mask and clamped count after 002 runs.
- tests/TestCase.php now applies every migrations/NNN_*.sql in order
  (not just 001), matching what the production Migrator does; future
  migrations won't need a per-test update.

Smoke tests:
- php -l on every touched PHP file.
- vendor/bin/phpunit: 88 tests, 208 assertions (was 74, 138).
- grep confirms no remaining references to setMaxWorkingDays /
  updateWeekMax / data-week-max / recomputeSumMax in source.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
achiappa 2 týždňov pred
rodič
commit
a6345821bf

+ 31 - 0
migrations/002_sprint_week_active_days.sql

@@ -0,0 +1,31 @@
+-- Phase 12: per-week weekday selection drives Arbeitstage.
+--
+-- Admins pick which of Mo/Di/Mi/Do/Fr are workdays for each week. The count
+-- of selected days (0..5) replaces the previously free-form
+-- max_working_days number input on the sprint view.
+--
+-- Bit layout of active_days_mask:
+--   bit 0 = Mo, bit 1 = Di, bit 2 = Mi, bit 3 = Do, bit 4 = Fr
+-- Default 31 = 0b11111 → Mo–Fr all active.
+--
+-- Backfill rule: clamp existing max_working_days to the nearest integer in
+-- 0..5 (rounding .5 up) and light up that many leading bits. Half-days go
+-- away at the week level; per-worker day cells still carry 0.5 granularity.
+
+ALTER TABLE sprint_weeks
+    ADD COLUMN active_days_mask INTEGER NOT NULL DEFAULT 31;
+
+-- Backfill existing rows. CAST(x+0.5 AS INTEGER) is SQLite's "round half up"
+-- for non-negative x; clamp into 0..5 and derive a mask with the first N
+-- bits set: (1 << N) - 1.
+UPDATE sprint_weeks
+SET active_days_mask = CASE
+        WHEN CAST(max_working_days + 0.5 AS INTEGER) <= 0 THEN 0
+        WHEN CAST(max_working_days + 0.5 AS INTEGER) >= 5 THEN 31
+        ELSE (1 << CAST(max_working_days + 0.5 AS INTEGER)) - 1
+    END,
+    max_working_days = CASE
+        WHEN CAST(max_working_days + 0.5 AS INTEGER) <= 0 THEN 0
+        WHEN CAST(max_working_days + 0.5 AS INTEGER) >= 5 THEN 5
+        ELSE CAST(max_working_days + 0.5 AS INTEGER)
+    END;

+ 2 - 33
public/assets/js/sprint-planner.js

@@ -5,8 +5,8 @@
  * 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}.
+ * - The Arbeitstage header row is derived from the weekday selection in
+ *   Sprint Settings — it's read-only here (Phase 12).
  * - 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.
@@ -154,15 +154,6 @@
         });
     }
 
-    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
     // ---------------------------------------------------------------------
@@ -233,27 +224,6 @@
         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
     // ---------------------------------------------------------------------
@@ -830,7 +800,6 @@
     $root.find('[data-sw-row]').each(function () {
         recomputeRow(parseInt($(this).data('sw-id'), 10));
     });
-    recomputeSumMax();
 
     // Restore persisted task-list UI state BEFORE applyFilters so hidden
     // columns don't briefly flash in before being toggled off.

+ 50 - 0
public/assets/js/sprint-settings.js

@@ -114,6 +114,56 @@
             .catch(function (e) { flash(e.message, true); });
     });
 
+    // ---------------------------------------------------------------------
+    // Per-week weekday checkboxes (Phase 12)
+    //
+    // Each row carries five [data-day-toggle] boxes. On any change we rebuild
+    // the row's mask from all five and send it in one PATCH — no debounce on
+    // per-checkbox granularity (each click is one state change), but we do
+    // delay per-row in case the user ticks several in quick succession.
+    // ---------------------------------------------------------------------
+
+    const weekDebounce = {};
+
+    function maskFromRow($row) {
+        let mask = 0;
+        $row.find('[data-day-toggle]').each(function () {
+            if ($(this).is(':checked')) {
+                const bit = parseInt($(this).data('bit'), 10);
+                if (Number.isInteger(bit)) { mask |= (1 << bit); }
+            }
+        });
+        return mask;
+    }
+
+    function popcount5(mask) {
+        let n = 0;
+        for (let i = 0; i < 5; i++) { if ((mask >> i) & 1) { n++; } }
+        return n;
+    }
+
+    $root.on('change', '[data-day-toggle]', function () {
+        const $row   = $(this).closest('[data-week-row]');
+        const weekId = parseInt($row.data('week-id'), 10);
+        const mask   = maskFromRow($row);
+
+        // Optimistic local update: derived count flips immediately.
+        $row.find('[data-week-count]').text(String(popcount5(mask)));
+
+        clearTimeout(weekDebounce[weekId]);
+        weekDebounce[weekId] = setTimeout(function () {
+            request('PATCH', '/sprints/' + sprintId + '/week/' + weekId, { active_days_mask: mask })
+                .then(function (data) {
+                    if (data && data.sprint_week) {
+                        $row.find('[data-week-count]')
+                            .text(String(data.sprint_week.max_working_days));
+                    }
+                    flash('Saved');
+                })
+                .catch(function (e) { flash(e.message, true); });
+        }, 250);
+    });
+
     // ---------------------------------------------------------------------
     // Worker picker
     // ---------------------------------------------------------------------

+ 1 - 1
public/index.php

@@ -163,7 +163,7 @@ $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(...));
+$router->patch('/sprints/{id}/week/{week_id}',        $sprintCtrl->updateWeekDays(...));
 
 // Phase 6 — Task list:
 $router->get('/audit',                                $auditCtrl->index(...));

+ 49 - 10
src/Controllers/SprintController.php

@@ -5,10 +5,12 @@ declare(strict_types=1);
 namespace App\Controllers;
 
 use App\Auth\SessionGuard;
+use App\Domain\SprintWeek;
 use App\Domain\User;
 use App\Http\Request;
 use App\Http\Response;
 use App\Http\View;
+use InvalidArgumentException;
 use App\Repositories\SprintRepository;
 use App\Repositories\SprintWeekRepository;
 use App\Repositories\SprintWorkerDayRepository;
@@ -721,8 +723,13 @@ final class SprintController
         ]);
     }
 
-    /** PATCH /sprints/{id}/week/{week_id} — JSON — edit max_working_days for one week. */
-    public function updateWeekMax(Request $req, array $params): Response
+    /**
+     * PATCH /sprints/{id}/week/{week_id} — JSON — set the active weekdays for
+     * one week. Accepts either `{"active_days_mask": 15}` or
+     * `{"active_days": ["Mo", "Di", "Mi", "Do"]}`; max_working_days is
+     * derived server-side as popcount(mask).
+     */
+    public function updateWeekDays(Request $req, array $params): Response
     {
         $gate = $this->gateJsonAdmin($req);
         if ($gate instanceof Response) {
@@ -739,21 +746,53 @@ final class SprintController
         }
 
         $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);
+        $mask = null;
+
+        if (array_key_exists('active_days_mask', $body)) {
+            $raw = $body['active_days_mask'];
+            if (!is_int($raw) || $raw < 0 || $raw > SprintWeek::MASK_ALL) {
+                return Response::err(
+                    'validation',
+                    'active_days_mask must be an integer 0..31',
+                    422,
+                    ['field' => 'active_days_mask'],
+                );
+            }
+            $mask = $raw;
+        } elseif (array_key_exists('active_days', $body)) {
+            if (!is_array($body['active_days']) || !array_is_list($body['active_days'])) {
+                return Response::err(
+                    'validation',
+                    'active_days must be a list of Mo/Di/Mi/Do/Fr',
+                    422,
+                    ['field' => 'active_days'],
+                );
+            }
+            try {
+                $mask = SprintWeek::daysToMask($body['active_days']);
+            } catch (InvalidArgumentException $e) {
+                return Response::err(
+                    'validation',
+                    $e->getMessage(),
+                    422,
+                    ['field' => 'active_days'],
+                );
+            }
+        } else {
+            return Response::err(
+                'validation',
+                'one of active_days_mask or active_days required',
+                422,
+            );
         }
 
-        if (abs($week->maxWorkingDays - $maxDays) < 1e-9) {
+        if ($week->activeDaysMask === $mask) {
             return Response::ok(['sprint_week' => $week->toAuditSnapshot()]);
         }
 
         $this->pdo->beginTransaction();
         try {
-            $result = $this->weeks->setMaxWorkingDays($weekId, $maxDays);
+            $result = $this->weeks->updateActiveDays($weekId, $mask);
             $this->audit->recordForRequest(
                 'UPDATE', 'sprint_week', $weekId,
                 $result['before']->toAuditSnapshot(),

+ 100 - 6
src/Domain/SprintWeek.php

@@ -4,8 +4,18 @@ declare(strict_types=1);
 
 namespace App\Domain;
 
+use InvalidArgumentException;
+
 final class SprintWeek
 {
+    /**
+     * Ordered list of German two-letter day labels mapped to bit positions.
+     * The order is load-bearing: `array_search` gives the bit index.
+     */
+    public const DAY_LABELS = ['Mo', 'Di', 'Mi', 'Do', 'Fr'];
+
+    public const MASK_ALL = 31; // 0b11111
+
     public function __construct(
         public readonly int    $id,
         public readonly int    $sprintId,
@@ -13,18 +23,102 @@ final class SprintWeek
         public readonly int    $isoWeek,
         public readonly string $startDate,
         public readonly float  $maxWorkingDays,
+        public readonly int    $activeDaysMask,
     ) {
     }
 
     public function toAuditSnapshot(): array
     {
         return [
-            'id'               => $this->id,
-            'sprint_id'        => $this->sprintId,
-            'sort_order'       => $this->sortOrder,
-            'iso_week'         => $this->isoWeek,
-            'start_date'       => $this->startDate,
-            'max_working_days' => $this->maxWorkingDays,
+            'id'                => $this->id,
+            'sprint_id'         => $this->sprintId,
+            'sort_order'        => $this->sortOrder,
+            'iso_week'          => $this->isoWeek,
+            'start_date'        => $this->startDate,
+            'max_working_days'  => $this->maxWorkingDays,
+            'active_days_mask'  => $this->activeDaysMask,
         ];
     }
+
+    /** @return list<string> e.g. ['Mo', 'Di', 'Do'] in canonical Mo→Fr order. */
+    public function activeDays(): array
+    {
+        return self::maskToDays($this->activeDaysMask);
+    }
+
+    public function hasDay(string $twoLetter): bool
+    {
+        $bit = array_search($twoLetter, self::DAY_LABELS, true);
+        if ($bit === false) {
+            return false;
+        }
+        return (($this->activeDaysMask >> $bit) & 1) === 1;
+    }
+
+    /** Convenience for building updated copies in tests. */
+    public function withMask(int $mask): self
+    {
+        return new self(
+            id:             $this->id,
+            sprintId:       $this->sprintId,
+            sortOrder:      $this->sortOrder,
+            isoWeek:        $this->isoWeek,
+            startDate:      $this->startDate,
+            maxWorkingDays: (float) self::popcount($mask),
+            activeDaysMask: $mask & self::MASK_ALL,
+        );
+    }
+
+    // -------------------------------------------------------------------
+    // Pure helpers — shared with the controller.
+    // -------------------------------------------------------------------
+
+    public static function popcount(int $mask): int
+    {
+        $mask &= self::MASK_ALL;
+        $n = 0;
+        for ($i = 0; $i < 5; $i++) {
+            if ((($mask >> $i) & 1) === 1) {
+                $n++;
+            }
+        }
+        return $n;
+    }
+
+    /** @return list<string> */
+    public static function maskToDays(int $mask): array
+    {
+        $out = [];
+        foreach (self::DAY_LABELS as $i => $label) {
+            if ((($mask >> $i) & 1) === 1) {
+                $out[] = $label;
+            }
+        }
+        return $out;
+    }
+
+    /**
+     * @param iterable<mixed> $days any iterable of strings
+     * @throws InvalidArgumentException on unknown label or duplicate
+     */
+    public static function daysToMask(iterable $days): int
+    {
+        $mask = 0;
+        $seen = [];
+        foreach ($days as $raw) {
+            if (!is_string($raw)) {
+                throw new InvalidArgumentException('active_days entries must be strings');
+            }
+            $bit = array_search($raw, self::DAY_LABELS, true);
+            if ($bit === false) {
+                throw new InvalidArgumentException("Unknown weekday label: {$raw}");
+            }
+            if (isset($seen[$bit])) {
+                throw new InvalidArgumentException("Duplicate weekday: {$raw}");
+            }
+            $seen[$bit] = true;
+            $mask |= (1 << $bit);
+        }
+        return $mask;
+    }
 }

+ 7 - 4
src/Repositories/SprintRepository.php

@@ -5,6 +5,7 @@ declare(strict_types=1);
 namespace App\Repositories;
 
 use App\Domain\Sprint;
+use App\Domain\SprintWeek;
 use DateTimeImmutable;
 use PDO;
 use RuntimeException;
@@ -123,7 +124,7 @@ final class SprintRepository
      * 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}>
+     * @return list<array{id:int, sort_order:int, iso_week:int, start_date:string, max_working_days:float, active_days_mask:int}>
      */
     public function materializeWeeks(int $sprintId, string $startDate, int $nWeeks): array
     {
@@ -137,8 +138,9 @@ final class SprintRepository
         }
 
         $insert = $this->pdo->prepare(
-            'INSERT INTO sprint_weeks (sprint_id, sort_order, iso_week, start_date, max_working_days)
-             VALUES (?, ?, ?, ?, ?)'
+            'INSERT INTO sprint_weeks
+             (sprint_id, sort_order, iso_week, start_date, max_working_days, active_days_mask)
+             VALUES (?, ?, ?, ?, ?, ?)'
         );
 
         $out = [];
@@ -146,7 +148,7 @@ final class SprintRepository
             $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]);
+            $insert->execute([$sprintId, $i, $iso, $ymd, 5.0, SprintWeek::MASK_ALL]);
 
             $out[] = [
                 'id'               => (int) $this->pdo->lastInsertId(),
@@ -154,6 +156,7 @@ final class SprintRepository
                 'iso_week'         => $iso,
                 'start_date'       => $ymd,
                 'max_working_days' => 5.0,
+                'active_days_mask' => SprintWeek::MASK_ALL,
             ];
         }
         return $out;

+ 15 - 8
src/Repositories/SprintWeekRepository.php

@@ -38,19 +38,23 @@ final class SprintWeekRepository
     }
 
     /**
-     * Update max_working_days on one week. Returns before/after for auditing.
+     * Update the weekday selection mask for a week. `max_working_days` is
+     * recomputed as `popcount(mask)` and written atomically with the mask —
+     * the two columns are never out of sync.
      *
      * @return array{before: SprintWeek, after: SprintWeek}
      */
-    public function setMaxWorkingDays(int $weekId, float $maxDays): array
+    public function updateActiveDays(int $weekId, int $mask): array
     {
         $before = $this->find($weekId);
         if ($before === null) {
-            throw new \RuntimeException("sprint_week {$weekId} not found");
+            throw new RuntimeException("sprint_week {$weekId} not found");
         }
+        $mask &= SprintWeek::MASK_ALL;
+        $count = (float) SprintWeek::popcount($mask);
         $this->pdo
-            ->prepare('UPDATE sprint_weeks SET max_working_days = ? WHERE id = ?')
-            ->execute([$maxDays, $weekId]);
+            ->prepare('UPDATE sprint_weeks SET active_days_mask = ?, max_working_days = ? WHERE id = ?')
+            ->execute([$mask, $count, $weekId]);
         $after = $this->find($weekId) ?? $before;
         return ['before' => $before, 'after' => $after];
     }
@@ -101,15 +105,16 @@ final class SprintWeekRepository
         }
 
         $insert = $this->pdo->prepare(
-            'INSERT INTO sprint_weeks (sprint_id, sort_order, iso_week, start_date, max_working_days)
-             VALUES (?, ?, ?, ?, ?)'
+            'INSERT INTO sprint_weeks
+             (sprint_id, sort_order, iso_week, start_date, max_working_days, active_days_mask)
+             VALUES (?, ?, ?, ?, ?, ?)'
         );
         $added = [];
         for ($i = $currentCount + 1; $i <= $targetCount; $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]);
+            $insert->execute([$sprintId, $i, $iso, $ymd, 5.0, SprintWeek::MASK_ALL]);
             $added[] = new SprintWeek(
                 id:              (int) $this->pdo->lastInsertId(),
                 sprintId:        $sprintId,
@@ -117,6 +122,7 @@ final class SprintWeekRepository
                 isoWeek:         $iso,
                 startDate:       $ymd,
                 maxWorkingDays:  5.0,
+                activeDaysMask:  SprintWeek::MASK_ALL,
             );
         }
         return ['added' => $added, 'removed' => []];
@@ -134,6 +140,7 @@ final class SprintWeekRepository
             isoWeek:        (int)    $row['iso_week'],
             startDate:      (string) $row['start_date'],
             maxWorkingDays: (float)  $row['max_working_days'],
+            activeDaysMask: (int)    ($row['active_days_mask'] ?? SprintWeek::MASK_ALL),
         );
     }
 }

+ 96 - 0
tests/Db/MigrationBackfillTest.php

@@ -0,0 +1,96 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Tests\Db;
+
+use PDO;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * Phase 12: verify migration 002 backfills active_days_mask from the legacy
+ * free-form max_working_days values, and clamps half-day values to whole
+ * integers in 0..5 without dropping rows or widening beyond Mo–Fr.
+ *
+ * We start from a 001-only schema (no active_days_mask column), hand-insert
+ * a row per legacy value we care about, then apply 002 and read back.
+ */
+final class MigrationBackfillTest extends TestCase
+{
+    private function makeLegacyDb(): PDO
+    {
+        $pdo = new PDO('sqlite::memory:', null, null, [
+            PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
+            PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
+            PDO::ATTR_EMULATE_PREPARES   => false,
+        ]);
+        $pdo->exec('PRAGMA foreign_keys = ON');
+
+        $sql = file_get_contents(__DIR__ . '/../../migrations/001_init.sql');
+        $this->assertNotFalse($sql, 'read 001_init.sql');
+        $pdo->exec($sql);
+
+        return $pdo;
+    }
+
+    /** @return list<array{legacyDays:float, expectedMask:int, expectedDays:float}> */
+    public static function cases(): array
+    {
+        return [
+            // whole numbers 0..5 stay as themselves
+            ['legacyDays' => 0.0, 'expectedMask' => 0,     'expectedDays' => 0.0],
+            ['legacyDays' => 1.0, 'expectedMask' => 0b1,   'expectedDays' => 1.0],
+            ['legacyDays' => 3.0, 'expectedMask' => 0b111, 'expectedDays' => 3.0],
+            ['legacyDays' => 5.0, 'expectedMask' => 31,    'expectedDays' => 5.0],
+            // half-days round up (2.5 → 3, 4.5 → 5)
+            ['legacyDays' => 2.5, 'expectedMask' => 0b111, 'expectedDays' => 3.0],
+            ['legacyDays' => 4.5, 'expectedMask' => 31,    'expectedDays' => 5.0],
+            // 0.5 rounds up to 1
+            ['legacyDays' => 0.5, 'expectedMask' => 0b1,   'expectedDays' => 1.0],
+        ];
+    }
+
+    public function testBackfillMapsLegacyValuesToMask(): void
+    {
+        $pdo = $this->makeLegacyDb();
+
+        // Seed a sprint + one week per case.
+        $pdo->exec("INSERT INTO sprints (id, name, start_date, end_date, reserve_fraction, is_archived, created_at, updated_at)
+                    VALUES (1, 'S', '2026-01-05', '2026-03-30', 0.2, 0, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')");
+
+        $insertWeek = $pdo->prepare(
+            'INSERT INTO sprint_weeks (id, sprint_id, sort_order, iso_week, start_date, max_working_days)
+             VALUES (?, 1, ?, ?, ?, ?)'
+        );
+        $cases = self::cases();
+        foreach ($cases as $i => $c) {
+            $insertWeek->execute([
+                $i + 1, $i + 1, 1, '2026-01-05', $c['legacyDays'],
+            ]);
+        }
+
+        // Apply migration 002.
+        $sql = file_get_contents(__DIR__ . '/../../migrations/002_sprint_week_active_days.sql');
+        $this->assertNotFalse($sql, 'read 002');
+        $pdo->exec($sql);
+
+        // The column now exists; legacy rows were rewritten.
+        $rows = $pdo->query('SELECT id, max_working_days, active_days_mask FROM sprint_weeks ORDER BY id')->fetchAll();
+        $this->assertCount(count($cases), $rows);
+
+        foreach ($cases as $i => $c) {
+            $row = $rows[$i];
+            $this->assertSame(
+                $c['expectedMask'],
+                (int) $row['active_days_mask'],
+                "mask for legacy={$c['legacyDays']} (row id={$row['id']})",
+            );
+            $this->assertEqualsWithDelta(
+                $c['expectedDays'],
+                (float) $row['max_working_days'],
+                1e-9,
+                "days for legacy={$c['legacyDays']}",
+            );
+        }
+    }
+}

+ 94 - 0
tests/Domain/SprintWeekTest.php

@@ -0,0 +1,94 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Tests\Domain;
+
+use App\Domain\SprintWeek;
+use InvalidArgumentException;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * Phase 12: SprintWeek's weekday helpers. Pure logic — no DB.
+ */
+final class SprintWeekTest extends TestCase
+{
+    public function testPopcountCoversAllFiveBits(): void
+    {
+        $this->assertSame(0, SprintWeek::popcount(0));
+        $this->assertSame(5, SprintWeek::popcount(31));
+        $this->assertSame(1, SprintWeek::popcount(1));
+        $this->assertSame(4, SprintWeek::popcount(15));
+        // bits outside the 5-day range are clamped away
+        $this->assertSame(5, SprintWeek::popcount(0xFFFF));
+    }
+
+    public function testMaskToDaysReturnsCanonicalOrder(): void
+    {
+        $this->assertSame(['Mo', 'Di', 'Mi', 'Do', 'Fr'], SprintWeek::maskToDays(31));
+        $this->assertSame(['Mo', 'Fr'],                   SprintWeek::maskToDays(0b10001));
+        $this->assertSame([],                             SprintWeek::maskToDays(0));
+    }
+
+    public function testDaysToMaskRoundTripsBothDirections(): void
+    {
+        foreach ([0, 1, 2, 7, 15, 17, 31] as $m) {
+            $days = SprintWeek::maskToDays($m);
+            $this->assertSame($m, SprintWeek::daysToMask($days), "round-trip for mask={$m}");
+        }
+    }
+
+    public function testDaysToMaskRejectsUnknownLabel(): void
+    {
+        $this->expectException(InvalidArgumentException::class);
+        SprintWeek::daysToMask(['Mo', 'Sa']); // Sa not in Mo–Fr set
+    }
+
+    public function testDaysToMaskRejectsDuplicates(): void
+    {
+        $this->expectException(InvalidArgumentException::class);
+        SprintWeek::daysToMask(['Mo', 'Mo']);
+    }
+
+    public function testHasDayMatchesMaskBit(): void
+    {
+        $w = new SprintWeek(
+            id: 1, sprintId: 1, sortOrder: 1, isoWeek: 1,
+            startDate: '2026-01-05', maxWorkingDays: 3.0,
+            activeDaysMask: 0b00111, // Mo Di Mi
+        );
+        $this->assertTrue($w->hasDay('Mo'));
+        $this->assertTrue($w->hasDay('Mi'));
+        $this->assertFalse($w->hasDay('Do'));
+        $this->assertFalse($w->hasDay('Fr'));
+        // Unknown label is a miss, not an error.
+        $this->assertFalse($w->hasDay('Sa'));
+    }
+
+    public function testWithMaskRecomputesMaxWorkingDays(): void
+    {
+        $w = new SprintWeek(
+            id: 1, sprintId: 1, sortOrder: 1, isoWeek: 1,
+            startDate: '2026-01-05', maxWorkingDays: 5.0,
+            activeDaysMask: 31,
+        );
+        $dropped = $w->withMask(0b01111); // Mo Di Mi Do
+        $this->assertSame(15, $dropped->activeDaysMask);
+        $this->assertSame(4.0, $dropped->maxWorkingDays);
+        $this->assertSame(['Mo', 'Di', 'Mi', 'Do'], $dropped->activeDays());
+        // Bits outside Mo–Fr are trimmed.
+        $this->assertSame(31, $w->withMask(0xFF)->activeDaysMask);
+    }
+
+    public function testAuditSnapshotIncludesMask(): void
+    {
+        $w = new SprintWeek(
+            id: 77, sprintId: 9, sortOrder: 2, isoWeek: 8,
+            startDate: '2026-02-16', maxWorkingDays: 2.0,
+            activeDaysMask: 0b00011,
+        );
+        $snap = $w->toAuditSnapshot();
+        $this->assertSame(0b00011, $snap['active_days_mask']);
+        $this->assertSame(2.0,     $snap['max_working_days']);
+    }
+}

+ 94 - 0
tests/Repositories/SprintWeekRepositoryTest.php

@@ -0,0 +1,94 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Tests\Repositories;
+
+use App\Domain\SprintWeek;
+use App\Repositories\SprintRepository;
+use App\Repositories\SprintWeekRepository;
+use App\Tests\TestCase;
+
+/**
+ * Phase 12: per-week weekday selection drives Arbeitstage.
+ */
+final class SprintWeekRepositoryTest extends TestCase
+{
+    /** @return array{SprintRepository, SprintWeekRepository, int} */
+    private function seedSprint(): array
+    {
+        $pdo     = $this->makeDb();
+        $sprints = new SprintRepository($pdo);
+        $weeks   = new SprintWeekRepository($pdo);
+
+        $sprint = $sprints->create('S', '2026-01-05', '2026-01-30', 0.2);
+        $sprints->materializeWeeks($sprint->id, '2026-01-05', 4);
+
+        return [$sprints, $weeks, $sprint->id];
+    }
+
+    public function testMaterializeWeeksDefaultsToAllFiveDays(): void
+    {
+        [, $weeks, $sprintId] = $this->seedSprint();
+
+        $rows = $weeks->allForSprint($sprintId);
+        $this->assertCount(4, $rows);
+        foreach ($rows as $w) {
+            $this->assertSame(SprintWeek::MASK_ALL, $w->activeDaysMask);
+            $this->assertSame(5.0, $w->maxWorkingDays);
+        }
+    }
+
+    public function testUpdateActiveDaysWritesBothColumns(): void
+    {
+        [, $weeks, $sprintId] = $this->seedSprint();
+        $first = $weeks->allForSprint($sprintId)[0];
+
+        $result = $weeks->updateActiveDays($first->id, 0b01111); // drop Fr
+
+        $this->assertSame(SprintWeek::MASK_ALL, $result['before']->activeDaysMask);
+        $this->assertSame(0b01111, $result['after']->activeDaysMask);
+        $this->assertSame(4.0,     $result['after']->maxWorkingDays);
+
+        // Second query hydrates the same row from disk.
+        $reloaded = $weeks->find($first->id);
+        $this->assertNotNull($reloaded);
+        $this->assertSame(0b01111, $reloaded->activeDaysMask);
+        $this->assertSame(4.0,     $reloaded->maxWorkingDays);
+    }
+
+    public function testUpdateActiveDaysClampsBitsOutsideMoFr(): void
+    {
+        [, $weeks, $sprintId] = $this->seedSprint();
+        $first = $weeks->allForSprint($sprintId)[0];
+
+        $result = $weeks->updateActiveDays($first->id, 0xFF);
+
+        $this->assertSame(SprintWeek::MASK_ALL, $result['after']->activeDaysMask);
+        $this->assertSame(5.0,                  $result['after']->maxWorkingDays);
+    }
+
+    public function testUpdateActiveDaysToEmptyMaskZeroesCount(): void
+    {
+        [, $weeks, $sprintId] = $this->seedSprint();
+        $first = $weeks->allForSprint($sprintId)[0];
+
+        $result = $weeks->updateActiveDays($first->id, 0);
+
+        $this->assertSame(0,   $result['after']->activeDaysMask);
+        $this->assertSame(0.0, $result['after']->maxWorkingDays);
+        $this->assertSame([],  $result['after']->activeDays());
+    }
+
+    public function testSyncCountAppendsWeeksWithAllDaysActive(): void
+    {
+        [, $weeks, $sprintId] = $this->seedSprint();
+
+        $diff = $weeks->syncCount($sprintId, '2026-01-05', 6);
+        $this->assertCount(2, $diff['added']);
+        foreach ($diff['added'] as $w) {
+            $this->assertSame(SprintWeek::MASK_ALL, $w->activeDaysMask);
+            $this->assertSame(5.0, $w->maxWorkingDays);
+        }
+    }
+}

+ 13 - 4
tests/TestCase.php

@@ -22,11 +22,20 @@ abstract class TestCase extends PhpUnitTestCase
         ]);
         $pdo->exec('PRAGMA foreign_keys = ON');
 
-        $sql = file_get_contents(__DIR__ . '/../migrations/001_init.sql');
-        if ($sql === false) {
-            $this->fail('Could not read migrations/001_init.sql');
+        // Apply every NNN_*.sql in order — matches the production Migrator.
+        $dir   = __DIR__ . '/../migrations';
+        $files = glob($dir . '/*.sql') ?: [];
+        sort($files);
+        foreach ($files as $file) {
+            if (!preg_match('#/\d{3,}_[A-Za-z0-9_\-]+\.sql$#', $file)) {
+                continue;
+            }
+            $sql = file_get_contents($file);
+            if ($sql === false) {
+                $this->fail("Could not read migration: {$file}");
+            }
+            $pdo->exec($sql);
         }
-        $pdo->exec($sql);
 
         return $pdo;
     }

+ 23 - 5
views/sprints/settings.php

@@ -5,6 +5,7 @@
 /** @var list<\App\Domain\SprintWeek>   $weeks */
 /** @var list<\App\Domain\SprintWorker> $sprintWorkers */
 /** @var list<\App\Domain\Worker>       $availableWorkers */
+use App\Domain\SprintWeek;
 use function App\Http\e;
 ?>
 <section class="space-y-6"
@@ -61,7 +62,8 @@ use function App\Http\e;
                 <h2 class="text-sm font-semibold text-slate-700 uppercase tracking-wider">Weeks</h2>
                 <p class="text-xs text-slate-500 mt-1">
                     Current: <?= count($weeks) ?> week<?= count($weeks) === 1 ? '' : 's' ?>.
-                    Per-week max-working-days is edited on the sprint page (Phase 5).
+                    Tick the weekdays that are workdays for each week; the count feeds
+                    the Arbeitstage header on the sprint page.
                 </p>
             </div>
             <form data-weeks-form class="flex items-end gap-2">
@@ -78,22 +80,38 @@ use function App\Http\e;
             </form>
         </div>
         <div class="overflow-hidden rounded border border-slate-200">
-            <table class="min-w-full text-sm">
+            <table class="min-w-full text-sm" data-weeks-table>
                 <thead class="bg-slate-50 text-slate-600 text-xs uppercase tracking-wider">
                     <tr>
                         <th class="text-left px-3 py-2 font-semibold">#</th>
                         <th class="text-left px-3 py-2 font-semibold">KW</th>
                         <th class="text-left px-3 py-2 font-semibold">Start</th>
-                        <th class="text-right px-3 py-2 font-semibold">Max days</th>
+                        <?php foreach (SprintWeek::DAY_LABELS as $label): ?>
+                            <th class="text-center px-2 py-2 font-semibold w-10"><?= e($label) ?></th>
+                        <?php endforeach; ?>
+                        <th class="text-right px-3 py-2 font-semibold">Arbeitstage</th>
                     </tr>
                 </thead>
                 <tbody class="divide-y divide-slate-100">
                     <?php foreach ($weeks as $w): ?>
-                        <tr>
+                        <tr data-week-row data-week-id="<?= (int) $w->id ?>">
                             <td class="px-3 py-2 font-mono"><?= (int) $w->sortOrder ?></td>
                             <td class="px-3 py-2 font-mono">KW<?= (int) $w->isoWeek ?></td>
                             <td class="px-3 py-2 font-mono"><?= e($w->startDate) ?></td>
-                            <td class="px-3 py-2 font-mono text-right"><?= e(number_format($w->maxWorkingDays, 1)) ?></td>
+                            <?php foreach (SprintWeek::DAY_LABELS as $bit => $label): ?>
+                                <td class="px-2 py-2 text-center">
+                                    <input type="checkbox"
+                                           data-day-toggle
+                                           data-bit="<?= (int) $bit ?>"
+                                           data-label="<?= e($label) ?>"
+                                           aria-label="<?= e($label) ?>"
+                                           <?= $w->hasDay($label) ? 'checked' : '' ?>
+                                           class="rounded border-slate-300 focus:ring-slate-400">
+                                </td>
+                            <?php endforeach; ?>
+                            <td class="px-3 py-2 font-mono text-right" data-week-count>
+                                <?= (int) $w->maxWorkingDays ?>
+                            </td>
                         </tr>
                     <?php endforeach; ?>
                 </tbody>

+ 10 - 9
views/sprints/show.php

@@ -85,21 +85,22 @@ if (!function_exists('fmt_days')) {
                     </tr>
                 </thead>
                 <tbody class="divide-y divide-slate-100" data-tbody>
-                    <!-- Arbeitstage (max working days) row -->
+                    <!-- Arbeitstage row — derived from weekday selection in Sprint Settings. -->
                     <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
+                            <?php if ($currentUser->isAdmin): ?>
+                                <a href="/sprints/<?= (int) $sprint->id ?>/settings"
+                                   class="ml-1 text-[10px] font-normal text-slate-500 hover:underline"
+                                   title="Pick weekdays in Settings">(edit)</a>
+                            <?php endif; ?>
                         </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; ?>
+                                <span class="font-mono" data-week-arbeitstage data-week-id="<?= (int) $w->id ?>"
+                                      title="<?= e(implode(' ', $w->activeDays())) ?: '—' ?>">
+                                    <?= e(fmt_days($w->maxWorkingDays)) ?>
+                                </span>
                             </td>
                         <?php endforeach; ?>
                         <td class="px-2 py-1 text-center font-mono font-semibold" data-sum-max>