* SPDX-License-Identifier: Apache-2.0 * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * See the LICENSE file in the project root for the full license text. */ declare(strict_types=1); namespace App\Domain; use InvalidArgumentException; final class SprintWeek { /** * Ordered list of German two-letter day labels mapped to bit positions. * The order is load-bearing: `array_search` gives the bit index. */ public const DAY_LABELS = ['Mo', 'Di', 'Mi', 'Do', 'Fr']; public const MASK_ALL = 31; // 0b11111 public function __construct( public readonly int $id, public readonly int $sprintId, public readonly int $sortOrder, public readonly int $isoWeek, public readonly string $startDate, public readonly float $maxWorkingDays, public readonly int $activeDaysMask, ) { } public function toAuditSnapshot(): array { return [ 'id' => $this->id, 'sprint_id' => $this->sprintId, 'sort_order' => $this->sortOrder, 'iso_week' => $this->isoWeek, 'start_date' => $this->startDate, 'max_working_days' => $this->maxWorkingDays, 'active_days_mask' => $this->activeDaysMask, ]; } /** @return list e.g. ['Mo', 'Di', 'Do'] in canonical Mo→Fr order. */ public function activeDays(): array { return self::maskToDays($this->activeDaysMask); } public function hasDay(string $twoLetter): bool { $bit = array_search($twoLetter, self::DAY_LABELS, true); if ($bit === false) { return false; } return (($this->activeDaysMask >> $bit) & 1) === 1; } /** Convenience for building updated copies in tests. */ public function withMask(int $mask): self { return new self( id: $this->id, sprintId: $this->sprintId, sortOrder: $this->sortOrder, isoWeek: $this->isoWeek, startDate: $this->startDate, maxWorkingDays: (float) self::popcount($mask), activeDaysMask: $mask & self::MASK_ALL, ); } // ------------------------------------------------------------------- // Pure helpers — shared with the controller. // ------------------------------------------------------------------- public static function popcount(int $mask): int { $mask &= self::MASK_ALL; $n = 0; for ($i = 0; $i < 5; $i++) { if ((($mask >> $i) & 1) === 1) { $n++; } } return $n; } /** @return list */ public static function maskToDays(int $mask): array { $out = []; foreach (self::DAY_LABELS as $i => $label) { if ((($mask >> $i) & 1) === 1) { $out[] = $label; } } return $out; } /** * @param iterable $days any iterable of strings * @throws InvalidArgumentException on unknown label or duplicate */ public static function daysToMask(iterable $days): int { $mask = 0; $seen = []; foreach ($days as $raw) { if (!is_string($raw)) { throw new InvalidArgumentException('active_days entries must be strings'); } $bit = array_search($raw, self::DAY_LABELS, true); if ($bit === false) { throw new InvalidArgumentException("Unknown weekday label: {$raw}"); } if (isset($seen[$bit])) { throw new InvalidArgumentException("Duplicate weekday: {$raw}"); } $seen[$bit] = true; $mask |= (1 << $bit); } return $mask; } }