Răsfoiți Sursa

Phase 21: derive sprint week count from start/end dates

Drops the manual "Set to N / Apply" form on the settings page; PATCH
/sprints/{id} now resyncs sprint_weeks whenever start_date or end_date
changes (count = floor((end-start)/7)+1, capped at 26). Existing rows
realign via SprintWeekRepository::realignDates preserving the weekday
mask; trailing rows shrink with the same audit-cascaded-days flow as
the legacy replaceWeeks. Per-week Mo-Fr checkboxes unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
chiappa 3 zile în urmă
părinte
comite
e2f19d60fc

+ 16 - 2
SPEC.md

@@ -210,8 +210,8 @@ Pages (HTML):
 JSON (admin-only, CSRF via `X-CSRF-Token` header; envelope per spec §7):
 | Method | Path                                         | What          |
 |--------|----------------------------------------------|---------------|
-| PATCH  | `/sprints/{id}`                              | name/dates/reserve |
-| POST   | `/sprints/{id}/weeks`                        | resize week set |
+| PATCH  | `/sprints/{id}`                              | name/dates/reserve — when start_date or end_date changes, week rows are auto-resynced (count = ⌊(end−start)/7⌋+1, capped at 26; existing rows realign + audit) |
+| POST   | `/sprints/{id}/weeks`                        | resize week set (legacy; UI no longer calls it — kept for back-compat) |
 | POST   | `/sprints/{id}/workers`                      | add sprint worker |
 | DELETE | `/sprints/{id}/workers/{sw_id}`              | remove sprint worker (audits cascaded children) |
 | POST   | `/sprints/{id}/workers/reorder`              | reorder sprint workers |
@@ -936,6 +936,20 @@ with a `BOOTSTRAP_ADMIN` audit row.
       doc-block annotation; PHPUnit 11 deprecated metadata in
       doc-comments so we use the `#[DataProvider]` attribute.
 
+- [x] **Phase 21 — Auto-derived week count in sprint settings**: the
+      "Set to N / Apply" form is gone; `PATCH /sprints/{id}` now resyncs
+      `sprint_weeks` whenever `start_date` or `end_date` changes. Target
+      count = `floor((end − start)/7) + 1`, capped at 26 (422 above).
+      Existing rows are realigned (`SprintWeekRepository::realignDates`
+      rewrites `start_date`/`iso_week` per `sort_order` offset, preserving
+      `active_days_mask`); appended rows default to `MASK_ALL`; trailing
+      rows shrink with the same audit-cascaded-days flow as the legacy
+      `replaceWeeks`. The response gains a `weeks_synced` flag so
+      `sprint-settings.js` can reload the page after a date edit. The
+      per-week weekday checkboxes (Mo Di Mi Do Fr) and `POST
+      /sprints/{id}/weeks` are unchanged — the JSON endpoint is kept for
+      back-compat but the UI no longer calls it. +5 tests, 148 total.
+
 ### Upcoming
 
 Nothing scheduled.

+ 10 - 19
public/assets/js/sprint-settings.js

@@ -1,8 +1,7 @@
 /**
  * /sprints/{id}/settings — vanilla JS + SortableJS.
- * Mirrors the previous jQuery/jQuery UI implementation feature-for-feature:
- *  - debounced PATCH /sprints/{id} for sprint meta on change
- *  - POST /sprints/{id}/weeks for week count resize (full reload on success)
+ *  - debounced PATCH /sprints/{id} for sprint meta on change; when the
+ *    response signals weeks were resynced, reload to pick up the new rows
  *  - PATCH /sprints/{id}/week/{week_id} for per-week weekday mask
  *  - POST /sprints/{id}/workers for adding a worker, DELETE for removing
  *  - PATCH /sprints/{id}/workers/{sw_id} for RTB and reorder
@@ -74,7 +73,14 @@
 
     function patchMeta(payload) {
         return request('PATCH', '/sprints/' + sprintId, payload)
-            .then(() => flash('Saved'))
+            .then(function (data) {
+                flash('Saved');
+                // Server resyncs the week set when start_date/end_date change.
+                // Reload so the table reflects added/removed rows.
+                if (data && data.weeks_synced) {
+                    window.location.reload();
+                }
+            })
             .catch((e) => flash(e.message, true));
     }
     const metaDebounce = {};
@@ -89,21 +95,6 @@
         }, 0);
     });
 
-    // ---- Week count resize ---------------------------------------------
-
-    on(root, 'submit', '[data-weeks-form]', function (ev) {
-        ev.preventDefault();
-        const inp = this.querySelector('input[name="n_weeks"]');
-        const n = parseInt(inp ? inp.value : '', 10);
-        if (!Number.isInteger(n) || n < 1) {
-            flash('Weeks must be a positive integer', true);
-            return;
-        }
-        request('POST', '/sprints/' + sprintId + '/weeks', { n_weeks: n })
-            .then(() => window.location.reload())
-            .catch((e) => flash(e.message, true));
-    });
-
     // ---- Per-week weekday checkboxes (Phase 12) ------------------------
 
     function maskFromRow(row) {

+ 83 - 1
src/Controllers/SprintController.php

@@ -359,6 +359,32 @@ final class SprintController
             return Response::err('validation', 'end_date must be on or after start_date', 422);
         }
 
+        // When the date range moves, the week set is fully derived from it:
+        // count = floor((end - start)/7) + 1, capped at 26.
+        $datesChanged = isset($changes['start_date']) || isset($changes['end_date']);
+        $targetWeeks  = null;
+        $cascadedDays = [];
+        if ($datesChanged) {
+            $targetWeeks = self::weeksBetween($effectiveStart, $effectiveEnd);
+            if ($targetWeeks > 26) {
+                return Response::err(
+                    'validation',
+                    'Date range spans more than 26 weeks',
+                    422,
+                );
+            }
+            // Snapshot cells that would be cascade-deleted by a shrink BEFORE
+            // we open the transaction, so the audit has the rows it needs.
+            $existing = $this->weeks->allForSprint($id);
+            if ($targetWeeks < count($existing)) {
+                foreach (array_slice($existing, $targetWeeks) as $w) {
+                    foreach ($this->days->allForSprintWeek($w->id) as $d) {
+                        $cascadedDays[] = $d;
+                    }
+                }
+            }
+        }
+
         if ($changes === []) {
             return Response::ok(['sprint' => $sprint->toAuditSnapshot()]);
         }
@@ -372,13 +398,69 @@ final class SprintController
                 $result['after']->toAuditSnapshot(),
                 $req, $actor,
             );
+
+            if ($datesChanged && $targetWeeks !== null) {
+                // 1) Realign existing rows' start_date/iso_week to the new offset.
+                foreach ($this->weeks->realignDates($id, $effectiveStart) as $d) {
+                    $this->audit->recordForRequest(
+                        'UPDATE', 'sprint_week', $d['after']->id,
+                        $d['before']->toAuditSnapshot(),
+                        $d['after']->toAuditSnapshot(),
+                        $req, $actor,
+                    );
+                }
+                // 2) Audit cells that the upcoming shrink will cascade.
+                foreach ($cascadedDays as $cell) {
+                    $this->audit->recordForRequest(
+                        'DELETE', 'sprint_worker_days', $cell->id,
+                        $cell->toAuditSnapshot(), null,
+                        $req, $actor,
+                    );
+                }
+                // 3) Resize the week set.
+                $diff = $this->weeks->syncCount($id, $effectiveStart, $targetWeeks);
+                foreach ($diff['added'] as $w) {
+                    $this->audit->recordForRequest(
+                        'CREATE', 'sprint_week', $w->id,
+                        null, $w->toAuditSnapshot(),
+                        $req, $actor,
+                    );
+                }
+                foreach ($diff['removed'] as $w) {
+                    $this->audit->recordForRequest(
+                        'DELETE', 'sprint_week', $w->id,
+                        $w->toAuditSnapshot(), null,
+                        $req, $actor,
+                    );
+                }
+            }
+
             $this->pdo->commit();
         } catch (Throwable) {
             $this->pdo->rollBack();
             return Response::err('db_error', 'Could not save sprint', 500);
         }
 
-        return Response::ok(['sprint' => $result['after']->toAuditSnapshot()]);
+        return Response::ok([
+            'sprint'       => $result['after']->toAuditSnapshot(),
+            'weeks_synced' => $datesChanged,
+        ]);
+    }
+
+    /**
+     * Number of calendar weeks the inclusive range [start..end] spans.
+     * Matches the "every 7-day block since start" model used by
+     * {@see SprintWeekRepository::syncCount()}.
+     */
+    private static function weeksBetween(string $startYmd, string $endYmd): int
+    {
+        $start = DateTimeImmutable::createFromFormat('Y-m-d', $startYmd);
+        $end   = DateTimeImmutable::createFromFormat('Y-m-d', $endYmd);
+        if ($start === false || $end === false) {
+            return 1;
+        }
+        $days = (int) $start->diff($end)->format('%r%a');
+        return max(1, intdiv($days, 7) + 1);
     }
 
     /** POST /sprints/{id}/weeks — JSON — resize the week set. */

+ 49 - 0
src/Repositories/SprintWeekRepository.php

@@ -59,6 +59,55 @@ final class SprintWeekRepository
         return ['before' => $before, 'after' => $after];
     }
 
+    /**
+     * Realign existing weeks' `start_date` / `iso_week` so that week i
+     * (1-indexed by `sort_order`) starts at `$sprintStartDate + (i-1)*7 days`.
+     * `active_days_mask` and `max_working_days` are preserved.
+     *
+     * Returns before/after pairs only for rows that actually changed, so the
+     * caller can audit each UPDATE.
+     *
+     * @return list<array{before: SprintWeek, after: SprintWeek}>
+     */
+    public function realignDates(int $sprintId, string $sprintStartDate): array
+    {
+        $d0 = DateTimeImmutable::createFromFormat('Y-m-d', $sprintStartDate);
+        if ($d0 === false) {
+            throw new RuntimeException("Invalid sprintStartDate: {$sprintStartDate}");
+        }
+
+        $existing = $this->allForSprint($sprintId);
+        if ($existing === []) {
+            return [];
+        }
+
+        $update = $this->pdo->prepare(
+            'UPDATE sprint_weeks SET iso_week = ?, start_date = ? WHERE id = ?'
+        );
+
+        $diffs = [];
+        foreach ($existing as $w) {
+            $expectedStart = $d0->modify('+' . ($w->sortOrder - 1) . ' weeks');
+            $ymd = $expectedStart->format('Y-m-d');
+            $iso = (int) $expectedStart->format('W');
+            if ($w->startDate === $ymd && $w->isoWeek === $iso) {
+                continue;
+            }
+            $update->execute([$iso, $ymd, $w->id]);
+            $after = new SprintWeek(
+                id:              $w->id,
+                sprintId:        $w->sprintId,
+                sortOrder:       $w->sortOrder,
+                isoWeek:         $iso,
+                startDate:       $ymd,
+                maxWorkingDays:  $w->maxWorkingDays,
+                activeDaysMask:  $w->activeDaysMask,
+            );
+            $diffs[] = ['before' => $w, 'after' => $after];
+        }
+        return $diffs;
+    }
+
     /**
      * Resize the week set of a sprint to $targetCount weeks.
      *

+ 45 - 0
tests/Controllers/SprintControllerTest.php

@@ -0,0 +1,45 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Tests\Controllers;
+
+use App\Controllers\SprintController;
+use App\Tests\TestCase;
+use ReflectionMethod;
+
+/**
+ * Pure-static guards on SprintController. Mirrors ImportControllerTest:
+ * exercise the bits that do not need PDO or session wiring.
+ */
+final class SprintControllerTest extends TestCase
+{
+    public function testWeeksBetweenInclusiveSameDayIsOneWeek(): void
+    {
+        $this->assertSame(1, self::call('weeksBetween', '2026-01-05', '2026-01-05'));
+    }
+
+    public function testWeeksBetweenSpansFullCalendarWeeks(): void
+    {
+        // Mon → Fri of the same week → 1 week.
+        $this->assertSame(1, self::call('weeksBetween', '2026-01-05', '2026-01-09'));
+        // Mon → Sun (end of the same week, +6 days) → 1 week.
+        $this->assertSame(1, self::call('weeksBetween', '2026-01-05', '2026-01-11'));
+        // Mon → Mon next week (+7 days) flips to 2 weeks.
+        $this->assertSame(2, self::call('weeksBetween', '2026-01-05', '2026-01-12'));
+        // Mon → Fri three weeks later → 3 weeks.
+        $this->assertSame(3, self::call('weeksBetween', '2026-01-05', '2026-01-23'));
+    }
+
+    public function testWeeksBetweenClampsToOneWhenDatesAreReversed(): void
+    {
+        $this->assertSame(1, self::call('weeksBetween', '2026-01-12', '2026-01-05'));
+    }
+
+    private static function call(string $method, mixed ...$args): mixed
+    {
+        $r = new ReflectionMethod(SprintController::class, $method);
+        $r->setAccessible(true);
+        return $r->invoke(null, ...$args);
+    }
+}

+ 30 - 0
tests/Repositories/SprintWeekRepositoryTest.php

@@ -91,4 +91,34 @@ final class SprintWeekRepositoryTest extends TestCase
             $this->assertSame(5.0, $w->maxWorkingDays);
         }
     }
+
+    public function testRealignDatesRewritesOffsetsAndPreservesMask(): void
+    {
+        [, $weeks, $sprintId] = $this->seedSprint();
+
+        // Customise week 2's mask so we can verify it survives realignment.
+        $week2 = $weeks->allForSprint($sprintId)[1];
+        $weeks->updateActiveDays($week2->id, 0b00111); // Mo Di Mi only
+
+        // Move the sprint start one week forward.
+        $diffs = $weeks->realignDates($sprintId, '2026-01-12');
+        $this->assertCount(4, $diffs, 'every existing week shifted one week');
+
+        $reloaded = $weeks->allForSprint($sprintId);
+        $this->assertSame('2026-01-12', $reloaded[0]->startDate);
+        $this->assertSame('2026-01-19', $reloaded[1]->startDate);
+        $this->assertSame('2026-01-26', $reloaded[2]->startDate);
+        $this->assertSame('2026-02-02', $reloaded[3]->startDate);
+
+        // Mask + maxWorkingDays untouched by realignment.
+        $this->assertSame(0b00111, $reloaded[1]->activeDaysMask);
+        $this->assertSame(3.0,     $reloaded[1]->maxWorkingDays);
+    }
+
+    public function testRealignDatesNoOpWhenAlreadyAligned(): void
+    {
+        [, $weeks, $sprintId] = $this->seedSprint();
+        $diffs = $weeks->realignDates($sprintId, '2026-01-05');
+        $this->assertSame([], $diffs, 'no UPDATEs when offsets already match');
+    }
 }

+ 9 - 22
views/sprints/settings.twig

@@ -50,27 +50,14 @@
     </section>
 
     <section class="rounded-lg border bg-white p-5 space-y-4 dark:bg-slate-800 dark:border-slate-700">
-        <div class="flex items-end justify-between gap-4">
-            <div>
-                <h2 class="text-sm font-semibold text-slate-700 uppercase tracking-wider dark:text-slate-200">Weeks</h2>
-                <p class="text-xs text-slate-500 mt-1 dark:text-slate-400">
-                    Current: {{ weeks|length }} week{{ weeks|length == 1 ? '' : 's' }}.
-                    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">
-                <label class="block">
-                    <span class="text-xs text-slate-600 dark:text-slate-400">Set to</span>
-                    <input name="n_weeks" type="number" min="1" max="26" step="1"
-                           value="{{ weeks|length }}"
-                           class="mt-1 w-24 rounded-md border border-slate-300 shadow-sm px-3 py-2 font-mono focus:outline-none focus:ring-2 focus:ring-slate-400 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-100 dark:focus:ring-slate-500">
-                </label>
-                <button type="submit"
-                        class="rounded-md bg-slate-900 text-white px-3 py-2 text-sm font-medium hover:bg-slate-800 dark:bg-slate-700 dark:hover:bg-slate-600">
-                    Apply
-                </button>
-            </form>
+        <div>
+            <h2 class="text-sm font-semibold text-slate-700 uppercase tracking-wider dark:text-slate-200">Weeks</h2>
+            <p class="text-xs text-slate-500 mt-1 dark:text-slate-400">
+                {{ weeks|length }} week{{ weeks|length == 1 ? '' : 's' }} — derived
+                from the sprint's start and end dates. Edit those above to add or
+                remove rows. Tick the weekdays that are workdays for each week;
+                the count feeds the Arbeitstage header on the sprint page.
+            </p>
         </div>
         <div class="overflow-hidden rounded border border-slate-200 dark:border-slate-700">
             <table class="min-w-full text-sm" data-weeks-table>
@@ -111,7 +98,7 @@
             </table>
         </div>
         <p class="text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded px-3 py-2 dark:bg-amber-900 dark:border-amber-800 dark:text-amber-200">
-            Reducing the week count deletes trailing weeks and any data attached to them.
+            Shortening the date range deletes trailing weeks and any data attached to them.
         </p>
     </section>