|
@@ -4,8 +4,18 @@ declare(strict_types=1);
|
|
|
|
|
|
|
|
namespace App\Domain;
|
|
namespace App\Domain;
|
|
|
|
|
|
|
|
|
|
+use InvalidArgumentException;
|
|
|
|
|
+
|
|
|
final class SprintWeek
|
|
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 function __construct(
|
|
|
public readonly int $id,
|
|
public readonly int $id,
|
|
|
public readonly int $sprintId,
|
|
public readonly int $sprintId,
|
|
@@ -13,18 +23,102 @@ final class SprintWeek
|
|
|
public readonly int $isoWeek,
|
|
public readonly int $isoWeek,
|
|
|
public readonly string $startDate,
|
|
public readonly string $startDate,
|
|
|
public readonly float $maxWorkingDays,
|
|
public readonly float $maxWorkingDays,
|
|
|
|
|
+ public readonly int $activeDaysMask,
|
|
|
) {
|
|
) {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
public function toAuditSnapshot(): array
|
|
public function toAuditSnapshot(): array
|
|
|
{
|
|
{
|
|
|
return [
|
|
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,
|
|
|
|
|
|
|
+ '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<string> 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<string> */
|
|
|
|
|
+ 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<mixed> $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;
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|