fix(planner): a place shows every booking linked to it, not just the first (#2201)

Four render sites resolved the day assignment's booking with find(), while the booking dialog happily links several to the same stop. A shared selector in dayMerge returns them all, time-sorted and stable, and the desktop plan tab, the place inspector, the mobile timeline and the mobile place sheet render the full list; the single-booking case is unchanged pixel for pixel.
This commit is contained in:
Maurice
2026-09-02 19:58:13 +02:00
committed by Maurice
parent 5d849ec292
commit faa27e3608
15 changed files with 425 additions and 217 deletions
@@ -3529,6 +3529,34 @@ describe('DayPlanSidebar', () => {
expect(screen.getByText(/Reservation pending/)).toBeInTheDocument()
})
it('FE-PLANNER-DAYPLAN-163b: every booking on the stop gets its own chip line (#2201)', async () => {
const user = userEvent.setup()
const place = buildPlace({ id: 1, name: 'Zoo' })
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
const a = buildAssignment({ id: 11, day_id: 10, order_index: 0, place })
const parking = buildReservation({
id: 530, type: 'parking', title: 'Parking pass', status: 'confirmed', assignment_id: 11,
reservation_time: '2025-06-01T09:00:00', reservation_end_time: '2025-06-01T09:30:00',
} as any)
const tickets = buildReservation({
id: 531, type: 'activity', title: 'Zoo tickets', status: 'pending', assignment_id: 11,
reservation_time: '2025-06-01T10:15:00',
} as any)
const onEditReservation = vi.fn()
render(<DayPlanSidebar {...makeDefaultProps({
// Newest first, the order the store hands them over after a local create.
days: [day], places: [place], assignments: { '10': [a] }, reservations: [tickets, parking],
onEditReservation,
})} />)
expect(screen.getByText('09:00 – 09:30')).toBeInTheDocument()
expect(screen.getByText('10:15')).toBeInTheDocument()
expect(screen.getByText(/Reservation confirmed/)).toBeInTheDocument()
expect(screen.getByText(/Reservation pending/)).toBeInTheDocument()
// Earliest first, so the first pencil belongs to the parking pass.
await user.click(screen.getAllByTitle('Edit')[0])
expect(onEditReservation).toHaveBeenCalledWith(parking)
})
it('FE-PLANNER-DAYPLAN-164: travellers on a place row are shown as avatars with an overflow count', () => {
const place = buildPlace({ id: 1, name: 'Group visit' })
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
@@ -31,6 +31,7 @@ import { isDayInAccommodationRange, getAccommodationAnchors, getDayBookendHotels
import {
TRANSPORT_TYPES, parseTimeToMinutes, getSpanPhase, hidesOnMiddleDay, getDisplayTimeForDay, getTransportRouteEndpoints,
getTransportForDay as _getTransportForDay, getMergedItems as _getMergedItems, isCarrierTransport, hasCarrierEndpointOnDay,
getAssignmentReservations,
type MergedItem,
} from '../../utils/dayMerge'
import { withinDriveRange } from '../../utils/geo'
@@ -2141,86 +2142,97 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
</div>
)}
{(() => {
const res = reservations.find(r => r.assignment_id === assignment.id)
if (!res) return null
const confirmed = res.status === 'confirmed'
const hasEndpoints = onToggleConnection && (res.endpoints || []).length >= 2
const active = hasEndpoints ? visibleConnectionIds.includes(res.id) : false
// The status, the time and the flight/train number used to sit in one
// pill strung together on a middle dot, which read as one run-on
// sentence. They are separate chips now: same tint so they still
// belong together, own outline so the eye can take them one at a time.
const RI = RES_ICONS[res.type] || Ticket
const tint = confirmed ? 'bg-[rgba(22,163,74,0.1)] text-[#16a34a]' : 'bg-[rgba(217,119,6,0.1)] text-[#d97706]'
const chip: React.CSSProperties = {
display: 'inline-flex', alignItems: 'center', gap: 3,
padding: '1px 6px', borderRadius: 5,
fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 600,
whiteSpace: 'nowrap',
}
const { time: st } = splitReservationDateTime(res.reservation_time)
const { time: et } = splitReservationDateTime(res.reservation_end_time)
const timeLabel = st || et
? `${st ? formatTime(st, locale, timeFormat) : ''}${et ? ` – ${formatTime(et, locale, timeFormat)}` : ''}`
: ''
let meta: any = {}
try { meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {}) } catch { meta = {} }
const carrierLabel = meta
? (meta.airline && meta.flight_number ? `${meta.airline} ${meta.flight_number}` : meta.flight_number || meta.train_number || '')
: ''
const linked = getAssignmentReservations(reservations, assignment.id)
if (linked.length === 0) return null
// A stop can carry more than one booking (a parking pass and the
// tickets for the same zoo), and every one of them is kept out of
// the timeline, so this row is where they have to appear (#2201).
// Stacked, because the chip lines are inline-flex and would
// otherwise run together on one.
return (
// No wrapping: the time belongs to the status it qualifies, so the
// chips stay on one line even when the row gets narrow.
<div style={{ marginTop: 3, display: 'inline-flex', alignItems: 'center', gap: 3, flexWrap: 'nowrap' }}>
<div className={tint} style={chip}>
<RI size={8} />
<span className="hidden sm:inline">{confirmed ? t('planner.resConfirmed') : t('planner.resPending')}</span>
</div>
{timeLabel && <span className={tint} style={{ ...chip, fontWeight: 500 }}>{timeLabel}</span>}
{carrierLabel && <span className={tint} style={{ ...chip, fontWeight: 500 }}>{carrierLabel}</span>}
{hasEndpoints && (
<button
type="button"
onClick={e => { e.stopPropagation(); onToggleConnection!(res.id) }}
title={t(active ? 'map.hideConnections' : 'map.showConnections')}
className={active ? 'bg-[#3b82f6] text-[#fff]' : 'bg-transparent text-content-faint'}
style={{
flexShrink: 0, appearance: 'none',
width: 20, height: 20, borderRadius: 4,
display: 'grid', placeItems: 'center', cursor: 'pointer',
border: 'none',
transition: 'color 120ms cubic-bezier(0.23,1,0.32,1), background 120ms cubic-bezier(0.23,1,0.32,1)',
}}
onMouseEnter={e => { if (!active) e.currentTarget.style.color = 'var(--text-primary)' }}
onMouseLeave={e => { if (!active) e.currentTarget.style.color = 'var(--text-faint)' }}
>
<RouteIcon size={11} />
</button>
)}
{canEditDays && (() => {
const isTransport = TRANSPORT_TYPES.has(res.type)
const handler = isTransport ? onEditTransport : onEditReservation
if (!handler) return null
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start' }}>
{linked.map(res => {
const confirmed = res.status === 'confirmed'
const hasEndpoints = onToggleConnection && (res.endpoints || []).length >= 2
const active = hasEndpoints ? visibleConnectionIds.includes(res.id) : false
// The status, the time and the flight/train number used to sit in one
// pill strung together on a middle dot, which read as one run-on
// sentence. They are separate chips now: same tint so they still
// belong together, own outline so the eye can take them one at a time.
const RI = RES_ICONS[res.type] || Ticket
const tint = confirmed ? 'bg-[rgba(22,163,74,0.1)] text-[#16a34a]' : 'bg-[rgba(217,119,6,0.1)] text-[#d97706]'
const chip: React.CSSProperties = {
display: 'inline-flex', alignItems: 'center', gap: 3,
padding: '1px 6px', borderRadius: 5,
fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 600,
whiteSpace: 'nowrap',
}
const { time: st } = splitReservationDateTime(res.reservation_time)
const { time: et } = splitReservationDateTime(res.reservation_end_time)
const timeLabel = st || et
? `${st ? formatTime(st, locale, timeFormat) : ''}${et ? ` – ${formatTime(et, locale, timeFormat)}` : ''}`
: ''
let meta: any = {}
try { meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {}) } catch { meta = {} }
const carrierLabel = meta
? (meta.airline && meta.flight_number ? `${meta.airline} ${meta.flight_number}` : meta.flight_number || meta.train_number || '')
: ''
return (
<button
type="button"
onClick={e => { e.stopPropagation(); handler(res) }}
title={t('common.edit')}
className="bg-transparent text-content-faint"
style={{
flexShrink: 0, appearance: 'none',
width: 20, height: 20, borderRadius: 4,
display: 'grid', placeItems: 'center', cursor: 'pointer',
border: 'none',
transition: 'color 120ms cubic-bezier(0.23,1,0.32,1), background 120ms cubic-bezier(0.23,1,0.32,1)',
}}
onMouseEnter={e => { e.currentTarget.style.color = 'var(--text-primary)' }}
onMouseLeave={e => { e.currentTarget.style.color = 'var(--text-faint)' }}
>
<Pencil size={11} />
</button>
// No wrapping: the time belongs to the status it qualifies, so the
// chips stay on one line even when the row gets narrow.
<div key={res.id} style={{ marginTop: 3, display: 'inline-flex', alignItems: 'center', gap: 3, flexWrap: 'nowrap' }}>
<div className={tint} style={chip}>
<RI size={8} />
<span className="hidden sm:inline">{confirmed ? t('planner.resConfirmed') : t('planner.resPending')}</span>
</div>
{timeLabel && <span className={tint} style={{ ...chip, fontWeight: 500 }}>{timeLabel}</span>}
{carrierLabel && <span className={tint} style={{ ...chip, fontWeight: 500 }}>{carrierLabel}</span>}
{hasEndpoints && (
<button
type="button"
onClick={e => { e.stopPropagation(); onToggleConnection!(res.id) }}
title={t(active ? 'map.hideConnections' : 'map.showConnections')}
className={active ? 'bg-[#3b82f6] text-[#fff]' : 'bg-transparent text-content-faint'}
style={{
flexShrink: 0, appearance: 'none',
width: 20, height: 20, borderRadius: 4,
display: 'grid', placeItems: 'center', cursor: 'pointer',
border: 'none',
transition: 'color 120ms cubic-bezier(0.23,1,0.32,1), background 120ms cubic-bezier(0.23,1,0.32,1)',
}}
onMouseEnter={e => { if (!active) e.currentTarget.style.color = 'var(--text-primary)' }}
onMouseLeave={e => { if (!active) e.currentTarget.style.color = 'var(--text-faint)' }}
>
<RouteIcon size={11} />
</button>
)}
{canEditDays && (() => {
const isTransport = TRANSPORT_TYPES.has(res.type)
const handler = isTransport ? onEditTransport : onEditReservation
if (!handler) return null
return (
<button
type="button"
onClick={e => { e.stopPropagation(); handler(res) }}
title={t('common.edit')}
className="bg-transparent text-content-faint"
style={{
flexShrink: 0, appearance: 'none',
width: 20, height: 20, borderRadius: 4,
display: 'grid', placeItems: 'center', cursor: 'pointer',
border: 'none',
transition: 'color 120ms cubic-bezier(0.23,1,0.32,1), background 120ms cubic-bezier(0.23,1,0.32,1)',
}}
onMouseEnter={e => { e.currentTarget.style.color = 'var(--text-primary)' }}
onMouseLeave={e => { e.currentTarget.style.color = 'var(--text-faint)' }}
>
<Pencil size={11} />
</button>
)
})()}
</div>
)
})()}
})}
</div>
)
})()}
@@ -425,6 +425,33 @@ describe('PlaceInspector', () => {
expect(screen.getByText('Museum Ticket')).toBeTruthy();
});
it('FE-PLANNER-INSPECTOR-030g: every booking on the stop gets its own strip (#2201)', () => {
const onEditReservation = vi.fn();
const parking = buildReservation({
id: 530, title: 'Parking pass', status: 'confirmed', type: 'parking', assignment_id: 99,
reservation_time: '2025-06-01T09:00:00',
} as any);
const tickets = buildReservation({
id: 531, title: 'Zoo tickets', status: 'pending', type: 'activity', assignment_id: 99,
reservation_time: '2025-06-01T10:15:00',
} as any);
const assignmentInDay = [{ id: 99, place, day_id: 1, place_id: place.id, order_index: 0, notes: null }];
render(
<PlaceInspector
{...defaultProps}
selectedDayId={1}
selectedAssignmentId={99}
assignments={{ '1': assignmentInDay }}
reservations={[tickets, parking]}
onEditReservation={onEditReservation}
/>
);
expect(screen.getByText('Parking pass')).toBeTruthy();
expect(screen.getByText('Zoo tickets')).toBeTruthy();
fireEvent.click(screen.getByText('Zoo tickets').closest('[role="button"]') as HTMLElement);
expect(onEditReservation).toHaveBeenCalledWith(tickets);
});
it('FE-PLANNER-INSPECTOR-030b: the linked reservation opens its editor (#2012)', () => {
const onEditReservation = vi.fn();
const reservation = buildReservation({ title: 'Museum Ticket', status: 'confirmed', assignment_id: 99 } as any);
@@ -30,7 +30,7 @@ import { splitReservationDateTime, formatTime, formatMoney } from '../../utils/f
import { useTripStore } from '../../store/tripStore'
import { formatDistance, formatElevation } from '../../utils/units'
import { getNavigationTargets, openNavigationTarget } from './placeNavigation'
import { TRANSPORT_TYPES } from '../../utils/dayMerge'
import { TRANSPORT_TYPES, getAssignmentReservations } from '../../utils/dayMerge'
import { NavigationMenu } from '../shared/NavigationMenu'
import { resolveOpenNow, resolvePlaceTimeZone, placeWeekdayIndex } from './placeOpenState'
import { convertHoursLine } from './placeHoursFormat'
@@ -839,96 +839,101 @@ function PlaceReservationParticipants({ selectedAssignmentId, reservations, assi
return (
<>
{(() => {
const res = selectedAssignmentId ? reservations.find(r => r.assignment_id === selectedAssignmentId) : null
const linked = getAssignmentReservations<Reservation>(reservations, selectedAssignmentId)
const assignment = selectedAssignmentId ? (assignments[String(selectedDayId)] || []).find(a => a.id === selectedAssignmentId) : null
const currentParticipants = assignment?.participants || []
const participantIds = currentParticipants.map(p => p.user_id)
const allJoined = currentParticipants.length === 0
const showParticipants = selectedAssignmentId && tripMembers.length > 1
if (!res && !showParticipants) return null
if (linked.length === 0 && !showParticipants) return null
return (
<div className={`grid ${res && showParticipants ? 'grid-cols-1 sm:grid-cols-2' : 'grid-cols-1'} gap-2`}>
{/* Reservation */}
{res && (() => {
const confirmed = res.status === 'confirmed'
// The strip summarised the booking but went nowhere, so its
// attachments and fields had no route from the map (#2012).
// A transport has its own form; picking by type is what the day
// sidebar does, and an absent handler means this user may not
// open this one — so the strip stays inert rather than lying.
const editor = TRANSPORT_TYPES.has(res.type) ? onEditTransport : onEditReservation
const open = editor ? () => editor(res) : undefined
return (
<div
role={open ? 'button' : undefined}
// No press-scale on the composite strip — shrinking it
// mid-click slides the links inside out from under the
// pointer (#2158).
data-no-press
aria-label={open ? t('inspector.editRes') : undefined}
tabIndex={open ? 0 : undefined}
onClick={open}
onKeyDown={open ? (e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); open() }
} : undefined}
title={open ? t('inspector.editRes') : undefined}
style={{ borderRadius: 12, overflow: 'hidden', border: `1px solid ${confirmed ? 'rgba(22,163,74,0.2)' : 'rgba(217,119,6,0.2)'}`, cursor: open ? 'pointer' : undefined, textAlign: 'left' }}
>
<div className={confirmed ? 'bg-[rgba(22,163,74,0.08)]' : 'bg-[rgba(217,119,6,0.08)]'} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 10px' }}>
<div className={confirmed ? 'bg-[#16a34a]' : 'bg-[#d97706]'} style={{ width: 6, height: 6, borderRadius: '50%', flexShrink: 0 }} />
<span className={confirmed ? 'text-[#16a34a]' : 'text-[#d97706]'} style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 700 }}>{confirmed ? t('reservations.confirmed') : t('reservations.pending')}</span>
<span style={{ flex: 1 }} />
<span className="text-content" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{res.title}</span>
</div>
<div style={{ padding: '6px 10px', display: 'flex', gap: 12, flexWrap: 'wrap' }}>
{(() => {
const { date, time: startTime } = splitReservationDateTime(res.reservation_time)
const { time: endTime } = splitReservationDateTime(res.reservation_end_time)
return (
<>
{date && (
<div>
<div className="text-content-faint" style={{ fontSize: 'calc(8px * var(--fs-scale-caption, 1))', fontWeight: 600, textTransform: 'uppercase' }}>{t('reservations.date')}</div>
<div className="text-content" style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 500, marginTop: 1 }}>{new Date(date + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })}</div>
</div>
)}
{(startTime || endTime) && (
<div>
<div className="text-content-faint" style={{ fontSize: 'calc(8px * var(--fs-scale-caption, 1))', fontWeight: 600, textTransform: 'uppercase' }}>{t('reservations.time')}</div>
<div className="text-content" style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 500, marginTop: 1 }}>
{startTime ? formatTime(startTime, locale, timeFormat) : ''}
{endTime ? ` – ${formatTime(endTime, locale, timeFormat)}` : ''}
</div>
</div>
)}
</>
)
})()}
{res.confirmation_number && (
<div>
<div className="text-content-faint" style={{ fontSize: 'calc(8px * var(--fs-scale-caption, 1))', fontWeight: 600, textTransform: 'uppercase' }}>{t('reservations.confirmationCode')}</div>
<div className="text-content" style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 500, marginTop: 1 }}>{res.confirmation_number}</div>
<div className={`grid ${linked.length > 0 && showParticipants ? 'grid-cols-1 sm:grid-cols-2' : 'grid-cols-1'} gap-2`}>
{/* Bookings pinned to this stop; several can share one (#2201) */}
{linked.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{linked.map(res => {
const confirmed = res.status === 'confirmed'
// The strip summarised the booking but went nowhere, so its
// attachments and fields had no route from the map (#2012).
// A transport has its own form; picking by type is what the day
// sidebar does, and an absent handler means this user may not
// open this one, so the strip stays inert rather than lying.
const editor = TRANSPORT_TYPES.has(res.type) ? onEditTransport : onEditReservation
const open = editor ? () => editor(res) : undefined
return (
<div
key={res.id}
role={open ? 'button' : undefined}
// No press-scale on the composite strip: shrinking it
// mid-click slides the links inside out from under the
// pointer (#2158).
data-no-press
aria-label={open ? t('inspector.editRes') : undefined}
tabIndex={open ? 0 : undefined}
onClick={open}
onKeyDown={open ? (e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); open() }
} : undefined}
title={open ? t('inspector.editRes') : undefined}
style={{ borderRadius: 12, overflow: 'hidden', border: `1px solid ${confirmed ? 'rgba(22,163,74,0.2)' : 'rgba(217,119,6,0.2)'}`, cursor: open ? 'pointer' : undefined, textAlign: 'left' }}
>
<div className={confirmed ? 'bg-[rgba(22,163,74,0.08)]' : 'bg-[rgba(217,119,6,0.08)]'} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 10px' }}>
<div className={confirmed ? 'bg-[#16a34a]' : 'bg-[#d97706]'} style={{ width: 6, height: 6, borderRadius: '50%', flexShrink: 0 }} />
<span className={confirmed ? 'text-[#16a34a]' : 'text-[#d97706]'} style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 700 }}>{confirmed ? t('reservations.confirmed') : t('reservations.pending')}</span>
<span style={{ flex: 1 }} />
<span className="text-content" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{res.title}</span>
</div>
)}
</div>
{res.notes && <div className="collab-note-md text-content-faint" style={{ padding: '0 10px 6px', fontSize: 'calc(10px * var(--fs-scale-caption, 1))', lineHeight: 1.4, wordBreak: 'break-word', overflowWrap: 'anywhere' }}><Markdown remarkPlugins={[remarkGfm, remarkBreaks]} components={markdownLinkComponents}>{res.notes}</Markdown></div>}
{(() => {
const meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {})
if (!meta || Object.keys(meta).length === 0) return null
const parts: string[] = []
if (meta.airline && meta.flight_number) parts.push(`${meta.airline} ${meta.flight_number}`)
else if (meta.flight_number) parts.push(meta.flight_number)
if (meta.departure_airport && meta.arrival_airport) parts.push(`${meta.departure_airport} → ${meta.arrival_airport}`)
if (meta.train_number) parts.push(meta.train_number)
if (meta.platform) parts.push(`Gl. ${meta.platform}`)
if (meta.check_in_time) parts.push(`Check-in ${meta.check_in_time}`)
if (meta.check_out_time) parts.push(`Check-out ${meta.check_out_time}`)
if (parts.length === 0) return null
return <div className="text-content-muted" style={{ padding: '0 10px 6px', fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 500 }}>{parts.join(' · ')}</div>
})()}
</div>
)
})()}
<div style={{ padding: '6px 10px', display: 'flex', gap: 12, flexWrap: 'wrap' }}>
{(() => {
const { date, time: startTime } = splitReservationDateTime(res.reservation_time)
const { time: endTime } = splitReservationDateTime(res.reservation_end_time)
return (
<>
{date && (
<div>
<div className="text-content-faint" style={{ fontSize: 'calc(8px * var(--fs-scale-caption, 1))', fontWeight: 600, textTransform: 'uppercase' }}>{t('reservations.date')}</div>
<div className="text-content" style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 500, marginTop: 1 }}>{new Date(date + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })}</div>
</div>
)}
{(startTime || endTime) && (
<div>
<div className="text-content-faint" style={{ fontSize: 'calc(8px * var(--fs-scale-caption, 1))', fontWeight: 600, textTransform: 'uppercase' }}>{t('reservations.time')}</div>
<div className="text-content" style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 500, marginTop: 1 }}>
{startTime ? formatTime(startTime, locale, timeFormat) : ''}
{endTime ? ` – ${formatTime(endTime, locale, timeFormat)}` : ''}
</div>
</div>
)}
</>
)
})()}
{res.confirmation_number && (
<div>
<div className="text-content-faint" style={{ fontSize: 'calc(8px * var(--fs-scale-caption, 1))', fontWeight: 600, textTransform: 'uppercase' }}>{t('reservations.confirmationCode')}</div>
<div className="text-content" style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 500, marginTop: 1 }}>{res.confirmation_number}</div>
</div>
)}
</div>
{res.notes && <div className="collab-note-md text-content-faint" style={{ padding: '0 10px 6px', fontSize: 'calc(10px * var(--fs-scale-caption, 1))', lineHeight: 1.4, wordBreak: 'break-word', overflowWrap: 'anywhere' }}><Markdown remarkPlugins={[remarkGfm, remarkBreaks]} components={markdownLinkComponents}>{res.notes}</Markdown></div>}
{(() => {
const meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {})
if (!meta || Object.keys(meta).length === 0) return null
const parts: string[] = []
if (meta.airline && meta.flight_number) parts.push(`${meta.airline} ${meta.flight_number}`)
else if (meta.flight_number) parts.push(meta.flight_number)
if (meta.departure_airport && meta.arrival_airport) parts.push(`${meta.departure_airport} → ${meta.arrival_airport}`)
if (meta.train_number) parts.push(meta.train_number)
if (meta.platform) parts.push(`Gl. ${meta.platform}`)
if (meta.check_in_time) parts.push(`Check-in ${meta.check_in_time}`)
if (meta.check_out_time) parts.push(`Check-out ${meta.check_out_time}`)
if (parts.length === 0) return null
return <div className="text-content-muted" style={{ padding: '0 10px 6px', fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 500 }}>{parts.join(' · ')}</div>
})()}
</div>
)
})}
</div>
)}
{/* Participants */}
{showParticipants && (
@@ -175,7 +175,7 @@ export default function MPlanTimeline({ planner, shell }: MPlanTimelineProps) {
<PlaceRow
assignment={row.assignment}
fullPlace={tl.fullPlaceOf(row.assignment)}
linkedRes={row.linkedRes}
linkedReservations={row.linkedReservations}
chrome={chrome}
reorder={reorderFor(row.item)}
drag={dragFor(row)}
@@ -119,10 +119,10 @@ const TIME_CHIP = 'flex-none whitespace-nowrap rounded-[6px] bg-[color:var(--m-i
// ── b3) Place row ────────────────────────────────────────────────────────────
export function PlaceRow({ assignment, fullPlace, linkedRes, chrome, reorder, drag, onOpen, onEdit, onRemove }: {
export function PlaceRow({ assignment, fullPlace, linkedReservations, chrome, reorder, drag, onOpen, onEdit, onRemove }: {
assignment: Assignment
fullPlace: Place | undefined
linkedRes: Reservation | null
linkedReservations: Reservation[]
chrome: RowChrome
reorder: ReactNode
drag?: RowDrag
@@ -134,11 +134,19 @@ export function PlaceRow({ assignment, fullPlace, linkedRes, chrome, reorder, dr
const place = assignment.place
const CatIcon = getCategoryIcon(place?.category?.icon)
const time = fmtTime(place?.place_time, chrome)
const sub = linkedRes
? [
linkedRes.status === 'confirmed' ? t('dayplan.confirmed') : t('dayplan.pendingRes'),
linkedRes.confirmation_number ? `#${linkedRes.confirmation_number}` : '',
].filter(Boolean).join(' · ')
// One line per booking on the stop (#2201). A single one keeps the bare
// status line it always had; once there are several, the title tells them
// apart, since "Confirmed" twice over says nothing.
const bookingLines = linkedReservations.map(r => ({
id: r.id,
text: [
linkedReservations.length > 1 ? r.title : '',
r.status === 'confirmed' ? t('dayplan.confirmed') : t('dayplan.pendingRes'),
r.confirmation_number ? `#${r.confirmation_number}` : '',
].filter(Boolean).join(' · '),
}))
const sub = bookingLines.length > 0
? bookingLines[0].text
: place?.address || place?.description || ''
return (
@@ -175,7 +183,7 @@ export function PlaceRow({ assignment, fullPlace, linkedRes, chrome, reorder, dr
<div className="flex items-center gap-1.5">
<CatIcon size={12} strokeWidth={2.2} className="flex-none text-m-muted" />
<span className="min-w-0 truncate text-[0.875rem] font-semibold">{place?.name}</span>
{linkedRes && (
{bookingLines.length > 0 && (
<span className="flex flex-none items-center gap-1 rounded-full bg-[color:var(--m-ic)] px-[7px] py-[2px] text-[0.5625rem] font-bold tracking-[.04em]">
<Ticket size={10} strokeWidth={2.2} />
{t('mobileTrip.resBadge')}
@@ -188,6 +196,11 @@ export function PlaceRow({ assignment, fullPlace, linkedRes, chrome, reorder, dr
{sub && <span className="min-w-0 truncate font-geist text-[0.71875rem] text-m-muted">{sub}</span>}
</div>
)}
{bookingLines.slice(1).map(line => (
<div key={line.id} className="mt-[2px] flex min-w-0 items-center gap-1.5">
<span className="min-w-0 truncate font-geist text-[0.71875rem] text-m-muted">{line.text}</span>
</div>
))}
{assignment.notes && (
// Day-specific note on this stop (#2163) — mirror of the desktop
// timeline's caption line, so the phone shows the note exists
@@ -0,0 +1,50 @@
import { describe, expect, it } from 'vitest'
import { buildPlanRows } from './planTimelineModel'
import type { MergedItem } from '../../../../utils/dayMerge'
import type { Reservation } from '../../../../types'
// FE-MOBILE-PLANROWS-001 to FE-MOBILE-PLANROWS-003
const zooStop = [
{ type: 'place', sortKey: 0, data: { id: 11, day_id: 5, order_index: 0, place: { id: 1, name: 'Zoo' } } },
] as unknown as MergedItem[]
describe('buildPlanRows', () => {
it('FE-MOBILE-PLANROWS-001: carries every booking linked to the stop, earliest first (#2201)', () => {
const reservations = [
{ id: 531, assignment_id: 11, title: 'Zoo tickets', reservation_time: '2025-06-01T10:15:00' },
{ id: 530, assignment_id: 11, title: 'Parking pass', reservation_time: '2025-06-01T09:00:00' },
{ id: 532, assignment_id: 12, title: 'Another stop', reservation_time: null },
] as unknown as Reservation[]
const rows = buildPlanRows({ merged: zooStop, reservations, routeSegments: [], dayId: 5 })
expect(rows).toHaveLength(1)
const row = rows[0]
if (row.kind !== 'place') throw new Error('expected a place row')
expect(row.linkedReservations.map(r => r.id)).toEqual([530, 531])
})
it('FE-MOBILE-PLANROWS-002: a stop without bookings gets an empty list', () => {
const reservations = [
{ id: 532, assignment_id: 12, title: 'Another stop', reservation_time: null },
] as unknown as Reservation[]
const rows = buildPlanRows({ merged: zooStop, reservations, routeSegments: [], dayId: 5 })
const row = rows[0]
if (row.kind !== 'place') throw new Error('expected a place row')
expect(row.linkedReservations).toEqual([])
})
it('FE-MOBILE-PLANROWS-003: an unlinked booking stays a transport row of its own', () => {
const merged = [
...zooStop,
{ type: 'transport', sortKey: 1, data: { id: 540, type: 'taxi', day_id: 5, title: 'Cab' } },
] as unknown as MergedItem[]
const reservations = [
{ id: 540, assignment_id: null, type: 'taxi', title: 'Cab', reservation_time: null },
] as unknown as Reservation[]
const rows = buildPlanRows({ merged, reservations, routeSegments: [], dayId: 5 })
expect(rows.map(r => r.kind)).toEqual(['place', 'transport'])
const place = rows[0]
if (place.kind !== 'place') throw new Error('expected a place row')
expect(place.linkedReservations).toEqual([])
})
})
@@ -1,6 +1,6 @@
import { Cloud, CloudDrizzle, CloudLightning, CloudRain, CloudSnow, Sun, Wind } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import { getDisplayTimeForDay, getSpanPhase, hidesOnMiddleDay, parseTimeToMinutes } from '../../../../utils/dayMerge'
import { getAssignmentReservations, getDisplayTimeForDay, getSpanPhase, hidesOnMiddleDay, parseTimeToMinutes } from '../../../../utils/dayMerge'
import { getDayBookendHotels, isDayInAccommodationRange } from '../../../../utils/dayOrder'
import type { MergedItem } from '../../../../utils/dayMerge'
import type { TransitLegDisplay } from '../../../../components/Planner/transitDisplay'
@@ -24,7 +24,7 @@ export interface TransitMeta {
}
export type PlanRow =
| { key: string; kind: 'place'; item: MergedItem; assignment: Assignment; linkedRes: Reservation | null }
| { key: string; kind: 'place'; item: MergedItem; assignment: Assignment; linkedReservations: Reservation[] }
| { key: string; kind: 'transport'; item: MergedItem; res: TransportEntry }
| { key: string; kind: 'transit'; item: MergedItem; res: TransportEntry; transit: TransitMeta }
| { key: string; kind: 'note'; item: MergedItem; note: DayNote }
@@ -100,7 +100,10 @@ export function buildPlanRows(opts: {
kind: 'place',
item,
assignment,
linkedRes: reservations.find(r => r.assignment_id === assignment.id) ?? null,
// All of them: a stop can carry a parking pass and the tickets for the same
// attraction, and getTransportForDay keeps every linked booking out of the
// timeline, so anything dropped here is gone from the plan tab (#2201).
linkedReservations: getAssignmentReservations(reservations, assignment.id),
})
} else if (item.type === 'note') {
const note = item.data as DayNote
@@ -11,7 +11,7 @@ const items = [
] as unknown as MergedItem[]
const rowFor = (i: number): PlanRow =>
({ key: `row-${i}`, kind: 'place', item: items[i], assignment: {}, linkedRes: null }) as unknown as PlanRow
({ key: `row-${i}`, kind: 'place', item: items[i], assignment: {}, linkedReservations: [] }) as unknown as PlanRow
const connRow = { key: 'conn-1', kind: 'conn', seg: {} } as unknown as PlanRow
@@ -21,7 +21,8 @@ import { safeHttpUrl } from '../../../../utils/safeUrl'
import { openFile } from '../../../../utils/fileDownload'
import { getNavigationTargets, openNavigationTarget } from '../../../../components/Planner/placeNavigation'
import { NavigationMenu } from '../../../../components/shared/NavigationMenu'
import type { Assignment, Day, TripMember } from '../../../../types'
import { getAssignmentReservations } from '../../../../utils/dayMerge'
import type { Assignment, Day, Reservation, TripMember } from '../../../../types'
import { ActionCircle, Eyebrow, INNER_CLS } from './MTripSheetUi'
/**
@@ -85,28 +86,27 @@ export default function MPlaceSheet({ planner, shell }: MTripSheetsProps) {
? ((planner.selectedAssignmentId ? dayAssignments.find(a => a.id === planner.selectedAssignmentId) : null)
?? dayAssignments.find(a => a.place?.id === place?.id))
: null
// The booking attached to that assignment. The desktop inspector shows this
// The bookings attached to that assignment. The desktop inspector shows this
// strip; the phone sheet never did, so a booking reached from a map marker was
// just as unreachable here, only invisibly so (#2012).
const linkedRes = assignmentInDay
? planner.reservations.find(r => r.assignment_id === assignmentInDay.id) ?? null
: null
// just as unreachable here, only invisibly so (#2012). All of them, because a
// stop can carry a parking pass next to its tickets (#2201).
const linkedReservations = getAssignmentReservations(planner.reservations, assignmentInDay?.id)
// A ferry or a flight has its own form — the reservation modal cannot hold one.
// Resolved up front so a user without the matching right gets no button at all,
// rather than one that does nothing (#2012).
const canOpenLinkedRes = !!linkedRes && planner.can(
planner.TRANSPORT_TYPES.has(linkedRes.type) ? 'day_edit' : 'reservation_edit',
const canOpenRes = (res: Reservation) => planner.can(
planner.TRANSPORT_TYPES.has(res.type) ? 'day_edit' : 'reservation_edit',
planner.trip,
)
const openLinkedRes = () => {
if (!linkedRes || !canOpenLinkedRes) return
if (planner.TRANSPORT_TYPES.has(linkedRes.type)) {
planner.setEditingTransport(linkedRes)
planner.setTransportModalDayId(linkedRes.day_id ?? null)
const openRes = (res: Reservation) => {
if (!canOpenRes(res)) return
if (planner.TRANSPORT_TYPES.has(res.type)) {
planner.setEditingTransport(res)
planner.setTransportModalDayId(res.day_id ?? null)
planner.setTransportModalAutomated(false)
planner.setShowTransportModal(true)
} else {
planner.setEditingReservation(linkedRes)
planner.setEditingReservation(res)
planner.setShowReservationModal(true)
}
// This sheet hangs off the place selection, not the sheet stack, so its own
@@ -452,27 +452,32 @@ export default function MPlaceSheet({ planner, shell }: MTripSheetsProps) {
</>
)}
{/* ── The booking attached to this stop (#2012) ── */}
{linkedRes && (
{/* ── The bookings attached to this stop (#2012, #2201) ── */}
{linkedReservations.length > 0 && (
<>
<Eyebrow className="mb-[6px] mt-3">{t('reservations.title')}</Eyebrow>
<button
type="button"
onClick={openLinkedRes}
disabled={!canOpenLinkedRes}
aria-label={canOpenLinkedRes ? t('inspector.editRes') : undefined}
className={`flex w-full items-center gap-2 rounded-[14px] px-3 py-[10px] text-left ${INNER_CLS}`}
>
<span
className="h-2 w-2 flex-none rounded-full"
style={{ background: linkedRes.status === 'confirmed' ? 'var(--m-st-confirmed)' : 'var(--m-st-pending)' }}
/>
<span className="min-w-0 flex-1 truncate text-[0.8125rem] font-semibold">{linkedRes.title}</span>
<span className="flex-none font-geist text-[0.65625rem] text-m-muted">
{linkedRes.status === 'confirmed' ? t('reservations.confirmed') : t('reservations.pending')}
</span>
{canOpenLinkedRes && <ChevronRight size={14} strokeWidth={2} className="flex-none text-m-faint" />}
</button>
<div className="flex flex-col gap-[6px]">
{linkedReservations.map(res => (
<button
key={res.id}
type="button"
onClick={() => openRes(res)}
disabled={!canOpenRes(res)}
aria-label={canOpenRes(res) ? t('inspector.editRes') : undefined}
className={`flex w-full items-center gap-2 rounded-[14px] px-3 py-[10px] text-left ${INNER_CLS}`}
>
<span
className="h-2 w-2 flex-none rounded-full"
style={{ background: res.status === 'confirmed' ? 'var(--m-st-confirmed)' : 'var(--m-st-pending)' }}
/>
<span className="min-w-0 flex-1 truncate text-[0.8125rem] font-semibold">{res.title}</span>
<span className="flex-none font-geist text-[0.65625rem] text-m-muted">
{res.status === 'confirmed' ? t('reservations.confirmed') : t('reservations.pending')}
</span>
{canOpenRes(res) && <ChevronRight size={14} strokeWidth={2} className="flex-none text-m-faint" />}
</button>
))}
</div>
</>
)}
+36 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
import { parseTimeToMinutes, getSpanPhase, hidesOnMiddleDay, getTransportRouteEndpoints, getDisplayTimeForDay, getTransportForDay, getMergedItems } from './dayMerge'
import { parseTimeToMinutes, getSpanPhase, hidesOnMiddleDay, getTransportRouteEndpoints, getDisplayTimeForDay, getTransportForDay, getAssignmentReservations, getMergedItems } from './dayMerge'
describe('parseTimeToMinutes', () => {
it('parses HH:MM string', () => {
@@ -199,6 +199,41 @@ describe('getTransportForDay', () => {
})
})
describe('getAssignmentReservations', () => {
it('returns every booking pinned to the assignment, not just the first (#2201)', () => {
const reservations = [
{ id: 1, assignment_id: 42, reservation_time: '2025-06-01T10:00:00' },
{ id: 2, assignment_id: 42, reservation_time: '2025-06-01T09:00:00' },
{ id: 3, assignment_id: 7, reservation_time: '2025-06-01T08:00:00' },
]
expect(getAssignmentReservations(reservations, 42).map(r => r.id)).toEqual([2, 1])
})
it('puts untimed bookings last and breaks ties on the id', () => {
const reservations = [
{ id: 5, assignment_id: 42, reservation_time: null },
{ id: 4, assignment_id: 42, reservation_time: null },
{ id: 6, assignment_id: 42, reservation_time: '2025-06-01T09:00:00' },
]
expect(getAssignmentReservations(reservations, 42).map(r => r.id)).toEqual([6, 4, 5])
})
it('returns nothing without an assignment', () => {
const reservations = [{ id: 1, assignment_id: 42, reservation_time: null }]
expect(getAssignmentReservations(reservations, null)).toEqual([])
expect(getAssignmentReservations(reservations, undefined)).toEqual([])
})
it('leaves the caller array untouched', () => {
const reservations = [
{ id: 1, assignment_id: 42, reservation_time: '2025-06-01T10:00:00' },
{ id: 2, assignment_id: 42, reservation_time: '2025-06-01T09:00:00' },
]
getAssignmentReservations(reservations, 42)
expect(reservations.map(r => r.id)).toEqual([1, 2])
})
})
describe('getMergedItems', () => {
it('merges places and notes sorted by sortKey', () => {
const dayAssignments = [
+30
View File
@@ -235,6 +235,36 @@ export function getTransportForDay(opts: {
}).flatMap(r => expandFlightLegsForDay(r, dayId, getDayOrder, days))
}
/**
* Every booking pinned to one day assignment. A stop can carry several (the parking
* pass and the tickets for the same zoo), and the exclusion above keeps all of them
* out of the timeline, so the place row is the only surface that can show them, and
* a `find()` there dropped the rest without trace (#2201).
*
* Earliest first, untimed last, id as the tiebreaker: the store's array order is
* newest-first for locally created bookings and load order otherwise, which would
* shuffle the same two bookings between a reload and a live update.
*/
export function getAssignmentReservations<T extends {
id: number
assignment_id?: number | null
reservation_time?: string | null
}>(reservations: T[], assignmentId: number | null | undefined): T[] {
if (!assignmentId) return []
return reservations
.filter(r => r.assignment_id === assignmentId)
.sort((a, b) => {
const at = a.reservation_time || ''
const bt = b.reservation_time || ''
if (at !== bt) {
if (!at) return 1
if (!bt) return -1
return at < bt ? -1 : 1
}
return a.id - b.id
})
}
/**
* Order items chronologically: anything with a time (a place's place_time, a
* transport/leg display time, a timed note) sorts by that time. An item WITHOUT a
@@ -73,12 +73,12 @@ const M_PLACE2: MergedItem = { type: 'place', sortKey: 4, data: PARK }
const MERGED = [M_PLACE, M_FLIGHT, M_TRANSIT, M_NOTE, M_PLACE2]
const ROWS: PlanRow[] = [
{ key: 'pl-11', kind: 'place', item: M_PLACE, assignment: MUSEUM, linkedRes: null },
{ key: 'pl-11', kind: 'place', item: M_PLACE, assignment: MUSEUM, linkedReservations: [] },
{ key: 'conn-pl-11', kind: 'conn', seg: SEG, assignmentId: 11 },
{ key: 'tr-21', kind: 'transport', item: M_FLIGHT, res: FLIGHT },
{ key: 'tr-22', kind: 'transit', item: M_TRANSIT, res: TRANSIT_RES, transit: TRANSIT },
{ key: 'note-41', kind: 'note', item: M_NOTE, note: NOTE },
{ key: 'pl-12', kind: 'place', item: M_PLACE2, assignment: PARK, linkedRes: null },
{ key: 'pl-12', kind: 'place', item: M_PLACE2, assignment: PARK, linkedReservations: [] },
{ key: 'conn-orphan', kind: 'conn', seg: { ...SEG, mode: 'walking' } },
]
@@ -265,7 +265,7 @@ describe('MPlanTimeline', () => {
it('FE-MOB-PLTL-011: a place row without a place still reports the assignment', () => {
const orphan = { id: 13, day_id: 2, place_id: 103, order_index: 2, place: null } as unknown as Assignment
const rows: PlanRow[] = [{ key: 'pl-13', kind: 'place', item: M_PLACE, assignment: orphan, linkedRes: null }]
const rows: PlanRow[] = [{ key: 'pl-13', kind: 'place', item: M_PLACE, assignment: orphan, linkedReservations: [] }]
const { planner, container } = renderTimeline({ rows })
fireEvent.click(container.querySelector('.cursor-pointer.items-center') as HTMLElement)
@@ -411,7 +411,7 @@ describe('MPlanTimeline', () => {
byPosition: { 2: { start: [{ pluginId: 'ev', id: 's1', dayId: 2, position: 'start', label: 'Morning prep', tone: 'default' }], end: [] } },
minutesByDay: { 2: 35 },
}
const rows: PlanRow[] = [{ key: 'pl-11', kind: 'place', item: M_PLACE, assignment: MUSEUM, linkedRes: null }]
const rows: PlanRow[] = [{ key: 'pl-11', kind: 'place', item: M_PLACE, assignment: MUSEUM, linkedReservations: [] }]
renderTimeline({ day: undefined, rows })
expect(screen.queryByText('Charging stop')).not.toBeInTheDocument()
@@ -82,7 +82,7 @@ describe('PlaceRow', () => {
const props = {
assignment: assignment(),
fullPlace: undefined,
linkedRes: null,
linkedReservations: [],
chrome: chrome(),
reorder: REORDER,
onOpen: vi.fn(),
@@ -116,7 +116,7 @@ describe('PlaceRow', () => {
it('FE-MOB-PLROW-007: a linked booking replaces the subtitle and adds the booking badge', () => {
const linkedRes = { id: 31, status: 'confirmed', confirmation_number: 'X9K' } as unknown as Reservation
render(<PlaceRow {...props} linkedRes={linkedRes} />)
render(<PlaceRow {...props} linkedReservations={[linkedRes]} />)
expect(screen.getByText('dayplan.confirmed · #X9K')).toBeInTheDocument()
expect(screen.getByText('mobileTrip.resBadge')).toBeInTheDocument()
@@ -124,7 +124,7 @@ describe('PlaceRow', () => {
it('FE-MOB-PLROW-008: a pending booking without a number shows just the status', () => {
const linkedRes = { id: 31, status: 'pending', confirmation_number: null } as unknown as Reservation
render(<PlaceRow {...props} linkedRes={linkedRes} />)
render(<PlaceRow {...props} linkedReservations={[linkedRes]} />)
expect(screen.getByText('dayplan.pendingRes')).toBeInTheDocument()
})
@@ -166,8 +166,8 @@ describe('planTimelineModel — buildPlanRows', () => {
merged: [placeItem(museum), placeItem(park)], reservations: [dinner], routeSegments: [], dayId: 2,
})
const [first, second] = rows.filter(r => r.kind === 'place')
expect(first.kind === 'place' && first.linkedRes).toBe(dinner)
expect(second.kind === 'place' && second.linkedRes).toBeNull()
expect(first.kind === 'place' && first.linkedReservations[0]).toBe(dinner)
expect(second.kind === 'place' && second.linkedReservations.length === 0).toBe(true)
})
it('FE-MOB-PTLM-014: keys a synthetic leg row by its leg index', () => {