From adf88e1d615dbbb503e06d9b78b902d68a27ab3e Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 28 Aug 2026 18:10:14 +0200 Subject: [PATCH] fix(reservations): accept a booking that starts and ends on the same day A permit valid for one day has no clock on either end. isEndBeforeStart filled both missing times with midnight and then compared strictly, so start and end came out equal and the form refused it: inline error, dead save button, toast on submit. It also made existing rows uneditable. A booking whose end is a bare date on the start day is exactly what the booking import writes, and what the mobile sheet has been allowed to save since 72a82b3c. Opening one of those on the desktop left the save button dead with no way to clear it. With a time missing on either side the booking is all-day, so only the dates are compared. An end date before the start is still refused, with or without times, and two explicit clocks still have to be in order. The hotel guard comes along for the same reason mobile carries it: the date panel is hidden for hotels, so switching type after typing dates left the button disabled with its explanation inside the hidden block. Fixes #2107 --- .../Planner/ReservationModal.test.tsx | 92 ++++++++++++++++++- .../components/Planner/ReservationModal.tsx | 23 +++-- 2 files changed, 108 insertions(+), 7 deletions(-) diff --git a/client/src/components/Planner/ReservationModal.test.tsx b/client/src/components/Planner/ReservationModal.test.tsx index dd7a549b8..8258f2d42 100644 --- a/client/src/components/Planner/ReservationModal.test.tsx +++ b/client/src/components/Planner/ReservationModal.test.tsx @@ -1,4 +1,4 @@ -// FE-PLANNER-RESMODAL-001 to FE-PLANNER-RESMODAL-080 +// FE-PLANNER-RESMODAL-001 to FE-PLANNER-RESMODAL-093 import { render, screen, waitFor, fireEvent, within } from '../../../tests/helpers/render'; import userEvent from '@testing-library/user-event'; import { http, HttpResponse } from 'msw'; @@ -1437,6 +1437,96 @@ describe('ReservationModal', () => { expect(times.map(i => i.value)).toEqual(['', '', '']); }); + // #2107 — a booking that lasts a whole day has no clock to compare, and filling the + // missing one with midnight made the comparison read as inverted. + it('FE-PLANNER-RESMODAL-089: the same start and end date with no times is accepted', async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + render(); + + await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Park permit'); + const datePickers = screen.getAllByTestId('date-picker'); + fireEvent.change(datePickers[0], { target: { value: '2026-05-02' } }); + fireEvent.change(datePickers[1], { target: { value: '2026-05-02' } }); + + expect(screen.queryByText(/End date\/time must be after start/i)).toBeNull(); + fireEvent.submit(document.querySelector('form')!); + await waitFor(() => expect(onSave).toHaveBeenCalled()); + }); + + it('FE-PLANNER-RESMODAL-090: an all-day booking is saved as bare dates on both ends', async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + render(); + + await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Park permit'); + const datePickers = screen.getAllByTestId('date-picker'); + fireEvent.change(datePickers[0], { target: { value: '2026-05-02' } }); + fireEvent.change(datePickers[1], { target: { value: '2026-05-02' } }); + fireEvent.submit(document.querySelector('form')!); + + // The stored shape matters: the calendar export branches on whether the value + // carries a clock, and a bare date is what makes it an all-day event. + await waitFor(() => expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ reservation_time: '2026-05-02', reservation_end_time: '2026-05-02' }), + )); + }); + + it('FE-PLANNER-RESMODAL-091: an end date before the start is still refused without times', async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + const addToast = vi.fn(); + window.__addToast = addToast; + render(); + + await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Backwards'); + const datePickers = screen.getAllByTestId('date-picker'); + fireEvent.change(datePickers[0], { target: { value: '2026-05-02' } }); + fireEvent.change(datePickers[1], { target: { value: '2026-05-01' } }); + fireEvent.submit(document.querySelector('form')!); + + expect(onSave).not.toHaveBeenCalled(); + expect(addToast).toHaveBeenCalledWith(expect.stringMatching(/End date\/time must be after start/i), 'error', undefined); + delete window.__addToast; + }); + + it('FE-PLANNER-RESMODAL-092: a stored booking whose end is a bare date on the start day stays editable', async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + // The shape the booking import and the mobile sheet both write. Nothing is typed + // here: the row alone used to leave the save button dead. + const res = buildReservation({ + id: 21, type: 'event', title: 'Day permit', + reservation_time: '2026-05-02T10:00:00', reservation_end_time: '2026-05-02', + }); + render(); + + expect(screen.queryByText(/End date\/time must be after start/i)).toBeNull(); + fireEvent.submit(document.querySelector('form')!); + await waitFor(() => expect(onSave).toHaveBeenCalled()); + }); + + it('FE-PLANNER-RESMODAL-093: switching to hotel clears a date error the hidden panel cannot explain', async () => { + render(); + + await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Switched'); + const datePickers = screen.getAllByTestId('date-picker'); + const timePickers = screen.getAllByTestId('time-picker'); + fireEvent.change(datePickers[0], { target: { value: '2026-05-02' } }); + fireEvent.change(timePickers[0], { target: { value: '19:00' } }); + fireEvent.change(datePickers[1], { target: { value: '2026-05-01' } }); + expect(screen.getByText(/End date\/time must be after start/i)).toBeTruthy(); + // The footer button reads 'Add' while creating and 'Update' while editing. + const save = () => Array.from(document.querySelectorAll('button')) + .find(b => /^\s*(Add|Update)\s*$/.test(b.textContent || '')) as HTMLButtonElement; + expect(save().disabled).toBe(true); + + // The date panel is hidden for hotels, and the message sits inside it, so + // asserting on the message proves nothing here. The save button is the part + // that stayed dead with no visible reason. + // The hotel type is labelled 'Accommodation' in the picker. + const hotelBtn = Array.from(document.querySelectorAll('button')) + .find(b => /^\s*Accommodation\s*$/.test(b.textContent || ''))!; + fireEvent.click(hotelBtn); + expect(save().disabled).toBe(false); + }); + it('FE-PLANNER-RESMODAL-088: double-encoded metadata still fills the check-in times', () => { const res = buildReservation({ id: 18, title: 'Grand Hotel', type: 'hotel' }); (res as unknown as { metadata: string }).metadata = diff --git a/client/src/components/Planner/ReservationModal.tsx b/client/src/components/Planner/ReservationModal.tsx index 4b550e6e1..167ba7b0b 100644 --- a/client/src/components/Planner/ReservationModal.tsx +++ b/client/src/components/Planner/ReservationModal.tsx @@ -224,14 +224,25 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p return next }) + // Mirrors the mobile sheet, which has had this shape since 72a82b3c while the + // desktop copy was never pulled across (#2107). Filling a missing clock with '00:00' made a + // day-long booking compare as midnight against midnight, and the comparison is + // strict, so an all-day permit on a single date was refused. It also left every + // booking with a date-only end time uneditable here, which is the shape the booking + // import and the mobile sheet both write. + // + // The hotel guard matters for the same reason it does on mobile: the date panel is + // hidden for hotels, so a type switch after typing dates would leave the save button + // dead with its explanation inside the hidden block. const isEndBeforeStart = (() => { - if (!form.end_date || !form.reservation_time) return false + if (form.type === 'hotel' || !form.end_date || !form.reservation_time) return false const startDate = form.reservation_time.split('T')[0] - const startTime = form.reservation_time.split('T')[1] || '00:00' - const endTime = form.reservation_end_time || '00:00' - const startFull = `${startDate}T${startTime}` - const endFull = `${form.end_date}T${endTime}` - return endFull <= startFull + const startTime = form.reservation_time.split('T')[1] || '' + const endTime = form.reservation_end_time || '' + // Without a time on either side the booking is all-day, so an end on the + // start day is fine — only compare the dates there. + if (!startTime || !endTime) return form.end_date < startDate + return `${form.end_date}T${endTime}` <= `${startDate}T${startTime}` })() const handleSubmit = async (e?: { preventDefault?: () => void }) => {