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 = [];
|
$byResource = [];
|
||||||
foreach ($this->bookings->findForDate($selected, $time, $type) as $booking) {
|
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;
|
$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 [
|
return [
|
||||||
'type' => $type,
|
'type' => $type,
|
||||||
'selected_date' => $selected,
|
'selected_date' => $selected,
|
||||||
@@ -85,8 +92,8 @@ final readonly class BoardPresenter
|
|||||||
'month_start' => $monthStart,
|
'month_start' => $monthStart,
|
||||||
'weeks' => $weeks,
|
'weeks' => $weeks,
|
||||||
'is_past' => $selected < $today,
|
'is_past' => $selected < $today,
|
||||||
'resources' => $this->resources->findActiveByType($type),
|
'resources' => $resources,
|
||||||
'bookings_by_resource' => $byResource,
|
'spots' => $spots,
|
||||||
'my_bookings_today' => $mine[$selected->format('Y-m-d')] ?? [],
|
'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;
|
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
|
public function equals(self $other): bool
|
||||||
{
|
{
|
||||||
return $this->startMinute === $other->startMinute && $this->endMinute === $other->endMinute;
|
return $this->startMinute === $other->startMinute && $this->endMinute === $other->endMinute;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use App\Booking\BoardPresenter;
|
|||||||
use App\Booking\BookingManager;
|
use App\Booking\BookingManager;
|
||||||
use App\Booking\DateParam;
|
use App\Booking\DateParam;
|
||||||
use App\Booking\ResourceAlreadyBookedException;
|
use App\Booking\ResourceAlreadyBookedException;
|
||||||
|
use App\Booking\Spot;
|
||||||
use App\Booking\SeriesResult;
|
use App\Booking\SeriesResult;
|
||||||
use App\Booking\TimeRange;
|
use App\Booking\TimeRange;
|
||||||
use App\Entity\Booking;
|
use App\Entity\Booking;
|
||||||
@@ -70,9 +71,17 @@ final class BookingController extends AbstractController
|
|||||||
$selected = DateParam::parseOrToday($date);
|
$selected = DateParam::parseOrToday($date);
|
||||||
$boardTime = BoardController::timeOf($request);
|
$boardTime = BoardController::timeOf($request);
|
||||||
|
|
||||||
// Prefilled from whatever span the board is showing, so narrowing the board and then
|
// Prefilled with the longest window still free inside the board's span -- NOT the span
|
||||||
// clicking a desk books exactly what was on screen.
|
// itself. On an all-day board a resource booked 09:00-13:00 would otherwise be offered
|
||||||
$data = new BookingRequest($selected, $boardTime);
|
// 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, [
|
$form = $this->createForm(BookingType::class, $data, [
|
||||||
'action' => $this->generateUrl('app_booking_new', ['id' => $resource->getId(), 'date' => $date] + $this->timeParameters($boardTime)),
|
'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;
|
$this->date = $date;
|
||||||
|
|
||||||
if (null !== $time && !$time->isAllDay()) {
|
if (null === $time || $time->isAllDay()) {
|
||||||
$this->allDay = false;
|
return;
|
||||||
$this->startTime = self::timeOf($time->startMinute);
|
|
||||||
$this->endTime = self::timeOf($time->endMinute);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$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
|
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::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
|
public function repeats(): bool
|
||||||
@@ -66,10 +76,10 @@ final class BookingRequest
|
|||||||
public function validate(ExecutionContextInterface $context): void
|
public function validate(ExecutionContextInterface $context): void
|
||||||
{
|
{
|
||||||
if (!$this->allDay) {
|
if (!$this->allDay) {
|
||||||
foreach (['startTime' => $this->startTime, 'endTime' => $this->endTime] as $field => $value) {
|
if (null === $this->startTime && null === $this->endTime) {
|
||||||
if (null === $value) {
|
$context->buildViolation('Bitte eine Uhrzeit angeben oder „Ganzer Tag" wählen.')
|
||||||
$context->buildViolation('Bitte eine Uhrzeit angeben.')->atPath($field)->addViolation();
|
->atPath('startTime')
|
||||||
}
|
->addViolation();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (null !== $this->startTime && null !== $this->endTime
|
if (null !== $this->startTime && null !== $this->endTime
|
||||||
|
|||||||
@@ -30,12 +30,14 @@ final class BookingType extends AbstractType
|
|||||||
])
|
])
|
||||||
->add('startTime', TimeType::class, [
|
->add('startTime', TimeType::class, [
|
||||||
'label' => 'Von',
|
'label' => 'Von',
|
||||||
|
'help' => 'leer = ab Tagesbeginn',
|
||||||
'widget' => 'single_text',
|
'widget' => 'single_text',
|
||||||
'input' => 'datetime_immutable',
|
'input' => 'datetime_immutable',
|
||||||
'required' => false,
|
'required' => false,
|
||||||
])
|
])
|
||||||
->add('endTime', TimeType::class, [
|
->add('endTime', TimeType::class, [
|
||||||
'label' => 'Bis',
|
'label' => 'Bis',
|
||||||
|
'help' => 'leer = bis Tagesende',
|
||||||
'widget' => 'single_text',
|
'widget' => 'single_text',
|
||||||
'input' => 'datetime_immutable',
|
'input' => 'datetime_immutable',
|
||||||
'required' => false,
|
'required' => false,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
<h2 class="!pb-0">{{ selected_date|format_datetime(pattern: 'EEEE, d. MMMM y') }}</h2>
|
<h2 class="!pb-0">{{ selected_date|format_datetime(pattern: 'EEEE, d. MMMM y') }}</h2>
|
||||||
<div class="flex items-center gap-4 text-xs text-gray-500">
|
<div class="flex items-center gap-4 text-xs text-gray-500">
|
||||||
<span class="flex items-center gap-1"><span class="w-3 h-3 rounded-sm bg-emerald-50 border-2 border-emerald-500"></span> frei</span>
|
<span class="flex items-center gap-1"><span class="w-3 h-3 rounded-sm bg-emerald-50 border-2 border-emerald-500"></span> frei</span>
|
||||||
|
<span class="flex items-center gap-1"><span class="w-3 h-3 rounded-sm bg-amber-50 border-2 border-amber-500"></span> teilweise frei</span>
|
||||||
<span class="flex items-center gap-1"><span class="w-3 h-3 rounded-sm bg-gray-200 border-2 border-gray-400"></span> belegt</span>
|
<span class="flex items-center gap-1"><span class="w-3 h-3 rounded-sm bg-gray-200 border-2 border-gray-400"></span> belegt</span>
|
||||||
<span class="flex items-center gap-1"><span class="w-3 h-3 rounded-sm bg-primary border-2 border-primary-dark"></span> deine</span>
|
<span class="flex items-center gap-1"><span class="w-3 h-3 rounded-sm bg-primary border-2 border-primary-dark"></span> deine</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -21,8 +21,8 @@
|
|||||||
|
|
||||||
{% for resource in resources %}
|
{% for resource in resources %}
|
||||||
{% if resource.svgElementId and geometry[resource.svgElementId] is defined %}
|
{% if resource.svgElementId and geometry[resource.svgElementId] is defined %}
|
||||||
{{ plan.spot(resource, geometry[resource.svgElementId], 140, 70,
|
{{ plan.spot(spots[resource.id], geometry[resource.svgElementId], 140, 70,
|
||||||
bookings_by_resource[resource.id]|default([]), selected_date, is_past, time_params) }}
|
selected_date, is_past, time_params) }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</svg>
|
</svg>
|
||||||
@@ -18,8 +18,8 @@
|
|||||||
|
|
||||||
{% for resource in resources %}
|
{% for resource in resources %}
|
||||||
{% if resource.svgElementId and geometry[resource.svgElementId] is defined %}
|
{% if resource.svgElementId and geometry[resource.svgElementId] is defined %}
|
||||||
{{ plan.spot(resource, geometry[resource.svgElementId], 200, 55,
|
{{ plan.spot(spots[resource.id], geometry[resource.svgElementId], 200, 55,
|
||||||
bookings_by_resource[resource.id]|default([]), selected_date, is_past, time_params) }}
|
selected_date, is_past, time_params) }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</svg>
|
</svg>
|
||||||
@@ -1,59 +1,69 @@
|
|||||||
{#
|
{#
|
||||||
The shared shape a plan is drawn from. Imported by each board/plan/_<type>.html.twig, so the
|
The shared shape a plan is drawn from. Imported by each board/plan/_<type>.html.twig, so the
|
||||||
per-type partials carry geometry only and free/taken/yours looks identical everywhere.
|
per-type partials carry geometry only and the states look identical everywhere.
|
||||||
|
|
||||||
Clicking always opens a modal: the create modal when free, the edit modal when it is yours
|
The state comes from App\Booking\Spot, not from counting bookings here. That distinction is
|
||||||
(which is where changing the times and releasing live). Nothing else is clickable, so the SVG
|
the fix for a real bug: a desk booked 09:00-13:00 used to render exactly like one booked all
|
||||||
carries no confirm dialogs or inline CSRF tokens.
|
day on an all-day board, and carried no hx-get either -- so a second person could not book the
|
||||||
|
afternoon at all unless they happened to narrow the span by hand first.
|
||||||
|
|
||||||
|
Clicking always opens a modal: the create modal when anything is still free, the edit modal
|
||||||
|
when it is yours (which is where changing the times and releasing live). Nothing else is
|
||||||
|
clickable, so the SVG carries no confirm dialogs or inline CSRF tokens.
|
||||||
#}
|
#}
|
||||||
{% macro spot(resource, geometry, width, height, bookings, selected_date, is_past, time_params) %}
|
{% macro spot(spot, geometry, width, height, selected_date, is_past, time_params) %}
|
||||||
{% set own = bookings|filter(b => b.user.id == app.user.id)|first|default(null) %}
|
{% set state = spot.state.name %}
|
||||||
{% set mine = own is not null %}
|
|
||||||
|
|
||||||
{% if mine %}
|
{% if state == 'MINE' %}
|
||||||
{% set fill = 'fill-primary' %}{% set stroke = 'stroke-primary-dark' %}{% set text = 'fill-white' %}
|
{% set fill = 'fill-primary' %}{% set stroke = 'stroke-primary-dark' %}{% set text = 'fill-white' %}
|
||||||
{% elseif bookings is not empty %}
|
{% elseif state == 'TAKEN' %}
|
||||||
{% set fill = 'fill-gray-200' %}{% set stroke = 'stroke-gray-400' %}{% set text = 'fill-gray-600' %}
|
{% set fill = 'fill-gray-200' %}{% set stroke = 'stroke-gray-400' %}{% set text = 'fill-gray-600' %}
|
||||||
|
{% elseif state == 'PARTLY' %}
|
||||||
|
{% set fill = 'fill-amber-50' %}{% set stroke = 'stroke-amber-500' %}{% set text = 'fill-amber-900' %}
|
||||||
{% else %}
|
{% else %}
|
||||||
{% set fill = 'fill-emerald-50' %}{% set stroke = 'stroke-emerald-500' %}{% set text = 'fill-emerald-900' %}
|
{% set fill = 'fill-emerald-50' %}{% set stroke = 'stroke-emerald-500' %}{% set text = 'fill-emerald-900' %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% set interactive = not is_past and (mine or bookings is empty) %}
|
{% set interactive = not is_past and spot.actionable %}
|
||||||
|
|
||||||
{# Several bookings can overlap the board's span once part-day bookings exist, so the shape
|
{# The second line: what you would be told at a glance. For a partly-free spot that is the
|
||||||
counts them and the tooltip spells them out. #}
|
window still available, because that is the reason to click it. #}
|
||||||
{% if mine %}
|
{% if state == 'MINE' %}
|
||||||
{% set caption = own.timeRange.allDay ? 'du' : 'du ' ~ own.timeRange %}
|
{% set caption = spot.own.timeRange.allDay ? 'du' : 'du ' ~ spot.own.timeRange %}
|
||||||
{% elseif bookings|length > 1 %}
|
{% elseif state == 'PARTLY' %}
|
||||||
{% set caption = bookings|length ~ ' Buchungen' %}
|
{% set caption = 'frei ' ~ spot.suggested %}
|
||||||
{% elseif bookings is not empty %}
|
{% elseif spot.bookings|length > 1 %}
|
||||||
{% set caption = bookings|first.user.name %}
|
{% set caption = spot.bookings|length ~ ' Buchungen' %}
|
||||||
|
{% elseif spot.bookings is not empty %}
|
||||||
|
{% set caption = spot.bookings|first.user.name %}
|
||||||
{% else %}
|
{% else %}
|
||||||
{% set caption = null %}
|
{% set caption = null %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<g class="{{ interactive ? 'cursor-pointer' : 'cursor-default' }}"
|
<g class="{{ interactive ? 'cursor-pointer' : 'cursor-default' }}"
|
||||||
{%- if interactive and mine %}
|
{%- if interactive and state == 'MINE' %}
|
||||||
hx-get="{{ path('app_booking_edit', { id: own.id }|merge(time_params)) }}"
|
hx-get="{{ path('app_booking_edit', { id: spot.own.id }|merge(time_params)) }}"
|
||||||
hx-target="body"
|
hx-target="body"
|
||||||
hx-swap="beforeend"
|
hx-swap="beforeend"
|
||||||
{%- elseif interactive %}
|
{%- elseif interactive %}
|
||||||
{# The modal is appended to the end of <body>; closing it is Stimulus, not htmx. #}
|
{# The modal is appended to the end of <body>; closing it is Stimulus, not htmx.
|
||||||
hx-get="{{ path('app_booking_new', { id: resource.id, date: selected_date|date('Y-m-d') }|merge(time_params)) }}"
|
It prefills the free window itself -- see BookingController::create. #}
|
||||||
|
hx-get="{{ path('app_booking_new', { id: spot.resource.id, date: selected_date|date('Y-m-d') }|merge(time_params)) }}"
|
||||||
hx-target="body"
|
hx-target="body"
|
||||||
hx-swap="beforeend"
|
hx-swap="beforeend"
|
||||||
{%- endif %}>
|
{%- endif %}>
|
||||||
<title>
|
<title>
|
||||||
{{- resource.label }}
|
{{- spot.resource.label }}
|
||||||
{%- if bookings is empty %} — frei
|
{%- if spot.bookings is empty %} — frei
|
||||||
{%- else %}
|
{%- else %}
|
||||||
{%- for booking in bookings %} — {{ booking.user.id == app.user.id ? 'du' : booking.user.name }} {{ booking.timeRange }}{% endfor %}
|
{%- for booking in spot.bookings %} — {{ booking.user.id == app.user.id ? 'du' : booking.user.name }} {{ booking.timeRange }}{% endfor %}
|
||||||
|
{%- for window in spot.free %} — frei {{ window }}{% endfor %}
|
||||||
{%- endif -%}
|
{%- endif -%}
|
||||||
</title>
|
</title>
|
||||||
<rect x="{{ geometry.x }}" y="{{ geometry.y }}" width="{{ width }}" height="{{ height }}" rx="6"
|
<rect x="{{ geometry.x }}" y="{{ geometry.y }}" width="{{ width }}" height="{{ height }}" rx="6"
|
||||||
class="{{ fill }} {{ stroke }}" stroke-width="2"></rect>
|
class="{{ fill }} {{ stroke }}" stroke-width="2"></rect>
|
||||||
<text x="{{ geometry.x + width / 2 }}" y="{{ geometry.y + (caption ? height / 2 - 4 : height / 2 + 4) }}"
|
<text x="{{ geometry.x + width / 2 }}" y="{{ geometry.y + (caption ? height / 2 - 4 : height / 2 + 4) }}"
|
||||||
text-anchor="middle" class="{{ text }} text-xs font-semibold">{{ resource.label }}</text>
|
text-anchor="middle" class="{{ text }} text-xs font-semibold">{{ spot.resource.label }}</text>
|
||||||
{% if caption %}
|
{% if caption %}
|
||||||
<text x="{{ geometry.x + width / 2 }}" y="{{ geometry.y + height / 2 + 12 }}"
|
<text x="{{ geometry.x + width / 2 }}" y="{{ geometry.y + height / 2 + 12 }}"
|
||||||
text-anchor="middle" class="{{ text }} text-[10px]">{{ caption }}</text>
|
text-anchor="middle" class="{{ text }} text-[10px]">{{ caption }}</text>
|
||||||
|
|||||||
@@ -7,23 +7,22 @@
|
|||||||
<div class="w-full max-w-md text-center">
|
<div class="w-full max-w-md text-center">
|
||||||
<img class="h-8 w-auto mx-auto mb-8" src="{{ asset('images/logo_ep.svg') }}" alt="ephub">
|
<img class="h-8 w-auto mx-auto mb-8" src="{{ asset('images/logo_ep.svg') }}" alt="ephub">
|
||||||
|
|
||||||
<h1>Anmelden</h1>
|
<h1>
|
||||||
<p class="subline pb-8">Schreibtische und Parkplätze buchen.</p>
|
Login E&P Hub
|
||||||
|
</h1>
|
||||||
|
<p class="subline pb-8">
|
||||||
|
Schreibtische und Parkplätze buchen.
|
||||||
|
</p>
|
||||||
|
|
||||||
{% if oauth2_configured %}
|
<a class="button button--primary w-full" href="{{ path('app_auth_init') }}">
|
||||||
<a class="button button--primary w-full" href="{{ path('app_auth_init') }}">
|
Mit myep anmelden
|
||||||
Mit myep anmelden
|
</a>
|
||||||
</a>
|
|
||||||
{% else %}
|
|
||||||
<div class="rounded-md border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-left">
|
|
||||||
<strong>Anmeldung noch nicht eingerichtet.</strong>
|
|
||||||
Für ephub muss auf myep erst ein OAuth2-Client registriert werden.
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if app.environment == 'dev' %}
|
{% if app.environment == 'dev' %}
|
||||||
<p class="pt-6 text-sm">
|
<p class="pt-6 text-sm">
|
||||||
<a class="underline text-gray-500" href="{{ path('app_dev_login') }}">Entwickler-Login</a>
|
<a class="underline text-gray-500" href="{{ path('app_dev_login') }}">
|
||||||
|
Entwickler-Login
|
||||||
|
</a>
|
||||||
</p>
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Booking;
|
||||||
|
|
||||||
|
use App\Booking\Spot;
|
||||||
|
use App\Booking\SpotState;
|
||||||
|
use App\Booking\TimeRange;
|
||||||
|
use App\Entity\Booking;
|
||||||
|
use App\Entity\Resource;
|
||||||
|
use App\Entity\ResourceType;
|
||||||
|
use App\Entity\User;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
class SpotTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testAResourceWithNothingBookedIsFree(): void
|
||||||
|
{
|
||||||
|
$spot = $this->spot([]);
|
||||||
|
|
||||||
|
self::assertSame(SpotState::FREE, $spot->state);
|
||||||
|
self::assertTrue($spot->isBookable());
|
||||||
|
self::assertNull($spot->own);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The reported bug: somebody else holds 09:00-13:00, the board shows the whole day, and the
|
||||||
|
* afternoon is still bookable. This used to come out as TAKEN and carried no link at all.
|
||||||
|
*/
|
||||||
|
public function testAResourceBookedForPartOfTheDayIsStillBookable(): void
|
||||||
|
{
|
||||||
|
$spot = $this->spot([$this->booking($this->someoneElse(), 540, 780)]);
|
||||||
|
|
||||||
|
self::assertSame(SpotState::PARTLY, $spot->state);
|
||||||
|
self::assertTrue($spot->isBookable());
|
||||||
|
self::assertTrue($spot->isActionable());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testTheSuggestedWindowIsTheLongestFreeOne(): void
|
||||||
|
{
|
||||||
|
// Taken 09:00-13:00 leaves 00:00-09:00 (9h) and 13:00-24:00 (11h).
|
||||||
|
$spot = $this->spot([$this->booking($this->someoneElse(), 540, 780)]);
|
||||||
|
|
||||||
|
self::assertSame('13:00–24:00', (string) $spot->suggested());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testTheSuggestedWindowIsAlwaysBookable(): void
|
||||||
|
{
|
||||||
|
$taken = $this->booking($this->someoneElse(), 540, 780);
|
||||||
|
$spot = $this->spot([$taken]);
|
||||||
|
|
||||||
|
// The whole point of suggesting a gap rather than the board's span: submitting it must
|
||||||
|
// not collide with what is already there.
|
||||||
|
self::assertFalse($spot->suggested()->overlaps($taken->getTimeRange()));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAResourceBookedAllDayIsTaken(): void
|
||||||
|
{
|
||||||
|
$spot = $this->spot([$this->booking($this->someoneElse(), 0, 1440)]);
|
||||||
|
|
||||||
|
self::assertSame(SpotState::TAKEN, $spot->state);
|
||||||
|
self::assertFalse($spot->isBookable());
|
||||||
|
self::assertFalse($spot->isActionable());
|
||||||
|
self::assertNull($spot->suggested());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSeveralBookingsCanLeaveNothingFree(): void
|
||||||
|
{
|
||||||
|
$spot = $this->spot([
|
||||||
|
$this->booking($this->someoneElse(), 0, 720),
|
||||||
|
$this->booking($this->someoneElse(), 720, 1440),
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertSame(SpotState::TAKEN, $spot->state);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSeveralBookingsCanStillLeaveAGapBetweenThem(): void
|
||||||
|
{
|
||||||
|
$spot = $this->spot([
|
||||||
|
$this->booking($this->someoneElse(), 0, 540),
|
||||||
|
$this->booking($this->someoneElse(), 780, 1440),
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertSame(SpotState::PARTLY, $spot->state);
|
||||||
|
self::assertSame('09:00–13:00', (string) $spot->suggested());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testYourOwnBookingWinsOverEverythingElse(): void
|
||||||
|
{
|
||||||
|
$viewer = $this->viewer();
|
||||||
|
$spot = Spot::for(
|
||||||
|
$this->resource(),
|
||||||
|
[$this->booking($viewer, 540, 780)],
|
||||||
|
TimeRange::wholeDay(),
|
||||||
|
$viewer,
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame(SpotState::MINE, $spot->state);
|
||||||
|
// Clicking opens the edit modal rather than the create one, so it is actionable but not
|
||||||
|
// offered as a new booking.
|
||||||
|
self::assertTrue($spot->isActionable());
|
||||||
|
self::assertNotNull($spot->own);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A narrowed board only asks about its own window, so what happens outside it is irrelevant.
|
||||||
|
*/
|
||||||
|
public function testStateIsJudgedWithinTheBoardsSpanOnly(): void
|
||||||
|
{
|
||||||
|
$spot = Spot::for(
|
||||||
|
$this->resource(),
|
||||||
|
[],
|
||||||
|
TimeRange::fromMinutes(780, 1020),
|
||||||
|
$this->viewer(),
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame(SpotState::FREE, $spot->state);
|
||||||
|
self::assertSame('13:00–17:00', (string) $spot->suggested());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<Booking> $bookings
|
||||||
|
*/
|
||||||
|
private function spot(array $bookings): Spot
|
||||||
|
{
|
||||||
|
return Spot::for($this->resource(), $bookings, TimeRange::wholeDay(), $this->viewer());
|
||||||
|
}
|
||||||
|
|
||||||
|
private function booking(User $user, int $start, int $end): Booking
|
||||||
|
{
|
||||||
|
return new Booking($user, $this->resource(), new \DateTimeImmutable('2026-10-01'), TimeRange::fromMinutes($start, $end));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resource(): Resource
|
||||||
|
{
|
||||||
|
return new Resource(ResourceType::DESK, 'D1', 'desk-01');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function viewer(): User
|
||||||
|
{
|
||||||
|
return new UserWithId('[email protected]', 'Anna', 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function someoneElse(): User
|
||||||
|
{
|
||||||
|
return new UserWithId('[email protected]', 'Bea', 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spot compares users by id, and an unpersisted User has none -- which would make every booking
|
||||||
|
* look like everyone's. This gives the fixtures stable ids without touching the database.
|
||||||
|
*/
|
||||||
|
class UserWithId extends User
|
||||||
|
{
|
||||||
|
public function __construct(string $email, string $name, private readonly int $fixedId)
|
||||||
|
{
|
||||||
|
parent::__construct($email, $name);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getId(): ?int
|
||||||
|
{
|
||||||
|
return $this->fixedId;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -83,6 +83,57 @@ class TimeRangeTest extends TestCase
|
|||||||
yield 'out of range is rejected' => ['25:00', '26:00', null];
|
yield 'out of range is rejected' => ['25:00', '26:00', null];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testRemainingIsTheWholeSpanWhenNothingIsTaken(): void
|
||||||
|
{
|
||||||
|
$free = TimeRange::remaining(TimeRange::wholeDay(), []);
|
||||||
|
|
||||||
|
self::assertSame(['00:00–24:00'], self::render($free));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRemainingSplitsAroundABookingInTheMiddle(): void
|
||||||
|
{
|
||||||
|
$free = TimeRange::remaining(TimeRange::wholeDay(), [TimeRange::fromMinutes(540, 780)]);
|
||||||
|
|
||||||
|
self::assertSame(['00:00–09:00', '13:00–24:00'], self::render($free));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRemainingIsEmptyWhenTheSpanIsFullyCovered(): void
|
||||||
|
{
|
||||||
|
self::assertSame([], TimeRange::remaining(TimeRange::wholeDay(), [TimeRange::wholeDay()]));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRemainingHandlesOverlappingAndUnsortedInput(): void
|
||||||
|
{
|
||||||
|
// Bookings on one resource cannot overlap, but the span may be covered by several that
|
||||||
|
// touch, and nothing guarantees the order they arrive in.
|
||||||
|
$free = TimeRange::remaining(TimeRange::wholeDay(), [
|
||||||
|
TimeRange::fromMinutes(780, 1020),
|
||||||
|
TimeRange::fromMinutes(540, 900),
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertSame(['00:00–09:00', '17:00–24:00'], self::render($free));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRemainingIgnoresWhatFallsOutsideTheSpan(): void
|
||||||
|
{
|
||||||
|
$free = TimeRange::remaining(TimeRange::fromMinutes(780, 1020), [TimeRange::fromMinutes(0, 540)]);
|
||||||
|
|
||||||
|
self::assertSame(['13:00–17:00'], self::render($free));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRemainingClipsABookingThatOverhangsTheSpan(): void
|
||||||
|
{
|
||||||
|
$free = TimeRange::remaining(TimeRange::fromMinutes(480, 1080), [TimeRange::fromMinutes(0, 600)]);
|
||||||
|
|
||||||
|
self::assertSame(['10:00–18:00'], self::render($free));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDurationIsInMinutes(): void
|
||||||
|
{
|
||||||
|
self::assertSame(240, TimeRange::fromMinutes(540, 780)->durationInMinutes());
|
||||||
|
self::assertSame(1440, TimeRange::wholeDay()->durationInMinutes());
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Regression guard for a trap that cost real debugging time: Twig resolves `range.allDay` by
|
* Regression guard for a trap that cost real debugging time: Twig resolves `range.allDay` by
|
||||||
* looking for a method literally named `allDay` BEFORE it tries `isAllDay()`, and it happily
|
* looking for a method literally named `allDay` BEFORE it tries `isAllDay()`, and it happily
|
||||||
@@ -97,4 +148,14 @@ class TimeRangeTest extends TestCase
|
|||||||
self::assertSame('no', $twig->render('t', ['range' => TimeRange::fromMinutes(9 * 60, 13 * 60)]));
|
self::assertSame('no', $twig->render('t', ['range' => TimeRange::fromMinutes(9 * 60, 13 * 60)]));
|
||||||
self::assertSame('yes', $twig->render('t', ['range' => TimeRange::wholeDay()]));
|
self::assertSame('yes', $twig->render('t', ['range' => TimeRange::wholeDay()]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<TimeRange> $ranges
|
||||||
|
*
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private static function render(array $ranges): array
|
||||||
|
{
|
||||||
|
return array_map(static fn (TimeRange $range): string => $range->start().'–'.$range->end(), $ranges);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -210,6 +210,56 @@ class BookingControllerTest extends WebTestCase
|
|||||||
self::assertCount(1, $crawler->filter(\sprintf('[hx-get="/bookings/%d/edit"]', $booking->getId())));
|
self::assertCount(1, $crawler->filter(\sprintf('[hx-get="/bookings/%d/edit"]', $booking->getId())));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The reported bug, end to end: one person holds 09:00-13:00, a second opens the board on
|
||||||
|
* its default all-day span, and must be able to take the rest of the day.
|
||||||
|
*
|
||||||
|
* The plan used to render that desk exactly like a fully booked one and give it no hx-get at
|
||||||
|
* all, so the second person could not reach the modal unless they happened to narrow the
|
||||||
|
* span by hand first.
|
||||||
|
*/
|
||||||
|
public function testASecondPersonCanBookTheRestOfADayFromTheDefaultBoard(): void
|
||||||
|
{
|
||||||
|
$desk = $this->resource('D1', svgElementId: 'desk-01');
|
||||||
|
$this->em->persist(new Booking($this->user('[email protected]'), $desk, new \DateTimeImmutable('2026-10-01'), TimeRange::fromMinutes(540, 780)));
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
$this->client->loginUser($this->user('[email protected]'));
|
||||||
|
|
||||||
|
// 1. The shape is clickable on the all-day board.
|
||||||
|
$crawler = $this->client->request('GET', '/book/desk?date=2026-10-01');
|
||||||
|
$opener = $crawler->filter(\sprintf('g[hx-get*="/bookings/new/%d/"]', $desk->getId()));
|
||||||
|
self::assertCount(1, $opener, 'a partly booked desk must still open the booking modal');
|
||||||
|
|
||||||
|
// 2. The modal it opens is prefilled with a window that is actually free.
|
||||||
|
$modal = $this->client->request('GET', $opener->attr('hx-get'), server: ['HTTP_HX-Request' => 'true']);
|
||||||
|
self::assertSame('13:00', $modal->filter('#booking_startTime')->attr('value'));
|
||||||
|
// Blank, not 24:00 -- <input type="time"> cannot hold it, and blank already means
|
||||||
|
// "to the end of the day" here just as it does on the board's span picker.
|
||||||
|
self::assertNull($modal->filter('#booking_endTime')->attr('value'));
|
||||||
|
|
||||||
|
// 3. Submitting it works.
|
||||||
|
$response = $this->submitBookingModal($desk, '2026-10-01', [
|
||||||
|
'startTime' => '13:00',
|
||||||
|
'endTime' => '17:00',
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
|
||||||
|
self::assertCount(2, $this->em->getRepository(Booking::class)->findBy(['resource' => $desk]));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAFullyBookedDeskIsNotClickable(): void
|
||||||
|
{
|
||||||
|
$desk = $this->resource('D1', svgElementId: 'desk-01');
|
||||||
|
$this->em->persist(new Booking($this->user('[email protected]'), $desk, new \DateTimeImmutable('2026-10-01')));
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
$this->client->loginUser($this->user('[email protected]'));
|
||||||
|
$crawler = $this->client->request('GET', '/book/desk?date=2026-10-01');
|
||||||
|
|
||||||
|
self::assertCount(0, $crawler->filter(\sprintf('g[hx-get*="/bookings/new/%d/"]', $desk->getId())));
|
||||||
|
}
|
||||||
|
|
||||||
public function testBookingPartOfADay(): void
|
public function testBookingPartOfADay(): void
|
||||||
{
|
{
|
||||||
$this->client->loginUser($this->user('[email protected]'));
|
$this->client->loginUser($this->user('[email protected]'));
|
||||||
|
|||||||
Reference in new issue
Block a user