mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 16:03:02 +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'
|
||||
|
||||
Reference in New Issue
Block a user