mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 22:16:26 +00:00
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:
@@ -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)}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user