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 */ 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, removed: list} */ 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 $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), ); } }