| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195 |
- <?php
- declare(strict_types=1);
- namespace App\Repositories;
- use App\Domain\SprintWeek;
- use DateTimeImmutable;
- use PDO;
- use RuntimeException;
- final class SprintWeekRepository
- {
- public function __construct(private readonly PDO $pdo)
- {
- }
- /** @return list<SprintWeek> ordered by sort_order ASC */
- public function allForSprint(int $sprintId): array
- {
- $stmt = $this->pdo->prepare(
- 'SELECT * FROM sprint_weeks WHERE sprint_id = ? ORDER BY sort_order ASC'
- );
- $stmt->execute([$sprintId]);
- $out = [];
- foreach ($stmt as $row) {
- $out[] = self::hydrate($row);
- }
- return $out;
- }
- public function find(int $id): ?SprintWeek
- {
- $stmt = $this->pdo->prepare('SELECT * FROM sprint_weeks WHERE id = ?');
- $stmt->execute([$id]);
- $row = $stmt->fetch();
- return is_array($row) ? self::hydrate($row) : null;
- }
- /**
- * 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 updateActiveDays(int $weekId, int $mask): array
- {
- $before = $this->find($weekId);
- if ($before === null) {
- throw new RuntimeException("sprint_week {$weekId} not found");
- }
- $mask &= SprintWeek::MASK_ALL;
- $count = (float) SprintWeek::popcount($mask);
- $this->pdo
- ->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];
- }
- /**
- * 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.
- *
- * - Added rows get max_working_days=5 and dates offset +7 days per week
- * from the sprint start.
- * - Removed rows are the trailing ones; any sprint_worker_days attached
- * to them cascade-delete via the FK.
- *
- * Returns the before/after diff for auditing.
- *
- * @return array{added: list<SprintWeek>, removed: list<SprintWeek>}
- */
- public function syncCount(
- int $sprintId,
- string $sprintStartDate,
- int $targetCount,
- ): array {
- if ($targetCount < 1) {
- throw new RuntimeException("targetCount must be >= 1, got {$targetCount}");
- }
- $existing = $this->allForSprint($sprintId);
- $currentCount = count($existing);
- if ($targetCount === $currentCount) {
- return ['added' => [], 'removed' => []];
- }
- if ($targetCount < $currentCount) {
- // Drop the trailing rows by sort_order.
- $toRemove = array_slice($existing, $targetCount);
- $ids = array_map(fn(SprintWeek $w) => $w->id, $toRemove);
- $placeholders = implode(',', array_fill(0, count($ids), '?'));
- $this->pdo
- ->prepare("DELETE FROM sprint_weeks WHERE id IN ({$placeholders})")
- ->execute($ids);
- return ['added' => [], 'removed' => $toRemove];
- }
- // Append rows.
- $d0 = DateTimeImmutable::createFromFormat('Y-m-d', $sprintStartDate);
- if ($d0 === false) {
- throw new RuntimeException("Invalid sprintStartDate: {$sprintStartDate}");
- }
- $insert = $this->pdo->prepare(
- '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, SprintWeek::MASK_ALL]);
- $added[] = new SprintWeek(
- id: (int) $this->pdo->lastInsertId(),
- sprintId: $sprintId,
- sortOrder: $i,
- isoWeek: $iso,
- startDate: $ymd,
- maxWorkingDays: 5.0,
- activeDaysMask: SprintWeek::MASK_ALL,
- );
- }
- return ['added' => $added, 'removed' => []];
- }
- /**
- * @param array<string,mixed> $row
- */
- private static function hydrate(array $row): SprintWeek
- {
- return new SprintWeek(
- id: (int) $row['id'],
- sprintId: (int) $row['sprint_id'],
- sortOrder: (int) $row['sort_order'],
- 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),
- );
- }
- }
|