feat(planner): make transit journeys first-class entries (#1065)

A saved transit route is now its own reservation type instead of piggybacking
on train/bus: it gets a tram icon and its own color everywhere, and the day
timeline renders the itinerary inline — line badges in their official colors
with walk segments, plus the transfer count and walking time — instead of a
generic transport row.

Clicking the row opens the itinerary view (journey summary, stop-by-stop legs
with times, platforms, headsigns and operators) rather than the edit form;
editing stays reachable from an Edit action inside that view. The transit
type is registered across the timeline merge, transport modal, reservations
panel, file manager, map overlays and detail panels, with a translated type
label in every locale.
This commit is contained in:
Maurice
2026-07-02 18:08:28 +02:00
committed by Maurice
parent f2304e076b
commit 3c87d37cd9
38 changed files with 148 additions and 44 deletions
@@ -1 +1 @@
export const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transport_other'])
export const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transit', 'transport_other'])
@@ -2,7 +2,7 @@ import { createElement, useEffect, useMemo, useRef, useState } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { Marker, Polyline, Tooltip, useMap, useMapEvents } from 'react-leaflet'
import L from 'leaflet'
import { Plane, Train, Ship, Car, Bus, Sailboat, Bike, CarTaxiFront, Route } from 'lucide-react'
import { Plane, Train, Ship, Car, Bus, Sailboat, Bike, CarTaxiFront, Route, TramFront } from 'lucide-react'
import { escapeHtml } from '@trek/shared'
import { useSettingsStore } from '../../store/settingsStore'
import type { Reservation, ReservationEndpoint } from '../../types'
@@ -11,8 +11,8 @@ const ENDPOINT_PANE = 'reservation-endpoints'
const AIRPORT_BADGE_HALF_PX = 16
const BADGE_GAP_PX = 5
type TransportType = 'flight' | 'train' | 'cruise' | 'car' | 'bus' | 'taxi' | 'bicycle' | 'ferry' | 'transport_other'
const TRANSPORT_TYPES: TransportType[] = ['flight', 'train', 'cruise', 'car', 'bus', 'taxi', 'bicycle', 'ferry', 'transport_other']
type TransportType = 'flight' | 'train' | 'cruise' | 'car' | 'bus' | 'taxi' | 'bicycle' | 'ferry' | 'transit' | 'transport_other'
const TRANSPORT_TYPES: TransportType[] = ['flight', 'train', 'cruise', 'car', 'bus', 'taxi', 'bicycle', 'ferry', 'transit', 'transport_other']
const TRANSPORT_COLOR = '#3b82f6'
@@ -25,6 +25,7 @@ const TYPE_META: Record<TransportType, { color: string; icon: typeof Plane; geod
taxi: { color: TRANSPORT_COLOR, icon: CarTaxiFront, geodesic: false },
bicycle: { color: TRANSPORT_COLOR, icon: Bike, geodesic: false },
ferry: { color: TRANSPORT_COLOR, icon: Sailboat, geodesic: true },
transit: { color: TRANSPORT_COLOR, icon: TramFront, geodesic: false },
transport_other: { color: TRANSPORT_COLOR, icon: Route, geodesic: false },
}
@@ -9,15 +9,15 @@
import { createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import type mapboxgl from 'mapbox-gl'
import { Plane, Train, Ship, Car, Bus, Sailboat, Bike, CarTaxiFront, Route } from 'lucide-react'
import { Plane, Train, Ship, Car, Bus, Sailboat, Bike, CarTaxiFront, Route, TramFront } from 'lucide-react'
import { escapeHtml } from '@trek/shared'
import type { Reservation, ReservationEndpoint } from '../../types'
export const RESERVATION_SOURCE_ID = 'trek-reservations'
export const RESERVATION_LINE_LAYER_ID = 'trek-reservations-lines'
type TransportType = 'flight' | 'train' | 'cruise' | 'car' | 'bus' | 'taxi' | 'bicycle' | 'ferry' | 'transport_other'
const TRANSPORT_TYPES: TransportType[] = ['flight', 'train', 'cruise', 'car', 'bus', 'taxi', 'bicycle', 'ferry', 'transport_other']
type TransportType = 'flight' | 'train' | 'cruise' | 'car' | 'bus' | 'taxi' | 'bicycle' | 'ferry' | 'transit' | 'transport_other'
const TRANSPORT_TYPES: TransportType[] = ['flight', 'train', 'cruise', 'car', 'bus', 'taxi', 'bicycle', 'ferry', 'transit', 'transport_other']
const TRANSPORT_COLOR = '#3b82f6'
const TYPE_META: Record<TransportType, { icon: typeof Plane; geodesic: boolean }> = {
@@ -29,6 +29,7 @@ const TYPE_META: Record<TransportType, { icon: typeof Plane; geodesic: boolean }
taxi: { icon: CarTaxiFront, geodesic: false },
bicycle: { icon: Bike, geodesic: false },
ferry: { icon: Sailboat, geodesic: true },
transit: { icon: TramFront, geodesic: false },
transport_other: { icon: Route, geodesic: false },
}
@@ -1,9 +1,9 @@
import React, { useState, useEffect, useRef } from 'react'
import ReactDOM from 'react-dom'
import { X, Sun, Cloud, CloudRain, CloudSnow, CloudDrizzle, CloudLightning, Wind, Droplets, Sunrise, Sunset, Hotel, Calendar, MapPin, LogIn, LogOut, Hash, Pencil, Plane, Utensils, Train, Car, Ship, Ticket, FileText, Users, ChevronsDown, ChevronsUp } from 'lucide-react'
import { X, Sun, Cloud, CloudRain, CloudSnow, CloudDrizzle, CloudLightning, Wind, Droplets, Sunrise, Sunset, Hotel, Calendar, MapPin, LogIn, LogOut, Hash, Pencil, Plane, Utensils, Train, Car, Ship, Ticket, FileText, Users, ChevronsDown, ChevronsUp, TramFront } from 'lucide-react'
const RES_TYPE_ICONS = { flight: Plane, hotel: Hotel, restaurant: Utensils, train: Train, car: Car, cruise: Ship, event: Ticket, tour: Users, other: FileText }
const RES_TYPE_COLORS = { flight: '#3b82f6', hotel: '#8b5cf6', restaurant: '#ef4444', train: '#06b6d4', car: '#6b7280', cruise: '#0ea5e9', event: '#f59e0b', tour: '#10b981', other: '#6b7280' }
const RES_TYPE_ICONS = { flight: Plane, hotel: Hotel, restaurant: Utensils, train: Train, car: Car, cruise: Ship, transit: TramFront, event: Ticket, tour: Users, other: FileText }
const RES_TYPE_COLORS = { flight: '#3b82f6', hotel: '#8b5cf6', restaurant: '#ef4444', train: '#06b6d4', car: '#6b7280', cruise: '#0ea5e9', transit: '#7c3aed', event: '#f59e0b', tour: '#10b981', other: '#6b7280' }
import { weatherApi, accommodationsApi } from '../../api/client'
import { useCanDo } from '../../store/permissionsStore'
import { useTripStore } from '../../store/tripStore'
@@ -1,11 +1,11 @@
import {
FileText, Info, Clock, MapPin, Navigation, Train, Plane, Bus, Car, Ship,
Coffee, Ticket, Star, Heart, Camera, Flag, Lightbulb, AlertTriangle,
ShoppingBag, Bookmark, Hotel, Utensils, Users, Sailboat, Bike, CarTaxiFront, Route,
ShoppingBag, Bookmark, Hotel, Utensils, Users, Sailboat, Bike, CarTaxiFront, Route, TramFront,
Wine, ParkingSquare, Fuel, Footprints, Mountain, Waves, Sun, Umbrella, Music, Landmark, Gift,
} from 'lucide-react'
export const RES_ICONS = { flight: Plane, hotel: Hotel, restaurant: Utensils, train: Train, car: Car, cruise: Ship, bus: Bus, ferry: Sailboat, bicycle: Bike, taxi: CarTaxiFront, transport_other: Route, event: Ticket, tour: Users, other: FileText }
export const RES_ICONS = { flight: Plane, hotel: Hotel, restaurant: Utensils, train: Train, car: Car, cruise: Ship, bus: Bus, ferry: Sailboat, bicycle: Bike, taxi: CarTaxiFront, transit: TramFront, transport_other: Route, event: Ticket, tour: Users, other: FileText }
export const NOTE_ICONS = [
{ id: 'FileText', Icon: FileText },
@@ -50,4 +50,4 @@ export const TYPE_ICONS = {
transport_other: '🧭', event: '🎫', other: '📋',
}
export const TRANSPORT_DETAIL_COLORS = { flight: '#3b82f6', train: '#06b6d4', bus: '#059669', ferry: '#0d9488', bicycle: '#84cc16', taxi: '#ca8a04', car: '#6b7280', cruise: '#0ea5e9', transport_other: '#6b7280' }
export const TRANSPORT_DETAIL_COLORS = { flight: '#3b82f6', train: '#06b6d4', bus: '#059669', ferry: '#0d9488', bicycle: '#84cc16', taxi: '#ca8a04', car: '#6b7280', cruise: '#0ea5e9', transit: '#7c3aed', transport_other: '#6b7280' }
@@ -321,6 +321,36 @@ describe('DayPlanSidebar', () => {
expect(screen.queryByLabelText('Edit')).not.toBeInTheDocument()
})
it('FE-PLANNER-DAYPLAN-104: a transit journey renders line chips and opens its itinerary view, not the edit form (#1065)', async () => {
const user = userEvent.setup()
const onEditTransport = vi.fn()
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
const res = {
...buildReservation({
id: 300, type: 'transit', title: 'Fernsehturm → Zoo',
reservation_time: '2025-06-01T08:30:00', day_id: 10,
}),
metadata: {
transit: {
provider: 'transitous', duration: 1800, transfers: 1, walk_seconds: 240,
legs: [
{ mode: 'WALK', duration: 240, from: { name: 'Start' }, to: { name: 'Alexanderplatz' } },
{ mode: 'SUBWAY', line: 'U2', line_color: '#FF3300', line_text_color: '#FFFFFF', headsign: 'Ruhleben', duration: 1440, stops: 6, from: { name: 'Alexanderplatz', time: '08:36' }, to: { name: 'Zoo', time: '09:00' } },
],
},
},
}
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], reservations: [res as any], onEditTransport })} />)
// Line chip + transfer summary render inline in the timeline row.
expect(screen.getByText('U2')).toBeInTheDocument()
expect(screen.getByText(/1 transfers/)).toBeInTheDocument()
// Clicking the row opens the itinerary detail view — not the edit form.
await user.click(screen.getByText('Fernsehturm → Zoo'))
expect(onEditTransport).not.toHaveBeenCalled()
expect(await screen.findByText('Itinerary')).toBeInTheDocument()
expect(screen.getByText(/Ruhleben/)).toBeInTheDocument()
})
// ── Day info button ─────────────────────────────────────────────────────
it('FE-PLANNER-DAYPLAN-018: clicking day header calls onDayDetail', async () => {
@@ -1955,6 +1955,11 @@ 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(' · ')
}
// A transit journey (#1065) renders its itinerary inline —
// line badges in their colors instead of a plain subtitle,
// so the connection is recognisable at a glance.
const transitMeta = res.type === 'transit' && meta.transit && Array.isArray(meta.transit.legs) ? meta.transit : null
// 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)
@@ -1965,8 +1970,12 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
<React.Fragment key={`transport-${res.id}-${legKey}-${day.id}`}>
<div
onClick={() => {
if (!canEditDays) return
const target = reservations.find(x => x.id === res.id) ?? res
// A transit journey opens its itinerary view — the rich
// stop-by-stop breakdown, not the generic edit form (#1065).
// Editing stays reachable from inside that view.
if (transitMeta) { setTransportDetail(target); return }
if (!canEditDays) return
if (TRANSPORT_TYPES.has(res.type)) onEditTransport?.(target)
else onEditReservation?.(target)
}}
@@ -2024,7 +2033,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
borderTop: showDropLine ? '2px solid var(--text-primary)' : undefined,
borderBottom: showDropLineAfter ? '2px solid var(--text-primary)' : undefined,
background: `${color}08`,
cursor: canEditDays && onEditTransport ? 'pointer' : 'default', userSelect: 'none',
cursor: (transitMeta || (canEditDays && onEditTransport)) ? 'pointer' : 'default', userSelect: 'none',
transition: 'background 0.1s',
opacity: draggingId === res.id ? 0.4 : spanPhase === 'middle' ? 0.65 : 1,
}}
@@ -2068,7 +2077,31 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
)
})()}
</div>
{subtitle && (
{transitMeta ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginTop: 3, flexWrap: 'wrap' }}>
{transitMeta.legs.map((leg: { mode?: string; line?: string | null; line_color?: string | null; line_text_color?: string | null }, li: number) => (
<React.Fragment key={li}>
{li > 0 && <span style={{ fontSize: 9, color: 'var(--text-faint)' }}>›</span>}
{leg.mode === 'WALK'
? <Footprints size={10} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
: (
<span style={{
display: 'inline-flex', alignItems: 'center', borderRadius: 4, padding: '0 5px',
fontSize: 'calc(9.5px * var(--fs-scale-caption, 1))', fontWeight: 700, lineHeight: '15px',
background: leg.line_color || 'var(--bg-tertiary)',
color: leg.line_color ? (leg.line_text_color || '#fff') : 'var(--text-primary)',
}}>
{leg.line || leg.mode}
</span>
)}
</React.Fragment>
))}
<span style={{ fontSize: 'calc(9.5px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', marginLeft: 2 }}>
{transitMeta.transfers > 0 ? t('transit.transfers', { count: transitMeta.transfers }) : t('transit.direct')}
{transitMeta.walk_seconds > 59 && <> · <Footprints size={9} style={{ display: 'inline', verticalAlign: '-1px' }} /> {t('transit.min', { count: Math.round(transitMeta.walk_seconds / 60) })}</>}
</span>
</div>
) : subtitle && (
<div style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', marginTop: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{subtitle}
</div>
@@ -2400,6 +2433,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
transportDetail={transportDetail}
setTransportDetail={setTransportDetail}
onNavigateToFiles={onNavigateToFiles}
onEdit={canEditDays && onEditTransport ? (res) => { setTransportDetail(null); onEditTransport(res) } : undefined}
t={t}
locale={locale}
timeFormat={timeFormat}
@@ -1,5 +1,5 @@
import ReactDOM from 'react-dom'
import { Ticket, FileText, ExternalLink, Footprints, ArrowRight } from 'lucide-react'
import { Ticket, FileText, ExternalLink, Footprints, ArrowRight, Pencil } from 'lucide-react'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkBreaks from 'remark-breaks'
@@ -13,13 +13,15 @@ interface DayPlanSidebarTransportDetailModalProps {
transportDetail: Reservation | null
setTransportDetail: (v: Reservation | null) => void
onNavigateToFiles?: () => void
/** Opens the edit form for this reservation (shown as a footer action). */
onEdit?: (res: Reservation) => void
t: (key: string, params?: Record<string, any>) => string
locale: string
timeFormat: string
}
export function DayPlanSidebarTransportDetailModal({
transportDetail, setTransportDetail, onNavigateToFiles, t, locale, timeFormat,
transportDetail, setTransportDetail, onNavigateToFiles, onEdit, t, locale, timeFormat,
}: DayPlanSidebarTransportDetailModalProps) {
if (!transportDetail) return null
return ReactDOM.createPortal(
@@ -119,10 +121,24 @@ export function DayPlanSidebarTransportDetailModal({
{/* Public-transit itinerary (#1065) — legs from the transit search */}
{meta.transit?.legs && Array.isArray(meta.transit.legs) && meta.transit.legs.length > 0 && (
<>
{/* journey summary: duration · transfers · walking */}
<div className="bg-surface-tertiary text-content" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 10, padding: '9px 12px', borderRadius: 8, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, flexWrap: 'wrap' }}>
{meta.transit.duration > 0 && (
<span>{Math.floor(meta.transit.duration / 3600) > 0 ? `${Math.floor(meta.transit.duration / 3600)} h ${Math.round((meta.transit.duration % 3600) / 60)} min` : t('transit.min', { count: Math.round(meta.transit.duration / 60) })}</span>
)}
<span className="text-content-faint">·</span>
<span>{meta.transit.transfers > 0 ? t('transit.transfers', { count: meta.transit.transfers }) : t('transit.direct')}</span>
{meta.transit.walk_seconds > 59 && (
<>
<span className="text-content-faint">·</span>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}><Footprints size={13} /> {t('transit.min', { count: Math.round(meta.transit.walk_seconds / 60) })}</span>
</>
)}
</div>
<div className="bg-surface-tertiary" style={{ padding: '10px 12px', borderRadius: 8 }}>
<div className="text-content-faint" style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.03em', marginBottom: 8 }}>
{t('transit.itinerary')}
{typeof meta.transit.transfers === 'number' && meta.transit.transfers > 0 && ` · ${t('transit.transfers', { count: meta.transit.transfers })}`}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{meta.transit.legs.map((leg: { mode?: string; line?: string | null; line_color?: string | null; line_text_color?: string | null; headsign?: string | null; duration?: number; stops?: number; from?: { name?: string; time?: string | null }; to?: { name?: string; time?: string | null } }, i: number) => {
@@ -163,6 +179,7 @@ export function DayPlanSidebarTransportDetailModal({
})}
</div>
</div>
</>
)}
{/* Notizen */}
@@ -210,8 +227,17 @@ export function DayPlanSidebarTransportDetailModal({
)
})()}
{/* Schließen */}
<div style={{ textAlign: 'right' }}>
{/* Aktionen */}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
{onEdit && (
<button onClick={() => onEdit(res)} className="bg-surface-tertiary text-content" style={{
fontSize: 'calc(12px * var(--fs-scale-body, 1))',
border: 'none', borderRadius: 8, padding: '6px 14px', cursor: 'pointer', fontWeight: 600, fontFamily: 'inherit',
display: 'inline-flex', alignItems: 'center', gap: 5,
}}>
<Pencil size={12} /> {t('common.edit')}
</button>
)}
<button onClick={() => setTransportDetail(null)} className="bg-accent text-accent-text" style={{
fontSize: 'calc(12px * var(--fs-scale-body, 1))',
border: 'none', borderRadius: 8, padding: '6px 16px', cursor: 'pointer', fontWeight: 600, fontFamily: 'inherit',
@@ -109,7 +109,7 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
const hasCode = !!r.confirmation_number
const dateCols = [hasDate, hasTime, hasCode].filter(Boolean).length
const TRANSPORT_TYPES_SET = new Set(['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transport_other'])
const TRANSPORT_TYPES_SET = new Set(['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transit', 'transport_other'])
const isTransportType = TRANSPORT_TYPES_SET.has(r.type)
const isHotel = r.type === 'hotel'
// For a hotel linked to an accommodation, the accommodation's own start/end days are
@@ -98,7 +98,7 @@ describe('TransitSearchModal', () => {
await waitFor(() => expect(onAdd).toHaveBeenCalled())
const payload = onAdd.mock.calls[0][0]
expect(payload.type).toBe('train') // dominant leg = SUBWAY → train
expect(payload.type).toBe('transit') // first-class transit type (#1065)
expect(payload.title).toBe('Fernsehturm → Zoologischer Garten')
expect(payload.day_id).toBe(10)
expect(payload.reservation_time).toBe('2025-06-01T08:30')
@@ -56,20 +56,6 @@ function legIcon(mode: string) {
return Train
}
/** TREK reservation type for the itinerary's dominant (longest) transit leg. */
function dominantType(legs: TransitLeg[]): 'train' | 'bus' | 'ferry' | 'transport_other' {
let best: TransitLeg | null = null
for (const l of legs) {
if (l.mode === 'WALK') continue
if (!best || l.duration > best.duration) best = l
}
if (!best) return 'transport_other'
if (best.mode === 'BUS' || best.mode === 'COACH') return 'bus'
if (best.mode === 'FERRY') return 'ferry'
if (best.mode === 'FUNICULAR' || best.mode === 'AERIAL_LIFT') return 'transport_other'
return 'train'
}
function tzAt(lat: number, lng: number): string {
try { return tzlookup(lat, lng) } catch { return 'UTC' }
}
@@ -419,7 +405,10 @@ export default function TransitSearchModal({ isOpen, onClose, day, days, places,
const payload = {
title: `${from.name} → ${to.name}`,
type: dominantType(it.legs),
// Its own first-class type: transit journeys get their own icon, their
// rich timeline row and the itinerary detail view instead of the
// generic transport rendering.
type: 'transit',
status: 'confirmed',
day_id: day.id,
end_day_id: endDay ? endDay.id : day.id,
@@ -1,6 +1,6 @@
import { useState, useEffect, useRef } from 'react'
import { useParams } from 'react-router-dom'
import { Plane, Train, Car, Ship, Bus, Sailboat, Bike, CarTaxiFront, Route, Paperclip, FileText, X, ExternalLink, Link2, Plus, Trash2 } from 'lucide-react'
import { Plane, Train, Car, Ship, Bus, Sailboat, Bike, CarTaxiFront, Route, TramFront, 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'
@@ -20,7 +20,7 @@ import type { BookingExpenseRequest } from './BookingCostsSection.types'
import type { BookingReviewDraft } from './parsedItemToDraft'
import { typeToCostCategory } from '@trek/shared'
const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transport_other'] as const
const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transit', 'transport_other'] as const
type TransportType = typeof TRANSPORT_TYPES[number]
interface EndpointPick {
@@ -96,6 +96,7 @@ const TYPE_OPTIONS = [
{ value: 'bicycle', labelKey: 'reservations.type.bicycle', Icon: Bike },
{ value: 'cruise', labelKey: 'reservations.type.cruise', Icon: Ship },
{ value: 'ferry', labelKey: 'reservations.type.ferry', Icon: Sailboat },
{ value: 'transit', labelKey: 'reservations.type.transit', Icon: TramFront },
{ value: 'transport_other', labelKey: 'reservations.type.transport_other', Icon: Route },
]
@@ -43,7 +43,7 @@ export function parsedItemToDraft(item: BookingImportPreviewItem): BookingReview
}
/** Transport types route to the TransportModal; everything else to the ReservationModal. */
const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transport_other'])
const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transit', 'transport_other'])
export function isTransportItem(item: BookingImportPreviewItem): boolean {
return TRANSPORT_TYPES.has(item.type)
}
+1 -1
View File
@@ -7,7 +7,7 @@ import { getDayBookendHotels } from '../utils/dayOrder'
import type { TripStoreState } from '../store/tripStore'
import type { RouteSegment, RouteResult, Accommodation } from '../types'
const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transport_other']
const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transit', 'transport_other']
const NO_ACCOMMODATIONS: Accommodation[] = []
+1 -1
View File
@@ -1,4 +1,4 @@
export const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transport_other'])
export const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transit', 'transport_other'])
export interface MergedItem {
type: 'place' | 'note' | 'transport'
+1
View File
@@ -63,6 +63,7 @@ const reservations: TranslationStrings = {
'reservations.type.ferry': 'عبّارة',
'reservations.type.bicycle': 'دراجة',
'reservations.type.taxi': 'سيارة أجرة',
'reservations.type.transit': 'النقل العام',
'reservations.type.transport_other': 'أخرى',
'reservations.confirm.delete': 'هل تريد حذف الحجز "{name}"؟',
'reservations.confirm.deleteTitle': 'حذف الحجز؟',
+1
View File
@@ -63,6 +63,7 @@ const reservations: TranslationStrings = {
'reservations.type.ferry': 'Balsa',
'reservations.type.bicycle': 'Bicicleta',
'reservations.type.taxi': 'Táxi',
'reservations.type.transit': 'Transporte público',
'reservations.type.transport_other': 'Outro',
'reservations.confirm.delete': 'Tem certeza de que deseja excluir a reserva "{name}"?',
'reservations.confirm.deleteTitle': 'Excluir reserva?',
+1
View File
@@ -63,6 +63,7 @@ const reservations: TranslationStrings = {
'reservations.type.ferry': 'Trajekt',
'reservations.type.bicycle': 'Kolo',
'reservations.type.taxi': 'Taxi',
'reservations.type.transit': 'Veřejná doprava',
'reservations.type.transport_other': 'Ostatní',
'reservations.confirm.delete': 'Opravdu chcete smazat rezervaci „{name}”?',
'reservations.confirm.deleteTitle': 'Smazat rezervaci?',
+1
View File
@@ -66,6 +66,7 @@ const reservations: TranslationStrings = {
'reservations.type.ferry': 'Fähre',
'reservations.type.bicycle': 'Fahrrad',
'reservations.type.taxi': 'Taxi',
'reservations.type.transit': 'ÖPNV',
'reservations.type.transport_other': 'Sonstiges',
'reservations.confirm.delete': 'Möchtest du die Reservierung "{name}" wirklich löschen?',
'reservations.confirm.deleteTitle': 'Buchung löschen?',
+1
View File
@@ -65,6 +65,7 @@ const reservations: TranslationStrings = {
'reservations.type.ferry': 'Ferry',
'reservations.type.bicycle': 'Bicycle',
'reservations.type.taxi': 'Taxi',
'reservations.type.transit': 'Public transit',
'reservations.type.transport_other': 'Other',
'reservations.confirm.delete': 'Are you sure you want to delete the reservation "{name}"?',
'reservations.confirm.deleteTitle': 'Delete booking?',
+1
View File
@@ -36,6 +36,7 @@ const reservations: TranslationStrings = {
'reservations.type.ferry': 'Ferry',
'reservations.type.bicycle': 'Bicicleta',
'reservations.type.taxi': 'Taxi',
'reservations.type.transit': 'Transporte público',
'reservations.type.transport_other': 'Otro',
'reservations.confirm.delete': '¿Seguro que quieres eliminar la reserva "{name}"?',
'reservations.confirm.deleteTitle': '¿Eliminar reserva?',
+1
View File
@@ -65,6 +65,7 @@ const reservations: TranslationStrings = {
'reservations.type.ferry': 'Ferry',
'reservations.type.bicycle': 'Vélo',
'reservations.type.taxi': 'Taxi',
'reservations.type.transit': 'Transports en commun',
'reservations.type.transport_other': 'Autre',
'reservations.confirm.delete': 'Voulez-vous vraiment supprimer la réservation « {name} » ?',
'reservations.confirm.deleteTitle': 'Supprimer la réservation ?',
+1
View File
@@ -65,6 +65,7 @@ const reservations: TranslationStrings = {
'reservations.type.ferry': 'Φέρι',
'reservations.type.bicycle': 'Ποδήλατο',
'reservations.type.taxi': 'Ταξί',
'reservations.type.transit': 'Μέσα μαζικής μεταφοράς',
'reservations.type.transport_other': 'Άλλο',
'reservations.confirm.delete': 'Είστε σίγουροι ότι θέλετε να διαγράψετε την κράτηση "{name}";',
'reservations.confirm.deleteTitle': 'Διαγραφή κράτησης;',
+1
View File
@@ -65,6 +65,7 @@ const reservations: TranslationStrings = {
'reservations.type.ferry': 'Komp',
'reservations.type.bicycle': 'Kerékpár',
'reservations.type.taxi': 'Taxi',
'reservations.type.transit': 'Tömegközlekedés',
'reservations.type.transport_other': 'Egyéb',
'reservations.confirm.delete': 'Biztosan törölni szeretnéd a(z) "{name}" foglalást?',
'reservations.confirm.deleteTitle': 'Foglalás törlése?',
+1
View File
@@ -64,6 +64,7 @@ const reservations: TranslationStrings = {
'reservations.type.ferry': 'Feri',
'reservations.type.bicycle': 'Sepeda',
'reservations.type.taxi': 'Taksi',
'reservations.type.transit': 'Transportasi umum',
'reservations.type.transport_other': 'Lainnya',
'reservations.confirm.delete': 'Yakin ingin menghapus reservasi "{name}"?',
'reservations.confirm.deleteTitle': 'Hapus pemesanan?',
+1
View File
@@ -64,6 +64,7 @@ const reservations: TranslationStrings = {
'reservations.type.ferry': 'Traghetto',
'reservations.type.bicycle': 'Bicicletta',
'reservations.type.taxi': 'Taxi',
'reservations.type.transit': 'Trasporto pubblico',
'reservations.type.transport_other': 'Altro',
'reservations.confirm.delete': 'Sei sicuro di voler eliminare la prenotazione "{name}"?',
'reservations.confirm.deleteTitle': 'Eliminare la prenotazione?',
+1
View File
@@ -63,6 +63,7 @@ const reservations: TranslationStrings = {
'reservations.type.ferry': 'フェリー',
'reservations.type.bicycle': '自転車',
'reservations.type.taxi': 'タクシー',
'reservations.type.transit': '公共交通機関',
'reservations.type.transport_other': 'その他',
'reservations.confirm.delete': '予約「{name}」を削除しますか?',
'reservations.confirm.deleteTitle': '予約を削除しますか?',
+1
View File
@@ -63,6 +63,7 @@ const reservations: TranslationStrings = {
'reservations.type.ferry': '페리',
'reservations.type.bicycle': '자전거',
'reservations.type.taxi': '택시',
'reservations.type.transit': '대중교통',
'reservations.type.transport_other': '기타',
'reservations.confirm.delete': '예약 "{name}"을(를) 삭제할까요?',
'reservations.confirm.deleteTitle': '예약을 삭제할까요?',
+1
View File
@@ -64,6 +64,7 @@ const reservations: TranslationStrings = {
'reservations.type.ferry': 'Veerboot',
'reservations.type.bicycle': 'Fiets',
'reservations.type.taxi': 'Taxi',
'reservations.type.transit': 'Openbaar vervoer',
'reservations.type.transport_other': 'Overig',
'reservations.confirm.delete': 'Weet je zeker dat je de reservering "{name}" wilt verwijderen?',
'reservations.confirm.deleteTitle': 'Boeking verwijderen?',
+1
View File
@@ -64,6 +64,7 @@ const reservations: TranslationStrings = {
'reservations.type.ferry': 'Prom',
'reservations.type.bicycle': 'Rower',
'reservations.type.taxi': 'Taksówka',
'reservations.type.transit': 'Transport publiczny',
'reservations.type.transport_other': 'Inne',
'reservations.confirm.delete': 'Czy na pewno chcesz usunąć rezerwację "{name}"?',
'reservations.confirm.deleteTitle': 'Usunąć rezerwację?',
+1
View File
@@ -63,6 +63,7 @@ const reservations: TranslationStrings = {
'reservations.type.ferry': 'Паром',
'reservations.type.bicycle': 'Велосипед',
'reservations.type.taxi': 'Такси',
'reservations.type.transit': 'Общественный транспорт',
'reservations.type.transport_other': 'Другое',
'reservations.confirm.delete': 'Вы уверены, что хотите удалить бронирование «{name}»?',
'reservations.confirm.deleteTitle': 'Удалить бронирование?',
+1
View File
@@ -63,6 +63,7 @@ const reservations: TranslationStrings = {
'reservations.type.ferry': 'Färja',
'reservations.type.bicycle': 'Cyckel',
'reservations.type.taxi': 'Taxi',
'reservations.type.transit': 'Kollektivtrafik',
'reservations.type.transport_other': 'Annat',
'reservations.confirm.delete': 'Är du säker på att du vill ta bort bokningen "{name}"?',
'reservations.confirm.deleteTitle': 'Ta bort bokningen?',
+1
View File
@@ -64,6 +64,7 @@ const reservations: TranslationStrings = {
'reservations.type.ferry': 'Feribot',
'reservations.type.bicycle': 'Bisiklet',
'reservations.type.taxi': 'Taksi',
'reservations.type.transit': 'Toplu taşıma',
'reservations.type.transport_other': 'Diğer',
'reservations.confirm.delete': '"{name}" rezervasyonunu silmek istediğinizden emin misiniz?',
'reservations.confirm.deleteTitle': 'Rezervasyon silinsin mi?',
+1
View File
@@ -63,6 +63,7 @@ const reservations: TranslationStrings = {
'reservations.type.ferry': 'Пором',
'reservations.type.bicycle': 'Велосипед',
'reservations.type.taxi': 'Таксі',
'reservations.type.transit': 'Громадський транспорт',
'reservations.type.transport_other': 'Інше',
'reservations.confirm.delete': 'Ви впевнені, що хочете видалити бронювання «{name}»?',
'reservations.confirm.deleteTitle': 'Видалити бронювання?',
+1
View File
@@ -63,6 +63,7 @@ const reservations: TranslationStrings = {
'reservations.type.ferry': 'Phà',
'reservations.type.bicycle': 'Xe đạp',
'reservations.type.taxi': 'Taxi',
'reservations.type.transit': 'Giao thông công cộng',
'reservations.type.transport_other': 'Khác',
'reservations.confirm.delete': 'Bạn có chắc chắn muốn xóa đặt chỗ "{name}" không?',
'reservations.confirm.deleteTitle': 'Xóa đặt chỗ?',
+1
View File
@@ -63,6 +63,7 @@ const reservations: TranslationStrings = {
'reservations.type.ferry': '渡輪',
'reservations.type.bicycle': '自行車',
'reservations.type.taxi': '計程車',
'reservations.type.transit': '大眾運輸',
'reservations.type.transport_other': '其他',
'reservations.confirm.delete': '確定要刪除預訂「{name}」嗎?',
'reservations.confirm.deleteTitle': '刪除預訂?',
+1
View File
@@ -63,6 +63,7 @@ const reservations: TranslationStrings = {
'reservations.type.ferry': '渡轮',
'reservations.type.bicycle': '自行车',
'reservations.type.taxi': '出租车',
'reservations.type.transit': '公共交通',
'reservations.type.transport_other': '其他',
'reservations.confirm.delete': '确定要删除预订「{name}」吗?',
'reservations.confirm.deleteTitle': '删除预订?',
+1 -1
View File
@@ -13,7 +13,7 @@ Each day header in the plan sidebar has a **transit button** (tram icon) that op
- Pick **from** and **to** (stop/station search; the day's own places and hotels appear as quick picks), a **depart/arrive** time, and filter by mode: train, subway, tram, bus, ferry, cable car.
- Rank the results by **best route**, **fewer transfers**, or **less walking**.
- Each result shows local departure/arrival times, duration, transfers, walking time and the line badges in their official colors; expand it for the stop-by-stop breakdown.
- **Add to day** saves the chosen connection as a regular transport (typed by its dominant leg — train, bus, ferry …). It slots into the day timeline at its departure time and can be edited, deleted and drag-reordered like any other transport. The full itinerary stays visible in the transport's detail view; editing the transport keeps it as long as the origin and destination are unchanged.
- **Add to day** saves the chosen connection as a first-class **transit** entry. It slots into the day timeline at its departure time and shows its line badges, transfers and walking time right in the plan; clicking it opens the full stop-by-stop itinerary (with an Edit action for times/notes). It can be deleted and drag-reordered like any other transport, and editing keeps the stored itinerary as long as the origin and destination are unchanged.
Self-hosters can point the `TRANSIT_API_URL` environment variable at their own MOTIS instance.