From 3c87d37cd9bbce73d4fc5d3851e02e37752598b5 Mon Sep 17 00:00:00 2001 From: Maurice Date: Thu, 2 Jul 2026 18:08:28 +0200 Subject: [PATCH] feat(planner): make transit journeys first-class entries (#1065) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../components/Files/FileManager.constants.ts | 2 +- .../src/components/Map/ReservationOverlay.tsx | 7 ++-- .../src/components/Map/reservationsMapbox.ts | 7 ++-- .../src/components/Planner/DayDetailPanel.tsx | 6 +-- .../Planner/DayPlanSidebar.constants.ts | 6 +-- .../Planner/DayPlanSidebar.test.tsx | 30 ++++++++++++++ .../src/components/Planner/DayPlanSidebar.tsx | 40 +++++++++++++++++-- .../DayPlanSidebarTransportDetailModal.tsx | 36 ++++++++++++++--- .../components/Planner/ReservationsPanel.tsx | 2 +- .../Planner/TransitSearchModal.test.tsx | 2 +- .../components/Planner/TransitSearchModal.tsx | 19 ++------- .../src/components/Planner/TransportModal.tsx | 5 ++- .../components/Planner/parsedItemToDraft.ts | 2 +- client/src/hooks/useRouteCalculation.ts | 2 +- client/src/utils/dayMerge.ts | 2 +- shared/src/i18n/ar/reservations.ts | 1 + shared/src/i18n/br/reservations.ts | 1 + shared/src/i18n/cs/reservations.ts | 1 + shared/src/i18n/de/reservations.ts | 1 + shared/src/i18n/en/reservations.ts | 1 + shared/src/i18n/es/reservations.ts | 1 + shared/src/i18n/fr/reservations.ts | 1 + shared/src/i18n/gr/reservations.ts | 1 + shared/src/i18n/hu/reservations.ts | 1 + shared/src/i18n/id/reservations.ts | 1 + shared/src/i18n/it/reservations.ts | 1 + shared/src/i18n/ja/reservations.ts | 1 + shared/src/i18n/ko/reservations.ts | 1 + shared/src/i18n/nl/reservations.ts | 1 + shared/src/i18n/pl/reservations.ts | 1 + shared/src/i18n/ru/reservations.ts | 1 + shared/src/i18n/sv/reservations.ts | 1 + shared/src/i18n/tr/reservations.ts | 1 + shared/src/i18n/uk/reservations.ts | 1 + shared/src/i18n/vi/reservations.ts | 1 + shared/src/i18n/zh-TW/reservations.ts | 1 + shared/src/i18n/zh/reservations.ts | 1 + wiki/Transport-Flights-Trains-Cars.md | 2 +- 38 files changed, 148 insertions(+), 44 deletions(-) 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 && ( + + )}