mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 21:46:49 +00:00
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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,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'
|
||||
|
||||
@@ -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': 'حذف الحجز؟',
|
||||
|
||||
@@ -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?',
|
||||
|
||||
@@ -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?',
|
||||
|
||||
@@ -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?',
|
||||
|
||||
@@ -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?',
|
||||
|
||||
@@ -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?',
|
||||
|
||||
@@ -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 ?',
|
||||
|
||||
@@ -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': 'Διαγραφή κράτησης;',
|
||||
|
||||
@@ -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?',
|
||||
|
||||
@@ -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?',
|
||||
|
||||
@@ -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?',
|
||||
|
||||
@@ -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': '予約を削除しますか?',
|
||||
|
||||
@@ -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': '예약을 삭제할까요?',
|
||||
|
||||
@@ -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?',
|
||||
|
||||
@@ -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ę?',
|
||||
|
||||
@@ -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': 'Удалить бронирование?',
|
||||
|
||||
@@ -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?',
|
||||
|
||||
@@ -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?',
|
||||
|
||||
@@ -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': 'Видалити бронювання?',
|
||||
|
||||
@@ -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ỗ?',
|
||||
|
||||
@@ -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': '刪除預訂?',
|
||||
|
||||
@@ -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': '删除预订?',
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user