diff --git a/client/src/components/Files/FileManager.constants.ts b/client/src/components/Files/FileManager.constants.ts index 266d17a8d..acc21981a 100644 --- a/client/src/components/Files/FileManager.constants.ts +++ b/client/src/components/Files/FileManager.constants.ts @@ -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']) diff --git a/client/src/components/Map/ReservationOverlay.tsx b/client/src/components/Map/ReservationOverlay.tsx index 295dd7c6f..f86300325 100644 --- a/client/src/components/Map/ReservationOverlay.tsx +++ b/client/src/components/Map/ReservationOverlay.tsx @@ -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 = { @@ -29,6 +29,7 @@ const TYPE_META: Record { 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() + // 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 () => { diff --git a/client/src/components/Planner/DayPlanSidebar.tsx b/client/src/components/Planner/DayPlanSidebar.tsx index ca87cda12..8183b1753 100644 --- a/client/src/components/Planner/DayPlanSidebar.tsx +++ b/client/src/components/Planner/DayPlanSidebar.tsx @@ -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
{ - 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 ) })()}
- {subtitle && ( + {transitMeta ? ( +
+ {transitMeta.legs.map((leg: { mode?: string; line?: string | null; line_color?: string | null; line_text_color?: string | null }, li: number) => ( + + {li > 0 && ›} + {leg.mode === 'WALK' + ? + : ( + + {leg.line || leg.mode} + + )} + + ))} + + {transitMeta.transfers > 0 ? t('transit.transfers', { count: transitMeta.transfers }) : t('transit.direct')} + {transitMeta.walk_seconds > 59 && <> · {t('transit.min', { count: Math.round(transitMeta.walk_seconds / 60) })}} + +
+ ) : subtitle && (
{subtitle}
@@ -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} diff --git a/client/src/components/Planner/DayPlanSidebarTransportDetailModal.tsx b/client/src/components/Planner/DayPlanSidebarTransportDetailModal.tsx index 406b28ad8..ff53b936b 100644 --- a/client/src/components/Planner/DayPlanSidebarTransportDetailModal.tsx +++ b/client/src/components/Planner/DayPlanSidebarTransportDetailModal.tsx @@ -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 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 */} +
+ {meta.transit.duration > 0 && ( + {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) })} + )} + · + {meta.transit.transfers > 0 ? t('transit.transfers', { count: meta.transit.transfers }) : t('transit.direct')} + {meta.transit.walk_seconds > 59 && ( + <> + · + {t('transit.min', { count: Math.round(meta.transit.walk_seconds / 60) })} + + )} +
{t('transit.itinerary')} - {typeof meta.transit.transfers === 'number' && meta.transit.transfers > 0 && ` · ${t('transit.transfers', { count: meta.transit.transfers })}`}
{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({ })}
+ )} {/* Notizen */} @@ -210,8 +227,17 @@ export function DayPlanSidebarTransportDetailModal({ ) })()} - {/* Schließen */} -
+ {/* Aktionen */} +
+ {onEdit && ( + + )}