Support multi-leg (layover) flights (#1146)

* feat(transport): support multi-leg (layover) flights in the booking form

A flight booking can now hold an ordered chain of airports (e.g. FRA -> BER ->
HND) instead of a single departure/arrival pair. The route is entered as a list
of waypoints with a '+ add stop' button; each stop carries its own arrival and
departure time plus the airline/flight number of the segment leaving it, while
the whole booking keeps one price.

Stored without a schema change: the existing reservation_endpoints rows carry the
ordered waypoints (from/stop/to by sequence) and a metadata.legs array holds the
per-leg detail. Top-level metadata (departure_airport/arrival_airport/airline/
flight_number) mirrors the first and last leg, so a single-leg flight persists
exactly as before and legacy readers keep working.

* feat(planner): show each flight leg as its own day-plan entry, ordered by time

A multi-leg flight now expands into one entry per leg (BER -> FRA, then FRA ->
HND), each on its own day with its own times, instead of a single span. Each leg
is an addressable slot (reservation id + leg index) so places and notes can be
dropped into the layover gap between legs; the per-leg position is persisted in
metadata.legs[i].day_positions and survives a reload.

Day-plan items are now ordered chronologically: anything with a time (a place's
time, a flight leg, a timed note) sorts by that time, and untimed items inherit
the time of the item before them so they stay where they were placed.

* feat(planner): show the full multi-stop route in the bookings panel

The route row now lists every waypoint (FRA -> BER -> HND) by sequence instead of
just the first and last airport.

* feat(map): draw multi-leg flights as connected legs with a marker per airport

Both the Leaflet and Mapbox overlays now render a flight over all its waypoints:
one great-circle arc per leg and a marker at every airport, with the label
showing the full route and the summed distance. A single-leg flight is unchanged.

Also drops the floating stats badge that was drawn on transport arcs.

* fix(map): centre a clicked place above the bottom inspector panel

Selecting a place panned/flew it to the dead centre of the screen, where it sat
behind the detail card. Both overlays now bias the target into the visible area
above the bottom panel (Leaflet offsets the pan by the inspector inset; Mapbox
passes the padding to flyTo).

* feat: show the full multi-stop flight route in PDF and calendar export

The PDF day list and the ICS export now render the whole route (FRA → BER → HND)
for a multi-leg flight instead of just the first and last airport, falling back to
the flat metadata for single-leg flights. The ICS keeps a single event per booking.

* feat(import): group connecting flight legs into one multi-leg booking

When a booking confirmation contains several flight legs sharing a PNR that
connect at the same airport with a short layover (under 24h), they are now
imported as a single multi-leg booking (from/stop/to endpoints + metadata.legs)
instead of one booking per leg. A round trip (same PNR, multi-day gap) stays two
separate bookings, and a single flight is unchanged.

* i18n: translate the new flight-route strings into all languages

* i18n: translate the Costs page into every language

The Budget → Costs rework left the new costs.* strings untranslated in every
non-English locale (they fell back to English). Translate them across all
supported languages.

* Revert "fix(map): centre a clicked place above the bottom inspector panel"

This reverts commit 0936103f04.
This commit is contained in:
Maurice
2026-06-11 22:17:14 +02:00
committed by GitHub
parent e65acb3de7
commit bb477645a3
50 changed files with 2144 additions and 1508 deletions
+70 -70
View File
@@ -38,78 +38,78 @@ const budget: TranslationStrings = {
'予算項目のメンバーアイコンをクリックして緑にすると、支払い済みを示します。精算では、誰が誰にいくら支払うべきかを表示します。',
'budget.netBalances': '差引残高',
'budget.categoriesLabel': 'カテゴリ',
"costs.you": "You",
"costs.you": "自分",
"costs.youShort": "Y",
"costs.youLower": "you",
"costs.youOwe": "You owe",
"costs.youOweSub": "You should pay others",
"costs.youreOwed": "You're owed",
"costs.youreOwedSub": "Others should pay you",
"costs.totalSpend": "Total trip spend",
"costs.totalSpendSub": "Across all travelers",
"costs.to": "To",
"costs.from": "From",
"costs.allSettled": "You're all settled up",
"costs.nothingOwed": "Nothing owed to you",
"costs.yourShare": "Your share",
"costs.youPaid": "You paid",
"costs.expenses": "Expenses",
"costs.entries": "{count} entries",
"costs.searchPlaceholder": "Search expenses…",
"costs.filter.all": "All",
"costs.filter.mine": "Paid by me",
"costs.filter.owed": "I'm owed",
"costs.addExpense": "Add expense",
"costs.editExpense": "Edit expense",
"costs.noMatch": "No expenses match your search.",
"costs.emptyText": "No expenses yet. Add your first one.",
"costs.spent": "{amount} spent",
"costs.noDate": "No date",
"costs.noOnePaid": "No one paid yet",
"costs.youLent": "you lent {amount}",
"costs.youBorrowed": "you borrowed {amount}",
"costs.settleUp": "Settle up",
"costs.history": "History",
"costs.everyoneSquare": "Everyone's square",
"costs.nothingOutstanding": "No payments outstanding right now.",
"costs.pay": "pay",
"costs.pays": "pays",
"costs.settle": "Settle",
"costs.balances": "Balances",
"costs.byCategory": "By category",
"costs.noCategories": "No expenses yet.",
"costs.settleHistory": "Settle history",
"costs.noSettlements": "No settled payments yet.",
"costs.paymentsSettled": "{count} payments settled",
"costs.paid": "paid",
"costs.undo": "Undo",
"costs.whatFor": "What was it for?",
"costs.namePlaceholder": "e.g. Dinner, souvenirs, gas…",
"costs.totalAmount": "Total amount",
"costs.currency": "Currency",
"costs.day": "Day",
"costs.rateLabel": "1 {from} in {to}",
"costs.category": "Category",
"costs.whoPaid": "Who paid?",
"costs.splitBetween": "Split equally between",
"costs.pickSomeone": "Pick at least one person to split with.",
"costs.splitSummary": "Split {count} ways · {amount} each",
"costs.cat.accommodation": "Accommodation",
"costs.cat.food": "Food & drink",
"costs.cat.groceries": "Groceries",
"costs.cat.transport": "Transport",
"costs.cat.flights": "Flights",
"costs.cat.activities": "Activities",
"costs.cat.sightseeing": "Sightseeing",
"costs.cat.shopping": "Shopping",
"costs.cat.fees": "Fees & tickets",
"costs.cat.health": "Health",
"costs.cat.tips": "Tips",
"costs.cat.other": "Other",
"costs.daysCount": "{count} days",
"costs.travelers": "{count} travelers",
"costs.liveRate": "live rate",
"costs.settleAll": "Settle all",
"costs.youOwe": "支払う額",
"costs.youOweSub": "他の人に支払うべき額",
"costs.youreOwed": "受け取る額",
"costs.youreOwedSub": "他の人があなたに支払うべき額",
"costs.totalSpend": "旅行の合計支出",
"costs.totalSpendSub": "全員の合計",
"costs.to": "支払先",
"costs.from": "支払元",
"costs.allSettled": "すべて精算済みです",
"costs.nothingOwed": "受け取る額はありません",
"costs.yourShare": "あなたの負担分",
"costs.youPaid": "あなたが支払った額",
"costs.expenses": "支出",
"costs.entries": "{count}",
"costs.searchPlaceholder": "支出を検索…",
"costs.filter.all": "すべて",
"costs.filter.mine": "自分が支払った分",
"costs.filter.owed": "受け取る分",
"costs.addExpense": "支出を追加",
"costs.editExpense": "支出を編集",
"costs.noMatch": "検索条件に一致する支出はありません。",
"costs.emptyText": "支出はまだありません。最初の支出を追加しましょう。",
"costs.spent": "{amount}を支出",
"costs.noDate": "日付なし",
"costs.noOnePaid": "まだ誰も支払っていません",
"costs.youLent": "{amount}を立て替えました",
"costs.youBorrowed": "{amount}を借りています",
"costs.settleUp": "精算する",
"costs.history": "履歴",
"costs.everyoneSquare": "全員が清算済みです",
"costs.nothingOutstanding": "現在、未払いの支払いはありません。",
"costs.pay": "支払う",
"costs.pays": "支払う",
"costs.settle": "精算",
"costs.balances": "残高",
"costs.byCategory": "カテゴリ別",
"costs.noCategories": "支出はまだありません。",
"costs.settleHistory": "精算履歴",
"costs.noSettlements": "精算済みの支払いはまだありません。",
"costs.paymentsSettled": "{count}件の支払いを精算済み",
"costs.paid": "支払済み",
"costs.undo": "元に戻す",
"costs.whatFor": "何の支出ですか?",
"costs.namePlaceholder": "例:夕食、お土産、ガソリン…",
"costs.totalAmount": "合計金額",
"costs.currency": "通貨",
"costs.day": "",
"costs.rateLabel": "1 {from} = {to}",
"costs.category": "カテゴリ",
"costs.whoPaid": "誰が支払いましたか?",
"costs.splitBetween": "均等に分割する相手",
"costs.pickSomeone": "分割する相手を少なくとも1人選んでください。",
"costs.splitSummary": "{count}人で分割 · {amount}",
"costs.cat.accommodation": "宿泊",
"costs.cat.food": "飲食",
"costs.cat.groceries": "食料品",
"costs.cat.transport": "交通",
"costs.cat.flights": "航空券",
"costs.cat.activities": "アクティビティ",
"costs.cat.sightseeing": "観光",
"costs.cat.shopping": "買い物",
"costs.cat.fees": "手数料・チケット",
"costs.cat.health": "健康",
"costs.cat.tips": "チップ",
"costs.cat.other": "その他",
"costs.daysCount": "{count}日間",
"costs.travelers": "{count}人の旅行者",
"costs.liveRate": "リアルタイムレート",
"costs.settleAll": "すべて精算",
};
export default budget;
+5
View File
@@ -27,6 +27,11 @@ const reservations: TranslationStrings = {
'reservations.meta.flightNumber': '便名',
'reservations.meta.from': '出発地',
'reservations.meta.to': '到着地',
'reservations.layover.route': '経路',
'reservations.layover.stop': '経由地',
'reservations.layover.addStop': '経由地を追加',
'reservations.layover.connection': '乗り継ぎ便',
'reservations.layover.layover': '乗り継ぎ',
'reservations.needsReview': '要確認',
'reservations.needsReviewHint':
'空港を自動で特定できませんでした。場所を確認してください。',