fix: allow booking the free part of a partly booked day
This commit is contained in:
1 parent
a88ff7ff24
commit
78839838f0
15 files changed
+541
-60
No files matched your search
@@ -73,11 +73,18 @@ final readonly class BoardPresenter
|
||||
|
||||
$byResource = [];
|
||||
foreach ($this->bookings->findForDate($selected, $time, $type) as $booking) {
|
||||
// Several bookings can overlap the span on one resource once times are in play; the
|
||||
// first is enough to colour the shape, and the tooltip lists them all.
|
||||
$byResource[$booking->getResource()->getId()][] = $booking;
|
||||
}
|
||||
|
||||
// One Spot per resource, carrying its own state and what is still free of the span --
|
||||
// several bookings can overlap the span on one resource once times are in play.
|
||||
$resources = $this->resources->findActiveByType($type);
|
||||
|
||||
$spots = [];
|
||||
foreach ($resources as $resource) {
|
||||
$spots[$resource->getId()] = Spot::for($resource, $byResource[$resource->getId()] ?? [], $time, $user);
|
||||
}
|
||||
|
||||
return [
|
||||
'type' => $type,
|
||||
'selected_date' => $selected,
|
||||
@@ -85,8 +92,8 @@ final readonly class BoardPresenter
|
||||
'month_start' => $monthStart,
|
||||
'weeks' => $weeks,
|
||||
'is_past' => $selected < $today,
|
||||
'resources' => $this->resources->findActiveByType($type),
|
||||
'bookings_by_resource' => $byResource,
|
||||
'resources' => $resources,
|
||||
'spots' => $spots,
|
||||
'my_bookings_today' => $mine[$selected->format('Y-m-d')] ?? [],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Booking;
|
||||
|
||||
use App\Entity\Booking;
|
||||
use App\Entity\Resource;
|
||||
use App\Entity\User;
|
||||
|
||||
/**
|
||||
* One shape on the floor plan: a resource, what is booked on it within the board's span, and
|
||||
* what is still free.
|
||||
*
|
||||
* The state decision lives here rather than in Twig so it can be tested directly -- it was wrong
|
||||
* in the template once already.
|
||||
*/
|
||||
final readonly class Spot
|
||||
{
|
||||
/**
|
||||
* @param list<Booking> $bookings overlapping the span, in start order
|
||||
* @param list<TimeRange> $free what is left of the span
|
||||
*/
|
||||
private function __construct(
|
||||
public Resource $resource,
|
||||
public array $bookings,
|
||||
public ?Booking $own,
|
||||
public array $free,
|
||||
public SpotState $state,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<Booking> $bookings everything booked on this resource that overlaps $span
|
||||
*/
|
||||
public static function for(Resource $resource, array $bookings, TimeRange $span, User $viewer): self
|
||||
{
|
||||
$own = null;
|
||||
foreach ($bookings as $booking) {
|
||||
if ($booking->belongsTo($viewer)) {
|
||||
$own = $booking;
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$free = TimeRange::remaining($span, array_map(
|
||||
static fn (Booking $booking): TimeRange => $booking->getTimeRange(),
|
||||
$bookings,
|
||||
));
|
||||
|
||||
// Yours wins over partly-free: clicking your own booking should open it, and the same
|
||||
// person can still take another window on the same resource from the edit modal's day.
|
||||
$state = match (true) {
|
||||
null !== $own => SpotState::MINE,
|
||||
[] === $bookings => SpotState::FREE,
|
||||
[] === $free => SpotState::TAKEN,
|
||||
default => SpotState::PARTLY,
|
||||
};
|
||||
|
||||
return new self($resource, $bookings, $own, $free, $state);
|
||||
}
|
||||
|
||||
/**
|
||||
* The longest free window, which is what the booking modal prefills -- so the suggested span
|
||||
* is always one that can actually be booked.
|
||||
*/
|
||||
public function suggested(): ?TimeRange
|
||||
{
|
||||
$longest = null;
|
||||
|
||||
foreach ($this->free as $range) {
|
||||
if (null === $longest || $range->durationInMinutes() > $longest->durationInMinutes()) {
|
||||
$longest = $range;
|
||||
}
|
||||
}
|
||||
|
||||
return $longest;
|
||||
}
|
||||
|
||||
/** Whether a NEW booking can start here -- i.e. any of the span is still free. */
|
||||
public function isBookable(): bool
|
||||
{
|
||||
return $this->state->isBookable();
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether clicking the shape does anything. Wider than isBookable(): your own booking is
|
||||
* clickable too, it just opens the edit modal instead of the create one.
|
||||
*/
|
||||
public function isActionable(): bool
|
||||
{
|
||||
return SpotState::MINE === $this->state || $this->isBookable();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Booking;
|
||||
|
||||
/**
|
||||
* How one resource looks on the floor plan, for the day and span the board is showing.
|
||||
*
|
||||
* PARTLY exists because of a real bug: with only "free" and "taken", a desk booked 09:00-13:00
|
||||
* looked identical to one booked all day on an all-day board, and was equally unclickable -- so
|
||||
* nobody could book the afternoon without first knowing to narrow the span by hand.
|
||||
*/
|
||||
enum SpotState
|
||||
{
|
||||
case FREE;
|
||||
case PARTLY;
|
||||
case TAKEN;
|
||||
case MINE;
|
||||
|
||||
public function isBookable(): bool
|
||||
{
|
||||
return self::FREE === $this || self::PARTLY === $this;
|
||||
}
|
||||
}
|
||||
@@ -103,6 +103,52 @@ final readonly class TimeRange implements \Stringable
|
||||
return $this->startMinute < $other->endMinute && $other->startMinute < $this->endMinute;
|
||||
}
|
||||
|
||||
public function durationInMinutes(): int
|
||||
{
|
||||
return $this->endMinute - $this->startMinute;
|
||||
}
|
||||
|
||||
/**
|
||||
* What is left of $within once every range in $taken is removed.
|
||||
*
|
||||
* This is what makes a partly-booked resource bookable: the floor plan needs to know not
|
||||
* "is anything booked here" but "is anything still free here", and the booking modal needs a
|
||||
* span it can actually prefill without colliding.
|
||||
*
|
||||
* @param list<self> $taken may overlap each other and may reach outside $within
|
||||
*
|
||||
* @return list<self> the free sub-ranges, in order; empty when nothing is left
|
||||
*/
|
||||
public static function remaining(self $within, array $taken): array
|
||||
{
|
||||
usort($taken, static fn (self $a, self $b): int => $a->startMinute <=> $b->startMinute);
|
||||
|
||||
$free = [];
|
||||
$cursor = $within->startMinute;
|
||||
|
||||
foreach ($taken as $range) {
|
||||
if ($range->endMinute <= $cursor) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($range->startMinute > $cursor) {
|
||||
$free[] = new self($cursor, min($range->startMinute, $within->endMinute));
|
||||
}
|
||||
|
||||
$cursor = max($cursor, $range->endMinute);
|
||||
|
||||
if ($cursor >= $within->endMinute) {
|
||||
return $free;
|
||||
}
|
||||
}
|
||||
|
||||
if ($cursor < $within->endMinute) {
|
||||
$free[] = new self($cursor, $within->endMinute);
|
||||
}
|
||||
|
||||
return $free;
|
||||
}
|
||||
|
||||
public function equals(self $other): bool
|
||||
{
|
||||
return $this->startMinute === $other->startMinute && $this->endMinute === $other->endMinute;
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Booking\BoardPresenter;
|
||||
use App\Booking\BookingManager;
|
||||
use App\Booking\DateParam;
|
||||
use App\Booking\ResourceAlreadyBookedException;
|
||||
use App\Booking\Spot;
|
||||
use App\Booking\SeriesResult;
|
||||
use App\Booking\TimeRange;
|
||||
use App\Entity\Booking;
|
||||
@@ -70,9 +71,17 @@ final class BookingController extends AbstractController
|
||||
$selected = DateParam::parseOrToday($date);
|
||||
$boardTime = BoardController::timeOf($request);
|
||||
|
||||
// Prefilled from whatever span the board is showing, so narrowing the board and then
|
||||
// clicking a desk books exactly what was on screen.
|
||||
$data = new BookingRequest($selected, $boardTime);
|
||||
// Prefilled with the longest window still free inside the board's span -- NOT the span
|
||||
// itself. On an all-day board a resource booked 09:00-13:00 would otherwise be offered
|
||||
// as 00:00-24:00, collide with itself on submit, and leave the user exactly as stuck as
|
||||
// before. Falls back to the board's span when nothing is booked at all.
|
||||
$spot = Spot::for(
|
||||
$resource,
|
||||
$this->repository->findOverlapping($resource, $selected, $boardTime),
|
||||
$boardTime,
|
||||
$this->currentUser(),
|
||||
);
|
||||
$data = new BookingRequest($selected, $spot->suggested() ?? $boardTime);
|
||||
|
||||
$form = $this->createForm(BookingType::class, $data, [
|
||||
'action' => $this->generateUrl('app_booking_new', ['id' => $resource->getId(), 'date' => $date] + $this->timeParameters($boardTime)),
|
||||
|
||||
+20
-10
@@ -37,20 +37,30 @@ final class BookingRequest
|
||||
{
|
||||
$this->date = $date;
|
||||
|
||||
if (null !== $time && !$time->isAllDay()) {
|
||||
$this->allDay = false;
|
||||
$this->startTime = self::timeOf($time->startMinute);
|
||||
$this->endTime = self::timeOf($time->endMinute);
|
||||
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 <input type="time"> 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) {
|
||||
if ($this->allDay || (null === $this->startTime && null === $this->endTime)) {
|
||||
return TimeRange::wholeDay();
|
||||
}
|
||||
|
||||
return TimeRange::fromTimes($this->startTime, $this->endTime);
|
||||
// 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
|
||||
@@ -66,10 +76,10 @@ final class BookingRequest
|
||||
public function validate(ExecutionContextInterface $context): void
|
||||
{
|
||||
if (!$this->allDay) {
|
||||
foreach (['startTime' => $this->startTime, 'endTime' => $this->endTime] as $field => $value) {
|
||||
if (null === $value) {
|
||||
$context->buildViolation('Bitte eine Uhrzeit angeben.')->atPath($field)->addViolation();
|
||||
}
|
||||
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
|
||||
|
||||
@@ -30,12 +30,14 @@ final class BookingType extends AbstractType
|
||||
])
|
||||
->add('startTime', TimeType::class, [
|
||||
'label' => 'Von',
|
||||
'help' => 'leer = ab Tagesbeginn',
|
||||
'widget' => 'single_text',
|
||||
'input' => 'datetime_immutable',
|
||||
'required' => false,
|
||||
])
|
||||
->add('endTime', TimeType::class, [
|
||||
'label' => 'Bis',
|
||||
'help' => 'leer = bis Tagesende',
|
||||
'widget' => 'single_text',
|
||||
'input' => 'datetime_immutable',
|
||||
'required' => false,
|
||||
|
||||
Reference in new issue
Block a user