From f6bcdb8e6f91e187c974a1d920b4abe2902bd74e Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 6 Nov 2026 16:17:32 +0100 Subject: [PATCH] fix(mobile): a stay in the day plan opens its booking's overview, not the editor Tapping the accommodation in the plan went straight to the stay's editor, where travellers only wanted to look it up. It now opens the overview of the booking behind it, as on the desktop, with check-in, check-out and nights as tiles (one shared stayStats for both), and only a stay without a booking or a place still leads to the editor. Edit in that overview opened the transport editor for every booking, a stay or a restaurant table included; non-transport bookings now open the booking editor, as in the Bookings tab. A stay with two linked bookings showed its chips twice in the day header, because the list repeats a stay per booking; the header counts it once. --- .../Planner/bookings/BookingDetailDialog.tsx | 12 ++----- .../Planner/bookings/bookingFacts.ts | 25 ++++++++++++++ .../screens/trip/plan/MPlanTimeline.tsx | 13 +++++--- .../trip/plan/planTimelineModel.test.ts | 13 ++++++-- .../screens/trip/plan/planTimelineModel.ts | 5 +++ .../screens/trip/sheets/MTransportSheet.tsx | 33 ++++++++++++++++--- 6 files changed, 80 insertions(+), 21 deletions(-) diff --git a/client/src/components/Planner/bookings/BookingDetailDialog.tsx b/client/src/components/Planner/bookings/BookingDetailDialog.tsx index 63a68671a..e4beafb89 100644 --- a/client/src/components/Planner/bookings/BookingDetailDialog.tsx +++ b/client/src/components/Planner/bookings/BookingDetailDialog.tsx @@ -20,7 +20,7 @@ import { formatMoney, formatTime } from '../../../utils/formatters' import { copyText } from '../../../utils/clipboard' import { fmtTransitDuration } from '../transitDisplay' import { parseMeta, displayTitle } from './bookingsModel' -import { formatDay, type BookingFacts } from './bookingFacts' +import { stayStats, type BookingFacts } from './bookingFacts' import { AirTrailPill, BOX, Eyebrow, Field, FileRows, ReviewPill, TravelerChips, fs, toneOf } from './bookingParts' import { TransitLegs, transitSummary } from './transitParts' import { BookingDialogHeader, StatusPill, TypePill } from './BookingDialogShell' @@ -79,10 +79,7 @@ export default function BookingDetailDialog(p: BookingDetailDialogProps) { const stats: { value: string; label: string }[] = [] if (facts.isHotel) { - if (facts.startDate) stats.push({ value: formatDay(facts.startDate, locale, false), label: t('reservations.meta.checkIn') + (meta.check_in_time ? ` ${meta.check_in_time}` : '') }) - if (facts.endDate) stats.push({ value: formatDay(facts.endDate, locale, false), label: t('reservations.meta.checkOut') + (meta.check_out_time ? ` ${meta.check_out_time}` : '') }) - const nights = nightsBetween(facts.startDate, facts.endDate) - if (nights) stats.push({ value: String(nights), label: t('reservations.nights', { count: nights }) }) + stats.push(...stayStats(facts, meta, t, locale)) } else { const from = facts.endpoints[0] const to = facts.endpoints[facts.endpoints.length - 1] @@ -318,8 +315,3 @@ function DetailField({ label, children }: { label: string; children: ReactNode } ) } -function nightsBetween(a: string | null, b: string | null): number { - if (!a || !b) return 0 - const n = Math.round((Date.parse(`${b}T00:00:00Z`) - Date.parse(`${a}T00:00:00Z`)) / 864e5) - return n > 0 ? n : 0 -} diff --git a/client/src/components/Planner/bookings/bookingFacts.ts b/client/src/components/Planner/bookings/bookingFacts.ts index c58828e14..584fe515b 100644 --- a/client/src/components/Planner/bookings/bookingFacts.ts +++ b/client/src/components/Planner/bookings/bookingFacts.ts @@ -133,3 +133,28 @@ export function bookingFacts(r: Reservation, c: FactsContext): BookingFacts { isHotel, } } + +export function nightsBetween(a: string | null, b: string | null): number { + if (!a || !b) return 0 + const n = Math.round((Date.parse(`${b}T00:00:00Z`) - Date.parse(`${a}T00:00:00Z`)) / 864e5) + return n > 0 ? n : 0 +} + +/** + * A stay's three tiles: check-in and check-out with their dates and times, and + * the nights between. The desktop booking dialog and the phone's booking sheet + * show the same ones. + */ +export function stayStats( + facts: BookingFacts, + meta: { check_in_time?: string | null; check_out_time?: string | null }, + t: FactsContext['t'], + locale: string, +): { value: string; label: string }[] { + const stats: { value: string; label: string }[] = [] + if (facts.startDate) stats.push({ value: formatDay(facts.startDate, locale, false), label: t('reservations.meta.checkIn') + (meta.check_in_time ? ` ${meta.check_in_time}` : '') }) + if (facts.endDate) stats.push({ value: formatDay(facts.endDate, locale, false), label: t('reservations.meta.checkOut') + (meta.check_out_time ? ` ${meta.check_out_time}` : '') }) + const nights = nightsBetween(facts.startDate, facts.endDate) + if (nights) stats.push({ value: String(nights), label: t('reservations.nights', { count: nights }) }) + return stats +} diff --git a/client/src/mobile/screens/trip/plan/MPlanTimeline.tsx b/client/src/mobile/screens/trip/plan/MPlanTimeline.tsx index 65f040688..2fd4e1ad6 100644 --- a/client/src/mobile/screens/trip/plan/MPlanTimeline.tsx +++ b/client/src/mobile/screens/trip/plan/MPlanTimeline.tsx @@ -124,14 +124,17 @@ function PlanDayPage({ planner, shell, dayId: pageDayId, active, editing, legMen } const day = tl.day const dayId = day?.id - // A stay chip opens the stay, the same way the stay card in the day sheet - // does: the editor for members who may edit days, otherwise the hotel's - // place. A stay with neither still leads to the day sheet, so the chip - // never goes dead (#2210). + // A stay chip shows the stay before anything can be changed, as on the + // desktop: its booking's overview (dates, nights, status, Edit) where one is + // booked, else the hotel's place. Only a stay with neither opens the editor + // for members who may edit days, or the day sheet, so the chip never goes + // dead (#2210). const openStay = (chip: HotelChip) => { if (!day) return - if (canEdit) shell.openSheet('accommodation', { dayId: day.id, accId: chip.accId, from: 'timeline' }) + const booking = planner.reservations.find(r => r.accommodation_id != null && String(r.accommodation_id) === String(chip.accId)) + if (booking) shell.openSheet('transport', { reservationId: booking.id }) else if (chip.placeId != null) planner.handlePlaceClick(chip.placeId) + else if (canEdit) shell.openSheet('accommodation', { dayId: day.id, accId: chip.accId, from: 'timeline' }) else shell.openSheet('day', { dayId: day.id }) } // The icon alone tells check-out from check-in; the accessible name says it. diff --git a/client/src/mobile/screens/trip/plan/planTimelineModel.test.ts b/client/src/mobile/screens/trip/plan/planTimelineModel.test.ts index 488fe6354..bd44ebbb7 100644 --- a/client/src/mobile/screens/trip/plan/planTimelineModel.test.ts +++ b/client/src/mobile/screens/trip/plan/planTimelineModel.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' -import { buildPlanRows } from './planTimelineModel' +import { buildPlanRows, hotelChipsForDay } from './planTimelineModel' import type { MergedItem } from '../../../../utils/dayMerge' -import type { Place, Reservation, RouteSegment } from '../../../../types' +import type { Accommodation, Day, Place, Reservation, RouteSegment } from '../../../../types' // FE-MOBILE-PLANROWS-001 to FE-MOBILE-PLANROWS-003 @@ -84,3 +84,12 @@ describe('buildPlanRows', () => { expect(rows.some(row => row.kind === 'place' && row.invalidTour)).toBe(false) }) }) + +describe('hotelChipsForDay', () => { + it('shows a stay with two linked bookings once, though the list repeats it', () => { + const days = [{ id: 1, day_number: 1 }, { id: 2, day_number: 2 }] as unknown as Day[] + const stay = { id: 7, start_day_id: 1, end_day_id: 2, place_name: 'Liège', check_in: '15:00', place_id: null } as unknown as Accommodation + const chips = hotelChipsForDay(days[0], days, [stay, { ...stay }]) + expect(chips.map(c => c.key)).toEqual(['in-7']) + }) +}) diff --git a/client/src/mobile/screens/trip/plan/planTimelineModel.ts b/client/src/mobile/screens/trip/plan/planTimelineModel.ts index 01b750f85..458c82bfb 100644 --- a/client/src/mobile/screens/trip/plan/planTimelineModel.ts +++ b/client/src/mobile/screens/trip/plan/planTimelineModel.ts @@ -199,7 +199,12 @@ const accommodationName = (a: Accommodation): string => a.place_name || a.reserv export function hotelChipsForDay(day: Day, days: Day[], accommodations: Accommodation[]): HotelChip[] { const inRange = accommodations.filter(a => isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days)) const chips: HotelChip[] = [] + // The list repeats a stay once per linked booking (the server's join keeps + // one row per booking); a stay with two bookings is still one stay. + const seen = new Set() for (const a of inRange) { + if (seen.has(a.id)) continue + seen.add(a.id) const stay = { name: accommodationName(a), accId: a.id, placeId: a.place_id ?? null } if (a.end_day_id === day.id) { chips.push({ ...stay, key: `out-${a.id}`, variant: 'checkout', time: a.check_out || null }) diff --git a/client/src/mobile/screens/trip/sheets/MTransportSheet.tsx b/client/src/mobile/screens/trip/sheets/MTransportSheet.tsx index 899976a13..2d0816e6e 100644 --- a/client/src/mobile/screens/trip/sheets/MTransportSheet.tsx +++ b/client/src/mobile/screens/trip/sheets/MTransportSheet.tsx @@ -15,6 +15,7 @@ import type { Reservation } from '../../../../types' import { useSearchParams } from 'react-router' import { isTransportReservationType } from '@trek/shared' import { useBookingLink } from '../../../../hooks/useBookingLink' +import { bookingFacts, stayStats } from '../../../../components/Planner/bookings/bookingFacts' import { Eyebrow, INNER_CLS, StatBox, TileHeader, displayTime } from './MTripSheetUi' interface TransportSheetPayload { @@ -173,10 +174,27 @@ export default function MTransportSheet({ planner, shell }: MTripSheetsProps) { revealOnMap(res) } + // A stay shows its check-in, check-out and nights, as the desktop dialog does. + const hotelStats = res.type === 'hotel' + ? stayStats( + bookingFacts(res, { t, locale, timeFormat, days: planner.days, assignmentLookup: {}, hasLinkedCost: false }), + meta, + t, + locale, + ) + : [] + + // The editor that fits the booking: transports have their own, everything + // else (a stay, a table, a ticket) the booking editor, as in the Bookings tab. const editTransport = () => { - planner.setEditingTransport(res) - planner.setTransportModalDayId(res.day_id ?? null) - planner.setShowTransportModal(true) + if (isTransportReservationType(res.type)) { + planner.setEditingTransport(res) + planner.setTransportModalDayId(res.day_id ?? null) + planner.setShowTransportModal(true) + } else { + planner.setEditingReservation(res) + planner.setShowReservationModal(true) + } shell.closeSheet() } @@ -198,8 +216,15 @@ export default function MTransportSheet({ planner, shell }: MTripSheetsProps) {
+ {/* ── A stay: check-in, check-out, nights ── */} + {hotelStats.length > 0 && ( +
+ {hotelStats.map(s => )} +
+ )} + {/* ── Times / seat stats ── */} - {(depTime || arrTime || seat || platform) && ( + {hotelStats.length === 0 && (depTime || arrTime || seat || platform) && (
{depTime && (