date = $date; if (null === $time || $time->isAllDay()) { return; } $this->allDay = false; // An edge that is open to the edge of the day is left blank rather than filled in: // 24:00 has no representation in at all, and a blank already means // "to the end of the day" here, exactly as it does on the board's span picker. $this->startTime = TimeRange::DAY_START === $time->startMinute ? null : self::timeOf($time->startMinute); $this->endTime = TimeRange::DAY_END === $time->endMinute ? null : self::timeOf($time->endMinute); } public function timeRange(): TimeRange { if ($this->allDay || (null === $this->startTime && null === $this->endTime)) { return TimeRange::wholeDay(); } // One-sided input is open to that edge of the day -- the same rule TimeRange::parse() // applies to the board's ?from=&to= pair, so the two never disagree. return TimeRange::fromMinutes( null === $this->startTime ? TimeRange::DAY_START : self::minutesOf($this->startTime), null === $this->endTime ? TimeRange::DAY_END : self::minutesOf($this->endTime), ); } public function repeats(): bool { return null !== $this->repeat; } /** * Cross-field rules. These live here rather than in the form type so they hold for any caller, * and so the messages land on the field the user has to fix. */ #[Assert\Callback] public function validate(ExecutionContextInterface $context): void { if (!$this->allDay) { if (null === $this->startTime && null === $this->endTime) { $context->buildViolation('Bitte eine Uhrzeit angeben oder „Ganzer Tag" wählen.') ->atPath('startTime') ->addViolation(); } if (null !== $this->startTime && null !== $this->endTime && self::minutesOf($this->endTime) <= self::minutesOf($this->startTime)) { $context->buildViolation('Das Ende muss nach dem Beginn liegen.')->atPath('endTime')->addViolation(); } } $this->validateHorizon($context); if (!$this->repeats()) { return; } if (null === $this->until) { $context->buildViolation('Bitte ein Enddatum für die Serie angeben.')->atPath('until')->addViolation(); return; } if (null !== $this->date && $this->until < $this->date) { $context->buildViolation('Das Serienende muss nach dem Beginn liegen.')->atPath('until')->addViolation(); return; } if (!$this->unlimitedHorizon && $this->until > Horizon::latest()) { $context->buildViolation(\sprintf('Eine Serie darf höchstens %d Monate in die Zukunft laufen.', Horizon::MONTHS)) ->atPath('until') ->addViolation(); } } /** * The booking horizon, measured from today rather than from the chosen day -- see Horizon. * Not an attribute constraint because it has to be liftable per role, which an attribute * cannot express. */ private function validateHorizon(ExecutionContextInterface $context): void { if ($this->unlimitedHorizon || null === $this->date) { return; } if ($this->date > Horizon::latest()) { $context->buildViolation(\sprintf('Es kann höchstens %d Monate im Voraus gebucht werden.', Horizon::MONTHS)) ->atPath('date') ->addViolation(); } } private static function timeOf(int $minutes): \DateTimeImmutable { // 24:00 has no representation as a time-of-day, so an all-day end never reaches here. return (new \DateTimeImmutable('today'))->setTime(intdiv($minutes, 60), $minutes % 60); } private static function minutesOf(\DateTimeInterface $time): int { return (int) $time->format('G') * 60 + (int) $time->format('i'); } }