feat(planner): reach a hotel's booking from the plan, not only its place

A booked stay's pill in the day head gets a second target that opens the booking; the name still opens the place. The place panel of a hotel, on the desktop and on the phone, lists the stay booked for it next to the bookings pinned to the stop. (#2363)
This commit is contained in:
Maurice
2026-10-01 09:43:53 +02:00
parent 104f0eef38
commit c80ee6b12a
34 changed files with 155 additions and 17 deletions
@@ -4338,6 +4338,44 @@ describe('DayPlanSidebar', () => {
expect(onPlaceClick).toHaveBeenCalledWith(8)
})
it('FE-PLANNER-DAYPLAN-176b: a booked stay opens its booking from the pill, the name still opens the place (#2363)', async () => {
const user = userEvent.setup()
const days = [
buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }),
buildDay({ id: 11, date: '2025-06-02', title: 'Day 2' }),
]
const accommodations: Accommodation[] = [
{ id: 3, trip_id: 1, start_day_id: 10, end_day_id: 11, place_id: 9, place_lat: 51.5, place_lng: -0.12, place_name: 'Booked Hotel' },
{ id: 4, trip_id: 1, start_day_id: 10, end_day_id: 11, place_id: 8, place_lat: 51.5, place_lng: -0.12, place_name: 'Unbooked Hotel' },
]
const stay = buildReservation({ id: 610, type: 'hotel', title: 'Booked Hotel stay', accommodation_id: 3 } as any)
const onPlaceClick = vi.fn()
const onOpenBooking = vi.fn()
render(<DayPlanSidebar {...makeDefaultProps({ days, accommodations, reservations: [stay], onPlaceClick, onOpenBooking })} />)
const header = dayHeader('Day 1')
const bookingButtons = header.querySelectorAll('[data-dp="day-pill-booking"]')
// Only the stay that has a booking gets the second target.
expect(bookingButtons).toHaveLength(1)
expect(bookingButtons[0]).toHaveAccessibleName('Open booking')
await user.click(bookingButtons[0] as HTMLElement)
expect(onOpenBooking).toHaveBeenCalledWith(stay)
expect(onPlaceClick).not.toHaveBeenCalled()
await user.click(within(header).getByText('Booked Hotel'))
expect(onPlaceClick).toHaveBeenCalledWith(9)
})
it('FE-PLANNER-DAYPLAN-176c: without a booking detail to open, the stay pill has no booking button', () => {
const days = [buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }), buildDay({ id: 11, date: '2025-06-02', title: 'Day 2' })]
const accommodations: Accommodation[] = [
{ id: 3, trip_id: 1, start_day_id: 10, end_day_id: 11, place_id: 9, place_lat: 51.5, place_lng: -0.12, place_name: 'Booked Hotel' },
]
const stay = buildReservation({ id: 610, type: 'hotel', title: 'Booked Hotel stay', accommodation_id: 3 } as any)
render(<DayPlanSidebar {...makeDefaultProps({ days, accommodations, reservations: [stay] })} />)
expect(dayHeader('Day 1').querySelector('[data-dp="day-pill-booking"]')).toBeNull()
})
it('FE-PLANNER-DAYPLAN-177: clicking an active rental badge opens its booking detail', async () => {
const user = userEvent.setup()
const days = [
@@ -1898,16 +1898,32 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
const pending = pendingStays.has(acc.id)
const named = state ? `${state}: ${accName}` : accName
const tip = pending ? `${named} (${t('reservations.pending')})` : named
// The name opens the place, as before; a stay with a booking gets a
// second target in the same pill that opens the booking (#2363).
const stayBooking = onOpenBooking
? reservations.find(r => r.accommodation_id != null && Number(r.accommodation_id) === acc.id)
: undefined
return (
<Tooltip key={acc.id} label={tip}>
<button type="button" data-dp="day-pill" data-pending={pending || undefined}
onClick={e => { e.stopPropagation(); if ((acc as any).place_id) onPlaceClick((acc as any).place_id) }}
className={`inline-flex min-w-0 max-w-full items-center gap-1 rounded-full px-2 py-[2px] font-geist font-normal text-content-secondary hover:text-content ${pending ? 'border border-dashed border-warning bg-warning-soft' : 'bg-surface-card shadow-sm'}`}
style={{ ...fs(10.5), cursor: (acc as any).place_id ? 'pointer' : 'default' }}>
<Hotel size={11} strokeWidth={2} className={`flex-none ${iconTone}`} />
<span className="truncate">{accName}</span>
</button>
</Tooltip>
<span key={acc.id} className={`inline-flex min-w-0 max-w-full items-stretch overflow-hidden rounded-full ${pending ? 'border border-dashed border-warning bg-warning-soft' : 'bg-surface-card shadow-sm'}`}>
<Tooltip label={tip}>
<button type="button" data-dp="day-pill" data-pending={pending || undefined}
onClick={e => { e.stopPropagation(); if ((acc as any).place_id) onPlaceClick((acc as any).place_id) }}
className={`inline-flex min-w-0 items-center gap-1 py-[2px] font-geist font-normal text-content-secondary hover:text-content ${stayBooking ? 'pl-2 pr-1.5' : 'px-2'}`}
style={{ ...fs(10.5), cursor: (acc as any).place_id ? 'pointer' : 'default' }}>
<Hotel size={11} strokeWidth={2} className={`flex-none ${iconTone}`} />
<span className="truncate">{accName}</span>
</button>
</Tooltip>
{stayBooking && (
<Tooltip label={t('day.openStayBooking')}>
<button type="button" data-dp="day-pill-booking" aria-label={t('day.openStayBooking')}
onClick={e => { e.stopPropagation(); onOpenBooking!(stayBooking) }}
className="flex flex-none items-center border-l border-edge-faint pl-1.5 pr-2 text-content-faint hover:text-content">
<Ticket size={11} strokeWidth={2} />
</button>
</Tooltip>
)}
</span>
)
})}
{/* Active rental car badges */}
@@ -430,6 +430,20 @@ describe('PlaceInspector', () => {
expect(screen.getByText('Museum Ticket')).toBeTruthy();
});
it('FE-PLANNER-INSPECTOR-030h: a hotel shows the stay booked for it and opens that booking (#2363)', () => {
const onOpenBooking = vi.fn();
const stay = buildReservation({
id: 540, title: 'Hotel Adlon, 3 nights', status: 'confirmed', type: 'hotel', assignment_id: null,
accommodation_place_id: place.id,
} as any);
const elsewhere = buildReservation({ id: 541, title: 'Other hotel', type: 'hotel', accommodation_place_id: place.id + 1 } as any);
render(<PlaceInspector {...defaultProps} reservations={[stay, elsewhere]} onOpenBooking={onOpenBooking} />);
expect(screen.queryByText('Other hotel')).toBeNull();
fireEvent.click(screen.getByText('Hotel Adlon, 3 nights').closest('[role="button"]') as HTMLElement);
expect(onOpenBooking).toHaveBeenCalledWith(stay);
});
it('FE-PLANNER-INSPECTOR-030g: every booking on the stop gets its own strip (#2201)', () => {
const onEditReservation = vi.fn();
const parking = buildReservation({
@@ -34,7 +34,7 @@ import { useTripStore } from '../../store/tripStore'
import { useCanDo } from '../../store/permissionsStore'
import { formatDistance, formatElevation } from '../../utils/units'
import { navigationTargetLabel, getNavigationTargets, openNavigationTarget } from './placeNavigation'
import { TRANSPORT_TYPES, getAssignmentReservations } from '../../utils/dayMerge'
import { TRANSPORT_TYPES, getPlaceBookings } from '../../utils/dayMerge'
import { NavigationMenu } from '../shared/NavigationMenu'
import { resolveOpenNow, resolvePlaceTimeZone, placeWeekdayIndex, type OpeningPeriod } from './placeOpenState'
import { convertHoursLine } from './placeHoursFormat'
@@ -479,7 +479,7 @@ export default function PlaceInspector({
{/* Bookings + participants — trip-only (collections have no days) */}
{mode === 'trip' && (
<PlaceBookingsAndPeople selectedAssignmentId={selectedAssignmentId} reservations={reservations}
<PlaceBookingsAndPeople selectedAssignmentId={selectedAssignmentId} placeId={place.id} reservations={reservations}
assignments={assignments} selectedDayId={selectedDayId} tripMembers={tripMembers}
timeFormat={timeFormat} onSetParticipants={onSetParticipants}
onEditTransport={onEditTransport} onEditReservation={onEditReservation} onOpenBooking={onOpenBooking} />
@@ -758,6 +758,7 @@ function InspectorHead({ place, category, openNow, phone, rating, ratingCount, p
interface PlaceBookingsAndPeopleProps {
selectedAssignmentId: number | null
placeId: number
reservations: Reservation[]
assignments: AssignmentsMap
selectedDayId: number | null
@@ -770,10 +771,10 @@ interface PlaceBookingsAndPeopleProps {
}
/** The bookings pinned to this stop and who joins it, side by side from sm up. */
function PlaceBookingsAndPeople({ selectedAssignmentId, reservations, assignments, selectedDayId,
function PlaceBookingsAndPeople({ selectedAssignmentId, placeId, reservations, assignments, selectedDayId,
tripMembers, timeFormat, onSetParticipants, onEditTransport, onEditReservation, onOpenBooking }: PlaceBookingsAndPeopleProps) {
const { t } = useTranslation()
const linked = getAssignmentReservations<Reservation>(reservations, selectedAssignmentId)
const linked = getPlaceBookings<Reservation>(reservations, selectedAssignmentId, placeId)
const assignment = selectedAssignmentId ? (assignments[String(selectedDayId)] || []).find(a => a.id === selectedAssignmentId) : null
const currentParticipants = assignment?.participants || []
const participantIds = currentParticipants.map(p => p.user_id)