feat(airtrail): import connecting flights as one multi-leg booking

AirTrail flights always imported as separate single-leg reservations, so a
layover could not be expressed and the connection country ended up counted
as visited in Atlas (#1535).

The import picker now detects connection chains among the listed flights —
each leg departing from the airport the previous one landed at, onward
within 24 hours, and never back to the origin (an out-and-back is a return,
not a connection) — and offers to import each chain as one flight with
layover stops, on by default. The joined booking keeps per-leg airline,
flight number, times and seat in metadata.legs, files every leg on its own
trip day, and mirrors the first/last leg flat, exactly like the manual
multi-leg form. With the connection stored as a stop endpoint, the existing
Atlas role filter excludes the layover country on its own.

AirTrail has no multi-leg flight a joined booking could round-trip to, so
it imports detached from live sync, with every source flight id recorded in
metadata.airtrail_ids — the picker and the server-side dedupe both treat
those legs as imported, per leg, even across trip members. The server
re-validates each requested chain and falls back to individual imports when
it does not actually connect.
This commit is contained in:
Maurice
2026-07-13 16:17:04 +02:00
parent 2a722a5be0
commit 545295d430
32 changed files with 1025 additions and 44 deletions
+2 -2
View File
@@ -744,8 +744,8 @@ export const airtrailApi = {
sync: (): Promise<{ changed: number }> => apiClient.post('/integrations/airtrail/sync').then(r => r.data),
// flights + import are added with the trip-planner import (P2)
flights: () => apiClient.get('/integrations/airtrail/flights').then(r => r.data),
import: (tripId: number, flightIds: string[]) =>
apiClient.post(`/trips/${tripId}/reservations/import/airtrail`, { flightIds }).then(r => r.data),
import: (tripId: number, flightIds: string[], connections?: string[][]) =>
apiClient.post(`/trips/${tripId}/reservations/import/airtrail`, connections?.length ? { flightIds, connections } : { flightIds }).then(r => r.data),
}
export const journeyApi = {
@@ -0,0 +1,202 @@
// FE-PLANNER-AIRTRAIL-001 to FE-PLANNER-AIRTRAIL-008
import { render, screen, waitFor } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
import { useAuthStore } from '../../store/authStore';
import { useTripStore } from '../../store/tripStore';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildUser, buildTrip, buildReservation } from '../../../tests/helpers/factories';
import type { AirtrailFlight } from '@trek/shared';
import AirTrailImportModal, { detectConnections } from './AirTrailImportModal';
const flight = (over: Partial<AirtrailFlight> = {}): AirtrailFlight => ({
id: '101',
fromCode: 'BRU',
fromName: 'Brussels',
toCode: 'HEL',
toName: 'Helsinki-Vantaa',
date: '2026-08-01',
departure: '2026-08-01T06:00:00.000+00:00',
arrival: '2026-08-01T09:30:00.000+00:00',
airline: 'Finnair',
flightNumber: 'AY1502',
aircraft: null,
seatClass: 'economy',
...over,
});
const legHelJfk = (): AirtrailFlight =>
flight({
id: '102',
fromCode: 'HEL',
fromName: 'Helsinki-Vantaa',
toCode: 'JFK',
toName: 'John F. Kennedy Intl.',
departure: '2026-08-01T11:00:00.000+00:00',
arrival: '2026-08-01T19:00:00.000+00:00',
flightNumber: 'AY15',
});
const unrelatedFlight = (): AirtrailFlight =>
flight({
id: '103',
fromCode: 'LHR',
fromName: 'London Heathrow',
toCode: 'JFK',
toName: 'John F. Kennedy Intl.',
date: '2026-08-05',
departure: '2026-08-05T10:00:00.000+00:00',
arrival: '2026-08-05T18:00:00.000+00:00',
flightNumber: 'BA117',
});
describe('detectConnections (#1535)', () => {
it('FE-PLANNER-AIRTRAIL-001: chains flights that connect at the same airport within 24h', () => {
const chains = detectConnections([legHelJfk(), flight(), unrelatedFlight()]);
expect(chains).toHaveLength(1);
expect(chains[0].map(f => f.id)).toEqual(['101', '102']);
});
it('FE-PLANNER-AIRTRAIL-002: does not chain when the layover exceeds 24h', () => {
const late = { ...legHelJfk(), date: '2026-08-03', departure: '2026-08-03T11:00:00.000+00:00', arrival: '2026-08-03T19:00:00.000+00:00' };
expect(detectConnections([flight(), late])).toHaveLength(0);
});
it('FE-PLANNER-AIRTRAIL-003: skips flights without instants instead of guessing', () => {
expect(detectConnections([flight({ departure: null, arrival: null }), legHelJfk()])).toHaveLength(0);
});
it('FE-PLANNER-AIRTRAIL-009: does not chain an out-and-back return into a bogus connection', () => {
const returnFlight = flight({
id: '105',
fromCode: 'HEL',
fromName: 'Helsinki-Vantaa',
toCode: 'BRU',
toName: 'Brussels',
departure: '2026-08-01T18:00:00.000+00:00',
arrival: '2026-08-01T21:30:00.000+00:00',
flightNumber: 'AY1503',
});
expect(detectConnections([flight(), returnFlight])).toHaveLength(0);
});
});
describe('AirTrailImportModal', () => {
const defaultProps = { isOpen: true, onClose: vi.fn(), tripId: 1 };
beforeEach(() => {
resetAllStores();
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
seedStore(useTripStore, {
trip: buildTrip({ id: 1, start_date: '2026-08-01', end_date: '2026-08-10' }),
reservations: [],
});
server.use(
http.get('/api/integrations/airtrail/flights', () =>
HttpResponse.json({ flights: [flight(), legHelJfk(), unrelatedFlight()] }),
),
http.get('/api/trips/1/reservations', () => HttpResponse.json({ reservations: [] })),
);
});
it('FE-PLANNER-AIRTRAIL-004: offers to join a detected connection, on by default', async () => {
render(<AirTrailImportModal {...defaultProps} />);
const joinRow = await screen.findByText(/one flight with a layover in HEL/i);
expect(joinRow).toBeInTheDocument();
// No join offer for the unrelated flight.
expect(screen.queryByText(/layover in JFK/i)).not.toBeInTheDocument();
});
it('FE-PLANNER-AIRTRAIL-005: sends the chain as a connection on import', async () => {
const user = userEvent.setup();
let body: any = null;
server.use(
http.post('/api/trips/1/reservations/import/airtrail', async ({ request }) => {
body = await request.json();
return HttpResponse.json({ imported: body.flightIds, skipped: [] });
}),
);
render(<AirTrailImportModal {...defaultProps} />);
await screen.findByText(/one flight with a layover in HEL/i);
await user.click(screen.getByRole('button', { name: /Import 3/i }));
await waitFor(() => expect(body).not.toBeNull());
expect([...body.flightIds].sort()).toEqual(['101', '102', '103']);
expect(body.connections).toEqual([['101', '102']]);
});
it('FE-PLANNER-AIRTRAIL-006: sends no connection when the join is toggled off', async () => {
const user = userEvent.setup();
let body: any = null;
server.use(
http.post('/api/trips/1/reservations/import/airtrail', async ({ request }) => {
body = await request.json();
return HttpResponse.json({ imported: body.flightIds, skipped: [] });
}),
);
render(<AirTrailImportModal {...defaultProps} />);
await user.click(await screen.findByText(/one flight with a layover in HEL/i));
await user.click(screen.getByRole('button', { name: /Import 3/i }));
await waitFor(() => expect(body).not.toBeNull());
expect(body.connections).toBeUndefined();
});
it('FE-PLANNER-AIRTRAIL-007: re-enabling the join selects all of its legs', async () => {
const user = userEvent.setup();
render(<AirTrailImportModal {...defaultProps} />);
const joinRow = await screen.findByText(/one flight with a layover in HEL/i);
// Deselect one member — the join reads unchecked, one click brings both back.
await user.click(screen.getByText('Finnair AY15'));
expect(screen.getByRole('button', { name: /Import 2/i })).toBeInTheDocument();
await user.click(joinRow);
expect(screen.getByRole('button', { name: /Import 3/i })).toBeInTheDocument();
});
it('FE-PLANNER-AIRTRAIL-010: a surviving sub-chain keeps its join offer when a later leg was already imported', async () => {
const legJfkLax = flight({
id: '106',
fromCode: 'JFK',
fromName: 'John F. Kennedy Intl.',
toCode: 'LAX',
toName: 'Los Angeles Intl.',
departure: '2026-08-01T21:00:00.000+00:00',
arrival: '2026-08-02T00:30:00.000+00:00',
flightNumber: 'AY99',
});
seedStore(useTripStore, {
trip: buildTrip({ id: 1, start_date: '2026-08-01', end_date: '2026-08-10' }),
reservations: [
buildReservation({ type: 'flight', external_source: 'airtrail', external_id: '106' }) as any,
],
});
server.use(
http.get('/api/integrations/airtrail/flights', () =>
HttpResponse.json({ flights: [flight(), legHelJfk(), legJfkLax] }),
),
);
render(<AirTrailImportModal {...defaultProps} />);
// BRU→HEL→JFK still connects even though JFK→LAX is gone from the pool.
expect(await screen.findByText(/one flight with a layover in HEL/i)).toBeInTheDocument();
expect(screen.getByText(/^Imported$/)).toBeInTheDocument();
});
it('FE-PLANNER-AIRTRAIL-008: legs of a joined import are marked imported via metadata.airtrail_ids', async () => {
seedStore(useTripStore, {
trip: buildTrip({ id: 1, start_date: '2026-08-01', end_date: '2026-08-10' }),
reservations: [
buildReservation({
type: 'flight',
external_source: 'airtrail',
external_id: '101',
metadata: JSON.stringify({ airtrail_ids: ['101', '102'] }),
}) as any,
],
});
render(<AirTrailImportModal {...defaultProps} />);
await screen.findByText('Finnair AY1502');
// Both legs disabled — including the one whose id only lives in the metadata.
expect(screen.getAllByText(/^Imported$/)).toHaveLength(2);
// A chain with an imported member cannot be joined again.
expect(screen.queryByText(/one flight with a layover/i)).not.toBeInTheDocument();
});
});
@@ -7,6 +7,7 @@ import { useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast'
import { airtrailApi, reservationsApi } from '../../api/client'
import { useTripStore } from '../../store/tripStore'
import { parseReservationMetadata } from '../../utils/flightLegs'
interface AirTrailImportModalProps {
isOpen: boolean
@@ -15,6 +16,40 @@ interface AirTrailImportModalProps {
pushUndo?: (label: string, undoFn: () => Promise<void> | void) => void
}
/**
* Ordered chains of connecting flights — each arrives where the next departs,
* onward within 24 h — that the picker offers to import as ONE multi-leg
* booking with the connection as a layover stop (#1535). A flight landing back
* at the chain's origin is a return, not a connection, so a same-day
* out-and-back never gets a join offer. Same rules the server re-validates
* with.
*/
export function detectConnections(flights: AirtrailFlight[]): AirtrailFlight[][] {
const sorted = flights
.filter(f => f.departure && f.arrival && f.fromCode && f.toCode)
.sort((a, b) => Date.parse(a.departure!) - Date.parse(b.departure!))
const chains: AirtrailFlight[][] = []
let chain: AirtrailFlight[] = []
for (const f of sorted) {
const prev = chain[chain.length - 1]
if (prev) {
const gap = Date.parse(f.departure!) - Date.parse(prev.arrival!)
if (
prev.toCode!.toUpperCase() === f.fromCode!.toUpperCase() &&
f.toCode!.toUpperCase() !== chain[0].fromCode!.toUpperCase() &&
gap >= 0 && gap <= 24 * 3600 * 1000
) {
chain.push(f)
continue
}
}
if (chain.length > 1) chains.push(chain)
chain = [f]
}
if (chain.length > 1) chains.push(chain)
return chains
}
/** Locale-aware date (e.g. de → 13.06.2026, en-US → 06/13/2026). */
function fmtDate(d: string | null, locale: string): string {
if (!d) return ''
@@ -44,11 +79,16 @@ export default function AirTrailImportModal({ isOpen, onClose, tripId, pushUndo
const [flights, setFlights] = useState<AirtrailFlight[]>([])
const [selected, setSelected] = useState<Set<string>>(() => new Set())
// AirTrail flight ids already linked to a reservation in this trip.
// AirTrail flight ids already linked to a reservation in this trip. A joined
// multi-leg import carries only its first leg in external_id — the other legs
// sit in metadata.airtrail_ids (#1535).
const importedIds = useMemo(() => {
const set = new Set<string>()
for (const r of reservations) {
if (r.external_source === 'airtrail' && r.external_id) set.add(String(r.external_id))
if (r.external_source !== 'airtrail') continue
if (r.external_id) set.add(String(r.external_id))
const ids = parseReservationMetadata(r).airtrail_ids
if (Array.isArray(ids)) for (const id of ids) set.add(String(id))
}
return set
}, [reservations])
@@ -56,10 +96,27 @@ export default function AirTrailImportModal({ isOpen, onClose, tripId, pushUndo
const inRange = (f: AirtrailFlight): boolean =>
!!(f.date && trip?.start_date && trip?.end_date && f.date >= trip.start_date && f.date <= trip.end_date)
// Detected connection chains that can still be joined. Already-imported
// flights are excluded BEFORE detection so a surviving sub-chain (e.g. the
// first two legs when the third was imported earlier) still gets its offer.
// Joining is on by default; joinOff remembers the chains the user opted out of.
const chains = useMemo(
() => detectConnections(flights.filter(f => !importedIds.has(f.id))),
[flights, importedIds],
)
const chainKey = (chain: AirtrailFlight[]) => chain.map(f => f.id).join('+')
const chainOf = useMemo(() => {
const map = new Map<string, AirtrailFlight[]>()
for (const chain of chains) for (const f of chain) map.set(f.id, chain)
return map
}, [chains])
const [joinOff, setJoinOff] = useState<Set<string>>(() => new Set())
useEffect(() => {
if (!isOpen) return
setError('')
setSelected(new Set())
setJoinOff(new Set())
setLoading(true)
airtrailApi
.flights()
@@ -94,6 +151,28 @@ export default function AirTrailImportModal({ isOpen, onClose, tripId, pushUndo
})
}
const chainJoined = (chain: AirtrailFlight[]) =>
!joinOff.has(chainKey(chain)) && chain.every(f => selected.has(f.id))
const toggleJoin = (chain: AirtrailFlight[]) => {
const key = chainKey(chain)
if (chainJoined(chain)) {
setJoinOff(prev => new Set(prev).add(key))
} else {
// Turning the join on implies wanting all of its legs.
setJoinOff(prev => {
const next = new Set(prev)
next.delete(key)
return next
})
setSelected(prev => {
const next = new Set(prev)
for (const f of chain) next.add(f.id)
return next
})
}
}
const handleClose = () => { onClose() }
const handleImport = async () => {
@@ -102,7 +181,8 @@ export default function AirTrailImportModal({ isOpen, onClose, tripId, pushUndo
setImporting(true)
setError('')
try {
const result: AirtrailImportResult = await airtrailApi.import(tripId, ids)
const connections = chains.filter(chainJoined).map(chain => chain.map(f => f.id))
const result: AirtrailImportResult = await airtrailApi.import(tripId, ids, connections)
await loadReservations(tripId)
const imported = result.imported ?? []
@@ -175,6 +255,58 @@ export default function AirTrailImportModal({ isOpen, onClose, tripId, pushUndo
)
}
const renderChain = (chain: AirtrailFlight[]) => {
const joined = chainJoined(chain)
const stops = chain.slice(0, -1).map(f => f.toCode ?? f.toName ?? '?').join(', ')
return (
<div key={chainKey(chain)} style={{ border: '1px solid var(--border-primary)', borderRadius: 12, padding: '8px 8px 0', marginBottom: 8 }}>
{chain.map(renderFlight)}
<button
onClick={() => toggleJoin(chain)}
className="bg-transparent"
style={{
display: 'flex', alignItems: 'center', gap: 10, width: '100%', textAlign: 'left',
border: 'none', borderTop: '1px solid var(--border-faint)', borderRadius: 0,
padding: '9px 4px 10px', cursor: 'pointer', fontFamily: 'inherit',
}}
>
<span style={{
flexShrink: 0, width: 16, height: 16, borderRadius: 5,
border: `1.5px solid ${joined ? 'var(--accent)' : 'var(--border-primary)'}`,
background: joined ? 'var(--accent)' : 'transparent',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
{joined && <Check size={11} color="var(--accent-text)" strokeWidth={3} />}
</span>
<span style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-primary)' }}>
{t('reservations.airtrail.joinConnection', { stops })}
</span>
</button>
</div>
)
}
// A chain renders once as a group, at the position of its first listed leg —
// its other legs are swallowed wherever else they would appear. Computed per
// section up front so a chain straddling the trip range can't leave the other
// section holding nothing but its header.
const sectionItems = useMemo(() => {
const rendered = new Set<string>()
const build = (list: AirtrailFlight[]) =>
list.flatMap((f): Array<{ key: string; chain?: AirtrailFlight[]; flight?: AirtrailFlight }> => {
const chain = chainOf.get(f.id)
if (!chain) return [{ key: f.id, flight: f }]
const key = chain.map(c => c.id).join('+')
if (rendered.has(key)) return []
rendered.add(key)
return [{ key, chain }]
})
return { during: build(during), others: build(others) }
}, [during, others, chainOf])
const renderItem = (item: { chain?: AirtrailFlight[]; flight?: AirtrailFlight }) =>
item.chain ? renderChain(item.chain) : renderFlight(item.flight!)
return ReactDOM.createPortal(
<div
className="bg-[rgba(0,0,0,0.4)]"
@@ -213,21 +345,21 @@ export default function AirTrailImportModal({ isOpen, onClose, tripId, pushUndo
</div>
)}
{!loading && during.length > 0 && (
{!loading && sectionItems.during.length > 0 && (
<>
<div style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 700, color: 'var(--text-primary)', margin: '2px 0 8px' }}>
{t('reservations.airtrail.duringTrip')}
</div>
{during.map(renderFlight)}
{sectionItems.during.map(renderItem)}
</>
)}
{!loading && others.length > 0 && (
{!loading && sectionItems.others.length > 0 && (
<>
<div style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 700, color: 'var(--text-faint)', margin: `${during.length > 0 ? 14 : 2}px 0 8px` }}>
<div style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 700, color: 'var(--text-faint)', margin: `${sectionItems.during.length > 0 ? 14 : 2}px 0 8px` }}>
{t('reservations.airtrail.otherFlights')}
</div>
{others.map(renderFlight)}
{sectionItems.others.map(renderItem)}
</>
)}