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
@@ -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);
});
});
+7
View File
@@ -68,6 +68,13 @@ export type AirtrailFlight = z.infer<typeof airtrailFlightSchema>;
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<typeof airtrailImportSchema>;
+1
View File
@@ -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.',
+1
View File
@@ -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.',
+1
View File
@@ -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.',
+1
View File
@@ -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.',
+1
View File
@@ -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.',
+1
View File
@@ -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.',
+1
View File
@@ -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.',
+1
View File
@@ -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.',
+1
View File
@@ -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.',
+1
View File
@@ -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.',
+1
View File
@@ -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.',
+1
View File
@@ -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.',
+1
View File
@@ -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.',
+1
View File
@@ -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.',
+1
View File
@@ -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.',
+1
View File
@@ -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.',
+1
View File
@@ -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.',
+1
View File
@@ -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.',
+1
View File
@@ -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.',
+1
View File
@@ -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í.',
+1
View File
@@ -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.',
+1
View File
@@ -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': '先保存预订,然后打开费用编辑器。',