feat: add two- and four-weekly series and drop the weekday picker

This commit is contained in:
fromme committed 2026-09-14 08:25:05 +02:00
1 parent 8b88384d68
commit faef4d70a8
10 files changed
+166 -103

No files matched your search

+33
View File
@@ -4,16 +4,49 @@ declare(strict_types=1);
namespace App\Booking;
/**
* How often a series repeats.
*
* Every rule is a whole number of days, which is what lets BookingSeries::occurrences() be a
* plain stride from the start date. Every stride that is a multiple of 7 therefore keeps the
* weekday fixed by construction -- that is why a series needs no weekday field at all.
*
* There is deliberately no calendar-monthly rule: "the 31st of each month" has no counterpart in
* five months of the year, and it would not hold the weekday. FOUR_WEEKLY is the nearest thing
* that behaves, at the cost of drifting against the calendar -- 13 occurrences a year, not 12,
* which is why it is not labelled "Monatlich".
*/
enum RecurrenceRule: string
{
case DAILY = 'daily';
case WEEKLY = 'weekly';
case TWO_WEEKLY = 'two_weekly';
case FOUR_WEEKLY = 'four_weekly';
public function label(): string
{
return match ($this) {
self::DAILY => 'Täglich',
self::WEEKLY => 'Wöchentlich',
self::TWO_WEEKLY => 'Alle zwei Wochen',
self::FOUR_WEEKLY => 'Alle vier Wochen',
};
}
/** Days between two occurrences. */
public function intervalDays(): int
{
return match ($this) {
self::DAILY => 1,
self::WEEKLY => 7,
self::TWO_WEEKLY => 14,
self::FOUR_WEEKLY => 28,
};
}
/** Whether the rule lands on the same weekday every time, i.e. whether naming it adds anything. */
public function keepsWeekday(): bool
{
return 0 === $this->intervalDays() % 7;
}
}
+1 -2
View File
@@ -132,7 +132,7 @@ final class BookingController extends AbstractController
]);
// The day and any recurrence belong to the original booking; only the span is editable
// here, so a series is never silently rewritten by editing one of its occurrences.
$form->remove('date')->remove('repeat')->remove('weekdays')->remove('until');
$form->remove('date')->remove('repeat')->remove('until');
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
@@ -207,7 +207,6 @@ final class BookingController extends AbstractController
$data->date,
$data->until,
$data->timeRange(),
$data->weekdays,
));
}
+10 -32
View File
@@ -41,15 +41,6 @@ class BookingSeries
#[ORM\Column(enumType: RecurrenceRule::class)]
private RecurrenceRule $rule;
/**
* ISO weekdays (1 = Monday) the series falls on. Empty for a DAILY rule, which means
* every calendar day.
*
* @var list<int>
*/
#[ORM\Column]
private array $weekdays = [];
#[ORM\Column(type: 'date_immutable')]
private \DateTimeImmutable $startDate;
@@ -69,9 +60,6 @@ class BookingSeries
#[ORM\OneToMany(targetEntity: Booking::class, mappedBy: 'series')]
private Collection $bookings;
/**
* @param list<int> $weekdays
*/
public function __construct(
User $user,
Resource $resource,
@@ -79,7 +67,6 @@ class BookingSeries
\DateTimeImmutable $startDate,
\DateTimeImmutable $endDate,
TimeRange $time,
array $weekdays = [],
) {
$this->user = $user;
$this->resource = $resource;
@@ -88,7 +75,6 @@ class BookingSeries
$this->endDate = $endDate->setTime(0, 0);
$this->startMinute = $time->startMinute;
$this->endMinute = $time->endMinute;
$this->weekdays = RecurrenceRule::WEEKLY === $rule ? array_values(array_unique($weekdays)) : [];
$this->createdAt = new \DateTimeImmutable();
$this->bookings = new ArrayCollection();
}
@@ -96,17 +82,18 @@ class BookingSeries
/**
* Every day this series falls on, in order.
*
* A rule is only a step size, so this is a stride from the start date. For any rule whose
* interval is a multiple of a week that means every occurrence lands on the same weekday as
* the start -- the series never has to be told which weekday it runs on.
*
* @return list<\DateTimeImmutable>
*/
public function occurrences(): array
{
$dates = [];
$step = \sprintf('+%d days', $this->rule->intervalDays());
for ($day = $this->startDate; $day <= $this->endDate; $day = $day->modify('+1 day')) {
if (RecurrenceRule::WEEKLY === $this->rule && !\in_array((int) $day->format('N'), $this->weekdays, true)) {
continue;
}
for ($day = $this->startDate; $day <= $this->endDate; $day = $day->modify($step)) {
$dates[] = $day;
}
@@ -133,14 +120,6 @@ class BookingSeries
return $this->rule;
}
/**
* @return list<int>
*/
public function getWeekdays(): array
{
return $this->weekdays;
}
public function getStartDate(): \DateTimeImmutable
{
return $this->startDate;
@@ -178,12 +157,11 @@ class BookingSeries
{
$description = $this->rule->label();
if (RecurrenceRule::WEEKLY === $this->rule && [] !== $this->weekdays) {
// Naming the weekday only says something for a rule that actually holds it fixed; for a
// daily series it would just repeat the start date back at the reader.
if ($this->rule->keepsWeekday()) {
$names = ['', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
// Sort the numbers, not the names -- alphabetical would read "Di, Do, Fr, Mi, Mo".
$weekdays = $this->weekdays;
sort($weekdays);
$description .= ' ('.implode(', ', array_map(static fn (int $day): string => $names[$day], $weekdays)).')';
$description .= ' ('.$names[(int) $this->startDate->format('N')].')';
}
return $description.', '.$this->getTimeRange();
-7
View File
@@ -28,9 +28,6 @@ final class BookingRequest
public ?RecurrenceRule $repeat = null;
/** @var list<int> ISO weekdays, 1 = Monday */
public array $weekdays = [];
public ?\DateTimeImmutable $until = null;
public function __construct(?\DateTimeImmutable $date = null, ?TimeRange $time = null)
@@ -92,10 +89,6 @@ final class BookingRequest
return;
}
if (RecurrenceRule::WEEKLY === $this->repeat && [] === $this->weekdays) {
$context->buildViolation('Bitte mindestens einen Wochentag wählen.')->atPath('weekdays')->addViolation();
}
if (null === $this->until) {
$context->buildViolation('Bitte ein Enddatum für die Serie angeben.')->atPath('until')->addViolation();
-8
View File
@@ -7,7 +7,6 @@ namespace App\Form;
use App\Booking\RecurrenceRule;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\DateType;
use Symfony\Component\Form\Extension\Core\Type\EnumType;
use Symfony\Component\Form\Extension\Core\Type\TimeType;
@@ -49,13 +48,6 @@ final class BookingType extends AbstractType
'placeholder' => 'Einmalig',
'choice_label' => static fn (RecurrenceRule $rule): string => $rule->label(),
])
->add('weekdays', ChoiceType::class, [
'label' => 'Wochentage',
'choices' => ['Mo' => 1, 'Di' => 2, 'Mi' => 3, 'Do' => 4, 'Fr' => 5, 'Sa' => 6, 'So' => 7],
'multiple' => true,
'expanded' => true,
'required' => false,
])
->add('until', DateType::class, [
'label' => 'Serie bis',
'widget' => 'single_text',