SprintWeek.php 4.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132
  1. <?php
  2. /*
  3. * Copyright 2026 Alessandro Chiapparini <sprint_planer_web@chiapparini.org>
  4. * SPDX-License-Identifier: Apache-2.0
  5. *
  6. * Licensed under the Apache License, Version 2.0 (the "License");
  7. * you may not use this file except in compliance with the License.
  8. * See the LICENSE file in the project root for the full license text.
  9. */
  10. declare(strict_types=1);
  11. namespace App\Domain;
  12. use InvalidArgumentException;
  13. final class SprintWeek
  14. {
  15. /**
  16. * Ordered list of German two-letter day labels mapped to bit positions.
  17. * The order is load-bearing: `array_search` gives the bit index.
  18. */
  19. public const DAY_LABELS = ['Mo', 'Di', 'Mi', 'Do', 'Fr'];
  20. public const MASK_ALL = 31; // 0b11111
  21. public function __construct(
  22. public readonly int $id,
  23. public readonly int $sprintId,
  24. public readonly int $sortOrder,
  25. public readonly int $isoWeek,
  26. public readonly string $startDate,
  27. public readonly float $maxWorkingDays,
  28. public readonly int $activeDaysMask,
  29. ) {
  30. }
  31. public function toAuditSnapshot(): array
  32. {
  33. return [
  34. 'id' => $this->id,
  35. 'sprint_id' => $this->sprintId,
  36. 'sort_order' => $this->sortOrder,
  37. 'iso_week' => $this->isoWeek,
  38. 'start_date' => $this->startDate,
  39. 'max_working_days' => $this->maxWorkingDays,
  40. 'active_days_mask' => $this->activeDaysMask,
  41. ];
  42. }
  43. /** @return list<string> e.g. ['Mo', 'Di', 'Do'] in canonical Mo→Fr order. */
  44. public function activeDays(): array
  45. {
  46. return self::maskToDays($this->activeDaysMask);
  47. }
  48. public function hasDay(string $twoLetter): bool
  49. {
  50. $bit = array_search($twoLetter, self::DAY_LABELS, true);
  51. if ($bit === false) {
  52. return false;
  53. }
  54. return (($this->activeDaysMask >> $bit) & 1) === 1;
  55. }
  56. /** Convenience for building updated copies in tests. */
  57. public function withMask(int $mask): self
  58. {
  59. return new self(
  60. id: $this->id,
  61. sprintId: $this->sprintId,
  62. sortOrder: $this->sortOrder,
  63. isoWeek: $this->isoWeek,
  64. startDate: $this->startDate,
  65. maxWorkingDays: (float) self::popcount($mask),
  66. activeDaysMask: $mask & self::MASK_ALL,
  67. );
  68. }
  69. // -------------------------------------------------------------------
  70. // Pure helpers — shared with the controller.
  71. // -------------------------------------------------------------------
  72. public static function popcount(int $mask): int
  73. {
  74. $mask &= self::MASK_ALL;
  75. $n = 0;
  76. for ($i = 0; $i < 5; $i++) {
  77. if ((($mask >> $i) & 1) === 1) {
  78. $n++;
  79. }
  80. }
  81. return $n;
  82. }
  83. /** @return list<string> */
  84. public static function maskToDays(int $mask): array
  85. {
  86. $out = [];
  87. foreach (self::DAY_LABELS as $i => $label) {
  88. if ((($mask >> $i) & 1) === 1) {
  89. $out[] = $label;
  90. }
  91. }
  92. return $out;
  93. }
  94. /**
  95. * @param iterable<mixed> $days any iterable of strings
  96. * @throws InvalidArgumentException on unknown label or duplicate
  97. */
  98. public static function daysToMask(iterable $days): int
  99. {
  100. $mask = 0;
  101. $seen = [];
  102. foreach ($days as $raw) {
  103. if (!is_string($raw)) {
  104. throw new InvalidArgumentException('active_days entries must be strings');
  105. }
  106. $bit = array_search($raw, self::DAY_LABELS, true);
  107. if ($bit === false) {
  108. throw new InvalidArgumentException("Unknown weekday label: {$raw}");
  109. }
  110. if (isset($seen[$bit])) {
  111. throw new InvalidArgumentException("Duplicate weekday: {$raw}");
  112. }
  113. $seen[$bit] = true;
  114. $mask |= (1 << $bit);
  115. }
  116. return $mask;
  117. }
  118. }