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
@@ -158,6 +158,7 @@ interface TransportItem {
res: Reservation
from: ReservationEndpoint
to: ReservationEndpoint
waypoints: ReservationEndpoint[]
type: TransportType
arcs: [number, number][][]
primaryArc: [number, number][]
@@ -353,15 +354,29 @@ export default function ReservationOverlay({ reservations, showConnections, show
const out: TransportItem[] = []
for (const r of reservations) {
if (!TRANSPORT_TYPES.includes(r.type as TransportType)) continue
const eps = r.endpoints || []
const from = eps.find(e => e.role === 'from')
const to = eps.find(e => e.role === 'to')
if (!from || !to) continue
// Ordered waypoints (from · stops · to). A single-leg booking has exactly two,
// so the arc + markers below are byte-identical to before for it.
const waypoints = (r.endpoints || [])
.filter(e => e.role === 'from' || e.role === 'to' || e.role === 'stop')
.slice()
.sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0))
if (waypoints.length < 2) continue
const from = waypoints[0]
const to = waypoints[waypoints.length - 1]
const type = r.type as TransportType
const isGeo = TYPE_META[type].geodesic
const arcs = isGeo
? splitAntimeridian(greatCircle([from.lat, from.lng], [to.lat, to.lng]))
: [[[from.lat, from.lng], [to.lat, to.lng]] as [number, number][]]
// One arc per leg (between consecutive waypoints), concatenated.
const arcs: [number, number][][] = []
let distanceKm = 0
for (let i = 0; i < waypoints.length - 1; i++) {
const a = waypoints[i]
const b = waypoints[i + 1]
const segArcs = isGeo
? splitAntimeridian(greatCircle([a.lat, a.lng], [b.lat, b.lng]))
: [[[a.lat, a.lng], [b.lat, b.lng]] as [number, number][]]
arcs.push(...segArcs)
distanceKm += haversineKm([a.lat, a.lng], [b.lat, b.lng])
}
const primaryIdx = arcs.reduce((best, seg, idx, all) => seg.length > all[best].length ? idx : best, 0)
const primaryArc = arcs[primaryIdx] ?? []
const fallback: [number, number] = primaryArc.length > 0
@@ -369,12 +384,15 @@ export default function ReservationOverlay({ reservations, showConnections, show
: [(from.lat + to.lat) / 2, (from.lng + to.lng) / 2]
const duration = computeDuration(from, to, r.reservation_time || null, r.reservation_end_time || null)
const distance = `${Math.round(haversineKm([from.lat, from.lng], [to.lat, to.lng]))} km`
const mainLabel = from.code && to.code ? `${from.code}${to.code}` : null
const distance = `${Math.round(distanceKm)} km`
// Show the full route (FRA → BER → HND) when every waypoint has a code.
const mainLabel = waypoints.every(w => w.code)
? waypoints.map(w => w.code).join(' → ')
: (from.code && to.code ? `${from.code}${to.code}` : null)
const subParts = [duration, distance].filter(Boolean) as string[]
const subLabel = subParts.length > 0 ? subParts.join(' · ') : null
out.push({ res: r, from, to, type, arcs, primaryArc, fallback, mainLabel, subLabel })
out.push({ res: r, from, to, waypoints, type, arcs, primaryArc, fallback, mainLabel, subLabel })
}
return out
}, [reservations])
@@ -416,38 +434,21 @@ export default function ReservationOverlay({ reservations, showConnections, show
/>
)))}
{visibleItems.flatMap(item => [
{visibleItems.flatMap(item => item.waypoints.map((wp, wi) => (
<Marker
key={`from-${item.res.id}`}
position={[item.from.lat, item.from.lng]}
icon={endpointIcon(item.type, showEndpointLabels && labelVisibleIds.has(item.res.id) ? (item.from.code || cleanName(item.from.name)) : null)}
key={`wp-${item.res.id}-${wi}`}
position={[wp.lat, wp.lng]}
icon={endpointIcon(item.type, showEndpointLabels && labelVisibleIds.has(item.res.id) ? (wp.code || cleanName(wp.name)) : null)}
pane={ENDPOINT_PANE}
zIndexOffset={1000}
eventHandlers={{ click: () => onEndpointClick?.(item.res.id) }}
>
<Tooltip direction="top" offset={[0, -8]} opacity={1} className="map-tooltip">
<div style={{ fontWeight: 600, fontSize: 12 }}>{item.from.name}</div>
<div style={{ fontWeight: 600, fontSize: 12 }}>{wp.name}</div>
{item.res.title && <div className="text-content-muted" style={{ fontSize: 11 }}>{item.res.title}</div>}
</Tooltip>
</Marker>,
<Marker
key={`to-${item.res.id}`}
position={[item.to.lat, item.to.lng]}
icon={endpointIcon(item.type, showEndpointLabels && labelVisibleIds.has(item.res.id) ? (item.to.code || cleanName(item.to.name)) : null)}
pane={ENDPOINT_PANE}
zIndexOffset={1000}
eventHandlers={{ click: () => onEndpointClick?.(item.res.id) }}
>
<Tooltip direction="top" offset={[0, -8]} opacity={1} className="map-tooltip">
<div style={{ fontWeight: 600, fontSize: 12 }}>{item.to.name}</div>
{item.res.title && <div className="text-content-muted" style={{ fontSize: 11 }}>{item.res.title}</div>}
</Tooltip>
</Marker>,
])}
{showStats && visibleItems.map(item => item.type === 'flight' && (item.mainLabel || item.subLabel) && labelVisibleIds.has(item.res.id) && (
<StatsLabel key={`stats-${item.res.id}`} item={item} />
))}
</Marker>
)))}
</>
)
}
+29 -32
View File
@@ -126,6 +126,7 @@ interface TransportItem {
res: Reservation
from: ReservationEndpoint
to: ReservationEndpoint
waypoints: ReservationEndpoint[]
type: TransportType
arcs: [number, number][][]
primaryArc: [number, number][]
@@ -137,23 +138,38 @@ function buildItems(reservations: Reservation[]): TransportItem[] {
const out: TransportItem[] = []
for (const r of reservations) {
if (!TRANSPORT_TYPES.includes(r.type as TransportType)) continue
const eps = r.endpoints || []
const from = eps.find(e => e.role === 'from')
const to = eps.find(e => e.role === 'to')
if (!from || !to) continue
// Ordered waypoints (from · stops · to); a single-leg booking has exactly two.
const waypoints = (r.endpoints || [])
.filter(e => e.role === 'from' || e.role === 'to' || e.role === 'stop')
.slice()
.sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0))
if (waypoints.length < 2) continue
const from = waypoints[0]
const to = waypoints[waypoints.length - 1]
const type = r.type as TransportType
const isGeo = TYPE_META[type].geodesic
const arcs = isGeo
? splitAntimeridian(greatCircle([from.lat, from.lng], [to.lat, to.lng]))
: [[[from.lat, from.lng], [to.lat, to.lng]] as [number, number][]]
// One arc per leg (between consecutive waypoints), concatenated.
const arcs: [number, number][][] = []
let distanceKm = 0
for (let i = 0; i < waypoints.length - 1; i++) {
const a = waypoints[i]
const b = waypoints[i + 1]
const segArcs = isGeo
? splitAntimeridian(greatCircle([a.lat, a.lng], [b.lat, b.lng]))
: [[[a.lat, a.lng], [b.lat, b.lng]] as [number, number][]]
arcs.push(...segArcs)
distanceKm += haversineKm([a.lat, a.lng], [b.lat, b.lng])
}
const primaryIdx = arcs.reduce((best, seg, idx, all) => seg.length > all[best].length ? idx : best, 0)
const primaryArc = arcs[primaryIdx] ?? []
const duration = computeDuration(from, to, r.reservation_time || null, r.reservation_end_time || null)
const distance = `${Math.round(haversineKm([from.lat, from.lng], [to.lat, to.lng]))} km`
const mainLabel = from.code && to.code ? `${from.code}${to.code}` : null
const distance = `${Math.round(distanceKm)} km`
const mainLabel = waypoints.every(w => w.code)
? waypoints.map(w => w.code).join(' → ')
: (from.code && to.code ? `${from.code}${to.code}` : null)
const subParts = [duration, distance].filter(Boolean) as string[]
const subLabel = subParts.length > 0 ? subParts.join(' · ') : null
out.push({ res: r, from, to, type, arcs, primaryArc, mainLabel, subLabel })
out.push({ res: r, from, to, waypoints, type, arcs, primaryArc, mainLabel, subLabel })
}
return out
}
@@ -321,7 +337,7 @@ export class ReservationMapboxOverlay {
if (show) {
for (const item of visibleItems) {
const showLabel = this.opts.showEndpointLabels && labelVisibleIds.has(item.res.id)
for (const ep of [item.from, item.to]) {
for (const ep of item.waypoints) {
const label = showLabel ? (ep.code || cleanName(ep.name)) : null
const el = document.createElement('div')
el.innerHTML = endpointMarkerHtml(item.type, label)
@@ -342,29 +358,10 @@ export class ReservationMapboxOverlay {
}
}
// ── stats label (flights only) ──────────────────────────────────
// Stats badge removed — the floating route/duration label on the arc is no
// longer drawn; only the connection line and the airport markers remain.
this.statsMarkers.forEach(s => s.marker.remove())
this.statsMarkers = []
if (show && this.opts.showStats) {
for (const item of visibleItems) {
if (item.type !== 'flight') continue
if (!labelVisibleIds.has(item.res.id)) continue
if (!item.mainLabel && !item.subLabel) continue
const arc = item.primaryArc
if (arc.length < 2) continue
const mid = arc[Math.floor(arc.length / 2)]!
const { html, width, height } = buildStatsHtml(item.mainLabel, item.subLabel)
const el = document.createElement('div')
el.style.cssText = `width:${width}px;height:${height}px;pointer-events:none;`
el.innerHTML = html
const marker = new mapboxgl.Marker({ element: el, anchor: 'center' })
.setLngLat([mid[1], mid[0]])
.addTo(map)
this.statsMarkers.push({ marker, arc })
}
}
// Prime rotation once so labels don't flash horizontal on first paint.
this.updateStatsRotation()
}
// Match the Leaflet overlay's "rotate the label along the arc" look.
+7 -1
View File
@@ -215,7 +215,13 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
const icon = reservationIconSvg(r.type)
const color = RESERVATION_COLOR_MAP[r.type] || '#3b82f6'
let subtitle = ''
if (r.type === 'flight') subtitle = [meta.airline, meta.flight_number, meta.departure_airport && meta.arrival_airport ? `${meta.departure_airport}${meta.arrival_airport}` : ''].filter(Boolean).join(' · ')
if (r.type === 'flight') {
// Full route over all waypoints (FRA → BER → HND), falling back to the
// flat metadata pair for legacy single-leg flights without endpoints.
const stops = (r.endpoints || []).slice().sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0)).map(e => e.code || e.name)
const route = stops.length >= 2 ? stops.join(' → ') : (meta.departure_airport && meta.arrival_airport ? `${meta.departure_airport}${meta.arrival_airport}` : '')
subtitle = [meta.airline, meta.flight_number, route].filter(Boolean).join(' · ')
}
else if (r.type === 'train') subtitle = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : '', meta.seat ? `Seat ${meta.seat}` : ''].filter(Boolean).join(' · ')
else if (r.type === 'restaurant') subtitle = [meta.party_size ? `${meta.party_size} guests` : ''].filter(Boolean).join(' · ')
else if (r.type === 'event') subtitle = [meta.venue].filter(Boolean).join(' · ')
@@ -171,7 +171,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
const [timeConfirm, setTimeConfirm] = useState<{
dayId: number; fromId: number; time: string;
// For drag & drop reorder
fromType?: string; toType?: string; toId?: number; insertAfter?: boolean;
fromType?: string; toType?: string; toId?: number; insertAfter?: boolean; toLegIndex?: number | null;
// For arrow reorder
reorderIds?: number[];
} | null>(null)
@@ -471,6 +471,9 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
const assignmentIds: number[] = []
const noteUpdates: { id: number; sort_order: number }[] = []
const transportUpdates: { id: number; day_plan_position: number }[] = []
// Multi-leg flight legs share a reservation id, so their positions can't live in
// the single per-booking slot — collect them per leg, keyed reservationId → legIndex → pos.
const legPosUpdates: Record<number, Record<number, number>> = {}
let placeCount = 0
let i = 0
@@ -491,7 +494,10 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
group.forEach((g, idx) => {
const pos = base + (idx + 1) / (group.length + 1)
if (g.type === 'note') noteUpdates.push({ id: g.data.id, sort_order: pos })
else if (g.type === 'transport') transportUpdates.push({ id: g.data.id, day_plan_position: pos })
else if (g.type === 'transport') {
if (g.data.__leg) ((legPosUpdates[g.data.id] ??= {})[g.data.__leg.index] = pos)
else transportUpdates.push({ id: g.data.id, day_plan_position: pos })
}
})
}
}
@@ -510,6 +516,30 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
}))
setTransportPosVersion(v => v + 1)
}
// Per-leg positions of multi-leg flights live in metadata.legs[i].day_positions
// (the single per-booking slot can't hold one position per leg).
const legResIds = Object.keys(legPosUpdates)
if (legResIds.length) {
for (const ridStr of legResIds) {
const rid = Number(ridStr)
const r = useTripStore.getState().reservations.find(x => x.id === rid)
if (!r) continue
let parsed: any = {}
try { parsed = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata || {}) } catch { parsed = {} }
if (!Array.isArray(parsed.legs)) continue
const legs = parsed.legs.map((leg: any, i: number) => {
const pos = legPosUpdates[rid][i]
return pos == null ? leg : { ...leg, day_positions: { ...(leg.day_positions || {}), [dayId]: pos } }
})
// Send metadata as an OBJECT (like the form does) — passing a JSON string
// here double-encodes it on the server, which wipes metadata.legs on read
// and collapses the flight back to a single span.
const newMeta = { ...parsed, legs }
useTripStore.setState(state => ({ reservations: state.reservations.map(x => (x.id === rid ? { ...x, metadata: newMeta } : x)) }))
await tripActions.updateReservation(tripId, rid, { metadata: newMeta })
}
setTransportPosVersion(v => v + 1)
}
if (assignmentIds.length) await onReorder(dayId, assignmentIds)
if (transportUpdates.length) {
onRouteRefresh?.()
@@ -528,8 +558,11 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
}
const handleMergedDrop = async (dayId, fromType, fromId, toType, toId, insertAfter = false) => {
const handleMergedDrop = async (dayId, fromType, fromId, toType, toId, insertAfter = false, toLegIndex = null) => {
const m = getMergedItems(dayId)
// Multi-leg flights expose one item per leg sharing the same reservation id;
// disambiguate the drop target by leg index so you can drop BETWEEN legs.
const matchTo = (i: any) => i.type === toType && i.data.id === toId && (toLegIndex == null || i.data?.__leg?.index === toLegIndex)
// Check if a timed place is being moved → would it break chronological order?
if (fromType === 'place') {
@@ -537,11 +570,11 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
const fromMinutes = parseTimeToMinutes(fromItem?.data?.place?.place_time)
if (fromItem && fromMinutes !== null) {
const fromIdx = m.findIndex(i => i.type === fromType && i.data.id === fromId)
const toIdx = m.findIndex(i => i.type === toType && i.data.id === toId)
const toIdx = m.findIndex(matchTo)
if (fromIdx !== -1 && toIdx !== -1) {
const simulated = [...m]
const [moved] = simulated.splice(fromIdx, 1)
let insertIdx = simulated.findIndex(i => i.type === toType && i.data.id === toId)
let insertIdx = simulated.findIndex(matchTo)
if (insertIdx === -1) insertIdx = simulated.length
if (insertAfter) insertIdx += 1
simulated.splice(insertIdx, 0, moved)
@@ -558,7 +591,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
if (!isChronological) {
const placeTime = fromItem.data.place.place_time
const timeStr = placeTime.includes(':') ? placeTime.substring(0, 5) : placeTime
setTimeConfirm({ dayId, fromType, fromId, toType, toId, insertAfter, time: timeStr })
setTimeConfirm({ dayId, fromType, fromId, toType, toId, insertAfter, toLegIndex, time: timeStr })
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null
return
}
@@ -568,7 +601,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
// Build new order: remove the dragged item, insert at target position
const fromIdx = m.findIndex(i => i.type === fromType && i.data.id === fromId)
const toIdx = m.findIndex(i => i.type === toType && i.data.id === toId)
const toIdx = m.findIndex(matchTo)
if (fromIdx === -1 || toIdx === -1 || fromIdx === toIdx) {
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null
return
@@ -576,7 +609,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
const newOrder = [...m]
const [moved] = newOrder.splice(fromIdx, 1)
let adjustedTo = newOrder.findIndex(i => i.type === toType && i.data.id === toId)
let adjustedTo = newOrder.findIndex(matchTo)
if (adjustedTo === -1) adjustedTo = newOrder.length
if (insertAfter) adjustedTo += 1
newOrder.splice(adjustedTo, 0, moved)
@@ -590,7 +623,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
const confirmTimeRemoval = async () => {
if (!timeConfirm) return
const saved = { ...timeConfirm }
const { dayId, fromId, reorderIds, fromType, toType, toId, insertAfter } = saved
const { dayId, fromId, reorderIds, fromType, toType, toId, insertAfter, toLegIndex } = saved
setTimeConfirm(null)
// Remove time from assignment
@@ -633,13 +666,14 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
// Drag & drop reorder
if (fromType && toType) {
const matchTo = (i: any) => i.type === toType && i.data.id === toId && (toLegIndex == null || i.data?.__leg?.index === toLegIndex)
const fromIdx = m.findIndex(i => i.type === fromType && i.data.id === fromId)
const toIdx = m.findIndex(i => i.type === toType && i.data.id === toId)
const toIdx = m.findIndex(matchTo)
if (fromIdx === -1 || toIdx === -1 || fromIdx === toIdx) return
const newOrder = [...m]
const [moved] = newOrder.splice(fromIdx, 1)
let adjustedTo = newOrder.findIndex(i => i.type === toType && i.data.id === toId)
let adjustedTo = newOrder.findIndex(matchTo)
if (adjustedTo === -1) adjustedTo = newOrder.length
if (insertAfter) adjustedTo += 1
newOrder.splice(adjustedTo, 0, moved)
@@ -1311,6 +1345,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
const isAfter = dropTargetRef.current.startsWith('transport-after-')
const parts = dropTargetRef.current.replace('transport-after-', '').replace('transport-', '').split('-')
const transportId = Number(parts[0])
const legPart = parts.find(p => /^leg\d+$/.test(p))
const toLegIndex = legPart ? Number(legPart.slice(3)) : null
if (placeId) {
onAssignToDay?.(parseInt(placeId), day.id)
@@ -1318,15 +1354,15 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
const r = reservations.find(x => x.id === Number(fromReservationId))
if (r) { const update = computeMultiDayMove(r, day.id, phase); tripActions.updateReservation(tripId, r.id, update).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) }
} else if (fromReservationId) {
handleMergedDrop(day.id, 'transport', Number(fromReservationId), 'transport', transportId, isAfter)
handleMergedDrop(day.id, 'transport', Number(fromReservationId), 'transport', transportId, isAfter, toLegIndex)
} else if (assignmentId && fromDayId !== day.id) {
tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
} else if (assignmentId) {
handleMergedDrop(day.id, 'place', Number(assignmentId), 'transport', transportId, isAfter)
handleMergedDrop(day.id, 'place', Number(assignmentId), 'transport', transportId, isAfter, toLegIndex)
} else if (noteId && fromDayId !== day.id) {
tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
} else if (noteId) {
handleMergedDrop(day.id, 'note', Number(noteId), 'transport', transportId, isAfter)
handleMergedDrop(day.id, 'note', Number(noteId), 'transport', transportId, isAfter, toLegIndex)
}
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; window.__dragData = null
return
@@ -1372,9 +1408,10 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
</div>
) : (
merged.map((item, idx) => {
const itemKey = item.type === 'transport' ? `transport-${item.data.id}-${day.id}` : (item.type === 'place' ? `place-${item.data.id}` : `note-${item.data.id}`)
const legSuffix = item.data?.__leg ? `-leg${item.data.__leg.index}` : ''
const itemKey = item.type === 'transport' ? `transport-${item.data.id}${legSuffix}-${day.id}` : (item.type === 'place' ? `place-${item.data.id}` : `note-${item.data.id}`)
const showDropLine = (!!draggingId || !!dropTargetKey) && dropTargetKey === itemKey
const showDropLineAfter = item.type === 'transport' && (!!draggingId || !!dropTargetKey) && dropTargetKey === `transport-after-${item.data.id}-${day.id}`
const showDropLineAfter = item.type === 'transport' && (!!draggingId || !!dropTargetKey) && dropTargetKey === `transport-after-${item.data.id}${legSuffix}-${day.id}`
if (item.type === 'place') {
const assignment = item.data
@@ -1722,7 +1759,13 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
// Subtitle aus Metadaten zusammensetzen
let subtitle = ''
if (res.type === 'flight') {
if (res.__leg) {
// One leg of a multi-leg flight — show this segment's own route.
const parts = [res.__leg.airline, res.__leg.flight_number].filter(Boolean)
if (res.__leg.from || res.__leg.to)
parts.push([res.__leg.from, res.__leg.to].filter(Boolean).join(' → '))
subtitle = parts.join(' · ')
} else if (res.type === 'flight') {
const parts = [meta.airline, meta.flight_number].filter(Boolean)
if (meta.departure_airport || meta.arrival_airport)
parts.push([meta.departure_airport, meta.arrival_airport].filter(Boolean).join(' → '))
@@ -1731,28 +1774,32 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
subtitle = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : '', meta.seat ? `Sitz ${meta.seat}` : ''].filter(Boolean).join(' · ')
}
// Multi-day span phase
const spanLabel = getSpanLabel(res, spanPhase)
// Multi-day span phase (single-leg / non-flight only — a
// multi-leg flight is shown as one row per leg, see below).
const spanLabel = res.__leg ? null : getSpanLabel(res, spanPhase)
const displayTime = getDisplayTimeForDay(res, day.id)
const legKey = res.__leg ? `leg${res.__leg.index}` : 'x'
return (
<React.Fragment key={`transport-${res.id}-${day.id}`}>
<React.Fragment key={`transport-${res.id}-${legKey}-${day.id}`}>
<div
onClick={() => {
if (!canEditDays) return
if (TRANSPORT_TYPES.has(res.type)) onEditTransport?.(res)
else onEditReservation?.(res)
const target = reservations.find(x => x.id === res.id) ?? res
if (TRANSPORT_TYPES.has(res.type)) onEditTransport?.(target)
else onEditReservation?.(target)
}}
onDragOver={e => {
e.preventDefault(); e.stopPropagation()
const rect = e.currentTarget.getBoundingClientRect()
const inBottom = e.clientY > rect.top + rect.height / 2
const key = inBottom ? `transport-after-${res.id}-${day.id}` : `transport-${res.id}-${day.id}`
const ls = res.__leg ? `-leg${res.__leg.index}` : ''
const key = inBottom ? `transport-after-${res.id}${ls}-${day.id}` : `transport-${res.id}${ls}-${day.id}`
if (dropTargetRef.current !== key) setDropTargetKey(key)
}}
draggable={canEditDays && spanPhase !== 'middle'}
draggable={canEditDays && spanPhase !== 'middle' && !res.__leg}
onDragStart={e => {
if (!canEditDays || spanPhase === 'middle') { e.preventDefault(); return }
if (!canEditDays || spanPhase === 'middle' || res.__leg) { e.preventDefault(); return }
// setData is required for the drag to start reliably (Firefox) and
// matches how place/note items initiate their drag.
e.dataTransfer.setData('reservationId', String(res.id))
@@ -1773,15 +1820,15 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
const r2 = reservations.find(x => x.id === Number(fromReservationId))
if (r2) { const update = computeMultiDayMove(r2, day.id, phase); tripActions.updateReservation(tripId, r2.id, update).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) }
} else if (fromReservationId) {
handleMergedDrop(day.id, 'transport', Number(fromReservationId), 'transport', res.id, insertAfter)
handleMergedDrop(day.id, 'transport', Number(fromReservationId), 'transport', res.id, insertAfter, res.__leg?.index ?? null)
} else if (fromAssignmentId && fromDayId !== day.id) {
tripActions.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
} else if (fromAssignmentId) {
handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'transport', res.id, insertAfter)
handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'transport', res.id, insertAfter, res.__leg?.index ?? null)
} else if (noteId && fromDayId !== day.id) {
tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
} else if (noteId) {
handleMergedDrop(day.id, 'note', Number(noteId), 'transport', res.id, insertAfter)
handleMergedDrop(day.id, 'note', Number(noteId), 'transport', res.id, insertAfter, res.__leg?.index ?? null)
}
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; window.__dragData = null
}}
@@ -1801,7 +1848,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
opacity: draggingId === res.id ? 0.4 : spanPhase === 'middle' ? 0.65 : 1,
}}
>
{canEditDays && spanPhase !== 'middle' && (
{canEditDays && spanPhase !== 'middle' && !res.__leg && (
<div className="dp-grip" style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
<GripVertical size={13} strokeWidth={1.8} />
</div>
@@ -1846,7 +1893,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
</div>
)}
</div>
{onToggleConnection && (res.endpoints || []).length >= 2 && (() => {
{onToggleConnection && (!res.__leg || res.__leg.index === 0) && (res.endpoints || []).length >= 2 && (() => {
const active = visibleConnectionIds.includes(res.id)
return (
<button
@@ -271,19 +271,21 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
)}
{(() => {
const eps = r.endpoints || []
const from = eps.find(e => e.role === 'from')
const to = eps.find(e => e.role === 'to')
if (!from || !to) return null
// Full route over all waypoints (from · stops · to), ordered by sequence.
const eps = (r.endpoints || []).slice().sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0))
if (eps.length < 2) return null
return (
<div className="bg-surface-tertiary text-content" style={{
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
padding: '8px 12px', borderRadius: 10,
fontSize: 12.5,
fontSize: 12.5, flexWrap: 'wrap',
}}>
<span style={{ fontWeight: 500, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{from.name}</span>
<TypeIcon size={14} style={{ color: typeInfo.color, flexShrink: 0 }} />
<span style={{ fontWeight: 500, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{to.name}</span>
{eps.map((ep, i) => (
<span key={i} style={{ display: 'inline-flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
{i > 0 && <TypeIcon size={14} style={{ color: typeInfo.color, flexShrink: 0 }} />}
<span style={{ fontWeight: 500, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{ep.name}</span>
</span>
))}
</div>
)
})()}
+225 -105
View File
@@ -1,6 +1,6 @@
import { useState, useEffect, useMemo, useRef } from 'react'
import { useParams } from 'react-router-dom'
import { Plane, Train, Car, Ship, Bus, Sailboat, Bike, CarTaxiFront, Route, Paperclip, FileText, X, ExternalLink, Link2 } from 'lucide-react'
import { Plane, Train, Car, Ship, Bus, Sailboat, Bike, CarTaxiFront, Route, Paperclip, FileText, X, ExternalLink, Link2, Plus, Trash2 } from 'lucide-react'
import Modal from '../shared/Modal'
import CustomSelect from '../shared/CustomSelect'
import CustomTimePicker from '../shared/CustomTimePicker'
@@ -14,6 +14,7 @@ import { formatDate, splitReservationDateTime } from '../../utils/formatters'
import { openFile } from '../../utils/fileDownload'
import apiClient from '../../api/client'
import type { Day, Reservation, ReservationEndpoint, TripFile } from '../../types'
import { parseReservationMetadata, orderedEndpoints } from '../../utils/flightLegs'
const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transport_other'] as const
type TransportType = typeof TRANSPORT_TYPES[number]
@@ -23,7 +24,7 @@ interface EndpointPick {
location?: LocationPoint
}
function endpointFromAirport(a: Airport, role: 'from' | 'to', sequence: number, date: string | null, time: string | null): Omit<ReservationEndpoint, 'id' | 'reservation_id'> {
function endpointFromAirport(a: Airport, role: 'from' | 'to' | 'stop', sequence: number, date: string | null, time: string | null): Omit<ReservationEndpoint, 'id' | 'reservation_id'> {
return {
role, sequence,
name: a.city ? `${a.city} (${a.iata})` : a.name,
@@ -63,6 +64,24 @@ function locationFromEndpoint(e: ReservationEndpoint | undefined): LocationPoint
return { name: e.name, lat: e.lat, lng: e.lng, address: null }
}
// ── Multi-leg flight waypoints ─────────────────────────────────────────────
// A flight is an ordered list of airports. The origin has only a departure, the
// destination only an arrival, and each intermediate stop has both — plus the
// airline/flight number of the flight LEAVING it. N waypoints = N-1 legs. A
// single-leg flight is just two waypoints, so it persists exactly as before.
interface WaypointForm {
airport: Airport | null
arrDayId: string | number
arrTime: string
depDayId: string | number
depTime: string
airline: string
flight_number: string
}
function emptyWaypoint(dayId: string | number = ''): WaypointForm {
return { airport: null, arrDayId: dayId, arrTime: '', depDayId: dayId, depTime: '', airline: '', flight_number: '' }
}
const TYPE_OPTIONS = [
{ value: 'flight', labelKey: 'reservations.type.flight', Icon: Plane },
{ value: 'train', labelKey: 'reservations.type.train', Icon: Train },
@@ -122,6 +141,8 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
const [isSaving, setIsSaving] = useState(false)
const [fromPick, setFromPick] = useState<EndpointPick>({})
const [toPick, setToPick] = useState<EndpointPick>({})
// Flight route as an ordered list of airports (origin .. stops .. destination).
const [waypoints, setWaypoints] = useState<WaypointForm[]>([emptyWaypoint(), emptyWaypoint()])
const [uploadingFile, setUploadingFile] = useState(false)
const [pendingFiles, setPendingFiles] = useState<File[]>([])
const [showFilePicker, setShowFilePicker] = useState(false)
@@ -159,8 +180,38 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
budget_category: (meta.budget_category && budgetItems.some(i => i.category === meta.budget_category)) ? meta.budget_category : '',
})
if (type === 'flight') {
setFromPick({ airport: airportFromEndpoint(from) || undefined })
setToPick({ airport: airportFromEndpoint(to) || undefined })
const orderedEps = orderedEndpoints(reservation)
const metaLegs: any[] = Array.isArray(meta.legs) ? meta.legs : []
let wps: WaypointForm[]
if (orderedEps.length >= 2) {
wps = orderedEps.map((ep, i) => {
const legInto = metaLegs[i - 1] // leg arriving INTO waypoint i
const legOut = metaLegs[i] // leg departing FROM waypoint i
const isFirst = i === 0
const isLast = i === orderedEps.length - 1
return {
airport: airportFromEndpoint(ep),
arrDayId: legInto?.arr_day_id ?? (isLast ? (reservation.end_day_id ?? '') : ''),
arrTime: legInto?.arr_time ?? (!isFirst ? (ep.local_time ?? '') : ''),
depDayId: legOut?.dep_day_id ?? (isFirst ? (reservation.day_id ?? '') : ''),
depTime: legOut?.dep_time ?? (!isLast ? (ep.local_time ?? '') : ''),
airline: legOut?.airline ?? (isFirst ? (meta.airline ?? '') : ''),
flight_number: legOut?.flight_number ?? (isFirst ? (meta.flight_number ?? '') : ''),
}
})
} else {
// Legacy flight with no (or partial) endpoints — seed two waypoints.
const dep = emptyWaypoint(reservation.day_id ?? '')
dep.airport = airportFromEndpoint(from)
dep.depTime = splitReservationDateTime(reservation.reservation_time).time ?? ''
dep.airline = meta.airline ?? ''
dep.flight_number = meta.flight_number ?? ''
const arr = emptyWaypoint(reservation.end_day_id ?? reservation.day_id ?? '')
arr.airport = airportFromEndpoint(to)
arr.arrTime = splitReservationDateTime(reservation.reservation_end_time).time ?? ''
wps = [dep, arr]
}
setWaypoints(wps)
} else {
setFromPick({ location: locationFromEndpoint(from) || undefined })
setToPick({ location: locationFromEndpoint(to) || undefined })
@@ -169,6 +220,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
setForm({ ...defaultForm, start_day_id: selectedDayId ?? '', end_day_id: selectedDayId ?? '' })
setFromPick({})
setToPick({})
setWaypoints([emptyWaypoint(selectedDayId ?? ''), emptyWaypoint(selectedDayId ?? '')])
}
}, [isOpen, reservation, selectedDayId, budgetItems])
@@ -187,17 +239,45 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
return day?.date ? `${day.date}T${time}` : time
}
const metadata: Record<string, string> = {}
const dayDate = (id: string | number): string | null => days.find(d => d.id === Number(id))?.date ?? null
// Flight route as an ordered list of airports (origin .. stops .. destination).
const flightWps = form.type === 'flight' ? waypoints.filter(w => w.airport) : []
const firstWp = flightWps[0]
const lastWp = flightWps[flightWps.length - 1]
// Per-leg day-plan positions are owned by the day planner, not this form — keep
// them when re-saving so editing a flight doesn't reset where its legs sit.
const origLegs: any[] = reservation ? (parseReservationMetadata(reservation).legs || []) : []
const metadata: Record<string, any> = {}
if (form.type === 'flight') {
if (form.meta_airline) metadata.airline = form.meta_airline
if (form.meta_flight_number) metadata.flight_number = form.meta_flight_number
if (fromPick.airport) {
metadata.departure_airport = fromPick.airport.iata
metadata.departure_timezone = fromPick.airport.tz
// Top-level keys mirror the first/last leg so legacy readers keep working.
if (firstWp?.airline) metadata.airline = firstWp.airline
if (firstWp?.flight_number) metadata.flight_number = firstWp.flight_number
if (firstWp?.airport) {
metadata.departure_airport = firstWp.airport.iata
metadata.departure_timezone = firstWp.airport.tz
}
if (toPick.airport) {
metadata.arrival_airport = toPick.airport.iata
metadata.arrival_timezone = toPick.airport.tz
if (lastWp?.airport) {
metadata.arrival_airport = lastWp.airport.iata
metadata.arrival_timezone = lastWp.airport.tz
}
// Per-leg detail only for true multi-leg flights — a single-leg flight
// keeps the exact same (flat) metadata it had before this feature.
if (flightWps.length > 2) {
metadata.legs = flightWps.slice(0, -1).map((w, i) => {
const next = flightWps[i + 1]
return {
from: w.airport!.iata,
to: next.airport!.iata,
...(w.airline ? { airline: w.airline } : {}),
...(w.flight_number ? { flight_number: w.flight_number } : {}),
dep_day_id: w.depDayId ? Number(w.depDayId) : null,
dep_time: w.depTime || null,
arr_day_id: next.arrDayId ? Number(next.arrDayId) : null,
arr_time: next.arrTime || null,
...(origLegs[i]?.day_positions ? { day_positions: origLegs[i].day_positions } : {}),
}
})
}
} else if (form.type === 'train') {
if (form.meta_train_number) metadata.train_number = form.meta_train_number
@@ -213,21 +293,35 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
const endDate = (endDay ?? startDay)?.date ?? null
const endpoints: ReturnType<typeof endpointFromAirport>[] = []
if (form.type === 'flight') {
if (fromPick.airport) endpoints.push(endpointFromAirport(fromPick.airport, 'from', 0, startDate, form.departure_time || null))
if (toPick.airport) endpoints.push(endpointFromAirport(toPick.airport, 'to', 1, endDate, form.arrival_time || null))
flightWps.forEach((w, i) => {
const isFirst = i === 0
const isLast = i === flightWps.length - 1
const role: 'from' | 'to' | 'stop' = isFirst ? 'from' : isLast ? 'to' : 'stop'
const dId = isLast ? w.arrDayId : w.depDayId
const time = isLast ? w.arrTime : w.depTime
endpoints.push(endpointFromAirport(w.airport!, role, i, dayDate(dId), time || null))
})
} else {
if (fromPick.location) endpoints.push(endpointFromLocation(fromPick.location, 'from', 0, startDate, form.departure_time || null))
if (toPick.location) endpoints.push(endpointFromLocation(toPick.location, 'to', 1, endDate, form.arrival_time || null))
}
// Flights derive their span from the first/last waypoint; other transports
// keep using the single departure/arrival form fields unchanged.
const flightDepDay = firstWp && firstWp.depDayId ? Number(firstWp.depDayId) : null
const flightArrDay = lastWp && lastWp.arrDayId ? Number(lastWp.arrDayId) : null
const payload = {
title: form.title,
type: form.type,
status: form.status,
day_id: form.start_day_id ? Number(form.start_day_id) : null,
end_day_id: form.end_day_id ? Number(form.end_day_id) : null,
reservation_time: buildTime(startDay, form.departure_time),
reservation_end_time: buildTime(endDay ?? startDay, form.arrival_time),
day_id: form.type === 'flight' ? flightDepDay : (form.start_day_id ? Number(form.start_day_id) : null),
end_day_id: form.type === 'flight' ? flightArrDay : (form.end_day_id ? Number(form.end_day_id) : null),
reservation_time: form.type === 'flight'
? buildTime(days.find(d => d.id === flightDepDay), firstWp?.depTime || '')
: buildTime(startDay, form.departure_time),
reservation_end_time: form.type === 'flight'
? buildTime(days.find(d => d.id === flightArrDay), lastWp?.arrTime || '')
: buildTime(endDay ?? startDay, form.arrival_time),
location: null,
confirmation_number: form.confirmation_number || null,
notes: form.notes || null,
@@ -348,100 +442,126 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
placeholder={t('reservations.titlePlaceholder')} className={inputClass} />
</div>
{/* From / To endpoints */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className={labelClass}>{t('reservations.meta.from')}</label>
{form.type === 'flight' ? (
<AirportSelect value={fromPick.airport || null} onChange={a => setFromPick({ airport: a || undefined })} />
) : (
<LocationSelect value={fromPick.location || null} onChange={l => setFromPick({ location: l || undefined })} />
)}
{form.type === 'flight' ? (
/* ── Flight route: ordered airports (origin · stops · destination) ── */
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<label className={labelClass}>{t('reservations.layover.route')}</label>
{waypoints.map((wp, i) => {
const isFirst = i === 0
const isLast = i === waypoints.length - 1
const updateWp = (patch: Partial<WaypointForm>) => setWaypoints(prev => prev.map((w, j) => (j === i ? { ...w, ...patch } : w)))
const roleLabel = isFirst ? t('reservations.meta.from') : isLast ? t('reservations.meta.to') : t('reservations.layover.stop')
return (
<div key={i} style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<div className="bg-surface-card" style={{ border: '1px solid var(--border-primary)', borderRadius: 10, padding: 10, display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span className="text-content-faint" style={{ fontSize: 10, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.03em', flexShrink: 0 }}>{roleLabel}</span>
<div style={{ flex: 1, minWidth: 0 }}>
<AirportSelect value={wp.airport} onChange={a => updateWp({ airport: a || null })} />
</div>
{!isFirst && !isLast && (
<button type="button" onClick={() => setWaypoints(prev => prev.filter((_, j) => j !== i))} aria-label={t('common.delete')} className="text-content-faint" style={{ background: 'none', border: 'none', cursor: 'pointer', display: 'flex', padding: 4, flexShrink: 0 }}>
<Trash2 size={14} />
</button>
)}
</div>
{!isFirst && (
<div style={{ display: 'flex', gap: 8 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<label className={labelClass}>{t('reservations.arrivalDate')}</label>
<CustomSelect value={wp.arrDayId} onChange={v => updateWp({ arrDayId: v })} placeholder={t('dayplan.dayN', { n: '?' })} options={dayOptions} size="sm" />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<label className={labelClass}>{t('reservations.arrivalTime')}</label>
<CustomTimePicker value={wp.arrTime} onChange={v => updateWp({ arrTime: v })} />
</div>
{wp.airport && (
<div style={{ flex: 1, minWidth: 0 }}>
<label className={labelClass}>{t('reservations.meta.arrivalTimezone')}</label>
<div className={inputClass} style={{ padding: '8px 12px', color: 'var(--text-muted)', fontSize: 12, background: 'var(--bg-tertiary)' }}>{wp.airport.tz}</div>
</div>
)}
</div>
)}
{!isLast && (
<>
<div style={{ display: 'flex', gap: 8 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<label className={labelClass}>{t('reservations.departureDate')}</label>
<CustomSelect value={wp.depDayId} onChange={v => updateWp({ depDayId: v })} placeholder={t('dayplan.dayN', { n: '?' })} options={dayOptions} size="sm" />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<label className={labelClass}>{t('reservations.departureTime')}</label>
<CustomTimePicker value={wp.depTime} onChange={v => updateWp({ depTime: v })} />
</div>
{wp.airport && (
<div style={{ flex: 1, minWidth: 0 }}>
<label className={labelClass}>{t('reservations.meta.departureTimezone')}</label>
<div className={inputClass} style={{ padding: '8px 12px', color: 'var(--text-muted)', fontSize: 12, background: 'var(--bg-tertiary)' }}>{wp.airport.tz}</div>
</div>
)}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className={labelClass}>{t('reservations.meta.airline')}</label>
<input type="text" value={wp.airline} onChange={e => updateWp({ airline: e.target.value })} placeholder="Lufthansa" className={inputClass} />
</div>
<div>
<label className={labelClass}>{t('reservations.meta.flightNumber')}</label>
<input type="text" value={wp.flight_number} onChange={e => updateWp({ flight_number: e.target.value })} placeholder="LH 123" className={inputClass} />
</div>
</div>
</>
)}
</div>
{!isLast && (
<button type="button" onClick={() => setWaypoints(prev => [...prev.slice(0, i + 1), emptyWaypoint(prev[i]?.depDayId || ''), ...prev.slice(i + 1)])}
className="text-content-faint hover:text-content-secondary" style={{ width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5, padding: '6px 10px', border: '1px dashed var(--border-primary)', borderRadius: 8, background: 'none', fontSize: 11, cursor: 'pointer', fontFamily: 'inherit' }}>
<Plus size={12} /> {t('reservations.layover.addStop')}
</button>
)}
</div>
)
})}
</div>
<div>
<label className={labelClass}>{t('reservations.meta.to')}</label>
{form.type === 'flight' ? (
<AirportSelect value={toPick.airport || null} onChange={a => setToPick({ airport: a || undefined })} />
) : (
<LocationSelect value={toPick.location || null} onChange={l => setToPick({ location: l || undefined })} />
)}
</div>
</div>
{/* Departure row */}
<div style={{ display: 'flex', gap: 8 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<label className={labelClass}>
{form.type === 'flight' ? t('reservations.departureDate') : form.type === 'car' ? t('reservations.pickupDate') : t('reservations.date')}
</label>
<CustomSelect
value={form.start_day_id}
onChange={value => set('start_day_id', value)}
placeholder={t('dayplan.dayN', { n: '?' })}
options={dayOptions}
size="sm"
/>
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<label className={labelClass}>
{form.type === 'flight' ? t('reservations.departureTime') : form.type === 'car' ? t('reservations.pickupTime') : t('reservations.startTime')}
</label>
<CustomTimePicker value={form.departure_time} onChange={v => set('departure_time', v)} />
</div>
{form.type === 'flight' && fromPick.airport && (
<div style={{ flex: 1, minWidth: 0 }}>
<label className={labelClass}>{t('reservations.meta.departureTimezone')}</label>
<div className={inputClass} style={{ padding: '8px 12px', color: 'var(--text-muted)', fontSize: 12, background: 'var(--bg-tertiary)' }}>
{fromPick.airport.tz}
) : (
<>
{/* From / To endpoints (non-flight) */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className={labelClass}>{t('reservations.meta.from')}</label>
<LocationSelect value={fromPick.location || null} onChange={l => setFromPick({ location: l || undefined })} />
</div>
<div>
<label className={labelClass}>{t('reservations.meta.to')}</label>
<LocationSelect value={toPick.location || null} onChange={l => setToPick({ location: l || undefined })} />
</div>
</div>
)}
</div>
{/* Arrival row */}
<div style={{ display: 'flex', gap: 8 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<label className={labelClass}>
{form.type === 'flight' ? t('reservations.arrivalDate') : form.type === 'car' ? t('reservations.returnDate') : t('reservations.endDate')}
</label>
<CustomSelect
value={form.end_day_id}
onChange={value => set('end_day_id', value)}
placeholder={t('dayplan.dayN', { n: '?' })}
options={dayOptions}
size="sm"
/>
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<label className={labelClass}>
{form.type === 'flight' ? t('reservations.arrivalTime') : form.type === 'car' ? t('reservations.returnTime') : t('reservations.endTime')}
</label>
<CustomTimePicker value={form.arrival_time} onChange={v => set('arrival_time', v)} />
</div>
{form.type === 'flight' && toPick.airport && (
<div style={{ flex: 1, minWidth: 0 }}>
<label className={labelClass}>{t('reservations.meta.arrivalTimezone')}</label>
<div className={inputClass} style={{ padding: '8px 12px', color: 'var(--text-muted)', fontSize: 12, background: 'var(--bg-tertiary)' }}>
{toPick.airport.tz}
{/* Departure row */}
<div style={{ display: 'flex', gap: 8 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<label className={labelClass}>{form.type === 'car' ? t('reservations.pickupDate') : t('reservations.date')}</label>
<CustomSelect value={form.start_day_id} onChange={value => set('start_day_id', value)} placeholder={t('dayplan.dayN', { n: '?' })} options={dayOptions} size="sm" />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<label className={labelClass}>{form.type === 'car' ? t('reservations.pickupTime') : t('reservations.startTime')}</label>
<CustomTimePicker value={form.departure_time} onChange={v => set('departure_time', v)} />
</div>
</div>
)}
</div>
{/* Flight-specific fields */}
{form.type === 'flight' && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className={labelClass}>{t('reservations.meta.airline')}</label>
<input type="text" value={form.meta_airline} onChange={e => set('meta_airline', e.target.value)}
placeholder="Lufthansa" className={inputClass} />
{/* Arrival row */}
<div style={{ display: 'flex', gap: 8 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<label className={labelClass}>{form.type === 'car' ? t('reservations.returnDate') : t('reservations.endDate')}</label>
<CustomSelect value={form.end_day_id} onChange={value => set('end_day_id', value)} placeholder={t('dayplan.dayN', { n: '?' })} options={dayOptions} size="sm" />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<label className={labelClass}>{form.type === 'car' ? t('reservations.returnTime') : t('reservations.endTime')}</label>
<CustomTimePicker value={form.arrival_time} onChange={v => set('arrival_time', v)} />
</div>
</div>
<div>
<label className={labelClass}>{t('reservations.meta.flightNumber')}</label>
<input type="text" value={form.meta_flight_number} onChange={e => set('meta_flight_number', e.target.value)}
placeholder="LH 123" className={inputClass} />
</div>
</div>
</>
)}
{/* Train-specific fields */}
+4 -4
View File
@@ -126,18 +126,18 @@ describe('getMergedItems', () => {
expect(types).toEqual(['place', 'transport', 'place'])
})
it('per-day position overrides time-based insertion', () => {
it('orders a timed transport chronologically regardless of a stale per-day position', () => {
const dayAssignments = [
{ id: 1, order_index: 0, place: { place_time: '08:00' } },
{ id: 2, order_index: 1, place: { place_time: '13:00' } },
]
// Transport at 10:30 would normally go between the two places
// but per-day position 1.5 puts it after the second place
// The train is at 10:30, so it sorts between the 08:00 and 13:00 places by time —
// timed items are arranged chronologically even if an old manual position exists.
const dayTransports = [
{ id: 20, type: 'train', day_id: 5, end_day_id: 5, reservation_time: '10:30', day_positions: { 5: 1.5 } },
]
const result = getMergedItems({ dayAssignments, dayNotes: [], dayTransports, dayId: 5 })
const types = result.map(i => i.type)
expect(types).toEqual(['place', 'place', 'transport'])
expect(types).toEqual(['place', 'transport', 'place'])
})
})
+86 -5
View File
@@ -39,12 +39,66 @@ export function getDisplayTimeForDay(
return r.reservation_time || null
}
/** Per-leg detail of a multi-leg flight, or null for single-leg / non-flight. */
function parseFlightLegs(r: any): any[] | null {
if (r?.type !== 'flight') return null
let meta = r.metadata
if (typeof meta === 'string') { try { meta = JSON.parse(meta || '{}') } catch { meta = {} } }
// Defensive: recover metadata that was accidentally double-encoded by an earlier
// bug (a JSON string of a JSON string) so already-saved flights heal on read.
if (typeof meta === 'string') { try { meta = JSON.parse(meta || '{}') } catch { meta = {} } }
if (meta && Array.isArray(meta.legs) && meta.legs.length > 1) return meta.legs
return null
}
/**
* Expand a multi-leg flight into one synthetic reservation per leg that touches
* `dayId`, each with its own day span + departure/arrival time so it slots into
* the timeline independently. A single-leg flight (or any other reservation) is
* returned untouched, so existing behaviour is unchanged.
*/
export function expandFlightLegsForDay(
r: any,
dayId: number,
getDayOrder: (id: number) => number,
days: Array<{ id: number; date?: string | null }>
): any[] {
const legs = parseFlightLegs(r)
if (!legs) return [r]
const dateOf = (id: number | null): string | null => (id == null ? null : (days.find(d => d.id === id)?.date ?? null))
const thisOrder = getDayOrder(dayId)
const out: any[] = []
legs.forEach((leg, i) => {
const dep = leg.dep_day_id ?? r.day_id ?? null
const arr = leg.arr_day_id ?? dep
if (dep == null) return
const depOrder = getDayOrder(dep)
const arrOrder = getDayOrder(arr ?? dep)
if (!(thisOrder >= depOrder && thisOrder <= arrOrder)) return
const depDate = dateOf(dep)
const arrDate = dateOf(arr ?? dep)
out.push({
...r,
day_id: dep,
end_day_id: arr ?? dep,
reservation_time: leg.dep_time ? (depDate ? `${depDate}T${leg.dep_time}` : leg.dep_time) : null,
reservation_end_time: leg.arr_time ? (arrDate ? `${arrDate}T${leg.arr_time}` : leg.arr_time) : null,
// Each leg carries its OWN saved position (not the booking's) so items can be
// dropped between legs and persist; absent → falls back to time ordering.
day_positions: leg.day_positions || undefined,
day_plan_position: undefined,
__leg: { index: i, total: legs.length, from: leg.from ?? null, to: leg.to ?? null, airline: leg.airline ?? null, flight_number: leg.flight_number ?? null },
})
})
return out
}
/** Filter reservations that are active transports for the given day, excluding assignment-linked ones. */
export function getTransportForDay(opts: {
reservations: any[]
dayId: number
dayAssignmentIds: number[]
days: Array<{ id: number; day_number?: number }>
days: Array<{ id: number; day_number?: number; date?: string | null }>
}): any[] {
const { reservations, dayId, dayAssignmentIds, days } = opts
@@ -69,7 +123,34 @@ export function getTransportForDay(opts: {
return thisDayOrder >= startOrder && thisDayOrder <= endOrder
}
return startDayId === dayId
})
}).flatMap(r => expandFlightLegsForDay(r, dayId, getDayOrder, days))
}
/**
* Order items chronologically: anything with a time (a place's place_time, a
* transport/leg display time, a timed note) sorts by that time. An item WITHOUT a
* time inherits the time of the timed item before it, so untimed items stay where
* they were manually placed. Stable on the incoming order for ties.
*/
function applyChronoOrder(
items: MergedItem[],
dayId: number,
getDisplayTime: (r: any, dayId: number) => string | null
): MergedItem[] {
const timeOf = (it: MergedItem): number | null => {
if (it.type === 'place') return parseTimeToMinutes(it.data?.place?.place_time)
if (it.type === 'note') return parseTimeToMinutes(it.data?.time)
return parseTimeToMinutes(getDisplayTime(it.data, dayId))
}
let last = -Infinity
return items
.map((it, i) => {
const t = timeOf(it)
if (t != null) last = t
return { it, i, eff: t != null ? t : last }
})
.sort((a, b) => a.eff - b.eff || a.i - b.i)
.map(k => k.it)
}
/** Merge places, notes, and transports into a single ordered day timeline. */
@@ -94,9 +175,9 @@ export function getMergedItems(opts: {
minutes: parseTimeToMinutes(getDisplayTime(r, dayId)) ?? 0,
})).sort((a, b) => a.minutes - b.minutes)
if (timedTransports.length === 0) return baseItems
if (timedTransports.length === 0) return applyChronoOrder(baseItems, dayId, getDisplayTime)
if (baseItems.length === 0) {
return timedTransports.map((item, i) => ({ type: item.type, sortKey: i, data: item.data }))
return applyChronoOrder(timedTransports.map((item, i) => ({ type: item.type, sortKey: i, data: item.data })), dayId, getDisplayTime)
}
// Insert transports among base items based on per-day position or time
@@ -132,5 +213,5 @@ export function getMergedItems(opts: {
result.push({ type: timed.type, sortKey, data: timed.data })
}
return result.sort((a, b) => a.sortKey - b.sortKey)
return applyChronoOrder(result.sort((a, b) => a.sortKey - b.sortKey), dayId, getDisplayTime)
}
+105
View File
@@ -0,0 +1,105 @@
// Multi-leg (layover) flight support.
//
// A flight booking is ONE reservation whose route is an ordered chain of airports
// (e.g. FRA -> BER -> HND). The geometry + order are the source of truth in
// `reservation.endpoints` (role 'from' for the first airport, 'stop' for each
// intermediate one, 'to' for the last, ordered by `sequence`). The per-leg detail
// — airline, flight number, and each segment's own day/time — lives in
// `metadata.legs`. The top-level metadata (`departure_airport`/`arrival_airport`/
// `airline`/`flight_number`) and `day_id`/`end_day_id` mirror the FIRST and LAST
// leg so legacy readers keep working.
//
// A legacy single-leg flight (two endpoints, flat metadata, no `metadata.legs`)
// is normalised here into a one-leg chain, so every renderer can use one path.
import type { Reservation, ReservationEndpoint } from '../types'
export interface FlightLeg {
from: string | null // IATA code (or null)
to: string | null
airline?: string
flight_number?: string
dep_day_id?: number | null
dep_time?: string | null // 'HH:mm'
arr_day_id?: number | null
arr_time?: string | null
}
/** reservation.metadata may be a JSON string or an already-parsed object. */
export function parseReservationMetadata(r: Pick<Reservation, 'metadata'>): Record<string, any> {
const m = r.metadata
if (!m) return {}
if (typeof m === 'string') {
try {
let parsed = JSON.parse(m || '{}')
// Defensive: an earlier bug could double-encode metadata (a JSON string of a
// JSON string) — unwrap it once more so saved flights heal on read.
if (typeof parsed === 'string') { try { parsed = JSON.parse(parsed) } catch { /* keep */ } }
return (parsed && typeof parsed === 'object') ? parsed : {}
} catch { return {} }
}
return m as Record<string, any>
}
/** Endpoints ordered by `sequence` (geometry + order source of truth). */
export function orderedEndpoints(r: Pick<Reservation, 'endpoints'>): ReservationEndpoint[] {
return (r.endpoints || []).slice().sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0))
}
/**
* Ordered legs of a flight. `metadata.legs` is preferred; otherwise a single leg
* is derived from the endpoints (and finally the flat metadata) so that legacy
* single-leg flights and flights created before this feature still work.
*/
export function getFlightLegs(r: Reservation): FlightLeg[] {
const meta = parseReservationMetadata(r)
if (Array.isArray(meta.legs) && meta.legs.length > 0) {
return meta.legs.map((l: any): FlightLeg => ({
from: l.from ?? null,
to: l.to ?? null,
airline: l.airline || undefined,
flight_number: l.flight_number || undefined,
dep_day_id: l.dep_day_id ?? null,
dep_time: l.dep_time ?? null,
arr_day_id: l.arr_day_id ?? null,
arr_time: l.arr_time ?? null,
}))
}
// Legacy fallback: one leg from the endpoints / flat metadata.
const eps = orderedEndpoints(r)
const first = eps[0]
const last = eps[eps.length - 1]
const fromCode = first?.code ?? meta.departure_airport ?? null
const toCode = last?.code ?? meta.arrival_airport ?? null
if (!fromCode && !toCode) return []
return [{
from: fromCode,
to: toCode,
airline: meta.airline || undefined,
flight_number: meta.flight_number || undefined,
dep_day_id: r.day_id ?? null,
dep_time: first?.local_time ?? null,
arr_day_id: r.end_day_id ?? r.day_id ?? null,
arr_time: last?.local_time ?? null,
}]
}
/** Number of flight segments. 1 for a simple from -> to booking. */
export function legCount(r: Reservation): number {
return getFlightLegs(r).length
}
export function isMultiLegFlight(r: Reservation): boolean {
return r.type === 'flight' && legCount(r) > 1
}
/**
* Ordered route labels (IATA codes, or names when no code) for display, e.g.
* ['FRA','BER','HND']. Uses endpoints; falls back to the flat metadata pair.
*/
export function routeStops(r: Reservation): string[] {
const eps = orderedEndpoints(r)
if (eps.length >= 2) return eps.map(e => e.code || e.name)
const meta = parseReservationMetadata(r)
return [meta.departure_airport, meta.arrival_airport].filter(Boolean) as string[]
}
@@ -90,6 +90,90 @@ function mapFlight(r: KiReservation, source: ParsedBookingItem['source']): Parse
};
}
/** True when flight `b` is a short layover connection that continues flight `a`. */
function sameConnection(a: KiReservation, b: KiReservation): boolean {
const fa = a.reservationFor as KiFlight | undefined;
const fb = b.reservationFor as KiFlight | undefined;
if (!fa || !fb) return false;
const arrIata = fa.arrivalAirport?.iataCode?.toUpperCase();
const depIata = fb.departureAirport?.iataCode?.toUpperCase();
if (!arrIata || !depIata || arrIata !== depIata) return false; // must connect at the same airport
const arrIso = toIsoString(fa.arrivalTime);
const depIso = toIsoString(fb.departureTime);
if (arrIso && depIso) {
const gapMs = new Date(depIso).getTime() - new Date(arrIso).getTime();
// A real layover is forward in time and short — anything longer (e.g. a
// round-trip return days later) stays a separate booking.
if (gapMs < 0 || gapMs > 24 * 3600 * 1000) return false;
}
return true;
}
/** Collapse several connecting flight legs (same PNR) into one multi-leg booking. */
function mapFlightGroup(legs: KiReservation[], source: ParsedBookingItem['source']): ParsedBookingItem | null {
const flights = legs.map(l => l.reservationFor as KiFlight | undefined);
if (flights.some(f => !f)) return mapFlight(legs[0], source); // malformed → fall back to single
const fs = flights as KiFlight[];
const iataOf = (ap: KiFlight['departureAirport']) => ap?.iataCode?.toUpperCase() ?? null;
const makeEndpoint = (
ap: KiFlight['departureAirport'], role: 'from' | 'stop' | 'to', time: string | null, date: string | null,
): ParsedEndpoint | null => {
const iata = iataOf(ap);
const found = iata ? findByIata(iata) : null;
const label = found ? (found.city ? `${found.city} (${found.iata})` : found.name) : (ap?.name ?? iata ?? 'Unknown');
if (found) return { role, sequence: 0, name: label, code: found.iata, lat: found.lat, lng: found.lng, timezone: found.tz, local_time: time, local_date: date };
const c = coords(ap?.geo);
if (c) return { role, sequence: 0, name: label, code: iata, lat: c.lat, lng: c.lng, timezone: null, local_time: time, local_date: date };
return null;
};
const endpoints: ParsedEndpoint[] = [];
const metaLegs: Record<string, unknown>[] = [];
const first = fs[0];
const firstDep = splitIso(first.departureTime);
const originEp = makeEndpoint(first.departureAirport, 'from', firstDep.time, firstDep.date);
if (originEp) endpoints.push(originEp);
fs.forEach((f, i) => {
const isLast = i === fs.length - 1;
const arr = splitIso(f.arrivalTime);
const arrEp = makeEndpoint(f.arrivalAirport, isLast ? 'to' : 'stop', arr.time, arr.date);
if (arrEp) endpoints.push(arrEp);
const airline = f.airline?.name ?? f.airline?.iataCode ?? '';
metaLegs.push({
from: iataOf(f.departureAirport),
to: iataOf(f.arrivalAirport),
...(airline ? { airline } : {}),
...(f.flightNumber ? { flight_number: f.flightNumber } : {}),
dep_time: splitIso(f.departureTime).time,
arr_time: arr.time,
});
});
endpoints.forEach((e, i) => { e.sequence = i; });
const last = fs[fs.length - 1];
const airline = first.airline?.name ?? first.airline?.iataCode ?? '';
const route = [iataOf(first.departureAirport), ...fs.map(f => iataOf(f.arrivalAirport))].filter(Boolean).join(' → ');
return {
type: 'flight',
title: airline ? `${airline} ${route}` : `Flight ${route}`,
reservation_time: toIsoString(first.departureTime),
reservation_end_time: toIsoString(last.arrivalTime),
confirmation_number: legs[0].reservationNumber ?? null,
metadata: {
...(airline ? { airline } : {}),
...(first.flightNumber ? { flight_number: first.flightNumber } : {}),
...(iataOf(first.departureAirport) ? { departure_airport: iataOf(first.departureAirport) } : {}),
...(iataOf(last.arrivalAirport) ? { arrival_airport: iataOf(last.arrivalAirport) } : {}),
legs: metaLegs,
},
endpoints,
needs_review: endpoints.length < fs.length + 1,
source,
};
}
function mapTrain(r: KiReservation, source: ParsedBookingItem['source']): ParsedBookingItem | null {
const t = r.reservationFor as KiTrainTrip | undefined;
if (!t) return null;
@@ -233,8 +317,25 @@ export function mapReservations(kiItems: KiReservation[], fileName: string): { i
const source = { fileName, index: i };
let item: ParsedBookingItem | null = null;
// Group consecutive connecting flight legs that share a PNR into one booking.
if (r['@type'] === 'FlightReservation') {
const pnr = r.reservationNumber ?? null;
const group = [r];
while (
i + 1 < kiItems.length &&
kiItems[i + 1]['@type'] === 'FlightReservation' &&
pnr != null &&
(kiItems[i + 1].reservationNumber ?? null) === pnr &&
sameConnection(group[group.length - 1], kiItems[i + 1])
) {
group.push(kiItems[++i]);
}
item = group.length > 1 ? mapFlightGroup(group, source) : mapFlight(r, source);
if (item) items.push(item);
continue;
}
switch (r['@type']) {
case 'FlightReservation': item = mapFlight(r, source); break;
case 'TrainReservation': item = mapTrain(r, source); break;
case 'BusReservation': item = mapBus(r, source); break;
case 'BoatReservation': item = mapBoat(r, source); break;
+8 -2
View File
@@ -543,8 +543,14 @@ export function exportICS(tripId: string | number): { ics: string; filename: str
if (r.confirmation_number) desc += `\nConfirmation: ${r.confirmation_number}`;
if (meta.airline) desc += `\nAirline: ${meta.airline}`;
if (meta.flight_number) desc += `\nFlight: ${meta.flight_number}`;
if (meta.departure_airport) desc += `\nFrom: ${meta.departure_airport}`;
if (meta.arrival_airport) desc += `\nTo: ${meta.arrival_airport}`;
if (Array.isArray(meta.legs) && meta.legs.length > 1) {
// Multi-leg flight: show the whole route (FRA → BER → HND) on one event.
const stops = [meta.legs[0]?.from, ...meta.legs.map((l: { to?: string }) => l.to)].filter(Boolean);
if (stops.length) desc += `\nRoute: ${stops.join(' → ')}`;
} else {
if (meta.departure_airport) desc += `\nFrom: ${meta.departure_airport}`;
if (meta.arrival_airport) desc += `\nTo: ${meta.arrival_airport}`;
}
if (meta.train_number) desc += `\nTrain: ${meta.train_number}`;
if (r.notes) desc += `\n${r.notes}`;
if (desc) ics += `DESCRIPTION:${esc(desc)}\r\n`;
@@ -0,0 +1,70 @@
import { describe, it, expect } from 'vitest';
import { mapReservations } from '../../../src/nest/booking-import/kitinerary-mapper';
const airport = (iata: string, lat: number, lng: number) => ({
iataCode: iata,
name: iata,
geo: { latitude: lat, longitude: lng },
});
const flight = (pnr: string, dep: any, arr: any, depTime: string, arrTime: string, flightNumber: string) => ({
'@type': 'FlightReservation',
reservationNumber: pnr,
reservationFor: {
departureAirport: dep,
arrivalAirport: arr,
departureTime: depTime,
arrivalTime: arrTime,
airline: { name: 'Lufthansa', iataCode: 'LH' },
flightNumber,
},
});
const FRA = airport('FRA', 50.04, 8.57);
const BER = airport('BER', 52.36, 13.50);
const HND = airport('HND', 35.55, 139.78);
describe('kitinerary mapper — multi-leg flight grouping', () => {
it('groups two connecting same-PNR legs into one multi-leg booking', () => {
const { items } = mapReservations([
flight('ABC123', FRA, BER, '2026-06-11T10:00:00', '2026-06-11T12:00:00', 'LH 100'),
flight('ABC123', BER, HND, '2026-06-11T14:30:00', '2026-06-11T23:30:00', 'LH 200'),
] as any, 'test.json');
expect(items).toHaveLength(1);
const booking = items[0];
expect(booking.type).toBe('flight');
expect(booking.endpoints).toHaveLength(3);
expect(booking.endpoints!.map(e => e.role)).toEqual(['from', 'stop', 'to']);
expect(booking.endpoints!.map(e => e.sequence)).toEqual([0, 1, 2]);
const meta = booking.metadata as any;
expect(meta.legs).toHaveLength(2);
expect(meta.legs[0]).toMatchObject({ from: 'FRA', to: 'BER', flight_number: 'LH 100' });
expect(meta.legs[1]).toMatchObject({ from: 'BER', to: 'HND', flight_number: 'LH 200' });
expect(meta.departure_airport).toBe('FRA');
expect(meta.arrival_airport).toBe('HND');
expect(booking.reservation_time).toContain('10:00');
expect(booking.reservation_end_time).toContain('23:30');
});
it('keeps a round trip (same PNR, multi-day gap) as two separate bookings', () => {
const { items } = mapReservations([
flight('RT999', FRA, HND, '2026-06-11T10:00:00', '2026-06-11T20:00:00', 'LH 700'),
flight('RT999', HND, FRA, '2026-06-20T10:00:00', '2026-06-20T18:00:00', 'LH 701'),
] as any, 'test.json');
expect(items).toHaveLength(2);
expect((items[0].metadata as any).legs).toBeUndefined();
expect((items[1].metadata as any).legs).toBeUndefined();
});
it('leaves a single flight unchanged (two endpoints, no legs array)', () => {
const { items } = mapReservations([
flight('S1', FRA, BER, '2026-06-11T10:00:00', '2026-06-11T12:00:00', 'LH 1'),
] as any, 'test.json');
expect(items).toHaveLength(1);
expect(items[0].endpoints).toHaveLength(2);
expect((items[0].metadata as any).legs).toBeUndefined();
});
});
+72 -72
View File
@@ -38,78 +38,78 @@ const budget: TranslationStrings = {
'انقر على صورة العضو في بند الميزانية لتحديده باللون الأخضر — وهذا يعني أنه دفع. ثم تُظهر التسوية من يدين لمن وبكم.',
'budget.netBalances': 'الأرصدة الصافية',
'budget.categoriesLabel': 'فئات',
"costs.you": "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.you": "أنت",
"costs.youShort": "أنت",
"costs.youLower": "أنت",
"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": "اختر شخصًا واحدًا على الأقل للتقسيم معه.",
"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':
'تعذّر مطابقة المطار تلقائياً — يرجى تأكيد الموقع.',
+72 -72
View File
@@ -39,78 +39,78 @@ const budget: TranslationStrings = {
'Clique no avatar de um membro em um item do orçamento para marcá-lo em verde — significa que ele pagou. O acerto mostra quem deve quanto a quem.',
'budget.netBalances': 'Saldos líquidos',
'budget.categoriesLabel': 'categorias',
"costs.you": "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.you": "Você",
"costs.youShort": "V",
"costs.youLower": "você",
"costs.youOwe": "Você deve",
"costs.youOweSub": "Você deve pagar os outros",
"costs.youreOwed": "Devem a você",
"costs.youreOwedSub": "Os outros devem pagar você",
"costs.totalSpend": "Gasto total da viagem",
"costs.totalSpendSub": "Entre todos os viajantes",
"costs.to": "Para",
"costs.from": "De",
"costs.allSettled": "Suas contas estão acertadas",
"costs.nothingOwed": "Ninguém deve nada a você",
"costs.yourShare": "Sua parte",
"costs.youPaid": "Você pagou",
"costs.expenses": "Despesas",
"costs.entries": "{count} lançamentos",
"costs.searchPlaceholder": "Buscar despesas…",
"costs.filter.all": "Todas",
"costs.filter.mine": "Pagas por mim",
"costs.filter.owed": "Devem a mim",
"costs.addExpense": "Adicionar despesa",
"costs.editExpense": "Editar despesa",
"costs.noMatch": "Nenhuma despesa corresponde à busca.",
"costs.emptyText": "Nenhuma despesa ainda. Adicione a primeira.",
"costs.spent": "{amount} gastos",
"costs.noDate": "Sem data",
"costs.noOnePaid": "Ninguém pagou ainda",
"costs.youLent": "você emprestou {amount}",
"costs.youBorrowed": "você pegou emprestado {amount}",
"costs.settleUp": "Acertar contas",
"costs.history": "Histórico",
"costs.everyoneSquare": "Todos quitados",
"costs.nothingOutstanding": "Nenhum pagamento pendente no momento.",
"costs.pay": "paga",
"costs.pays": "paga",
"costs.settle": "Acertar",
"costs.balances": "Saldos",
"costs.byCategory": "Por categoria",
"costs.noCategories": "Nenhuma despesa ainda.",
"costs.settleHistory": "Histórico de acertos",
"costs.noSettlements": "Nenhum pagamento acertado ainda.",
"costs.paymentsSettled": "{count} pagamentos acertados",
"costs.paid": "pago",
"costs.undo": "Desfazer",
"costs.whatFor": "Para que foi?",
"costs.namePlaceholder": "ex.: jantar, lembranças, gasolina…",
"costs.totalAmount": "Valor total",
"costs.currency": "Moeda",
"costs.day": "Dia",
"costs.rateLabel": "1 {from} em {to}",
"costs.category": "Categoria",
"costs.whoPaid": "Quem pagou?",
"costs.splitBetween": "Dividir igualmente entre",
"costs.pickSomeone": "Escolha pelo menos uma pessoa para dividir.",
"costs.splitSummary": "Dividido entre {count} · {amount} cada",
"costs.cat.accommodation": "Hospedagem",
"costs.cat.food": "Comida e bebida",
"costs.cat.groceries": "Mercado",
"costs.cat.transport": "Transporte",
"costs.cat.flights": "Voos",
"costs.cat.activities": "Atividades",
"costs.cat.sightseeing": "Passeios turísticos",
"costs.cat.shopping": "Compras",
"costs.cat.fees": "Taxas e ingressos",
"costs.cat.health": "Saúde",
"costs.cat.tips": "Gorjetas",
"costs.cat.other": "Outros",
"costs.daysCount": "{count} dias",
"costs.travelers": "{count} viajantes",
"costs.liveRate": "taxa ao vivo",
"costs.settleAll": "Acertar tudo",
};
export default budget;
+5
View File
@@ -27,6 +27,11 @@ const reservations: TranslationStrings = {
'reservations.meta.flightNumber': 'Nº do voo',
'reservations.meta.from': 'De',
'reservations.meta.to': 'Para',
'reservations.layover.route': 'Rota',
'reservations.layover.stop': 'Parada',
'reservations.layover.addStop': 'Adicionar parada',
'reservations.layover.connection': 'Conexão',
'reservations.layover.layover': 'Escala',
'reservations.needsReview': 'Verificar',
'reservations.needsReviewHint':
'Aeroporto não pôde ser identificado automaticamente — confirme o local.',
+72 -72
View File
@@ -39,78 +39,78 @@ const budget: TranslationStrings = {
'Klikněte na avatar člena u rozpočtové položky pro zelené označení to znamená, že zaplatil. Vyúčtování pak ukazuje, kdo komu a kolik dluží.',
'budget.netBalances': 'Čisté zůstatky',
'budget.categoriesLabel': 'kategorie',
"costs.you": "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.you": "Vy",
"costs.youShort": "Vy",
"costs.youLower": "vy",
"costs.youOwe": "Dlužíte",
"costs.youOweSub": "Měli byste zaplatit ostatním",
"costs.youreOwed": "Dluží vám",
"costs.youreOwedSub": "Ostatní by měli zaplatit vám",
"costs.totalSpend": "Celkové výdaje na cestu",
"costs.totalSpendSub": "Za všechny cestovatele",
"costs.to": "Komu",
"costs.from": "Od",
"costs.allSettled": "Máte vše vyrovnáno",
"costs.nothingOwed": "Nikdo vám nic nedluží",
"costs.yourShare": "Váš podíl",
"costs.youPaid": "Zaplatili jste",
"costs.expenses": "Výdaje",
"costs.entries": "{count} položek",
"costs.searchPlaceholder": "Hledat výdaje…",
"costs.filter.all": "Vše",
"costs.filter.mine": "Zaplaceno mnou",
"costs.filter.owed": "Dluží mi",
"costs.addExpense": "Přidat výdaj",
"costs.editExpense": "Upravit výdaj",
"costs.noMatch": "Žádné výdaje neodpovídají vašemu hledání.",
"costs.emptyText": "Zatím žádné výdaje. Přidejte první.",
"costs.spent": "Utraceno {amount}",
"costs.noDate": "Bez data",
"costs.noOnePaid": "Zatím nikdo nezaplatil",
"costs.youLent": "půjčili jste {amount}",
"costs.youBorrowed": "vypůjčili jste si {amount}",
"costs.settleUp": "Vyrovnat",
"costs.history": "Historie",
"costs.everyoneSquare": "Všichni jsou vyrovnáni",
"costs.nothingOutstanding": "Momentálně žádné nevyrovnané platby.",
"costs.pay": "zaplatí",
"costs.pays": "zaplatí",
"costs.settle": "Vyrovnat",
"costs.balances": "Zůstatky",
"costs.byCategory": "Podle kategorie",
"costs.noCategories": "Zatím žádné výdaje.",
"costs.settleHistory": "Historie vyrovnání",
"costs.noSettlements": "Zatím žádné vyrovnané platby.",
"costs.paymentsSettled": "{count} plateb vyrovnáno",
"costs.paid": "zaplaceno",
"costs.undo": "Vrátit zpět",
"costs.whatFor": "Za co to bylo?",
"costs.namePlaceholder": "např. večeře, suvenýry, benzín…",
"costs.totalAmount": "Celková částka",
"costs.currency": "Měna",
"costs.day": "Den",
"costs.rateLabel": "1 {from} v {to}",
"costs.category": "Kategorie",
"costs.whoPaid": "Kdo zaplatil?",
"costs.splitBetween": "Rozdělit rovným dílem mezi",
"costs.pickSomeone": "Vyberte alespoň jednu osobu pro rozdělení.",
"costs.splitSummary": "Rozděleno na {count} dílů · {amount} každý",
"costs.cat.accommodation": "Ubytování",
"costs.cat.food": "Jídlo a pití",
"costs.cat.groceries": "Potraviny",
"costs.cat.transport": "Doprava",
"costs.cat.flights": "Lety",
"costs.cat.activities": "Aktivity",
"costs.cat.sightseeing": "Prohlídka památek",
"costs.cat.shopping": "Nákupy",
"costs.cat.fees": "Poplatky a vstupenky",
"costs.cat.health": "Zdraví",
"costs.cat.tips": "Spropitné",
"costs.cat.other": "Ostatní",
"costs.daysCount": "{count} d",
"costs.travelers": "{count} cestovatelů",
"costs.liveRate": "aktuální kurz",
"costs.settleAll": "Vyrovnat vše",
};
export default budget;
+5
View File
@@ -27,6 +27,11 @@ const reservations: TranslationStrings = {
'reservations.meta.flightNumber': 'Číslo letu',
'reservations.meta.from': 'Z',
'reservations.meta.to': 'Do',
'reservations.layover.route': 'Trasa',
'reservations.layover.stop': 'Zastávka',
'reservations.layover.addStop': 'Přidat zastávku',
'reservations.layover.connection': 'Přípoj',
'reservations.layover.layover': 'Mezipřistání',
'reservations.needsReview': 'Zkontrolovat',
'reservations.needsReviewHint':
'Letiště nebylo možné automaticky rozpoznat — potvrďte prosím místo.',
+5
View File
@@ -28,6 +28,11 @@ const reservations: TranslationStrings = {
'reservations.meta.flightNumber': 'Flugnr.',
'reservations.meta.from': 'Von',
'reservations.meta.to': 'Nach',
'reservations.layover.route': 'Route',
'reservations.layover.stop': 'Zwischenstopp',
'reservations.layover.addStop': 'Zwischenstopp hinzufügen',
'reservations.layover.connection': 'Anschlussflug',
'reservations.layover.layover': 'Zwischenstopp',
'reservations.needsReview': 'Prüfen',
'reservations.needsReviewHint':
'Flughafen konnte nicht automatisch erkannt werden — bitte Ort bestätigen.',
+5
View File
@@ -27,6 +27,11 @@ const reservations: TranslationStrings = {
'reservations.meta.flightNumber': 'Flight No.',
'reservations.meta.from': 'From',
'reservations.meta.to': 'To',
'reservations.layover.route': 'Route',
'reservations.layover.stop': 'Stop',
'reservations.layover.addStop': 'Add stop',
'reservations.layover.connection': 'Connection',
'reservations.layover.layover': 'Layover',
'reservations.needsReview': 'Review',
'reservations.needsReviewHint':
'Airport could not be matched automatically — please confirm the location.',
+72 -72
View File
@@ -40,78 +40,78 @@ const budget: TranslationStrings = {
'Haz clic en el avatar de un miembro en una partida del presupuesto para marcarlo en verde — esto significa que ha pagado. La liquidación muestra quién debe cuánto a quién.',
'budget.netBalances': 'Saldos netos',
'budget.categoriesLabel': 'categorías',
"costs.you": "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.you": "",
"costs.youShort": "",
"costs.youLower": "",
"costs.youOwe": "Debes",
"costs.youOweSub": "Deberías pagar a otros",
"costs.youreOwed": "Te deben",
"costs.youreOwedSub": "Otros deberían pagarte",
"costs.totalSpend": "Gasto total del viaje",
"costs.totalSpendSub": "Entre todos los viajeros",
"costs.to": "Para",
"costs.from": "De",
"costs.allSettled": "Estás al día con todo",
"costs.nothingOwed": "Nadie te debe nada",
"costs.yourShare": "Tu parte",
"costs.youPaid": "Pagaste",
"costs.expenses": "Gastos",
"costs.entries": "{count} entradas",
"costs.searchPlaceholder": "Buscar gastos…",
"costs.filter.all": "Todos",
"costs.filter.mine": "Pagados por mí",
"costs.filter.owed": "Me deben",
"costs.addExpense": "Añadir gasto",
"costs.editExpense": "Editar gasto",
"costs.noMatch": "Ningún gasto coincide con tu búsqueda.",
"costs.emptyText": "Aún no hay gastos. Añade el primero.",
"costs.spent": "{amount} gastados",
"costs.noDate": "Sin fecha",
"costs.noOnePaid": "Nadie ha pagado aún",
"costs.youLent": "prestaste {amount}",
"costs.youBorrowed": "tomaste prestado {amount}",
"costs.settleUp": "Saldar cuentas",
"costs.history": "Historial",
"costs.everyoneSquare": "Todos están en paz",
"costs.nothingOutstanding": "No hay pagos pendientes ahora mismo.",
"costs.pay": "paga",
"costs.pays": "paga",
"costs.settle": "Saldar",
"costs.balances": "Saldos",
"costs.byCategory": "Por categoría",
"costs.noCategories": "Aún no hay gastos.",
"costs.settleHistory": "Historial de pagos",
"costs.noSettlements": "Aún no hay pagos saldados.",
"costs.paymentsSettled": "{count} pagos saldados",
"costs.paid": "pagado",
"costs.undo": "Deshacer",
"costs.whatFor": "¿Para qué fue?",
"costs.namePlaceholder": "p. ej. Cena, souvenirs, gasolina…",
"costs.totalAmount": "Importe total",
"costs.currency": "Moneda",
"costs.day": "Día",
"costs.rateLabel": "1 {from} en {to}",
"costs.category": "Categoría",
"costs.whoPaid": "¿Quién pa?",
"costs.splitBetween": "Dividir a partes iguales entre",
"costs.pickSomeone": "Elige al menos una persona con quien dividir.",
"costs.splitSummary": "Dividido entre {count} · {amount} cada uno",
"costs.cat.accommodation": "Alojamiento",
"costs.cat.food": "Comida y bebida",
"costs.cat.groceries": "Compras de comida",
"costs.cat.transport": "Transporte",
"costs.cat.flights": "Vuelos",
"costs.cat.activities": "Actividades",
"costs.cat.sightseeing": "Turismo",
"costs.cat.shopping": "Compras",
"costs.cat.fees": "Tasas y entradas",
"costs.cat.health": "Salud",
"costs.cat.tips": "Propinas",
"costs.cat.other": "Otros",
"costs.daysCount": "{count} días",
"costs.travelers": "{count} viajeros",
"costs.liveRate": "tasa en vivo",
"costs.settleAll": "Saldar todo",
};
export default budget;
+5
View File
@@ -101,6 +101,11 @@ const reservations: TranslationStrings = {
'reservations.meta.flightNumber': 'N° de vuelo',
'reservations.meta.from': 'Desde',
'reservations.meta.to': 'Hasta',
'reservations.layover.route': 'Ruta',
'reservations.layover.stop': 'Escala',
'reservations.layover.addStop': 'Añadir escala',
'reservations.layover.connection': 'Conexión',
'reservations.layover.layover': 'Escala',
'reservations.needsReview': 'Revisar',
'reservations.needsReviewHint':
'No se pudo identificar el aeropuerto automáticamente — por favor confirma la ubicación.',
+70 -70
View File
@@ -40,78 +40,78 @@ const budget: TranslationStrings = {
"Cliquez sur l'avatar d'un membre sur un poste budgétaire pour le marquer en vert — cela signifie qu'il a payé. Le règlement indique ensuite qui doit combien à qui.",
'budget.netBalances': 'Soldes nets',
'budget.categoriesLabel': 'catégories',
"costs.you": "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.you": "Vous",
"costs.youShort": "V",
"costs.youLower": "vous",
"costs.youOwe": "Vous devez",
"costs.youOweSub": "Vous devez payer les autres",
"costs.youreOwed": "On vous doit",
"costs.youreOwedSub": "Les autres doivent vous payer",
"costs.totalSpend": "Dépenses totales du voyage",
"costs.totalSpendSub": "Tous voyageurs confondus",
"costs.to": "À",
"costs.from": "De",
"costs.allSettled": "Tout est réglé pour vous",
"costs.nothingOwed": "On ne vous doit rien",
"costs.yourShare": "Votre part",
"costs.youPaid": "Vous avez pa",
"costs.expenses": "penses",
"costs.entries": "{count} entrées",
"costs.searchPlaceholder": "Rechercher des dépenses…",
"costs.filter.all": "Toutes",
"costs.filter.mine": "Payées par moi",
"costs.filter.owed": "On me doit",
"costs.addExpense": "Ajouter une dépense",
"costs.editExpense": "Modifier la dépense",
"costs.noMatch": "Aucune dépense ne correspond à votre recherche.",
"costs.emptyText": "Aucune dépense pour le moment. Ajoutez la première.",
"costs.spent": "{amount} pensés",
"costs.noDate": "Aucune date",
"costs.noOnePaid": "Personne n'a encore payé",
"costs.youLent": "vous avez prêté {amount}",
"costs.youBorrowed": "vous avez emprunté {amount}",
"costs.settleUp": "Régler",
"costs.history": "Historique",
"costs.everyoneSquare": "Tout le monde est quitte",
"costs.nothingOutstanding": "Aucun paiement en attente pour le moment.",
"costs.pay": "payer",
"costs.pays": "paie",
"costs.settle": "Régler",
"costs.balances": "Soldes",
"costs.byCategory": "Par catégorie",
"costs.noCategories": "Aucune dépense pour le moment.",
"costs.settleHistory": "Historique des règlements",
"costs.noSettlements": "Aucun paiement réglé pour le moment.",
"costs.paymentsSettled": "{count} paiements réglés",
"costs.paid": "pa",
"costs.undo": "Annuler",
"costs.whatFor": "C'était pour quoi ?",
"costs.namePlaceholder": "ex. dîner, souvenirs, essence…",
"costs.totalAmount": "Montant total",
"costs.currency": "Devise",
"costs.day": "Jour",
"costs.rateLabel": "1 {from} en {to}",
"costs.category": "Catégorie",
"costs.whoPaid": "Qui a payé ?",
"costs.splitBetween": "Partager équitablement entre",
"costs.pickSomeone": "Choisissez au moins une personne avec qui partager.",
"costs.splitSummary": "Partagé en {count} · {amount} chacun",
"costs.cat.accommodation": "Hébergement",
"costs.cat.food": "Nourriture et boissons",
"costs.cat.groceries": "Courses",
"costs.cat.transport": "Transport",
"costs.cat.flights": "Flights",
"costs.cat.activities": "Activities",
"costs.cat.sightseeing": "Sightseeing",
"costs.cat.flights": "Vols",
"costs.cat.activities": "Activités",
"costs.cat.sightseeing": "Visites",
"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.cat.fees": "Frais et billets",
"costs.cat.health": "Santé",
"costs.cat.tips": "Pourboires",
"costs.cat.other": "Autre",
"costs.daysCount": "{count} jours",
"costs.travelers": "{count} voyageurs",
"costs.liveRate": "taux en direct",
"costs.settleAll": "Tout régler",
};
export default budget;
+5
View File
@@ -28,6 +28,11 @@ const reservations: TranslationStrings = {
'reservations.meta.flightNumber': 'N° de vol',
'reservations.meta.from': 'De',
'reservations.meta.to': 'À',
'reservations.layover.route': 'Itinéraire',
'reservations.layover.stop': 'Escale',
'reservations.layover.addStop': 'Ajouter une escale',
'reservations.layover.connection': 'Correspondance',
'reservations.layover.layover': 'Escale',
'reservations.needsReview': 'Vérifier',
'reservations.needsReviewHint':
"L'aéroport n'a pas pu être identifié automatiquement — veuillez confirmer l'emplacement.",
+72 -72
View File
@@ -40,78 +40,78 @@ const budget: TranslationStrings = {
'Κάντε κλικ στο avatar ενός μέλους σε μια εγγραφή προϋπολογισμού για να το επισημάνετε πράσινο — αυτό σημαίνει ότι πλήρωσε. Η εκκαθάριση δείχνει στη συνέχεια ποιος χρωστάει σε ποιον και πόσα.',
'budget.netBalances': 'Καθαρά Υπόλοιπα',
'budget.categoriesLabel': 'κατηγορίες',
"costs.you": "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.you": "Εσείς",
"costs.youShort": "Ε",
"costs.youLower": "εσείς",
"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": "Επιλέξτε τουλάχιστον ένα άτομο για τον διαμοιρασμό.",
"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
@@ -28,6 +28,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':
'Δεν ήταν δυνατή η αυτόματη αντιστοίχιση του αεροδρομίου — παρακαλώ επιβεβαιώστε την τοποθεσία.',
+72 -72
View File
@@ -40,78 +40,78 @@ const budget: TranslationStrings = {
'Kattints egy tag avatárjára egy költségvetési tételen a zöld jelöléshez — ez azt jelenti, hogy fizetett. Az elszámolás ezután mutatja, ki kinek mennyivel tartozik.',
'budget.netBalances': 'Nettó egyenlegek',
'budget.categoriesLabel': 'kategóriák',
"costs.you": "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.you": "Te",
"costs.youShort": "T",
"costs.youLower": "te",
"costs.youOwe": "Tartozol",
"costs.youOweSub": "Fizetned kell másoknak",
"costs.youreOwed": "Neked tartoznak",
"costs.youreOwedSub": "Mások fizetnek neked",
"costs.totalSpend": "Teljes utazási költség",
"costs.totalSpendSub": "Az összes utazóra vetítve",
"costs.to": "Kinek",
"costs.from": "Kitől",
"costs.allSettled": "Minden el van számolva",
"costs.nothingOwed": "Senki sem tartozik neked",
"costs.yourShare": "A te részed",
"costs.youPaid": "Te fizettél",
"costs.expenses": "Költségek",
"costs.entries": "{count} bejegyzés",
"costs.searchPlaceholder": "Költségek keresése…",
"costs.filter.all": "Mind",
"costs.filter.mine": "Én fizettem",
"costs.filter.owed": "Nekem tartoznak",
"costs.addExpense": "Költség hozzáadása",
"costs.editExpense": "Költség szerkesztése",
"costs.noMatch": "Nincs a keresésnek megfelelő költség.",
"costs.emptyText": "Még nincs költség. Add hozzá az elsőt.",
"costs.spent": "{amount} elköltve",
"costs.noDate": "Nincs dátum",
"costs.noOnePaid": "Még senki sem fizetett",
"costs.youLent": "{amount} kölcsönadtál",
"costs.youBorrowed": "{amount} kölcsönkértél",
"costs.settleUp": "Elszámolás",
"costs.history": "Előzmények",
"costs.everyoneSquare": "Mindenki kvittben van",
"costs.nothingOutstanding": "Jelenleg nincs kifizetendő összeg.",
"costs.pay": "fizet",
"costs.pays": "fizet",
"costs.settle": "Elszámol",
"costs.balances": "Egyenlegek",
"costs.byCategory": "Kategóriánként",
"costs.noCategories": "Még nincs költség.",
"costs.settleHistory": "Elszámolási előzmények",
"costs.noSettlements": "Még nincs elszámolt fizetés.",
"costs.paymentsSettled": "{count} fizetés elszámolva",
"costs.paid": "fizetve",
"costs.undo": "Visszavonás",
"costs.whatFor": "Mire volt?",
"costs.namePlaceholder": "pl. vacsora, ajándékok, benzin…",
"costs.totalAmount": "Teljes összeg",
"costs.currency": "Pénznem",
"costs.day": "Nap",
"costs.rateLabel": "1 {from} ennyi: {to}",
"costs.category": "Kategória",
"costs.whoPaid": "Ki fizetett?",
"costs.splitBetween": "Egyenlően elosztva köztük",
"costs.pickSomeone": "Válassz legalább egy személyt a megosztáshoz.",
"costs.splitSummary": "{count} fő közt megosztva · egyenként {amount}",
"costs.cat.accommodation": "Szállás",
"costs.cat.food": "Étel és ital",
"costs.cat.groceries": "Élelmiszer",
"costs.cat.transport": "Közlekedés",
"costs.cat.flights": "Repülőjáratok",
"costs.cat.activities": "Programok",
"costs.cat.sightseeing": "Városnézés",
"costs.cat.shopping": "Vásárlás",
"costs.cat.fees": "Díjak és jegyek",
"costs.cat.health": "Egészség",
"costs.cat.tips": "Borravaló",
"costs.cat.other": "Egyéb",
"costs.daysCount": "{count} nap",
"costs.travelers": "{count} utazó",
"costs.liveRate": "élő árfolyam",
"costs.settleAll": "Összes elszámolása",
};
export default budget;
+5
View File
@@ -29,6 +29,11 @@ const reservations: TranslationStrings = {
'reservations.meta.flightNumber': 'Járatszám',
'reservations.meta.from': 'Honnan',
'reservations.meta.to': 'Hová',
'reservations.layover.route': 'Útvonal',
'reservations.layover.stop': 'Megálló',
'reservations.layover.addStop': 'Megálló hozzáadása',
'reservations.layover.connection': 'Csatlakozás',
'reservations.layover.layover': 'Átszállás',
'reservations.needsReview': 'Ellenőrzés',
'reservations.needsReviewHint':
'A repülőteret nem sikerült automatikusan azonosítani — erősítsd meg a helyet.',
+72 -72
View File
@@ -39,78 +39,78 @@ const budget: TranslationStrings = {
'Klik foto anggota di item anggaran untuk menandainya hijau — artinya mereka sudah bayar. Penyelesaian lalu menunjukkan siapa berhutang ke siapa dan berapa.',
'budget.netBalances': 'Saldo Bersih',
'budget.categoriesLabel': 'kategori',
"costs.you": "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.you": "Kamu",
"costs.youShort": "K",
"costs.youLower": "kamu",
"costs.youOwe": "Kamu berhutang",
"costs.youOweSub": "Kamu harus membayar yang lain",
"costs.youreOwed": "Kamu dipinjami",
"costs.youreOwedSub": "Yang lain harus membayarmu",
"costs.totalSpend": "Total pengeluaran perjalanan",
"costs.totalSpendSub": "Untuk semua pelancong",
"costs.to": "Ke",
"costs.from": "Dari",
"costs.allSettled": "Semua sudah lunas",
"costs.nothingOwed": "Tidak ada yang berhutang padamu",
"costs.yourShare": "Bagianmu",
"costs.youPaid": "Kamu membayar",
"costs.expenses": "Pengeluaran",
"costs.entries": "{count} entri",
"costs.searchPlaceholder": "Cari pengeluaran…",
"costs.filter.all": "Semua",
"costs.filter.mine": "Dibayar olehku",
"costs.filter.owed": "Dipinjami padaku",
"costs.addExpense": "Tambah pengeluaran",
"costs.editExpense": "Edit pengeluaran",
"costs.noMatch": "Tidak ada pengeluaran yang cocok dengan pencarianmu.",
"costs.emptyText": "Belum ada pengeluaran. Tambahkan yang pertama.",
"costs.spent": "{amount} dibelanjakan",
"costs.noDate": "Tanpa tanggal",
"costs.noOnePaid": "Belum ada yang membayar",
"costs.youLent": "kamu meminjamkan {amount}",
"costs.youBorrowed": "kamu meminjam {amount}",
"costs.settleUp": "Lunasi",
"costs.history": "Riwayat",
"costs.everyoneSquare": "Semua sudah impas",
"costs.nothingOutstanding": "Tidak ada pembayaran tertunggak saat ini.",
"costs.pay": "bayar",
"costs.pays": "membayar",
"costs.settle": "Lunasi",
"costs.balances": "Saldo",
"costs.byCategory": "Per kategori",
"costs.noCategories": "Belum ada pengeluaran.",
"costs.settleHistory": "Riwayat pelunasan",
"costs.noSettlements": "Belum ada pembayaran yang dilunasi.",
"costs.paymentsSettled": "{count} pembayaran dilunasi",
"costs.paid": "dibayar",
"costs.undo": "Urungkan",
"costs.whatFor": "Untuk apa?",
"costs.namePlaceholder": "mis. Makan malam, oleh-oleh, bensin…",
"costs.totalAmount": "Jumlah total",
"costs.currency": "Mata uang",
"costs.day": "Hari",
"costs.rateLabel": "1 {from} dalam {to}",
"costs.category": "Kategori",
"costs.whoPaid": "Siapa yang membayar?",
"costs.splitBetween": "Bagi rata antara",
"costs.pickSomeone": "Pilih setidaknya satu orang untuk berbagi.",
"costs.splitSummary": "Dibagi {count} cara · {amount} masing-masing",
"costs.cat.accommodation": "Akomodasi",
"costs.cat.food": "Makanan & minuman",
"costs.cat.groceries": "Belanja kebutuhan",
"costs.cat.transport": "Transportasi",
"costs.cat.flights": "Penerbangan",
"costs.cat.activities": "Aktivitas",
"costs.cat.sightseeing": "Wisata",
"costs.cat.shopping": "Belanja",
"costs.cat.fees": "Biaya & tiket",
"costs.cat.health": "Kesehatan",
"costs.cat.tips": "Tip",
"costs.cat.other": "Lainnya",
"costs.daysCount": "{count} hari",
"costs.travelers": "{count} pelancong",
"costs.liveRate": "kurs langsung",
"costs.settleAll": "Lunasi semua",
};
export default budget;
+5
View File
@@ -28,6 +28,11 @@ const reservations: TranslationStrings = {
'reservations.meta.flightNumber': 'No. Penerbangan',
'reservations.meta.from': 'Dari',
'reservations.meta.to': 'Ke',
'reservations.layover.route': 'Rute',
'reservations.layover.stop': 'Persinggahan',
'reservations.layover.addStop': 'Tambah persinggahan',
'reservations.layover.connection': 'Sambungan',
'reservations.layover.layover': 'Transit',
'reservations.needsReview': 'Tinjau',
'reservations.needsReviewHint':
'Bandara tidak dapat dicocokkan otomatis — konfirmasi lokasi.',
+70 -70
View File
@@ -40,78 +40,78 @@ const budget: TranslationStrings = {
"Clicca sull'avatar di un membro su una voce di budget per contrassegnarlo in verde — significa che ha pagato. Il regolamento mostra poi chi deve quanto a chi.",
'budget.netBalances': 'Saldi netti',
'budget.categoriesLabel': 'categorie',
"costs.you": "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.you": "Tu",
"costs.youShort": "T",
"costs.youLower": "tu",
"costs.youOwe": "Devi",
"costs.youOweSub": "Dovresti pagare gli altri",
"costs.youreOwed": "Ti devono",
"costs.youreOwedSub": "Gli altri dovrebbero pagarti",
"costs.totalSpend": "Spesa totale del viaggio",
"costs.totalSpendSub": "Tra tutti i viaggiatori",
"costs.to": "A",
"costs.from": "Da",
"costs.allSettled": "Hai saldato tutto",
"costs.nothingOwed": "Nessuno ti deve nulla",
"costs.yourShare": "La tua quota",
"costs.youPaid": "Hai pagato",
"costs.expenses": "Spese",
"costs.entries": "{count} voci",
"costs.searchPlaceholder": "Cerca spese…",
"costs.filter.all": "Tutte",
"costs.filter.mine": "Pagate da me",
"costs.filter.owed": "Mi devono",
"costs.addExpense": "Aggiungi spesa",
"costs.editExpense": "Modifica spesa",
"costs.noMatch": "Nessuna spesa corrisponde alla ricerca.",
"costs.emptyText": "Ancora nessuna spesa. Aggiungi la prima.",
"costs.spent": "{amount} spesi",
"costs.noDate": "Nessuna data",
"costs.noOnePaid": "Nessuno ha ancora pagato",
"costs.youLent": "hai prestato {amount}",
"costs.youBorrowed": "hai preso in prestito {amount}",
"costs.settleUp": "Salda",
"costs.history": "Cronologia",
"costs.everyoneSquare": "Sono tutti in pari",
"costs.nothingOutstanding": "Nessun pagamento in sospeso al momento.",
"costs.pay": "paga",
"costs.pays": "paga",
"costs.settle": "Salda",
"costs.balances": "Saldi",
"costs.byCategory": "Per categoria",
"costs.noCategories": "Ancora nessuna spesa.",
"costs.settleHistory": "Cronologia saldi",
"costs.noSettlements": "Ancora nessun pagamento saldato.",
"costs.paymentsSettled": "{count} pagamenti saldati",
"costs.paid": "pagato",
"costs.undo": "Annulla",
"costs.whatFor": "Per cosa era?",
"costs.namePlaceholder": "es. Cena, souvenir, benzina…",
"costs.totalAmount": "Importo totale",
"costs.currency": "Valuta",
"costs.day": "Giorno",
"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.category": "Categoria",
"costs.whoPaid": "Chi ha pagato?",
"costs.splitBetween": "Dividi equamente tra",
"costs.pickSomeone": "Scegli almeno una persona con cui dividere.",
"costs.splitSummary": "Diviso in {count} · {amount} ciascuno",
"costs.cat.accommodation": "Alloggio",
"costs.cat.food": "Cibo e bevande",
"costs.cat.groceries": "Spesa alimentare",
"costs.cat.transport": "Trasporti",
"costs.cat.flights": "Voli",
"costs.cat.activities": "Attività",
"costs.cat.sightseeing": "Visite turistiche",
"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.cat.fees": "Tariffe e biglietti",
"costs.cat.health": "Salute",
"costs.cat.tips": "Mance",
"costs.cat.other": "Altro",
"costs.daysCount": "{count} giorni",
"costs.travelers": "{count} viaggiatori",
"costs.liveRate": "tasso in tempo reale",
"costs.settleAll": "Salda tutto",
};
export default budget;
+5
View File
@@ -27,6 +27,11 @@ const reservations: TranslationStrings = {
'reservations.meta.flightNumber': 'N. volo',
'reservations.meta.from': 'Da',
'reservations.meta.to': 'A',
'reservations.layover.route': 'Itinerario',
'reservations.layover.stop': 'Scalo',
'reservations.layover.addStop': 'Aggiungi scalo',
'reservations.layover.connection': 'Coincidenza',
'reservations.layover.layover': 'Sosta',
'reservations.needsReview': 'Verifica',
'reservations.needsReviewHint':
"L'aeroporto non è stato riconosciuto automaticamente — conferma la posizione.",
+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':
'空港を自動で特定できませんでした。場所を確認してください。',
+71 -71
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.youLower": "",
"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": "분할할 사람을 한 명 이상 선택하세요.",
"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':
'공항이 자동으로 매칭되지 않았습니다 — 위치를 확인해 주세요.',
+71 -71
View File
@@ -40,78 +40,78 @@ const budget: TranslationStrings = {
'Klik op de avatar van een lid bij een budgetpost om deze groen te markeren — dit betekent dat diegene heeft betaald. De afrekening toont vervolgens wie wie hoeveel verschuldigd is.',
'budget.netBalances': 'Nettosaldi',
'budget.categoriesLabel': 'categorieën',
"costs.you": "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.you": "Jij",
"costs.youShort": "J",
"costs.youLower": "jij",
"costs.youOwe": "Jij bent verschuldigd",
"costs.youOweSub": "Jij moet anderen betalen",
"costs.youreOwed": "Jij krijgt nog",
"costs.youreOwedSub": "Anderen moeten jou betalen",
"costs.totalSpend": "Totale reisuitgaven",
"costs.totalSpendSub": "Voor alle reizigers",
"costs.to": "Aan",
"costs.from": "Van",
"costs.allSettled": "Je bent helemaal afgerekend",
"costs.nothingOwed": "Niemand is jou iets verschuldigd",
"costs.yourShare": "Jouw aandeel",
"costs.youPaid": "Jij hebt betaald",
"costs.expenses": "Uitgaven",
"costs.entries": "{count} invoeren",
"costs.searchPlaceholder": "Uitgaven zoeken…",
"costs.filter.all": "Alles",
"costs.filter.mine": "Door mij betaald",
"costs.filter.owed": "Mij verschuldigd",
"costs.addExpense": "Uitgave toevoegen",
"costs.editExpense": "Uitgave bewerken",
"costs.noMatch": "Geen uitgaven komen overeen met je zoekopdracht.",
"costs.emptyText": "Nog geen uitgaven. Voeg je eerste toe.",
"costs.spent": "{amount} uitgegeven",
"costs.noDate": "Geen datum",
"costs.noOnePaid": "Nog niemand heeft betaald",
"costs.youLent": "je hebt {amount} voorgeschoten",
"costs.youBorrowed": "je hebt {amount} geleend",
"costs.settleUp": "Afrekenen",
"costs.history": "Geschiedenis",
"costs.everyoneSquare": "Iedereen is quitte",
"costs.nothingOutstanding": "Er staan op dit moment geen betalingen open.",
"costs.pay": "betaalt",
"costs.pays": "betaalt",
"costs.settle": "Afrekenen",
"costs.balances": "Saldi",
"costs.byCategory": "Per categorie",
"costs.noCategories": "Nog geen uitgaven.",
"costs.settleHistory": "Afrekengeschiedenis",
"costs.noSettlements": "Nog geen afgerekende betalingen.",
"costs.paymentsSettled": "{count} betalingen afgerekend",
"costs.paid": "betaald",
"costs.undo": "Ongedaan maken",
"costs.whatFor": "Waar was het voor?",
"costs.namePlaceholder": "bijv. Diner, souvenirs, benzine…",
"costs.totalAmount": "Totaalbedrag",
"costs.currency": "Valuta",
"costs.day": "Dag",
"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.category": "Categorie",
"costs.whoPaid": "Wie heeft betaald?",
"costs.splitBetween": "Gelijk verdelen over",
"costs.pickSomeone": "Kies minstens één persoon om mee te delen.",
"costs.splitSummary": "Verdeeld over {count} · {amount} elk",
"costs.cat.accommodation": "Accommodatie",
"costs.cat.food": "Eten & drinken",
"costs.cat.groceries": "Boodschappen",
"costs.cat.transport": "Vervoer",
"costs.cat.flights": "Vluchten",
"costs.cat.activities": "Activiteiten",
"costs.cat.sightseeing": "Bezienswaardigheden",
"costs.cat.shopping": "Winkelen",
"costs.cat.fees": "Kosten & tickets",
"costs.cat.health": "Gezondheid",
"costs.cat.tips": "Fooien",
"costs.cat.other": "Overig",
"costs.daysCount": "{count} dagen",
"costs.travelers": "{count} reizigers",
"costs.liveRate": "live koers",
"costs.settleAll": "Alles afrekenen",
};
export default budget;
+5
View File
@@ -28,6 +28,11 @@ const reservations: TranslationStrings = {
'reservations.meta.flightNumber': 'Vluchtnr.',
'reservations.meta.from': 'Van',
'reservations.meta.to': 'Naar',
'reservations.layover.route': 'Route',
'reservations.layover.stop': 'Tussenstop',
'reservations.layover.addStop': 'Tussenstop toevoegen',
'reservations.layover.connection': 'Aansluiting',
'reservations.layover.layover': 'Overstap',
'reservations.needsReview': 'Controleren',
'reservations.needsReviewHint':
'Luchthaven kon niet automatisch worden herkend — bevestig de locatie.',
+71 -71
View File
@@ -38,78 +38,78 @@ const budget: TranslationStrings = {
'budget.exportCsv': 'Eksportuj CSV',
'budget.table.date': 'Data',
'budget.categoriesLabel': 'kategorie',
"costs.you": "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.you": "Ty",
"costs.youShort": "T",
"costs.youLower": "ty",
"costs.youOwe": "Jesteś winien",
"costs.youOweSub": "Powinieneś zapłacić innym",
"costs.youreOwed": "Należy ci się",
"costs.youreOwedSub": "Inni powinni zapłacić tobie",
"costs.totalSpend": "Łączne wydatki na podróż",
"costs.totalSpendSub": "Wszystkich podróżnych",
"costs.to": "Do",
"costs.from": "Od",
"costs.allSettled": "Wszystko rozliczone",
"costs.nothingOwed": "Nikt nie jest ci nic winien",
"costs.yourShare": "Twój udział",
"costs.youPaid": "Zapłaciłeś",
"costs.expenses": "Wydatki",
"costs.entries": "Wpisów: {count}",
"costs.searchPlaceholder": "Szukaj wydatków…",
"costs.filter.all": "Wszystkie",
"costs.filter.mine": "Opłacone przeze mnie",
"costs.filter.owed": "Należy mi się",
"costs.addExpense": "Dodaj wydatek",
"costs.editExpense": "Edytuj wydatek",
"costs.noMatch": "Brak wydatków pasujących do wyszukiwania.",
"costs.emptyText": "Brak wydatków. Dodaj pierwszy.",
"costs.spent": "wydano {amount}",
"costs.noDate": "Brak daty",
"costs.noOnePaid": "Nikt jeszcze nie zapłacił",
"costs.youLent": "pożyczyłeś {amount}",
"costs.youBorrowed": "pożyczyłeś od innych {amount}",
"costs.settleUp": "Rozlicz",
"costs.history": "Historia",
"costs.everyoneSquare": "Wszyscy rozliczeni",
"costs.nothingOutstanding": "Brak nierozliczonych płatności.",
"costs.pay": "zapłać",
"costs.pays": "płaci",
"costs.settle": "Rozlicz",
"costs.balances": "Salda",
"costs.byCategory": "Według kategorii",
"costs.noCategories": "Brak wydatków.",
"costs.settleHistory": "Historia rozliczeń",
"costs.noSettlements": "Brak rozliczonych płatności.",
"costs.paymentsSettled": "Rozliczonych płatności: {count}",
"costs.paid": "zapłacono",
"costs.undo": "Cofnij",
"costs.whatFor": "Na co to było?",
"costs.namePlaceholder": "np. kolacja, pamiątki, paliwo…",
"costs.totalAmount": "Łączna kwota",
"costs.currency": "Waluta",
"costs.day": "Dzień",
"costs.rateLabel": "1 {from} w {to}",
"costs.category": "Kategoria",
"costs.whoPaid": "Kto zapłacił?",
"costs.splitBetween": "Podziel równo między",
"costs.pickSomeone": "Wybierz co najmniej jedną osobę do podziału.",
"costs.splitSummary": "Podział na {count} · {amount} na osobę",
"costs.cat.accommodation": "Nocleg",
"costs.cat.food": "Jedzenie i napoje",
"costs.cat.groceries": "Zakupy spożywcze",
"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.cat.flights": "Loty",
"costs.cat.activities": "Atrakcje",
"costs.cat.sightseeing": "Zwiedzanie",
"costs.cat.shopping": "Zakupy",
"costs.cat.fees": "Opłaty i bilety",
"costs.cat.health": "Zdrowie",
"costs.cat.tips": "Napiwki",
"costs.cat.other": "Inne",
"costs.daysCount": "Dni: {count}",
"costs.travelers": "Podróżnych: {count}",
"costs.liveRate": "kurs na żywo",
"costs.settleAll": "Rozlicz wszystko",
};
export default budget;
+5
View File
@@ -27,6 +27,11 @@ const reservations: TranslationStrings = {
'reservations.meta.flightNumber': 'Numer lotu',
'reservations.meta.from': 'Skąd',
'reservations.meta.to': 'Dokąd',
'reservations.layover.route': 'Trasa',
'reservations.layover.stop': 'Przystanek',
'reservations.layover.addStop': 'Dodaj przystanek',
'reservations.layover.connection': 'Połączenie',
'reservations.layover.layover': 'Przesiadka',
'reservations.meta.trainNumber': 'Numer pociągu',
'reservations.meta.platform': 'Peron',
'reservations.meta.seat': 'Miejsce',
+72 -72
View File
@@ -40,78 +40,78 @@ const budget: TranslationStrings = {
'Нажмите на аватар участника в строке бюджета, чтобы отметить его зелёным — это значит, что он заплатил. Взаиморасчёт покажет, кто кому и сколько должен.',
'budget.netBalances': 'Чистые балансы',
'budget.categoriesLabel': 'категорий',
"costs.you": "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.you": "Вы",
"costs.youShort": "В",
"costs.youLower": "вы",
"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": "Выберите хотя бы одного человека для разделения.",
"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
@@ -28,6 +28,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':
'Аэропорт не удалось определить автоматически — подтвердите местоположение.',
+72 -72
View File
@@ -39,78 +39,78 @@ const budget: TranslationStrings = {
'Bir bütçe kalemindeki üye avatarına tıklayarak yeşil işaretleyin — bu ödedikleri anlamına gelir. Hesaplaşma kimin kime ne kadar borçlu olduğunu gösterir.',
'budget.netBalances': 'Net Bakiyeler',
'budget.categoriesLabel': 'kategoriler',
"costs.you": "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.you": "Siz",
"costs.youShort": "S",
"costs.youLower": "siz",
"costs.youOwe": "Borcunuz",
"costs.youOweSub": "Başkalarına ödemeniz gerekiyor",
"costs.youreOwed": "Size borçlu",
"costs.youreOwedSub": "Başkaları size ödemeli",
"costs.totalSpend": "Toplam seyahat harcaması",
"costs.totalSpendSub": "Tüm yolcular genelinde",
"costs.to": "Alıcı",
"costs.from": "Gönderen",
"costs.allSettled": "Tüm hesaplar kapandı",
"costs.nothingOwed": "Size borç yok",
"costs.yourShare": "Sizin payınız",
"costs.youPaid": "Siz ödediniz",
"costs.expenses": "Harcamalar",
"costs.entries": "{count} kayıt",
"costs.searchPlaceholder": "Harcamalarda ara…",
"costs.filter.all": "Tümü",
"costs.filter.mine": "Benim ödediklerim",
"costs.filter.owed": "Bana borçlu",
"costs.addExpense": "Harcama ekle",
"costs.editExpense": "Harcamayı düzenle",
"costs.noMatch": "Aramanızla eşleşen harcama yok.",
"costs.emptyText": "Henüz harcama yok. İlkini ekleyin.",
"costs.spent": "{amount} harcandı",
"costs.noDate": "Tarih yok",
"costs.noOnePaid": "Henüz kimse ödemedi",
"costs.youLent": "{amount} verdiniz",
"costs.youBorrowed": "{amount} aldınız",
"costs.settleUp": "Hesaplaş",
"costs.history": "Geçmiş",
"costs.everyoneSquare": "Herkesin hesabı kapalı",
"costs.nothingOutstanding": "Şu anda bekleyen ödeme yok.",
"costs.pay": "öde",
"costs.pays": "ödüyor",
"costs.settle": "Hesaplaş",
"costs.balances": "Bakiyeler",
"costs.byCategory": "Kategoriye göre",
"costs.noCategories": "Henüz harcama yok.",
"costs.settleHistory": "Hesaplaşma geçmişi",
"costs.noSettlements": "Henüz kapatılmış ödeme yok.",
"costs.paymentsSettled": "{count} ödeme kapatıldı",
"costs.paid": "ödendi",
"costs.undo": "Geri al",
"costs.whatFor": "Ne içindi?",
"costs.namePlaceholder": "örn. Akşam yemeği, hediyelik, benzin…",
"costs.totalAmount": "Toplam tutar",
"costs.currency": "Para birimi",
"costs.day": "Gün",
"costs.rateLabel": "{to} cinsinden 1 {from}",
"costs.category": "Kategori",
"costs.whoPaid": "Kim ödedi?",
"costs.splitBetween": "Eşit olarak böl",
"costs.pickSomeone": "Paylaşmak için en az bir kişi seçin.",
"costs.splitSummary": "{count} kişiye bölündü · her biri {amount}",
"costs.cat.accommodation": "Konaklama",
"costs.cat.food": "Yiyecek & içecek",
"costs.cat.groceries": "Market alışverişi",
"costs.cat.transport": "Ulaşım",
"costs.cat.flights": "Uçuşlar",
"costs.cat.activities": "Etkinlikler",
"costs.cat.sightseeing": "Gezi & turlar",
"costs.cat.shopping": "Alışveriş",
"costs.cat.fees": "Ücretler & biletler",
"costs.cat.health": "Sağlık",
"costs.cat.tips": "Bahşişler",
"costs.cat.other": "Diğer",
"costs.daysCount": "{count} gün",
"costs.travelers": "{count} yolcu",
"costs.liveRate": "anlık kur",
"costs.settleAll": "Tümünü hesaplaş",
};
export default budget;
+5
View File
@@ -28,6 +28,11 @@ const reservations: TranslationStrings = {
'reservations.meta.flightNumber': 'Uçuş No.',
'reservations.meta.from': 'İtibaren',
'reservations.meta.to': 'İle',
'reservations.layover.route': 'Rota',
'reservations.layover.stop': 'Durak',
'reservations.layover.addStop': 'Durak ekle',
'reservations.layover.connection': 'Aktarma',
'reservations.layover.layover': 'Aktarma bekleme süresi',
'reservations.needsReview': 'Gözden geçirmek',
'reservations.needsReviewHint':
'Havaalanı otomatik olarak eşleştirilemedi; lütfen konumu onaylayın.',
+72 -72
View File
@@ -39,78 +39,78 @@ const budget: TranslationStrings = {
'Натисніть на аватар учасника в рядку бюджету, щоб відзначити його зеленим — це означає, що він заплатив. Взаєморозрахунок покаже, хто кому і скільки винен.',
'budget.netBalances': 'Чисті баланси',
'budget.categoriesLabel': 'категорії',
"costs.you": "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.you": "Ви",
"costs.youShort": "Ви",
"costs.youLower": "ви",
"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": "Виберіть хоча б одну особу для розподілу.",
"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':
'Аеропорт не вдалося визначити автоматично — підтвердіть місцезнаходження.',
+71 -71
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.youLower": "",
"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": "至少選擇一人來分攤。",
"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': '無法自動匹配機場 — 請確認位置。',
'reservations.searchLocation': '搜尋車站、港口、地址...',
+72 -72
View File
@@ -38,78 +38,78 @@ const budget: TranslationStrings = {
'点击预算项目上的成员头像将其标记为绿色——表示该成员已付款。结算会显示谁欠谁多少。',
'budget.netBalances': '净余额',
'budget.categoriesLabel': '类别',
"costs.you": "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.you": "",
"costs.youShort": "",
"costs.youLower": "",
"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": "至少选择一人参与分摊。",
"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': '无法自动匹配机场 — 请确认位置。',
'reservations.searchLocation': '搜索车站、港口、地址...',