瀏覽代碼

Sprint create: pre-check only weekdays inside the date range

materializeWeeks now takes endDate and seeds each week's
active_days_mask with the Mo–Fr bits whose calendar dates fall
inside [start, end] (max_working_days = popcount). The settings
page still renders all five checkboxes per week; only the in-range
ones are pre-checked, and editing/saving works as before.

Also format the Weeks-table Start column as dd.mm.YYYY, matching
the audit page's existing date convention.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ClaudePriv@chiappa.zhdk.ch 14 小時之前
父節點
當前提交
cccc7de3e1

+ 1 - 0
src/Controllers/SprintController.php

@@ -146,6 +146,7 @@ final class SprintController
             $weeks = $this->sprints->materializeWeeks(
                 $sprint->id,
                 $startD->format('Y-m-d'),
+                $endD->format('Y-m-d'),
                 $nWeeks,
             );
             foreach ($weeks as $w) {

+ 43 - 7
src/Repositories/SprintRepository.php

@@ -146,23 +146,32 @@ final class SprintRepository
     }
 
     /**
-     * Materialise N week rows for a sprint with sensible defaults.
+     * Materialise N week rows for a sprint.
      *
-     * Returns the inserted rows (before=null, after=row-snapshot) so the caller
-     * can audit each CREATE.
+     * Each week's `active_days_mask` is seeded from the sprint's `[startDate,
+     * endDate]` window: a Mo..Fr bit is set only when that calendar weekday
+     * falls inside the window. Weeks fully inside the range get `MASK_ALL`;
+     * the leading/trailing partial weeks get a trimmed mask. The user can
+     * still toggle the per-week checkboxes afterwards in Sprint Settings.
+     *
+     * Returns the inserted rows 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, active_days_mask:int}>
      */
-    public function materializeWeeks(int $sprintId, string $startDate, int $nWeeks): array
+    public function materializeWeeks(int $sprintId, string $startDate, string $endDate, int $nWeeks): array
     {
         if ($nWeeks < 1) {
             return [];
         }
 
         $d0 = DateTimeImmutable::createFromFormat('Y-m-d', $startDate);
+        $dN = DateTimeImmutable::createFromFormat('Y-m-d', $endDate);
         if ($d0 === false) {
             throw new RuntimeException("Invalid start_date: {$startDate}");
         }
+        if ($dN === false) {
+            throw new RuntimeException("Invalid end_date: {$endDate}");
+        }
 
         $insert = $this->pdo->prepare(
             'INSERT INTO sprint_weeks
@@ -175,20 +184,47 @@ 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, SprintWeek::MASK_ALL]);
+            $mask      = self::maskForWeek($weekStart, $d0, $dN);
+            $maxDays   = (float) SprintWeek::popcount($mask);
+            $insert->execute([$sprintId, $i, $iso, $ymd, $maxDays, $mask]);
 
             $out[] = [
                 'id'               => (int) $this->pdo->lastInsertId(),
                 'sort_order'       => $i,
                 'iso_week'         => $iso,
                 'start_date'       => $ymd,
-                'max_working_days' => 5.0,
-                'active_days_mask' => SprintWeek::MASK_ALL,
+                'max_working_days' => $maxDays,
+                'active_days_mask' => $mask,
             ];
         }
         return $out;
     }
 
+    /**
+     * Build the Mo..Fr bitmask of weekdays whose calendar date falls inside
+     * the sprint's [start, end] window. `weekStart` is this week's first
+     * day (sprint_start + N×7 days, not necessarily a Monday).
+     */
+    private static function maskForWeek(
+        DateTimeImmutable $weekStart,
+        DateTimeImmutable $sprintStart,
+        DateTimeImmutable $sprintEnd,
+    ): int {
+        $mask = 0;
+        for ($d = 0; $d < 7; $d++) {
+            $day = $weekStart->modify("+{$d} days");
+            $dow = (int) $day->format('N'); // 1=Mon … 7=Sun
+            if ($dow > 5) {
+                continue;
+            }
+            if ($day < $sprintStart || $day > $sprintEnd) {
+                continue;
+            }
+            $mask |= (1 << ($dow - 1));
+        }
+        return $mask;
+    }
+
     /**
      * @param array<string,mixed> $row
      */

+ 1 - 1
tests/Cascade/CascadeAuditTest.php

@@ -70,7 +70,7 @@ final class CascadeAuditTest extends TestCase
         $wBob   = $workers->create('Bob',   true, 0.0);
 
         $sprint = $sprints->create('S', '2026-01-05', '2026-01-30', 0.2);
-        $wks    = $sprints->materializeWeeks($sprint->id, '2026-01-05', 4);
+        $wks    = $sprints->materializeWeeks($sprint->id, '2026-01-05', '2026-01-30', 4);
         $weekIds = array_map(fn($w) => (int) $w['id'], $wks);
 
         $swAlice = $sw->add($sprint->id, $wAlice->id, 0.0);

+ 61 - 1
tests/Repositories/SprintWeekRepositoryTest.php

@@ -30,7 +30,7 @@ final class SprintWeekRepositoryTest extends TestCase
         $weeks   = new SprintWeekRepository($pdo);
 
         $sprint = $sprints->create('S', '2026-01-05', '2026-01-30', 0.2);
-        $sprints->materializeWeeks($sprint->id, '2026-01-05', 4);
+        $sprints->materializeWeeks($sprint->id, '2026-01-05', '2026-01-30', 4);
 
         return [$sprints, $weeks, $sprint->id];
     }
@@ -47,6 +47,66 @@ final class SprintWeekRepositoryTest extends TestCase
         }
     }
 
+    public function testMaterializeWeeksTrimsTrailingWeekToSprintEnd(): void
+    {
+        $pdo     = $this->makeDb();
+        $sprints = new SprintRepository($pdo);
+        $weeks   = new SprintWeekRepository($pdo);
+
+        // Mon 2026-01-05 → Wed 2026-01-14 → 2 weeks; the trailing week stops on Wed.
+        $sprint = $sprints->create('S', '2026-01-05', '2026-01-14', 0.2);
+        $sprints->materializeWeeks($sprint->id, '2026-01-05', '2026-01-14', 2);
+
+        $rows = $weeks->allForSprint($sprint->id);
+        $this->assertCount(2, $rows);
+
+        $this->assertSame(SprintWeek::MASK_ALL, $rows[0]->activeDaysMask);
+        $this->assertSame(5.0,                  $rows[0]->maxWorkingDays);
+
+        // Mo + Di + Mi = bit0|bit1|bit2 = 7, three workdays.
+        $this->assertSame(0b00111, $rows[1]->activeDaysMask);
+        $this->assertSame(3.0,     $rows[1]->maxWorkingDays);
+    }
+
+    public function testMaterializeWeeksTrimsLeadingPartialWeekFromMidWeekStart(): void
+    {
+        $pdo     = $this->makeDb();
+        $sprints = new SprintRepository($pdo);
+        $weeks   = new SprintWeekRepository($pdo);
+
+        // Sprint runs Wed 2026-01-07 → Fri 2026-01-23 (3 weeks).
+        $sprint = $sprints->create('S', '2026-01-07', '2026-01-23', 0.2);
+        $sprints->materializeWeeks($sprint->id, '2026-01-07', '2026-01-23', 3);
+
+        $rows = $weeks->allForSprint($sprint->id);
+        $this->assertCount(3, $rows);
+
+        // W1: Wed–Tue spans all five Mo..Fr weekdays inside the sprint.
+        $this->assertSame(SprintWeek::MASK_ALL, $rows[0]->activeDaysMask);
+        // W2: same, fully interior.
+        $this->assertSame(SprintWeek::MASK_ALL, $rows[1]->activeDaysMask);
+        // W3 starts Wed Jan 21 — only Wed/Thu/Fri fall inside [start, end].
+        // Mi + Do + Fr = bit2|bit3|bit4 = 28.
+        $this->assertSame(0b11100, $rows[2]->activeDaysMask);
+        $this->assertSame(3.0,     $rows[2]->maxWorkingDays);
+    }
+
+    public function testMaterializeWeeksWeekendOnlySpanProducesEmptyMask(): void
+    {
+        $pdo     = $this->makeDb();
+        $sprints = new SprintRepository($pdo);
+        $weeks   = new SprintWeekRepository($pdo);
+
+        // Single-day sprint on Sat 2026-01-10 → no weekdays in range.
+        $sprint = $sprints->create('S', '2026-01-10', '2026-01-10', 0.2);
+        $sprints->materializeWeeks($sprint->id, '2026-01-10', '2026-01-10', 1);
+
+        $rows = $weeks->allForSprint($sprint->id);
+        $this->assertCount(1, $rows);
+        $this->assertSame(0,   $rows[0]->activeDaysMask);
+        $this->assertSame(0.0, $rows[0]->maxWorkingDays);
+    }
+
     public function testUpdateActiveDaysWritesBothColumns(): void
     {
         [, $weeks, $sprintId] = $this->seedSprint();

+ 1 - 1
tests/Repositories/TaskAssignmentRepositoryTest.php

@@ -40,7 +40,7 @@ final class TaskAssignmentRepositoryTest extends TestCase
         $repo    = new TaskAssignmentRepository($pdo);
 
         $sprint = $sprints->create('S', '2026-01-05', '2026-01-30', 0.2);
-        $sprints->materializeWeeks($sprint->id, '2026-01-05', 4);
+        $sprints->materializeWeeks($sprint->id, '2026-01-05', '2026-01-30', 4);
         $worker = $workers->create('Alice', true, 0.0);
         $sworker = $sw->add($sprint->id, $worker->id, 0.0);
         $task    = $tasks->create($sprint->id, 'Build thing', null, 1);

+ 1 - 1
tests/Services/Import/SprintImporterCommitTest.php

@@ -177,7 +177,7 @@ final class SprintImporterCommitTest extends TestCase
         [$pdo, $importer, $req] = $this->build();
         $sprints = new SprintRepository($pdo);
         $sprint = $sprints->create('Existing', '2026-03-23', '2026-04-05', 0.2);
-        $sprints->materializeWeeks($sprint->id, '2026-03-23', 2); // populates sprint_weeks
+        $sprints->materializeWeeks($sprint->id, '2026-03-23', '2026-04-05', 2); // populates sprint_weeks
 
         $this->expectException(RuntimeException::class);
         $this->expectExceptionMessage('not empty');

+ 1 - 1
views/sprints/settings.twig

@@ -87,7 +87,7 @@
                         <tr data-week-row data-week-id="{{ w.id }}">
                             <td class="px-3 py-2 font-mono">{{ w.sortOrder }}</td>
                             <td class="px-3 py-2 font-mono">KW{{ w.isoWeek }}</td>
-                            <td class="px-3 py-2 font-mono">{{ w.startDate }}</td>
+                            <td class="px-3 py-2 font-mono">{{ w.startDate|date('d.m.Y', 'UTC') }}</td>
                             {% for bit, label in dayLabels %}
                                 <td class="px-2 py-2 text-center">
                                     <input type="checkbox"