fix(import): stop reading tickets as lodging, and stop scroll escaping a dropdown

Two v4.0.0 reports.

The file importer classified nearly every transport document as a hotel. Its
keyword table tested lodging second, on a group that included "check-in" and
"check-out" — words printed on almost every ferry, train and bus ticket there
is — so a ferry ticket came back a hotel, and the review then handed the user
the booking form, whose six chips contain no transport type at all. That is
where "everything ends up as other" comes from: it was the only honest pick
left. Transport is tested first now, the lodging pattern asks for a word that
names lodging, and the rental pattern asks for a rental rather than for a
pick-up time that ferries and shuttles also print.

Two more in the same function. The flight path forces its type on everything it
returns, and it claimed any document with two letters followed by digits — which
is what a German rail number looks like, so an IC ticket was imported as a
flight without the keyword table ever running. It now stands down when the
document already names another kind of transport. And a type neither form can
express opened the booking form regardless; it now follows the tab the import
was started from, while a type either form knows still wins over the tab, since
one PDF routinely holds a flight and a hotel. An unreadable type reaching the
transport form arrives as transport_other rather than as a flight: a wrong
flight looks right enough to be saved unnoticed.

The other report: a dropdown inside a sheet scrolled the page instead of its own
list. The panel is portaled to document.body and positioned fixed, so its scroll
chain runs to the viewport rather than to the sheet it looks like it belongs to,
and nothing stopped it. Three portaled lists carry overscroll-behavior: contain
now — the select, the navigation menu (which reaches the phone through the place
sheet, not only the desktop inspector) and the phone day picker.
@
This commit is contained in:
Maurice
2026-08-27 21:10:28 +02:00
committed by Maurice
parent 4d6820a4d4
commit 92575c0d49
15 changed files with 196 additions and 15 deletions
@@ -23,7 +23,7 @@ export function MobileDayPickerSheet(S: SidebarState) {
<div className="text-content" style={{ fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))', fontWeight: 700 }}>{dayPickerPlace.name}</div>
{dayPickerPlace.address && <div className="text-content-faint" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', marginTop: 2 }}>{dayPickerPlace.address}</div>}
</div>
<div style={{ overflowY: 'auto', padding: '8px 12px' }}>
<div style={{ overflowY: 'auto', overscrollBehavior: 'contain', padding: '8px 12px' }}>
{/* View details */}
<button type="button"
onClick={() => { onPlaceClick(dayPickerPlace.id); setDayPickerPlace(null); setMobileShowDays(false) }}
@@ -531,6 +531,18 @@ describe('TransportModal', () => {
{ id: 2, reservation_id: 1, role: 'to', sequence: 1, name: 'New York (JFK)', code: 'JFK', lat: 40.64, lng: -73.78, timezone: 'America/New_York', local_date: toDate, local_time: '13:00' },
]);
// #2076 — an import whose type could not be read used to arrive here as a flight.
// A wrong flight looks right enough to be saved without a second look; an
// explicit "other" asks to be corrected.
it('FE-PLANNER-TRANSMODAL-062: an unrecognised prefill type lands on transport_other, not flight', async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
const prefill = { title: 'Airport transfer', type: 'shuttle-voucher', status: 'pending' } as any;
render(<TransportModal {...defaultProps} prefill={prefill} onSave={onSave} />);
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalled());
expect(onSave.mock.calls[0][0].type).toBe('transport_other');
});
it('FE-PLANNER-TRANSMODAL-030: an import prefill resolves each waypoint day from its endpoint local_date (#1684)', async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
// A parsed import carries local_date per endpoint but no day_id at all.
@@ -225,9 +225,12 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
const eps = src.endpoints || []
const from = eps.find(e => e.role === 'from')
const to = eps.find(e => e.role === 'to')
// 'transport_other', not 'flight': an import whose type could not be read has
// to arrive as something the user corrects, and a wrong flight looks right
// enough to be saved unnoticed (#2076).
const type = (TRANSPORT_TYPES as readonly string[]).includes(src.type)
? src.type as TransportType
: 'flight'
: 'transport_other'
setForm({
title: src.title || '',
type,
@@ -44,6 +44,17 @@ export function parsedItemToDraft(item: BookingImportPreviewItem): BookingReview
/** Transport types route to the TransportModal; everything else to the ReservationModal. */
const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transit', 'transport_other'])
/** The types the booking form can express — its own chip list. */
const BOOKING_TYPES = new Set(['hotel', 'restaurant', 'event', 'tour', 'activity', 'parking', 'other'])
export function isTransportItem(item: BookingImportPreviewItem): boolean {
return TRANSPORT_TYPES.has(item.type)
}
/**
* Neither form can express this type, so neither is obviously right. The tab the
* user started the import from breaks the tie (#2076).
*/
export function isUnplaceableItem(item: BookingImportPreviewItem): boolean {
return !TRANSPORT_TYPES.has(item.type) && !BOOKING_TYPES.has(item.type)
}
@@ -80,6 +80,30 @@ describe('CustomSelect', () => {
expect(screen.queryByText('Cherry')).toBeNull();
});
// #2078 — the panel is portaled to document.body and positioned fixed, so its
// scroll chain runs to the viewport, not to the sheet it visually sits in. On a
// phone a flick past either end of the list moved the page instead.
it('FE-COMP-SELECT-009: the option list keeps its scroll to itself', async () => {
const user = userEvent.setup();
render(<CustomSelect value="" onChange={onChange} options={OPTIONS} />);
await user.click(screen.getByRole('button'));
const list = screen.getByText('Apple').closest('div[style*="overflow"]') as HTMLElement;
expect(list.style.overscrollBehavior).toBe('contain');
});
it('FE-COMP-SELECT-010: it is still a bounded scroller, not a contained page', async () => {
const user = userEvent.setup();
render(<CustomSelect value="" onChange={onChange} options={OPTIONS} />);
await user.click(screen.getByRole('button'));
// Guards the other half: containment without a height cap would just make the
// panel grow off screen.
const list = screen.getByText('Apple').closest('div[style*="overflow"]') as HTMLElement;
expect(list.style.overflowY).toBe('auto');
expect(Number.parseInt(list.style.maxHeight, 10)).toBeGreaterThan(0);
});
it('FE-COMP-SELECT-008: disabled state prevents the dropdown from opening', async () => {
const user = userEvent.setup();
render(<CustomSelect value="" onChange={onChange} options={OPTIONS} disabled={true} placeholder="Pick" />);
@@ -171,6 +171,11 @@ export default function CustomSelect({
<div style={{
maxHeight: Math.min(220, Math.max(96, (anchored?.maxHeight ?? 220) - (searchable ? 38 : 0))),
overflowY: 'auto',
// The panel is portaled to document.body and positioned fixed, so its
// scroll chain runs to the viewport rather than to the sheet it looks
// like it belongs to. On a phone that meant a flick past either end of
// the list moved the page instead (#2078).
overscrollBehavior: 'contain',
padding: '4px',
}}>
{filtered.length === 0 ? (
@@ -82,6 +82,10 @@ export function NavigationMenu({ targets, anchor, onClose, title }: NavigationMe
minWidth: 186,
maxHeight: `calc(100vh - ${EDGE * 2}px)`,
overflowY: 'auto',
// Portaled and fixed like CustomSelect's list, so it chains to the viewport
// unless told otherwise (#2078). This one reaches the phone through
// MPlaceSheet, not only the desktop inspector.
overscrollBehavior: 'contain',
padding: 5,
background: 'var(--bg-card)',
backdropFilter: 'blur(24px) saturate(180%)',
@@ -417,7 +417,7 @@ export default function MTripShell({
}}
/>
{planner.bookingImportAvailable && (
<MIconBtn ariaLabel={t('reservations.import.title')} onClick={() => planner.setShowBookingImport(true)} size={40} className="text-m-muted backdrop-blur-[24px] backdrop-saturate-[1.7]">
<MIconBtn ariaLabel={t('reservations.import.title')} onClick={() => { planner.setBookingImportKind('transports'); planner.setShowBookingImport(true) }} size={40} className="text-m-muted backdrop-blur-[24px] backdrop-saturate-[1.7]">
<Download size={15} strokeWidth={2} />
</MIconBtn>
)}
@@ -437,7 +437,7 @@ export default function MTripShell({
onClick={() => { planner.setEditingReservation(null); planner.setShowReservationModal(true) }}
/>
{planner.bookingImportAvailable && (
<MIconBtn ariaLabel={t('reservations.import.title')} onClick={() => planner.setShowBookingImport(true)} size={40} className="text-m-muted backdrop-blur-[24px] backdrop-saturate-[1.7]">
<MIconBtn ariaLabel={t('reservations.import.title')} onClick={() => { planner.setBookingImportKind('bookings'); planner.setShowBookingImport(true) }} size={40} className="text-m-muted backdrop-blur-[24px] backdrop-saturate-[1.7]">
<Download size={15} strokeWidth={2} />
</MIconBtn>
)}
+3 -3
View File
@@ -252,7 +252,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
prefillCoords, setPrefillCoords, editingAssignmentId, setEditingAssignmentId,
showTripForm, setShowTripForm, showMembersModal, setShowMembersModal,
showReservationModal, setShowReservationModal, editingReservation, setEditingReservation,
showBookingImport, setShowBookingImport, bookingImportAvailable,
showBookingImport, setShowBookingImport, setBookingImportKind, bookingImportAvailable,
airTrailAvailable, showAirTrailImport, setShowAirTrailImport,
bookingForAssignmentId, setBookingForAssignmentId,
showTransportModal, setShowTransportModal, editingTransport, setEditingTransport,
@@ -732,7 +732,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
assignments={assignments}
files={files}
onAdd={() => { setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(false); setShowTransportModal(true) }}
onImport={() => setShowBookingImport(true)}
onImport={() => { setBookingImportKind('transports'); setShowBookingImport(true) }}
bookingImportAvailable={bookingImportAvailable}
onAirTrailImport={() => setShowAirTrailImport(true)}
airTrailAvailable={airTrailAvailable}
@@ -758,7 +758,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
assignments={assignments}
files={files}
onAdd={() => { setEditingReservation(null); setShowReservationModal(true) }}
onImport={() => setShowBookingImport(true)}
onImport={() => { setBookingImportKind('bookings'); setShowBookingImport(true) }}
bookingImportAvailable={bookingImportAvailable}
onEdit={(r) => { setEditingReservation(r); setShowReservationModal(true) }}
onDelete={handleDeleteReservation}
@@ -193,6 +193,8 @@ function baseState(): HookState {
setEditingReservation: vi.fn(),
showBookingImport: false,
setShowBookingImport: vi.fn(),
bookingImportKind: 'bookings' as const,
setBookingImportKind: vi.fn(),
bookingImportAvailable: true,
airTrailAvailable: true,
showAirTrailImport: false,
@@ -1634,6 +1634,45 @@ describe('useTripPlanner — booking import review', () => {
expect(result.current.reservationPrefill?._sourceFiles).toEqual([file])
})
// #2076 — an item whose type neither form can express used to land in the booking
// form, which offers six chips and not one transport among them, so the only
// honest pick left was 'other'. The tab the import started from breaks the tie.
it('FE-TP-HOOK-112: an unreadable item opens the transport form when the import began there', async () => {
seedTrip()
const { result } = await renderPlanner()
const odd = { type: 'shuttle-voucher', title: 'Airport transfer' }
act(() => { result.current.setBookingImportKind('transports') })
act(() => { result.current.startImportReview([odd] as never) })
expect(result.current.showTransportModal).toBe(true)
expect(result.current.showReservationModal).toBe(false)
})
it('FE-TP-HOOK-113: the same item opens the booking form when the import began there', async () => {
seedTrip()
const { result } = await renderPlanner()
const odd = { type: 'shuttle-voucher', title: 'Airport transfer' }
act(() => { result.current.setBookingImportKind('bookings') })
act(() => { result.current.startImportReview([odd] as never) })
expect(result.current.showReservationModal).toBe(true)
expect(result.current.showTransportModal).toBe(false)
})
// A recognised type always wins over the tab: one PDF routinely holds both.
it('FE-TP-HOOK-114: a hotel imported from the transports tab still opens the booking form', async () => {
seedTrip()
const { result } = await renderPlanner()
act(() => { result.current.setBookingImportKind('transports') })
act(() => { result.current.startImportReview([hotelItem] as never) })
expect(result.current.showReservationModal).toBe(true)
expect(result.current.showTransportModal).toBe(false)
})
it('FE-TP-HOOK-090: advancing moves on to the transport item and then finishes the session', async () => {
seedTrip()
vi.mocked(accommodationsApi.list).mockResolvedValue({ accommodations: [{ id: 2 }] })
+11 -3
View File
@@ -9,7 +9,7 @@ import { Map, Ticket, PackageCheck, Wallet, FolderOpen, Users, Train } from 'luc
import { resolvePluginIcon } from '../../components/shared/PluginIcon'
import { useTranslation, translateApiError } from '../../i18n'
import { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi, healthApi, airtrailApi, mapsApi, placesApi } from '../../api/client'
import { parsedItemToDraft, isTransportItem, type BookingReviewDraft } from '../../components/Planner/parsedItemToDraft'
import { parsedItemToDraft, isTransportItem, isUnplaceableItem, type BookingReviewDraft } from '../../components/Planner/parsedItemToDraft'
import type { BookingImportPreviewItem } from '@trek/shared'
import { accommodationRepo } from '../../repo/accommodationRepo'
import { offlineDb, getImportFiles, deleteImportFiles } from '../../db/offlineDb'
@@ -228,6 +228,8 @@ export function useTripPlanner() {
const [showReservationModal, setShowReservationModal] = useState<boolean>(false)
const [editingReservation, setEditingReservation] = useState<Reservation | null>(null)
const [showBookingImport, setShowBookingImport] = useState<boolean>(false)
// Which tab opened the importer. Only ever a tie-breaker — see openImportItem.
const [bookingImportKind, setBookingImportKind] = useState<'transports' | 'bookings'>('bookings')
const [bookingImportAvailable, setBookingImportAvailable] = useState<boolean>(false)
const { available: airTrailAvailable } = useAirtrailConnection()
const [showAirTrailImport, setShowAirTrailImport] = useState<boolean>(false)
@@ -919,13 +921,19 @@ export function useTripPlanner() {
}
// Open the right edit modal for a parsed item, pre-filled, in create mode.
//
// A type neither form can express belongs to whichever tab the user started from.
// Handing an unreadable transport document to the booking form is what left them
// with six chips, none of them a transport, and 'other' as the only honest pick
// (#2076). A type either form DOES know always wins over the tab — one PDF
// routinely holds a flight and a hotel.
const openImportItem = (item: BookingImportPreviewItem) => {
const draft = parsedItemToDraft(item)
// Attach the file this item was parsed from so it lands in the booking's Files on save.
const srcName = item.source?.fileName
const srcFile = srcName ? importSourceFilesRef.current.find(f => f.name === srcName) : undefined
if (srcFile) draft._sourceFiles = [srcFile]
if (isTransportItem(item)) {
if (isTransportItem(item) || (isUnplaceableItem(item) && bookingImportKind === 'transports')) {
setShowReservationModal(false); setEditingReservation(null); setReservationPrefill(null)
setEditingTransport(null); setTransportModalDayId(null)
setTransportPrefill(draft); setShowTransportModal(true)
@@ -1041,7 +1049,7 @@ export function useTripPlanner() {
placeFormDayId, setPlaceFormDayId, reservationModalDayId, setReservationModalDayId,
showTripForm, setShowTripForm, showMembersModal, setShowMembersModal,
showReservationModal, setShowReservationModal, editingReservation, setEditingReservation,
showBookingImport, setShowBookingImport, bookingImportAvailable,
showBookingImport, setShowBookingImport, bookingImportKind, setBookingImportKind, bookingImportAvailable,
airTrailAvailable, showAirTrailImport, setShowAirTrailImport,
bookingForAssignmentId, setBookingForAssignmentId,
showTransportModal, setShowTransportModal, editingTransport, setEditingTransport,
+2
View File
@@ -133,6 +133,8 @@ export function buildPlanner(overrides: Partial<TripPlanner> = {}): TripPlanner
setEditingReservation: vi.fn(),
showBookingImport: false,
setShowBookingImport: vi.fn(),
bookingImportKind: 'bookings' as const,
setBookingImportKind: vi.fn(),
bookingImportAvailable: true,
airTrailAvailable: false,
showAirTrailImport: false,
@@ -44,17 +44,32 @@ const TYPE_HINT: Record<FlatType, string> = {
event: 'event/attraction. name = the event/ticket, address = the venue, start_time/end_time = full ISO, price/currency = ticket price.',
};
/** Keyword → reservation type, so an obvious document skips the costlier union/strong path. */
/**
* Keyword → reservation type, so an obvious document skips the costlier union/strong path.
*
* Order is the whole rule: the first pattern that matches wins. Lodging used to be
* tested second, on a group that included "check-in"/"check-out" — words printed on
* nearly every ferry, train and bus ticket there is — so a ferry ticket came back as
* a hotel and the user was handed a booking form with no transport type on it
* (#2076). Transport is tested first now, and the lodging pattern asks for a word
* that actually names lodging.
*
* "pick-up"/"drop-off" are gone from the rental pattern for the same reason: they
* appear on ferry, bus and airport-transfer vouchers. A rental names its rental.
*/
const TYPE_KEYWORDS: [FlatType, RegExp][] = [
['car', /\b(sixt|europcar|hertz|avis|enterprise|mietwagen|rental\s*car|autovermietung|anmietung|r(?:ü|ue)ckgabe|pick-?up|drop-?off)\b/i],
['hotel', /\b(hotel|check-?in|check-?out|(?:ü|ue)bernachtung|zimmer|room\s*night|lodging|airbnb|b&b|hostel|pension)\b/i],
['train', /\b(deutsche\s*bahn|bahn|train|railway|\bice\b|\bzug\b|gleis|sncf|trenitalia|renfe)\b/i],
['bus', /\b(flixbus|\bbus\b|coach|omnibus)\b/i],
['ferry', /\b(f(?:ä|ae)hre|ferry|cruise|kreuzfahrt)\b/i],
['ferry', /\b(f(?:ä|ae)hre|ferry|cruise|kreuzfahrt|einschiffung)\b/i],
['car', /\b(sixt|europcar|hertz|avis|enterprise|mietwagen|rental\s*car|autovermietung|anmietung)\b/i],
['hotel', /\b(hotel|(?:ü|ue)bernachtung|zimmer|room\s*night|lodging|airbnb|b&b|hostel|pension)\b/i],
['restaurant', /\b(restaurant|\btisch\b|table\s*for|men(?:ü|u)|gedeck)\b/i],
['event', /\b(ticket|concert|konzert|veranstaltung|eintritt|admission)\b/i],
];
/** The transport half of the table, for the flight gate below. */
const TRANSPORT_FLAT_TYPES = new Set<FlatType>(['train', 'bus', 'ferry', 'car']);
function detectType(text: string): FlatType | null {
for (const [type, re] of TYPE_KEYWORDS) if (re.test(text)) return type;
return null;
@@ -236,7 +251,15 @@ export async function routeExtraction(text: string, ctx: RouterContext): Promise
// Schicht 1 — exactly one model call.
let flats: FlatLike[];
try {
flats = detectFlightNumbers(text).length > 0 ? await extractFlights(text, ctx) : [await extractSingle(text, ctx)];
// The flight path forces type 'flight' on everything it returns, so it must not
// claim a document that already names another kind of transport: a German rail
// ticket carries numbers like "IC 2023" or "RE 4711", which the two-letters-plus-
// digits test cannot tell from an airline code, and the ticket came back a flight
// without detectType ever running (#2076).
const named = detectType(text);
const looksLikeFlight = detectFlightNumbers(text).length > 0
&& !(named && TRANSPORT_FLAT_TYPES.has(named));
flats = looksLikeFlight ? await extractFlights(text, ctx) : [await extractSingle(text, ctx)];
} catch (err) {
return { kiItems: [], warnings: [`AI parsing failed — ${err instanceof Error ? err.message : String(err)}`] };
}
@@ -165,4 +165,52 @@ describe('routeExtraction', () => {
expect(res.warnings[0]).toContain('AI parsing failed');
expect(res.warnings[0]).toContain('connection refused');
});
// #2076 — the lodging keywords used to be tested second and included
// "check-in"/"check-out", which is printed on nearly every ticket there is, so a
// ferry or train document came back a hotel and the user was handed a booking
// form with no transport type on it.
it('reads a ferry ticket that mentions check-in as a ferry, not a hotel', async () => {
extractEnforced.mockResolvedValue({ name: 'Puttgarden - Roedby' });
await routeExtraction('Faehre Puttgarden nach Roedby. Check-in bis 30 Minuten vor Abfahrt.', CTX);
expect(mapToKi.mock.calls[0][0][0].type).toBe('ferry');
});
it('reads a train ticket that mentions check-in as a train', async () => {
extractEnforced.mockResolvedValue({ name: 'Hamburg - Berlin' });
await routeExtraction('Deutsche Bahn. Gleis 7. Online check-in moeglich.', CTX);
expect(mapToKi.mock.calls[0][0][0].type).toBe('train');
});
// A rental voucher says who rented it. "Pick-up"/"drop-off" alone is printed on
// ferry, bus and airport-transfer documents too.
it('does not read a bus voucher with a pick-up time as a rental car', async () => {
extractEnforced.mockResolvedValue({ name: 'Shuttle' });
await routeExtraction('Flixbus Ticket. Pick-up 08:30 am Terminal 2.', CTX);
expect(mapToKi.mock.calls[0][0][0].type).toBe('bus');
});
// A pre-cruise hotel night names a cruise terminal; it is still a hotel.
it('keeps a hotel booking that merely names a terminal a hotel', async () => {
extractEnforced.mockResolvedValue({ name: 'Harbour Hotel' });
await routeExtraction('Hotel Harbour, 1 Uebernachtung vor der Abfahrt am Terminal.', CTX);
expect(mapToKi.mock.calls[0][0][0].type).toBe('hotel');
});
// German rail numbers match the two-letters-plus-digits airline test exactly, and
// the flight path forces type 'flight' on everything it returns.
it('does not send a German rail ticket down the flight path', async () => {
extractEnforced.mockResolvedValue({ name: 'Hamburg - Berlin' });
await routeExtraction('Deutsche Bahn IC 2023, Hamburg Hbf nach Berlin Hbf, Gleis 7.', CTX);
const flats = mapToKi.mock.calls[0][0];
expect(flats).toHaveLength(1);
expect(flats[0].type).toBe('train');
});
it('still sends a real flight itinerary down the flight path', async () => {
extractEnforced.mockResolvedValue({ flights: [{ vehicle_number: 'LH400', from_code: 'FRA', to_code: 'JFK' }] });
await routeExtraction('Lufthansa LH 400 FRA-JFK, Gate A12.', CTX);
expect(mapToKi.mock.calls[0][0][0].type).toBe('flight');
});
});