mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-14 00:01:03 +00:00
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.
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<number>()
|
||||
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 })
|
||||
|
||||
@@ -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) {
|
||||
</div>
|
||||
|
||||
<div className="m-scroll-fade mt-2 min-h-0 flex-1 overflow-y-auto px-[18px] pb-[18px] pt-[14px]">
|
||||
{/* ── A stay: check-in, check-out, nights ── */}
|
||||
{hotelStats.length > 0 && (
|
||||
<div className="mt-[14px] flex gap-[6px]">
|
||||
{hotelStats.map(s => <StatBox key={s.label} value={s.value} label={s.label} />)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Times / seat stats ── */}
|
||||
{(depTime || arrTime || seat || platform) && (
|
||||
{hotelStats.length === 0 && (depTime || arrTime || seat || platform) && (
|
||||
<div className="mt-[14px] flex gap-[6px]">
|
||||
{depTime && (
|
||||
<StatBox value={displayTime(depTime, locale, timeFormat)} label={depLabel} />
|
||||
|
||||
Reference in New Issue
Block a user