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 && (