From 545295d430808d96d90222f2ec3cdc0b29f4fbfa Mon Sep 17 00:00:00 2001 From: Maurice Date: Mon, 13 Jul 2026 16:17:04 +0200 Subject: [PATCH] feat(airtrail): import connecting flights as one multi-leg booking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- client/src/api/client.ts | 4 +- .../Planner/AirTrailImportModal.test.tsx | 202 ++++++++++++++++ .../Planner/AirTrailImportModal.tsx | 148 +++++++++++- .../airtrail-import.controller.ts | 2 +- .../src/services/airtrail/airtrailImport.ts | 218 ++++++++++++++--- .../src/services/airtrail/airtrailMapper.ts | 125 ++++++++++ .../unit/services/airtrailImport.test.ts | 223 ++++++++++++++++++ .../unit/services/airtrailMapper.test.ts | 100 +++++++- shared/src/airtrail/airtrail.schema.spec.ts | 18 ++ shared/src/airtrail/airtrail.schema.ts | 7 + shared/src/i18n/ar/reservations.ts | 1 + shared/src/i18n/br/reservations.ts | 1 + shared/src/i18n/cs/reservations.ts | 1 + shared/src/i18n/de/reservations.ts | 1 + shared/src/i18n/en/reservations.ts | 1 + shared/src/i18n/es/reservations.ts | 1 + shared/src/i18n/fr/reservations.ts | 1 + shared/src/i18n/gr/reservations.ts | 1 + shared/src/i18n/hu/reservations.ts | 1 + shared/src/i18n/id/reservations.ts | 1 + shared/src/i18n/it/reservations.ts | 1 + shared/src/i18n/ja/reservations.ts | 1 + shared/src/i18n/ko/reservations.ts | 1 + shared/src/i18n/nl/reservations.ts | 1 + shared/src/i18n/pl/reservations.ts | 1 + shared/src/i18n/ru/reservations.ts | 1 + shared/src/i18n/sv/reservations.ts | 1 + shared/src/i18n/tr/reservations.ts | 1 + shared/src/i18n/uk/reservations.ts | 1 + shared/src/i18n/vi/reservations.ts | 1 + shared/src/i18n/zh-TW/reservations.ts | 1 + shared/src/i18n/zh/reservations.ts | 1 + 32 files changed, 1025 insertions(+), 44 deletions(-) create mode 100644 client/src/components/Planner/AirTrailImportModal.test.tsx create mode 100644 server/tests/unit/services/airtrailImport.test.ts create mode 100644 shared/src/airtrail/airtrail.schema.spec.ts diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 3c8cb6c19..8b3d30a36 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -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 = { diff --git a/client/src/components/Planner/AirTrailImportModal.test.tsx b/client/src/components/Planner/AirTrailImportModal.test.tsx new file mode 100644 index 000000000..1f13587a2 --- /dev/null +++ b/client/src/components/Planner/AirTrailImportModal.test.tsx @@ -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 => ({ + 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(); + 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(); + 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(); + 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(); + 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(); + // 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(); + 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(); + }); +}); diff --git a/client/src/components/Planner/AirTrailImportModal.tsx b/client/src/components/Planner/AirTrailImportModal.tsx index 08c352d20..e1e9e69e3 100644 --- a/client/src/components/Planner/AirTrailImportModal.tsx +++ b/client/src/components/Planner/AirTrailImportModal.tsx @@ -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 } +/** + * 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([]) const [selected, setSelected] = useState>(() => 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() 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() + for (const chain of chains) for (const f of chain) map.set(f.id, chain) + return map + }, [chains]) + const [joinOff, setJoinOff] = useState>(() => 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 ( +
+ {chain.map(renderFlight)} + +
+ ) + } + + // 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() + 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(
)} - {!loading && during.length > 0 && ( + {!loading && sectionItems.during.length > 0 && ( <>
{t('reservations.airtrail.duringTrip')}
- {during.map(renderFlight)} + {sectionItems.during.map(renderItem)} )} - {!loading && others.length > 0 && ( + {!loading && sectionItems.others.length > 0 && ( <> -
0 ? 14 : 2}px 0 8px` }}> +
0 ? 14 : 2}px 0 8px` }}> {t('reservations.airtrail.otherFlights')}
- {others.map(renderFlight)} + {sectionItems.others.map(renderItem)} )} diff --git a/server/src/nest/integrations/airtrail-import.controller.ts b/server/src/nest/integrations/airtrail-import.controller.ts index 15c5dfb28..82f5dbda6 100644 --- a/server/src/nest/integrations/airtrail-import.controller.ts +++ b/server/src/nest/integrations/airtrail-import.controller.ts @@ -34,7 +34,7 @@ export class AirtrailImportController { ): Promise { this.requireEdit(tripId, user); try { - return await importAirtrailFlights(tripId, user.id, body.flightIds, socketId); + return await importAirtrailFlights(tripId, user.id, body.flightIds, socketId, body.connections ?? []); } catch (err: any) { throw new HttpException({ error: err?.message || 'AirTrail import failed' }, err?.status === 400 ? 400 : 502); } diff --git a/server/src/services/airtrail/airtrailImport.ts b/server/src/services/airtrail/airtrailImport.ts index d6b387a3e..b6e3b9833 100644 --- a/server/src/services/airtrail/airtrailImport.ts +++ b/server/src/services/airtrail/airtrailImport.ts @@ -4,14 +4,20 @@ import { broadcast } from '../../websocket'; import { createReservation } from '../reservationService'; import { getAirtrailCredentials } from './airtrailService'; import { AirtrailRequestError, listFlights } from './airtrailClient'; -import { canonicalHash, mapFlightToReservation } from './airtrailMapper'; +import type { AirtrailFlightRaw } from './airtrailClient'; +import { canonicalHash, mapFlightToReservation, mapFlightsToMultiLegReservation, normalizeFlight } from './airtrailMapper'; interface ExistingFlightRow { id: number; reservation_time: string | null; metadata: string | null; - from_code: string | null; - to_code: string | null; +} + +interface EndpointRow { + reservation_id: number; + code: string | null; + local_date: string | null; + sequence: number; } function depDate(t: string | null): string | null { @@ -31,10 +37,58 @@ function softSignature( return null; } +/** The signature a single AirTrail flight would get as its own reservation. */ +function flightSignature(flight: AirtrailFlightRaw): string | null { + const mapped = mapFlightToReservation(flight); + return softSignature( + depDate(mapped.reservation_time), + (mapped.metadata.flight_number as string) ?? null, + mapped.endpoints.find(e => e.role === 'from')?.code ?? null, + mapped.endpoints.find(e => e.role === 'to')?.code ?? null, + ); +} + +/** + * Order a requested join group by departure and verify it forms a real + * connection chain: each flight leaves the airport the previous one arrived at, + * onward in time and within 24 h — the same window the booking import applies + * to same-PNR legs. A leg landing back at the chain's origin is a return + * flight, never a connection. Flights without usable instants can't prove any + * of this, so they don't chain. Returns the ordered chain, or null when the + * flights don't chain; the members then import individually, exactly as if no + * join had been requested. + */ +function orderConnectionChain(group: AirtrailFlightRaw[]): AirtrailFlightRaw[] | null { + if (group.length < 2) return null; + const norm = group.map(raw => ({ raw, n: normalizeFlight(raw) })); + const depMs = (n: (typeof norm)[number]['n']): number => (n.departure ? Date.parse(n.departure) : NaN); + if (norm.some(x => Number.isNaN(depMs(x.n)))) return null; + norm.sort((a, b) => depMs(a.n) - depMs(b.n)); + const origin = norm[0].n.fromCode; + for (let i = 1; i < norm.length; i++) { + const prev = norm[i - 1].n; + const next = norm[i].n; + if (!prev.toCode || !next.fromCode || prev.toCode.toUpperCase() !== next.fromCode.toUpperCase()) return null; + if (origin && next.toCode && next.toCode.toUpperCase() === origin.toUpperCase()) return null; + const arrMs = prev.arrival ? Date.parse(prev.arrival) : NaN; + if (Number.isNaN(arrMs)) return null; + const gap = depMs(next) - arrMs; + if (gap < 0 || gap > 24 * 3600 * 1000) return null; + } + return norm.map(x => x.raw); +} + /** * Import the given AirTrail flights into a trip as reservations (type:'flight'), * recording the AirTrail linkage for two-way sync and broadcasting each one live. * + * `connections` names chains of selected flights to import as ONE multi-leg + * reservation each, with the connection airports as layover stops (#1535). A + * joined booking is created detached from live sync (sync_enabled = 0): AirTrail + * has no multi-leg flight entity it could round-trip to, and a pull would + * flatten the layover again. All member ids are kept in metadata.airtrail_ids + * so none of the legs is offered for re-import. + * * Dedup: a flight already linked to this trip is skipped ('already-imported'); a * flight that looks like one already in the trip — e.g. the same flight another * member already imported from their own AirTrail — is skipped ('already-in-trip'). @@ -46,58 +100,158 @@ export async function importAirtrailFlights( userId: number, flightIds: string[], socketId: string | undefined, + connections: string[][] = [], ): Promise { const creds = getAirtrailCredentials(userId); if (!creds) throw new AirtrailRequestError('AirTrail is not connected', 400); const wanted = new Set(flightIds.map(String)); const selected = (await listFlights(creds)).filter(f => wanted.has(String(f.id))); + const byId = new Map(selected.map(f => [String(f.id), f])); const result: AirtrailImportResult = { imported: [], skipped: [] }; - const linkedIds = new Set( - (db.prepare("SELECT external_id FROM reservations WHERE trip_id = ? AND external_source = 'airtrail'").all(tripId) as { - external_id: string | null; - }[]) - .map(r => r.external_id) - .filter((v): v is string => !!v), - ); - - const existing = db - .prepare( - `SELECT r.id, r.reservation_time, r.metadata, - (SELECT code FROM reservation_endpoints WHERE reservation_id = r.id AND role = 'from' LIMIT 1) AS from_code, - (SELECT code FROM reservation_endpoints WHERE reservation_id = r.id AND role = 'to' LIMIT 1) AS to_code - FROM reservations r WHERE r.trip_id = ? AND r.type = 'flight'`, - ) - .all(tripId) as ExistingFlightRow[]; - - const existingSigs = new Set(); - for (const row of existing) { - let fn: string | null = null; + // Every AirTrail id already linked to this trip: the external_id column plus + // the metadata.airtrail_ids of joined multi-leg imports. + const linkedIds = new Set(); + const linkedRows = db + .prepare("SELECT external_id, metadata FROM reservations WHERE trip_id = ? AND external_source = 'airtrail'") + .all(tripId) as { external_id: string | null; metadata: string | null }[]; + for (const row of linkedRows) { + if (row.external_id) linkedIds.add(row.external_id); try { - fn = row.metadata ? (JSON.parse(row.metadata).flight_number ?? null) : null; + const ids = row.metadata ? JSON.parse(row.metadata).airtrail_ids : null; + if (Array.isArray(ids)) for (const id of ids) linkedIds.add(String(id)); } catch { /* malformed metadata — ignore */ } - const sig = softSignature(depDate(row.reservation_time), fn, row.from_code, row.to_code); - if (sig) existingSigs.add(sig); + } + + const existing = db + .prepare("SELECT r.id, r.reservation_time, r.metadata FROM reservations r WHERE r.trip_id = ? AND r.type = 'flight'") + .all(tripId) as ExistingFlightRow[]; + const endpointsByReservation = new Map(); + const endpointRows = db + .prepare( + `SELECT e.reservation_id, e.code, e.local_date, e.sequence + FROM reservation_endpoints e JOIN reservations r ON r.id = e.reservation_id + WHERE r.trip_id = ? AND r.type = 'flight' ORDER BY e.sequence`, + ) + .all(tripId) as EndpointRow[]; + for (const ep of endpointRows) { + const list = endpointsByReservation.get(ep.reservation_id); + if (list) list.push(ep); + else endpointsByReservation.set(ep.reservation_id, [ep]); + } + + const days = db.prepare('SELECT id, date FROM days WHERE trip_id = ?').all(tripId) as { id: number; date: string | null }[]; + const dayIdByDate = new Map(); + const dayDateById = new Map(); + for (const day of days) { + if (!day.date) continue; + if (!dayIdByDate.has(day.date)) dayIdByDate.set(day.date, day.id); + dayDateById.set(day.id, day.date); + } + const resolveDayId = (date: string | null): number | null => (date ? (dayIdByDate.get(date) ?? null) : null); + + const existingSigs = new Set(); + for (const row of existing) { + let meta: Record = {}; + try { + meta = row.metadata ? JSON.parse(row.metadata) : {}; + } catch { + /* malformed metadata — ignore */ + } + const eps = endpointsByReservation.get(row.id) ?? []; + const legs: any[] | null = Array.isArray(meta.legs) ? meta.legs : null; + if (legs && legs.length > 1) { + // One signature per leg. The leg's departure date comes from its own + // dep_day_id when set; the positional endpoint fallback is only sound + // while no endpoint was dropped (e.g. for missing coordinates) — a + // misaligned date would produce a WRONG signature, worse than none. + const aligned = eps.length === legs.length + 1; + legs.forEach((leg, i) => { + const legDate = (typeof leg?.dep_day_id === 'number' ? dayDateById.get(leg.dep_day_id) : null) + ?? (aligned ? eps[i]?.local_date : null) + ?? null; + const sig = softSignature(legDate, leg?.flight_number ?? null, leg?.from ?? null, leg?.to ?? null); + if (sig) existingSigs.add(sig); + }); + } else { + const from = eps[0]?.code ?? null; + const to = eps.length > 1 ? eps[eps.length - 1].code : null; + const sig = softSignature(depDate(row.reservation_time), meta.flight_number ?? null, from, to); + if (sig) existingSigs.add(sig); + } + } + + // Resolve the requested joins into validated chains. A group that doesn't + // chain, overlaps another, or contains an already-linked/duplicate flight + // degrades to individual imports rather than failing the whole request. + const groupedIds = new Set(); + const chains: AirtrailFlightRaw[][] = []; + for (const ids of connections) { + const unique = [...new Set(ids.map(String))]; + const members = unique.map(id => byId.get(id)).filter((f): f is AirtrailFlightRaw => !!f); + const chain = members.length === unique.length && !unique.some(id => groupedIds.has(id)) + ? orderConnectionChain(members) + : null; + if (!chain) { + console.warn('[airtrail-import] join group is not a connection chain — importing flights individually'); + continue; + } + if (unique.some(id => linkedIds.has(id)) || chain.some(f => { + const sig = flightSignature(f); + return !!sig && existingSigs.has(sig); + })) { + continue; // a member already exists in the trip — let the single path sort it out + } + chains.push(chain); + for (const id of unique) groupedIds.add(id); + } + + for (const chain of chains) { + const ids = chain.map(f => String(f.id)); + try { + const mapped = mapFlightsToMultiLegReservation(chain, resolveDayId); + const { reservation } = createReservation(tripId, mapped as any); + const now = new Date().toISOString(); + db.prepare( + `UPDATE reservations SET external_source = 'airtrail', external_id = ?, external_owner_user_id = ?, + sync_enabled = 0, external_synced_at = ? WHERE id = ?`, + ).run(ids[0], userId, now, reservation.id); + + reservation.external_source = 'airtrail'; + reservation.external_id = ids[0]; + reservation.external_owner_user_id = userId; + reservation.sync_enabled = 0; + reservation.external_synced_at = now; + + broadcast(tripId, 'reservation:created', { reservation }, socketId); + for (const f of chain) { + const sig = flightSignature(f); + if (sig) existingSigs.add(sig); + } + ids.forEach(id => linkedIds.add(id)); + result.imported.push(...ids); + } catch (err) { + console.error('[airtrail-import] failed to import connection', ids.join('+'), err instanceof Error ? err.message : err); + for (const id of ids) { + result.skipped.push({ flightId: id, reason: 'invalid', detail: err instanceof Error ? err.message : undefined }); + } + } } for (const flight of selected) { const fid = String(flight.id); + if (groupedIds.has(fid)) continue; if (linkedIds.has(fid)) { result.skipped.push({ flightId: fid, reason: 'already-imported' }); continue; } const mapped = mapFlightToReservation(flight); - const sig = softSignature( - depDate(mapped.reservation_time), - (mapped.metadata.flight_number as string) ?? null, - mapped.endpoints.find(e => e.role === 'from')?.code ?? null, - mapped.endpoints.find(e => e.role === 'to')?.code ?? null, - ); + const sig = flightSignature(flight); if (sig && existingSigs.has(sig)) { result.skipped.push({ flightId: fid, reason: 'already-in-trip', detail: mapped.title }); continue; diff --git a/server/src/services/airtrail/airtrailMapper.ts b/server/src/services/airtrail/airtrailMapper.ts index 4c398628e..9198238a4 100644 --- a/server/src/services/airtrail/airtrailMapper.ts +++ b/server/src/services/airtrail/airtrailMapper.ts @@ -185,6 +185,131 @@ export function mapFlightToReservation(raw: AirtrailFlightRaw): MappedReservatio }; } +/** + * A chain of connecting AirTrail flights → ONE multi-leg reservation (#1535). + * + * Endpoints follow the manual multi-leg form: from → stop… → to, where a stop is + * the connection airport and carries the departure of the leg LEAVING it (the + * last endpoint carries the arrival). Per-leg airline/flight number/times/seat + * live in metadata.legs; the flat metadata mirrors the first/last leg so legacy + * readers keep working. Every source flight id rides along in + * metadata.airtrail_ids so the import dedupe recognizes each leg — the + * reservation itself is created detached from live sync, because AirTrail has no + * multi-leg flight entity a merged booking could round-trip to. + * + * `resolveDayId` maps a local calendar date to the trip's day id: the day + * planner files each leg by its own dep_day_id/arr_day_id, so an overnight + * connection must not inherit the whole booking's first day for every leg. + */ +export function mapFlightsToMultiLegReservation( + flights: AirtrailFlightRaw[], + resolveDayId: (date: string | null) => number | null = () => null, +): MappedReservation { + if (flights.length === 1) return mapFlightToReservation(flights[0]); + + let needsReview = flights.some(f => f.datePrecision && f.datePrecision !== 'day') ? 1 : 0; + const endpoints: MappedEndpoint[] = []; + const legs: Record[] = []; + + const first = flights[0]; + const firstDep = localParts(first.departureScheduled ?? first.departure, first.from?.tz ?? null); + const firstDate = first.date || firstDep.date; + if (hasCoords(first.from)) { + endpoints.push({ + role: 'from', + sequence: 0, + name: first.from.name || airportCode(first.from) || 'Departure', + code: airportCode(first.from), + lat: first.from.lat, + lng: first.from.lon, + timezone: first.from.tz, + local_time: firstDep.time, + local_date: firstDate, + }); + } else { + needsReview = 1; + } + + flights.forEach((f, i) => { + const isLast = i === flights.length - 1; + const dep = localParts(f.departureScheduled ?? f.departure, f.from?.tz ?? null); + const arr = localParts(f.arrivalScheduled ?? f.arrival, f.to?.tz ?? null); + + if (hasCoords(f.to)) { + // The connection airport's endpoint carries the ONWARD departure, matching + // what the manual form stores for a stop; only the final arrival keeps its + // own arrival time. + const next = isLast ? null : flights[i + 1]; + const nextDep = next ? localParts(next.departureScheduled ?? next.departure, next.from?.tz ?? null) : null; + endpoints.push({ + role: isLast ? 'to' : 'stop', + sequence: 0, + name: f.to.name || airportCode(f.to) || (isLast ? 'Arrival' : 'Stop'), + code: airportCode(f.to), + lat: f.to.lat, + lng: f.to.lon, + timezone: f.to.tz, + local_time: isLast ? arr.time : (nextDep?.time ?? null), + local_date: isLast ? arr.date : (next?.date || nextDep?.date || null), + }); + } else { + needsReview = 1; + } + + const seat = f.seats?.find(s => s.userId) ?? f.seats?.[0]; + const airline = entityName(f.airline); + legs.push({ + from: airportCode(f.from), + to: airportCode(f.to), + ...(airline ? { airline } : {}), + ...(f.flightNumber ? { flight_number: f.flightNumber } : {}), + dep_day_id: resolveDayId(f.date || dep.date), + dep_time: dep.time, + arr_day_id: resolveDayId(arr.date), + arr_time: arr.time, + ...(seat?.seatNumber ? { seat: seat.seatNumber } : {}), + }); + }); + endpoints.forEach((e, i) => { e.sequence = i; }); + + const last = flights[flights.length - 1]; + const lastArr = localParts(last.arrivalScheduled ?? last.arrival, last.to?.tz ?? null); + const reservation_time = firstDep.date && firstDep.time ? `${firstDep.date}T${firstDep.time}` : (firstDate ?? null); + const reservation_end_time = lastArr.date && lastArr.time ? `${lastArr.date}T${lastArr.time}` : null; + + const fromCode = airportCode(first.from); + const toCode = airportCode(last.to); + const airlineName = entityName(first.airline); + const airlineCode = entityCode(first.airline); + const aircraftCode = entityCode(first.aircraft); + const metadata: Record = { + legs, + airtrail_ids: flights.map(f => String(f.id)), + }; + if (airlineName) metadata.airline = airlineName; + if (airlineCode) metadata.airline_code = airlineCode; + if (first.flightNumber) metadata.flight_number = first.flightNumber; + if (aircraftCode) metadata.aircraft = aircraftCode; + if (fromCode) metadata.departure_airport = fromCode; + if (toCode) metadata.arrival_airport = toCode; + if (legs[0]?.seat) metadata.seat = legs[0].seat; + + const route = [fromCode, ...flights.map(f => airportCode(f.to))].filter(Boolean).join(' → '); + const notes = [...new Set(flights.map(f => f.note?.trim()).filter((n): n is string => !!n))].join('\n') || null; + + return { + title: route || first.flightNumber?.trim() || 'Flight', + type: 'flight', + status: 'confirmed', + reservation_time, + reservation_end_time, + notes, + metadata, + endpoints, + needs_review: needsReview, + }; +} + /** * Stable snapshot hash of an AirTrail flight, used by the sync engine to detect * remote changes (AirTrail exposes no updated_at/etag) and to suppress TREK's own diff --git a/server/tests/unit/services/airtrailImport.test.ts b/server/tests/unit/services/airtrailImport.test.ts new file mode 100644 index 000000000..beaa9fbe5 --- /dev/null +++ b/server/tests/unit/services/airtrailImport.test.ts @@ -0,0 +1,223 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +/** + * Import pipeline against the real test DB — only the AirTrail client and the + * per-user credentials are mocked. Covers the joined multi-leg import (#1535): + * one reservation per connection chain, detached from live sync, with every + * member id recorded for dedupe; plus the fallbacks when a requested join + * doesn't actually chain. + */ + +vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn() })); +vi.mock('../../../src/services/airtrail/airtrailService', () => ({ + getAirtrailCredentials: vi.fn(() => ({ baseUrl: 'https://at.example', apiKey: 'k', allowInsecureTls: false })), +})); +vi.mock('../../../src/services/airtrail/airtrailClient', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, listFlights: vi.fn() }; +}); + +import { db } from '../../../src/db/database'; +import { createUser, createTrip } from '../../helpers/factories'; +import { listFlights } from '../../../src/services/airtrail/airtrailClient'; +import type { AirtrailAirport, AirtrailFlightRaw } from '../../../src/services/airtrail/airtrailClient'; +import { importAirtrailFlights } from '../../../src/services/airtrail/airtrailImport'; + +const BRU: AirtrailAirport = { id: 1, icao: 'EBBR', iata: 'BRU', name: 'Brussels', lat: 50.9014, lon: 4.4844, tz: 'Europe/Brussels', country: 'BE' }; +const HEL: AirtrailAirport = { id: 2, icao: 'EFHK', iata: 'HEL', name: 'Helsinki-Vantaa', lat: 60.3172, lon: 24.9633, tz: 'Europe/Helsinki', country: 'FI' }; +const JFK: AirtrailAirport = { id: 3, icao: 'KJFK', iata: 'JFK', name: 'John F. Kennedy Intl.', lat: 40.6413, lon: -73.7781, tz: 'America/New_York', country: 'US' }; +const LHR: AirtrailAirport = { id: 4, icao: 'EGLL', iata: 'LHR', name: 'London Heathrow', lat: 51.4706, lon: -0.4619, tz: 'Europe/London', country: 'GB' }; + +function rawFlight(over: Partial = {}): AirtrailFlightRaw { + return { + id: 101, + from: BRU, + to: HEL, + date: '2026-08-01', + datePrecision: 'day', + departure: '2026-08-01T06:00:00.000+00:00', + arrival: '2026-08-01T09:30:00.000+00:00', + departureScheduled: null, + arrivalScheduled: null, + airline: { id: 1, icao: 'FIN', iata: 'AY', name: 'Finnair' }, + flightNumber: 'AY1502', + aircraft: null, + aircraftReg: null, + flightReason: 'leisure', + note: null, + seats: [{ userId: 'u1', guestName: null, seat: 'window', seatNumber: '12A', seatClass: 'economy' }], + ...over, + }; +} + +const legBruHel = () => rawFlight(); +const legHelJfk = () => + rawFlight({ + id: 102, + from: HEL, + to: JFK, + departure: '2026-08-01T11:00:00.000+00:00', + arrival: '2026-08-01T19:00:00.000+00:00', + flightNumber: 'AY15', + }); +/** No connection to the BRU→HEL leg — departs LHR. */ +const legLhrJfk = () => + rawFlight({ + id: 103, + from: LHR, + to: JFK, + departure: '2026-08-02T10:00:00.000+00:00', + arrival: '2026-08-02T18:00:00.000+00:00', + flightNumber: 'BA117', + }); + +function tripReservations(tripId: number) { + return db.prepare('SELECT * FROM reservations WHERE trip_id = ? ORDER BY id').all(tripId) as any[]; +} + +function endpointsOf(reservationId: number) { + return db + .prepare('SELECT role, code, sequence FROM reservation_endpoints WHERE reservation_id = ? ORDER BY sequence') + .all(reservationId) as { role: string; code: string | null; sequence: number }[]; +} + +let tripId: number; +let userId: number; + +beforeEach(() => { + vi.clearAllMocks(); + const { user } = createUser(db); + userId = user.id; + tripId = createTrip(db, userId, { start_date: '2026-08-01', end_date: '2026-08-05' }).id; +}); + +describe('importAirtrailFlights connection joining (#1535)', () => { + it('imports a connection chain as ONE multi-leg reservation, detached from live sync', async () => { + (listFlights as any).mockResolvedValue([legBruHel(), legHelJfk()]); + + // Deliberately unordered — the server orders the chain by departure itself. + const result = await importAirtrailFlights(tripId, userId, ['101', '102'], undefined, [['102', '101']]); + expect([...result.imported].sort()).toEqual(['101', '102']); + expect(result.skipped).toEqual([]); + + const rows = tripReservations(tripId); + expect(rows).toHaveLength(1); + const r = rows[0]; + expect(r.type).toBe('flight'); + expect(r.external_source).toBe('airtrail'); + expect(r.external_id).toBe('101'); + expect(r.sync_enabled).toBe(0); // AirTrail has no multi-leg entity to round-trip to + + const meta = JSON.parse(r.metadata); + expect(meta.airtrail_ids).toEqual(['101', '102']); + expect(meta.legs).toHaveLength(2); + expect(endpointsOf(r.id).map(e => [e.role, e.code])).toEqual([ + ['from', 'BRU'], + ['stop', 'HEL'], + ['to', 'JFK'], + ]); + + // Each leg is filed on its own trip day so the day planner renders the + // legs where they belong (both flights are on Aug 1 here). + const day1 = db.prepare("SELECT id FROM days WHERE trip_id = ? AND date = '2026-08-01'").get(tripId) as { id: number }; + expect(meta.legs[0]).toMatchObject({ dep_day_id: day1.id, arr_day_id: day1.id }); + expect(meta.legs[1]).toMatchObject({ dep_day_id: day1.id, arr_day_id: day1.id }); + }); + + it('resolves overnight-connection legs to their own days', async () => { + const overnightLeg2 = { + ...legHelJfk(), + date: '2026-08-02', + departure: '2026-08-02T07:00:00.000+00:00', + arrival: '2026-08-02T15:00:00.000+00:00', + }; + (listFlights as any).mockResolvedValue([legBruHel(), overnightLeg2]); + + await importAirtrailFlights(tripId, userId, ['101', '102'], undefined, [['101', '102']]); + const [r] = tripReservations(tripId); + const dayId = (d: string) => (db.prepare('SELECT id FROM days WHERE trip_id = ? AND date = ?').get(tripId, d) as { id: number }).id; + const legs = JSON.parse(r.metadata).legs; + expect(legs[0].dep_day_id).toBe(dayId('2026-08-01')); + expect(legs[1].dep_day_id).toBe(dayId('2026-08-02')); + expect(legs[1].arr_day_id).toBe(dayId('2026-08-02')); + }); + + it('refuses to join an out-and-back return as a connection', async () => { + const returnFlight = { + ...legBruHel(), + id: 104, + from: HEL, + to: BRU, + departure: '2026-08-01T18:00:00.000+00:00', + arrival: '2026-08-01T21:30:00.000+00:00', + flightNumber: 'AY1503', + }; + (listFlights as any).mockResolvedValue([legBruHel(), returnFlight]); + + const result = await importAirtrailFlights(tripId, userId, ['101', '104'], undefined, [['101', '104']]); + expect([...result.imported].sort()).toEqual(['101', '104']); + expect(tripReservations(tripId)).toHaveLength(2); // two singles, no bogus BRU→HEL→BRU booking + }); + + it('skips every member of a joined booking on a later import attempt', async () => { + (listFlights as any).mockResolvedValue([legBruHel(), legHelJfk()]); + await importAirtrailFlights(tripId, userId, ['101', '102'], undefined, [['101', '102']]); + + // Only leg 2 carries no external_id of its own — it must still be recognized + // via metadata.airtrail_ids. + const again = await importAirtrailFlights(tripId, userId, ['102'], undefined); + expect(again.imported).toEqual([]); + expect(again.skipped).toEqual([{ flightId: '102', reason: 'already-imported' }]); + expect(tripReservations(tripId)).toHaveLength(1); + }); + + it('recognizes a joined leg imported by another member via its per-leg signature', async () => { + (listFlights as any).mockResolvedValue([legBruHel(), legHelJfk()]); + await importAirtrailFlights(tripId, userId, ['101', '102'], undefined, [['101', '102']]); + + // The same physical HEL→JFK flight from another member's AirTrail carries a + // different id there — the flight-number@date signature must catch it. + const { user: other } = createUser(db); + (listFlights as any).mockResolvedValue([{ ...legHelJfk(), id: 999 }]); + const result = await importAirtrailFlights(tripId, other.id, ['999'], undefined); + expect(result.imported).toEqual([]); + expect(result.skipped).toEqual([{ flightId: '999', reason: 'already-in-trip', detail: expect.any(String) }]); + expect(tripReservations(tripId)).toHaveLength(1); + }); + + it('falls back to individual imports when the requested join does not chain', async () => { + (listFlights as any).mockResolvedValue([legBruHel(), legLhrJfk()]); + + const result = await importAirtrailFlights(tripId, userId, ['101', '103'], undefined, [['101', '103']]); + expect([...result.imported].sort()).toEqual(['101', '103']); + + const rows = tripReservations(tripId); + expect(rows).toHaveLength(2); + for (const r of rows) { + expect(r.sync_enabled).toBe(1); // plain imports keep live sync + expect(endpointsOf(r.id)).toHaveLength(2); + } + }); + + it('falls back to individual imports when the layover exceeds 24 h', async () => { + const lateLeg2 = { ...legHelJfk(), departure: '2026-08-03T11:00:00.000+00:00', arrival: '2026-08-03T19:00:00.000+00:00', date: '2026-08-03' }; + (listFlights as any).mockResolvedValue([legBruHel(), lateLeg2]); + + const result = await importAirtrailFlights(tripId, userId, ['101', '102'], undefined, [['101', '102']]); + expect([...result.imported].sort()).toEqual(['101', '102']); + expect(tripReservations(tripId)).toHaveLength(2); + }); + + it('imports singles exactly as before when no join is requested', async () => { + (listFlights as any).mockResolvedValue([legBruHel()]); + + const result = await importAirtrailFlights(tripId, userId, ['101'], undefined); + expect(result.imported).toEqual(['101']); + + const [r] = tripReservations(tripId); + expect(r.external_id).toBe('101'); + expect(r.sync_enabled).toBe(1); + expect(r.external_hash).toBeTruthy(); + expect(JSON.parse(r.metadata).airtrail_ids).toBeUndefined(); + }); +}); diff --git a/server/tests/unit/services/airtrailMapper.test.ts b/server/tests/unit/services/airtrailMapper.test.ts index bf83b6335..dfb9c1489 100644 --- a/server/tests/unit/services/airtrailMapper.test.ts +++ b/server/tests/unit/services/airtrailMapper.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { canonicalHash, mapFlightToReservation, normalizeFlight } from '../../../src/services/airtrail/airtrailMapper'; +import { canonicalHash, mapFlightToReservation, mapFlightsToMultiLegReservation, normalizeFlight } from '../../../src/services/airtrail/airtrailMapper'; import type { AirtrailFlightRaw } from '../../../src/services/airtrail/airtrailClient'; function airport(over: Partial = {}): NonNullable { @@ -211,3 +211,101 @@ describe('airtrailMapper.canonicalHash', () => { expect(canonicalHash(a)).toBe(canonicalHash(b)); }); }); + +describe('airtrailMapper.mapFlightsToMultiLegReservation (#1535)', () => { + const BRU = () => airport({ id: 10, icao: 'EBBR', iata: 'BRU', name: 'Brussels', lat: 50.9014, lon: 4.4844, tz: 'Europe/Brussels' }); + const HEL = (over: Partial = {}) => + airport({ id: 11, icao: 'EFHK', iata: 'HEL', name: 'Helsinki-Vantaa', lat: 60.3172, lon: 24.9633, tz: 'Europe/Helsinki', ...over }); + + const leg1 = (over: Partial = {}) => + flight({ + id: 101, + from: BRU(), + to: HEL(), + departure: null, + arrival: null, + departureScheduled: '2021-09-01T06:00:00.000+00:00', // 08:00 CEST in Brussels + arrivalScheduled: '2021-09-01T09:30:00.000+00:00', // 12:30 EEST in Helsinki + airline: { id: 2, icao: 'FIN', iata: 'AY', name: 'Finnair' }, + flightNumber: 'AY1502', + note: 'first hop', + seats: [{ userId: 'u1', guestName: null, seat: 'window', seatNumber: '12A', seatClass: 'economy' }], + ...over, + }); + const leg2 = (over: Partial = {}) => + flight({ + id: 102, + from: HEL(), + to: airport(), // JFK + departure: null, + arrival: null, + departureScheduled: '2021-09-01T11:00:00.000+00:00', // 14:00 EEST in Helsinki + arrivalScheduled: '2021-09-01T19:00:00.000+00:00', // 15:00 EDT at JFK + airline: { id: 2, icao: 'FIN', iata: 'AY', name: 'Finnair' }, + flightNumber: 'AY15', + note: null, + seats: [{ userId: 'u1', guestName: null, seat: 'aisle', seatNumber: '22C', seatClass: 'economy' }], + ...over, + }); + + it('builds one from → stop → to chain, the stop carrying the onward departure', () => { + const m = mapFlightsToMultiLegReservation([leg1(), leg2()]); + expect(m.endpoints.map(e => [e.role, e.code, e.sequence])).toEqual([ + ['from', 'BRU', 0], + ['stop', 'HEL', 1], + ['to', 'JFK', 2], + ]); + // The connection endpoint stores the departure of the leg LEAVING it, like + // the manual multi-leg form; only the final endpoint keeps its arrival. + expect(m.endpoints[1].local_time).toBe('14:00'); + expect(m.endpoints[1].local_date).toBe('2021-09-01'); + expect(m.endpoints[2].local_time).toBe('15:00'); + expect(m.reservation_time).toBe('2021-09-01T08:00'); + expect(m.reservation_end_time).toBe('2021-09-01T15:00'); + expect(m.title).toBe('BRU → HEL → JFK'); + }); + + it('stores per-leg details in metadata.legs and mirrors the first/last leg flat', () => { + const m = mapFlightsToMultiLegReservation([leg1(), leg2()]); + expect(m.metadata.legs).toEqual([ + { from: 'BRU', to: 'HEL', airline: 'Finnair', flight_number: 'AY1502', dep_day_id: null, dep_time: '08:00', arr_day_id: null, arr_time: '12:30', seat: '12A' }, + { from: 'HEL', to: 'JFK', airline: 'Finnair', flight_number: 'AY15', dep_day_id: null, dep_time: '14:00', arr_day_id: null, arr_time: '15:00', seat: '22C' }, + ]); + expect(m.metadata).toMatchObject({ + airline: 'Finnair', + airline_code: 'FIN', + flight_number: 'AY1502', + departure_airport: 'BRU', + arrival_airport: 'JFK', + seat: '12A', + airtrail_ids: ['101', '102'], + }); + expect(m.notes).toBe('first hop'); + }); + + it('flags needs_review but keeps the rest of the chain when the connection airport has no coordinates', () => { + const m = mapFlightsToMultiLegReservation([leg1({ to: HEL({ lat: null, lon: null }) }), leg2({ from: HEL({ lat: null, lon: null }) })]); + expect(m.needs_review).toBe(1); + expect(m.endpoints.map(e => [e.role, e.code])).toEqual([ + ['from', 'BRU'], + ['to', 'JFK'], + ]); + }); + + it('files each leg on its own day via the resolver — an overnight connection must not inherit day 1', () => { + const overnight = leg2({ + date: '2021-09-02', + departureScheduled: '2021-09-02T06:00:00.000+00:00', // 09:00 EEST next morning + arrivalScheduled: '2021-09-02T14:00:00.000+00:00', // 10:00 EDT + }); + const dayIds: Record = { '2021-09-01': 11, '2021-09-02': 12 }; + const m = mapFlightsToMultiLegReservation([leg1(), overnight], d => (d ? (dayIds[d] ?? null) : null)); + const legs = m.metadata.legs as any[]; + expect(legs[0]).toMatchObject({ dep_day_id: 11, arr_day_id: 11 }); + expect(legs[1]).toMatchObject({ dep_day_id: 12, arr_day_id: 12 }); + }); + + it('falls back to the single-flight mapping for a one-flight chain', () => { + expect(mapFlightsToMultiLegReservation([flight()])).toEqual(mapFlightToReservation(flight())); + }); +}); diff --git a/shared/src/airtrail/airtrail.schema.spec.ts b/shared/src/airtrail/airtrail.schema.spec.ts new file mode 100644 index 000000000..65176ed04 --- /dev/null +++ b/shared/src/airtrail/airtrail.schema.spec.ts @@ -0,0 +1,18 @@ +import { airtrailImportSchema } from './airtrail.schema'; + +import { describe, it, expect } from 'vitest'; + +describe('airtrailImportSchema', () => { + it('requires at least one flight id', () => { + expect(airtrailImportSchema.safeParse({ flightIds: ['101'] }).success).toBe(true); + expect(airtrailImportSchema.safeParse({ flightIds: [] }).success).toBe(false); + expect(airtrailImportSchema.safeParse({}).success).toBe(false); + }); + + it('carries connection chains through (#1535) — each with at least two legs', () => { + const parsed = airtrailImportSchema.parse({ flightIds: ['101', '102'], connections: [['101', '102']] }); + expect(parsed.connections).toEqual([['101', '102']]); + expect(airtrailImportSchema.parse({ flightIds: ['101'] }).connections).toBeUndefined(); + expect(airtrailImportSchema.safeParse({ flightIds: ['101'], connections: [['101']] }).success).toBe(false); + }); +}); diff --git a/shared/src/airtrail/airtrail.schema.ts b/shared/src/airtrail/airtrail.schema.ts index 5ad1ee8c7..21748425f 100644 --- a/shared/src/airtrail/airtrail.schema.ts +++ b/shared/src/airtrail/airtrail.schema.ts @@ -68,6 +68,13 @@ export type AirtrailFlight = z.infer; export const airtrailImportSchema = z.object({ flightIds: z.array(z.string()).min(1, 'Select at least one flight'), + /** + * Chains of selected flight ids to import as ONE multi-leg booking each, with + * the connection airports as layover stops (#1535). The server re-validates + * that each chain actually connects; one that doesn't falls back to + * individual imports. + */ + connections: z.array(z.array(z.string()).min(2)).optional(), }); export type AirtrailImport = z.infer; diff --git a/shared/src/i18n/ar/reservations.ts b/shared/src/i18n/ar/reservations.ts index f5d3b1e43..6ba3b6d04 100644 --- a/shared/src/i18n/ar/reservations.ts +++ b/shared/src/i18n/ar/reservations.ts @@ -160,6 +160,7 @@ const reservations: TranslationStrings = { 'reservations.airtrail.otherFlights': 'رحلات أخرى', 'reservations.airtrail.empty': 'لم يتم العثور على أي رحلات في حساب AirTrail الخاص بك.', 'reservations.airtrail.importCta': 'استيراد {count}', + 'reservations.airtrail.joinConnection': 'الاستيراد كرحلة واحدة مع توقف في {stops}', 'reservations.costsLabel': 'Costs', 'reservations.createExpense': 'Create expense', 'reservations.createExpenseHint': 'Saves the booking, then opens the Costs editor.', diff --git a/shared/src/i18n/br/reservations.ts b/shared/src/i18n/br/reservations.ts index e629b5b7a..2d9e3737e 100644 --- a/shared/src/i18n/br/reservations.ts +++ b/shared/src/i18n/br/reservations.ts @@ -160,6 +160,7 @@ const reservations: TranslationStrings = { 'reservations.airtrail.otherFlights': 'Outros voos', 'reservations.airtrail.empty': 'Nenhum voo encontrado na sua conta do AirTrail.', 'reservations.airtrail.importCta': 'Importar {count}', + 'reservations.airtrail.joinConnection': 'Importar como um único voo com escala em {stops}', 'reservations.costsLabel': 'Costs', 'reservations.createExpense': 'Create expense', 'reservations.createExpenseHint': 'Saves the booking, then opens the Costs editor.', diff --git a/shared/src/i18n/cs/reservations.ts b/shared/src/i18n/cs/reservations.ts index fdc64b715..c7ccb22d6 100644 --- a/shared/src/i18n/cs/reservations.ts +++ b/shared/src/i18n/cs/reservations.ts @@ -160,6 +160,7 @@ const reservations: TranslationStrings = { 'reservations.airtrail.otherFlights': 'Ostatní lety', 'reservations.airtrail.empty': 'Ve vašem účtu AirTrail nebyly nalezeny žádné lety.', 'reservations.airtrail.importCta': 'Importovat {count}', + 'reservations.airtrail.joinConnection': 'Importovat jako jeden let s přestupem v {stops}', 'reservations.costsLabel': 'Costs', 'reservations.createExpense': 'Create expense', 'reservations.createExpenseHint': 'Saves the booking, then opens the Costs editor.', diff --git a/shared/src/i18n/de/reservations.ts b/shared/src/i18n/de/reservations.ts index 153885558..12ed9b110 100644 --- a/shared/src/i18n/de/reservations.ts +++ b/shared/src/i18n/de/reservations.ts @@ -165,6 +165,7 @@ const reservations: TranslationStrings = { 'reservations.airtrail.otherFlights': 'Weitere Flüge', 'reservations.airtrail.empty': 'Keine Flüge in Ihrem AirTrail-Konto gefunden.', 'reservations.airtrail.importCta': '{count} importieren', + 'reservations.airtrail.joinConnection': 'Als einen Flug mit Zwischenstopp in {stops} importieren', 'reservations.costsLabel': 'Kosten', 'reservations.createExpense': 'Ausgabe erstellen', 'reservations.createExpenseHint': 'Speichert die Buchung und öffnet dann den Kosten-Editor.', diff --git a/shared/src/i18n/en/reservations.ts b/shared/src/i18n/en/reservations.ts index fb87cff91..108961ed1 100644 --- a/shared/src/i18n/en/reservations.ts +++ b/shared/src/i18n/en/reservations.ts @@ -161,6 +161,7 @@ const reservations: TranslationStrings = { 'reservations.airtrail.otherFlights': 'Other flights', 'reservations.airtrail.empty': 'No flights found in your AirTrail account.', 'reservations.airtrail.importCta': 'Import {count}', + 'reservations.airtrail.joinConnection': 'Import as one flight with a layover in {stops}', 'reservations.costsLabel': 'Costs', 'reservations.createExpense': 'Create expense', 'reservations.createExpenseHint': 'Saves the booking, then opens the Costs editor.', diff --git a/shared/src/i18n/es/reservations.ts b/shared/src/i18n/es/reservations.ts index d1311e4a0..54d5d4b0b 100644 --- a/shared/src/i18n/es/reservations.ts +++ b/shared/src/i18n/es/reservations.ts @@ -163,6 +163,7 @@ const reservations: TranslationStrings = { 'reservations.airtrail.otherFlights': 'Otros vuelos', 'reservations.airtrail.empty': 'No se encontraron vuelos en tu cuenta de AirTrail.', 'reservations.airtrail.importCta': 'Importar {count}', + 'reservations.airtrail.joinConnection': 'Importar como un solo vuelo con escala en {stops}', 'reservations.costsLabel': 'Costs', 'reservations.createExpense': 'Create expense', 'reservations.createExpenseHint': 'Saves the booking, then opens the Costs editor.', diff --git a/shared/src/i18n/fr/reservations.ts b/shared/src/i18n/fr/reservations.ts index 60c2a76e7..f2f7949a3 100644 --- a/shared/src/i18n/fr/reservations.ts +++ b/shared/src/i18n/fr/reservations.ts @@ -166,6 +166,7 @@ const reservations: TranslationStrings = { 'reservations.airtrail.otherFlights': 'Autres vols', 'reservations.airtrail.empty': 'Aucun vol trouvé dans votre compte AirTrail.', 'reservations.airtrail.importCta': 'Importer {count}', + 'reservations.airtrail.joinConnection': 'Importer comme un seul vol avec escale à {stops}', 'reservations.costsLabel': 'Costs', 'reservations.createExpense': 'Create expense', 'reservations.createExpenseHint': 'Saves the booking, then opens the Costs editor.', diff --git a/shared/src/i18n/gr/reservations.ts b/shared/src/i18n/gr/reservations.ts index 16cde85a6..835c7c535 100644 --- a/shared/src/i18n/gr/reservations.ts +++ b/shared/src/i18n/gr/reservations.ts @@ -164,6 +164,7 @@ const reservations: TranslationStrings = { 'reservations.airtrail.otherFlights': 'Άλλες πτήσεις', 'reservations.airtrail.empty': 'Δεν βρέθηκαν πτήσεις στον λογαριασμό σας στο AirTrail.', 'reservations.airtrail.importCta': 'Εισαγωγή {count}', + 'reservations.airtrail.joinConnection': 'Εισαγωγή ως μία πτήση με ενδιάμεση στάση σε {stops}', 'reservations.costsLabel': 'Costs', 'reservations.createExpense': 'Create expense', 'reservations.createExpenseHint': 'Saves the booking, then opens the Costs editor.', diff --git a/shared/src/i18n/hu/reservations.ts b/shared/src/i18n/hu/reservations.ts index ef22ceee6..bebe3d47d 100644 --- a/shared/src/i18n/hu/reservations.ts +++ b/shared/src/i18n/hu/reservations.ts @@ -164,6 +164,7 @@ const reservations: TranslationStrings = { 'reservations.airtrail.otherFlights': 'Egyéb járatok', 'reservations.airtrail.empty': 'Nem található járat az AirTrail-fiókodban.', 'reservations.airtrail.importCta': '{count} importálása', + 'reservations.airtrail.joinConnection': 'Importálás egyetlen járatként, átszállással itt: {stops}', 'reservations.costsLabel': 'Costs', 'reservations.createExpense': 'Create expense', 'reservations.createExpenseHint': 'Saves the booking, then opens the Costs editor.', diff --git a/shared/src/i18n/id/reservations.ts b/shared/src/i18n/id/reservations.ts index 08e2b9a3d..cdf0ce322 100644 --- a/shared/src/i18n/id/reservations.ts +++ b/shared/src/i18n/id/reservations.ts @@ -160,6 +160,7 @@ const reservations: TranslationStrings = { 'reservations.airtrail.otherFlights': 'Penerbangan lain', 'reservations.airtrail.empty': 'Tidak ada penerbangan ditemukan di akun AirTrail-mu.', 'reservations.airtrail.importCta': 'Impor {count}', + 'reservations.airtrail.joinConnection': 'Impor sebagai satu penerbangan dengan transit di {stops}', 'reservations.costsLabel': 'Costs', 'reservations.createExpense': 'Create expense', 'reservations.createExpenseHint': 'Saves the booking, then opens the Costs editor.', diff --git a/shared/src/i18n/it/reservations.ts b/shared/src/i18n/it/reservations.ts index fb9d78236..d0709d142 100644 --- a/shared/src/i18n/it/reservations.ts +++ b/shared/src/i18n/it/reservations.ts @@ -161,6 +161,7 @@ const reservations: TranslationStrings = { 'reservations.airtrail.otherFlights': 'Altri voli', 'reservations.airtrail.empty': 'Nessun volo trovato nel tuo account AirTrail.', 'reservations.airtrail.importCta': 'Importa {count}', + 'reservations.airtrail.joinConnection': 'Importa come un unico volo con scalo a {stops}', 'reservations.costsLabel': 'Costs', 'reservations.createExpense': 'Create expense', 'reservations.createExpenseHint': 'Saves the booking, then opens the Costs editor.', diff --git a/shared/src/i18n/ja/reservations.ts b/shared/src/i18n/ja/reservations.ts index a0ba758ca..2792a9363 100644 --- a/shared/src/i18n/ja/reservations.ts +++ b/shared/src/i18n/ja/reservations.ts @@ -160,6 +160,7 @@ const reservations: TranslationStrings = { 'reservations.airtrail.otherFlights': 'その他のフライト', 'reservations.airtrail.empty': 'AirTrail アカウントにフライトが見つかりませんでした。', 'reservations.airtrail.importCta': '{count} 件をインポート', + 'reservations.airtrail.joinConnection': '{stops}での乗り継ぎを含む1つのフライトとしてインポート', 'reservations.costsLabel': 'Costs', 'reservations.createExpense': 'Create expense', 'reservations.createExpenseHint': 'Saves the booking, then opens the Costs editor.', diff --git a/shared/src/i18n/ko/reservations.ts b/shared/src/i18n/ko/reservations.ts index b3829f95e..2031fea43 100644 --- a/shared/src/i18n/ko/reservations.ts +++ b/shared/src/i18n/ko/reservations.ts @@ -160,6 +160,7 @@ const reservations: TranslationStrings = { 'reservations.airtrail.otherFlights': '기타 항공편', 'reservations.airtrail.empty': 'AirTrail 계정에서 항공편을 찾을 수 없습니다.', 'reservations.airtrail.importCta': '{count}개 가져오기', + 'reservations.airtrail.joinConnection': '{stops} 경유 항공편 하나로 가져오기', 'reservations.costsLabel': 'Costs', 'reservations.createExpense': 'Create expense', 'reservations.createExpenseHint': 'Saves the booking, then opens the Costs editor.', diff --git a/shared/src/i18n/nl/reservations.ts b/shared/src/i18n/nl/reservations.ts index e96336656..f85ed6352 100644 --- a/shared/src/i18n/nl/reservations.ts +++ b/shared/src/i18n/nl/reservations.ts @@ -162,6 +162,7 @@ const reservations: TranslationStrings = { 'reservations.airtrail.otherFlights': 'Andere vluchten', 'reservations.airtrail.empty': 'Geen vluchten gevonden in je AirTrail-account.', 'reservations.airtrail.importCta': '{count} importeren', + 'reservations.airtrail.joinConnection': 'Importeren als één vlucht met een tussenstop in {stops}', 'reservations.costsLabel': 'Kosten', 'reservations.createExpense': 'Kostenpost aanmaken', 'reservations.createExpenseHint': 'Boeking opslaan en daarna de Onkosteneditor openen.', diff --git a/shared/src/i18n/pl/reservations.ts b/shared/src/i18n/pl/reservations.ts index f73546f51..4bccd2139 100644 --- a/shared/src/i18n/pl/reservations.ts +++ b/shared/src/i18n/pl/reservations.ts @@ -161,6 +161,7 @@ const reservations: TranslationStrings = { 'reservations.airtrail.otherFlights': 'Inne loty', 'reservations.airtrail.empty': 'Nie znaleziono lotów na Twoim koncie AirTrail.', 'reservations.airtrail.importCta': 'Importuj {count}', + 'reservations.airtrail.joinConnection': 'Importuj jako jeden lot z przesiadką w {stops}', 'reservations.costsLabel': 'Costs', 'reservations.createExpense': 'Create expense', 'reservations.createExpenseHint': 'Saves the booking, then opens the Costs editor.', diff --git a/shared/src/i18n/ru/reservations.ts b/shared/src/i18n/ru/reservations.ts index 6177a359d..90d39fc61 100644 --- a/shared/src/i18n/ru/reservations.ts +++ b/shared/src/i18n/ru/reservations.ts @@ -160,6 +160,7 @@ const reservations: TranslationStrings = { 'reservations.airtrail.otherFlights': 'Другие рейсы', 'reservations.airtrail.empty': 'В вашей учётной записи AirTrail не найдено рейсов.', 'reservations.airtrail.importCta': 'Импортировать {count}', + 'reservations.airtrail.joinConnection': 'Импортировать как один рейс с пересадкой в {stops}', 'reservations.costsLabel': 'Costs', 'reservations.createExpense': 'Create expense', 'reservations.createExpenseHint': 'Saves the booking, then opens the Costs editor.', diff --git a/shared/src/i18n/sv/reservations.ts b/shared/src/i18n/sv/reservations.ts index bb0f0021c..ee29bc2b7 100644 --- a/shared/src/i18n/sv/reservations.ts +++ b/shared/src/i18n/sv/reservations.ts @@ -161,6 +161,7 @@ const reservations: TranslationStrings = { 'reservations.airtrail.otherFlights': 'Övriga flygningar', 'reservations.airtrail.empty': 'Inga flygningar hittades i ditt AirTrail-konto.', 'reservations.airtrail.importCta': 'Importera {count}', + 'reservations.airtrail.joinConnection': 'Importera som en flygning med mellanlandning i {stops}', 'reservations.costsLabel': 'Kostnader', 'reservations.createExpense': 'Skapa utgift', 'reservations.createExpenseHint': 'Sparar bokningen och öppnar sedan kostnadsredigeraren.', diff --git a/shared/src/i18n/tr/reservations.ts b/shared/src/i18n/tr/reservations.ts index c951751e1..a76414fc9 100644 --- a/shared/src/i18n/tr/reservations.ts +++ b/shared/src/i18n/tr/reservations.ts @@ -162,6 +162,7 @@ const reservations: TranslationStrings = { 'reservations.airtrail.otherFlights': 'Diğer uçuşlar', 'reservations.airtrail.empty': 'AirTrail hesabınızda uçuş bulunamadı.', 'reservations.airtrail.importCta': '{count} içe aktar', + 'reservations.airtrail.joinConnection': '{stops} aktarmalı tek uçuş olarak içe aktar', 'reservations.costsLabel': 'Costs', 'reservations.createExpense': 'Create expense', 'reservations.createExpenseHint': 'Saves the booking, then opens the Costs editor.', diff --git a/shared/src/i18n/uk/reservations.ts b/shared/src/i18n/uk/reservations.ts index fd4e93abb..f3c484868 100644 --- a/shared/src/i18n/uk/reservations.ts +++ b/shared/src/i18n/uk/reservations.ts @@ -160,6 +160,7 @@ const reservations: TranslationStrings = { 'reservations.airtrail.otherFlights': 'Інші рейси', 'reservations.airtrail.empty': 'У вашому акаунті AirTrail не знайдено рейсів.', 'reservations.airtrail.importCta': 'Імпортувати {count}', + 'reservations.airtrail.joinConnection': 'Імпортувати як один рейс із пересадкою в {stops}', 'reservations.costsLabel': 'Costs', 'reservations.createExpense': 'Create expense', 'reservations.createExpenseHint': 'Saves the booking, then opens the Costs editor.', diff --git a/shared/src/i18n/vi/reservations.ts b/shared/src/i18n/vi/reservations.ts index 31f0d8d76..0fe1a1586 100644 --- a/shared/src/i18n/vi/reservations.ts +++ b/shared/src/i18n/vi/reservations.ts @@ -163,6 +163,7 @@ const reservations: TranslationStrings = { 'reservations.airtrail.otherFlights': 'Các chuyến bay khác', 'reservations.airtrail.empty': 'Không tìm thấy chuyến bay nào trong tài khoản AirTrail của bạn.', 'reservations.airtrail.importCta': 'Nhập {count}', + 'reservations.airtrail.joinConnection': 'Nhập thành một chuyến bay với điểm dừng tại {stops}', 'reservations.costsLabel': 'Chi phí', 'reservations.createExpense': 'Tạo chi phí', 'reservations.createExpenseHint': 'Lưu đặt chỗ, sau đó mở trình chỉnh sửa Chi phí.', diff --git a/shared/src/i18n/zh-TW/reservations.ts b/shared/src/i18n/zh-TW/reservations.ts index 30c519eca..18391b427 100644 --- a/shared/src/i18n/zh-TW/reservations.ts +++ b/shared/src/i18n/zh-TW/reservations.ts @@ -159,6 +159,7 @@ const reservations: TranslationStrings = { 'reservations.airtrail.otherFlights': '其他航班', 'reservations.airtrail.empty': '在你的 AirTrail 帳戶中找不到任何航班。', 'reservations.airtrail.importCta': '匯入 {count}', + 'reservations.airtrail.joinConnection': '匯入為一個航班(在{stops}轉機)', 'reservations.costsLabel': 'Costs', 'reservations.createExpense': 'Create expense', 'reservations.createExpenseHint': 'Saves the booking, then opens the Costs editor.', diff --git a/shared/src/i18n/zh/reservations.ts b/shared/src/i18n/zh/reservations.ts index 80cfd70b3..d176d6588 100644 --- a/shared/src/i18n/zh/reservations.ts +++ b/shared/src/i18n/zh/reservations.ts @@ -159,6 +159,7 @@ const reservations: TranslationStrings = { 'reservations.airtrail.otherFlights': '其他航班', 'reservations.airtrail.empty': '您的 AirTrail 账户中未找到航班。', 'reservations.airtrail.importCta': '导入 {count}', + 'reservations.airtrail.joinConnection': '导入为一个航班(在{stops}中转)', 'reservations.costsLabel': '费用', 'reservations.createExpense': '创建支出', 'reservations.createExpenseHint': '先保存预订,然后打开费用编辑器。',