| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132 |
- <?php
- /*
- * Copyright 2026 Alessandro Chiapparini <sprint_planer_web@chiapparini.org>
- * 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<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;
- }
- }
|