SprintWeekRepository.php 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195
  1. <?php
  2. declare(strict_types=1);
  3. namespace App\Repositories;
  4. use App\Domain\SprintWeek;
  5. use DateTimeImmutable;
  6. use PDO;
  7. use RuntimeException;
  8. final class SprintWeekRepository
  9. {
  10. public function __construct(private readonly PDO $pdo)
  11. {
  12. }
  13. /** @return list<SprintWeek> ordered by sort_order ASC */
  14. public function allForSprint(int $sprintId): array
  15. {
  16. $stmt = $this->pdo->prepare(
  17. 'SELECT * FROM sprint_weeks WHERE sprint_id = ? ORDER BY sort_order ASC'
  18. );
  19. $stmt->execute([$sprintId]);
  20. $out = [];
  21. foreach ($stmt as $row) {
  22. $out[] = self::hydrate($row);
  23. }
  24. return $out;
  25. }
  26. public function find(int $id): ?SprintWeek
  27. {
  28. $stmt = $this->pdo->prepare('SELECT * FROM sprint_weeks WHERE id = ?');
  29. $stmt->execute([$id]);
  30. $row = $stmt->fetch();
  31. return is_array($row) ? self::hydrate($row) : null;
  32. }
  33. /**
  34. * Update the weekday selection mask for a week. `max_working_days` is
  35. * recomputed as `popcount(mask)` and written atomically with the mask —
  36. * the two columns are never out of sync.
  37. *
  38. * @return array{before: SprintWeek, after: SprintWeek}
  39. */
  40. public function updateActiveDays(int $weekId, int $mask): array
  41. {
  42. $before = $this->find($weekId);
  43. if ($before === null) {
  44. throw new RuntimeException("sprint_week {$weekId} not found");
  45. }
  46. $mask &= SprintWeek::MASK_ALL;
  47. $count = (float) SprintWeek::popcount($mask);
  48. $this->pdo
  49. ->prepare('UPDATE sprint_weeks SET active_days_mask = ?, max_working_days = ? WHERE id = ?')
  50. ->execute([$mask, $count, $weekId]);
  51. $after = $this->find($weekId) ?? $before;
  52. return ['before' => $before, 'after' => $after];
  53. }
  54. /**
  55. * Realign existing weeks' `start_date` / `iso_week` so that week i
  56. * (1-indexed by `sort_order`) starts at `$sprintStartDate + (i-1)*7 days`.
  57. * `active_days_mask` and `max_working_days` are preserved.
  58. *
  59. * Returns before/after pairs only for rows that actually changed, so the
  60. * caller can audit each UPDATE.
  61. *
  62. * @return list<array{before: SprintWeek, after: SprintWeek}>
  63. */
  64. public function realignDates(int $sprintId, string $sprintStartDate): array
  65. {
  66. $d0 = DateTimeImmutable::createFromFormat('Y-m-d', $sprintStartDate);
  67. if ($d0 === false) {
  68. throw new RuntimeException("Invalid sprintStartDate: {$sprintStartDate}");
  69. }
  70. $existing = $this->allForSprint($sprintId);
  71. if ($existing === []) {
  72. return [];
  73. }
  74. $update = $this->pdo->prepare(
  75. 'UPDATE sprint_weeks SET iso_week = ?, start_date = ? WHERE id = ?'
  76. );
  77. $diffs = [];
  78. foreach ($existing as $w) {
  79. $expectedStart = $d0->modify('+' . ($w->sortOrder - 1) . ' weeks');
  80. $ymd = $expectedStart->format('Y-m-d');
  81. $iso = (int) $expectedStart->format('W');
  82. if ($w->startDate === $ymd && $w->isoWeek === $iso) {
  83. continue;
  84. }
  85. $update->execute([$iso, $ymd, $w->id]);
  86. $after = new SprintWeek(
  87. id: $w->id,
  88. sprintId: $w->sprintId,
  89. sortOrder: $w->sortOrder,
  90. isoWeek: $iso,
  91. startDate: $ymd,
  92. maxWorkingDays: $w->maxWorkingDays,
  93. activeDaysMask: $w->activeDaysMask,
  94. );
  95. $diffs[] = ['before' => $w, 'after' => $after];
  96. }
  97. return $diffs;
  98. }
  99. /**
  100. * Resize the week set of a sprint to $targetCount weeks.
  101. *
  102. * - Added rows get max_working_days=5 and dates offset +7 days per week
  103. * from the sprint start.
  104. * - Removed rows are the trailing ones; any sprint_worker_days attached
  105. * to them cascade-delete via the FK.
  106. *
  107. * Returns the before/after diff for auditing.
  108. *
  109. * @return array{added: list<SprintWeek>, removed: list<SprintWeek>}
  110. */
  111. public function syncCount(
  112. int $sprintId,
  113. string $sprintStartDate,
  114. int $targetCount,
  115. ): array {
  116. if ($targetCount < 1) {
  117. throw new RuntimeException("targetCount must be >= 1, got {$targetCount}");
  118. }
  119. $existing = $this->allForSprint($sprintId);
  120. $currentCount = count($existing);
  121. if ($targetCount === $currentCount) {
  122. return ['added' => [], 'removed' => []];
  123. }
  124. if ($targetCount < $currentCount) {
  125. // Drop the trailing rows by sort_order.
  126. $toRemove = array_slice($existing, $targetCount);
  127. $ids = array_map(fn(SprintWeek $w) => $w->id, $toRemove);
  128. $placeholders = implode(',', array_fill(0, count($ids), '?'));
  129. $this->pdo
  130. ->prepare("DELETE FROM sprint_weeks WHERE id IN ({$placeholders})")
  131. ->execute($ids);
  132. return ['added' => [], 'removed' => $toRemove];
  133. }
  134. // Append rows.
  135. $d0 = DateTimeImmutable::createFromFormat('Y-m-d', $sprintStartDate);
  136. if ($d0 === false) {
  137. throw new RuntimeException("Invalid sprintStartDate: {$sprintStartDate}");
  138. }
  139. $insert = $this->pdo->prepare(
  140. 'INSERT INTO sprint_weeks
  141. (sprint_id, sort_order, iso_week, start_date, max_working_days, active_days_mask)
  142. VALUES (?, ?, ?, ?, ?, ?)'
  143. );
  144. $added = [];
  145. for ($i = $currentCount + 1; $i <= $targetCount; $i++) {
  146. $weekStart = $d0->modify('+' . ($i - 1) . ' weeks');
  147. $iso = (int) $weekStart->format('W');
  148. $ymd = $weekStart->format('Y-m-d');
  149. $insert->execute([$sprintId, $i, $iso, $ymd, 5.0, SprintWeek::MASK_ALL]);
  150. $added[] = new SprintWeek(
  151. id: (int) $this->pdo->lastInsertId(),
  152. sprintId: $sprintId,
  153. sortOrder: $i,
  154. isoWeek: $iso,
  155. startDate: $ymd,
  156. maxWorkingDays: 5.0,
  157. activeDaysMask: SprintWeek::MASK_ALL,
  158. );
  159. }
  160. return ['added' => $added, 'removed' => []];
  161. }
  162. /**
  163. * @param array<string,mixed> $row
  164. */
  165. private static function hydrate(array $row): SprintWeek
  166. {
  167. return new SprintWeek(
  168. id: (int) $row['id'],
  169. sprintId: (int) $row['sprint_id'],
  170. sortOrder: (int) $row['sort_order'],
  171. isoWeek: (int) $row['iso_week'],
  172. startDate: (string) $row['start_date'],
  173. maxWorkingDays: (float) $row['max_working_days'],
  174. activeDaysMask: (int) ($row['active_days_mask'] ?? SprintWeek::MASK_ALL),
  175. );
  176. }
  177. }