From 06b94c14a5fab46c35eb3efa7178d129f66dd9d4 Mon Sep 17 00:00:00 2001 From: Maurice Date: Thu, 2 Jul 2026 21:41:31 +0200 Subject: [PATCH] feat(planner): polish the transit journey UI and fix its tab placement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Transit entries were classified as bookings by the planner's transport-type list and landed in the Bookings tab — they now sit in the Transports tab's own section, rendered as proper journey cards (tram icon, arrow title, leg chips, journey stats) instead of the generic booking card. The journey modal got a redesign: the title renames inline in the header with an icon arrow, the stats become three full-width tiles (duration / transfers / walking, each with an icon), status and booking-code fields are gone, and notes take the full width with a markdown write/preview toggle. The Automated search mode gains a proper header (icon, hint, day picker) and the day-plan row now shows walks with their minutes inside the chip sequence (🚶 3 › U2 › 🚶 3) instead of a detached direct/walk summary. "A → B" titles render with an arrow icon everywhere. The Transports tab's add button is simply "Transport" now that the dialog covers both modes. --- .../Planner/DayPlanSidebar.test.tsx | 13 +- .../src/components/Planner/DayPlanSidebar.tsx | 29 +-- .../components/Planner/ReservationsPanel.tsx | 116 ++++++++---- .../Planner/TransitJourneyModal.test.tsx | 23 ++- .../Planner/TransitJourneyModal.tsx | 171 ++++++++++-------- .../src/components/Planner/TransportModal.tsx | 19 +- .../src/components/Planner/transitDisplay.tsx | 79 ++++++++ .../src/pages/tripPlanner/useTripPlanner.ts | 2 +- shared/src/i18n/ar/common.ts | 1 + shared/src/i18n/ar/transport.ts | 2 +- shared/src/i18n/ar/trip.ts | 4 + shared/src/i18n/br/common.ts | 1 + shared/src/i18n/br/transport.ts | 2 +- shared/src/i18n/br/trip.ts | 4 + shared/src/i18n/cs/common.ts | 1 + shared/src/i18n/cs/transport.ts | 2 +- shared/src/i18n/cs/trip.ts | 4 + shared/src/i18n/de/common.ts | 1 + shared/src/i18n/de/transport.ts | 2 +- shared/src/i18n/de/trip.ts | 4 + shared/src/i18n/en/common.ts | 1 + shared/src/i18n/en/transport.ts | 2 +- shared/src/i18n/en/trip.ts | 4 + shared/src/i18n/es/common.ts | 1 + shared/src/i18n/es/transport.ts | 2 +- shared/src/i18n/es/trip.ts | 4 + shared/src/i18n/fr/common.ts | 1 + shared/src/i18n/fr/transport.ts | 2 +- shared/src/i18n/fr/trip.ts | 4 + shared/src/i18n/gr/common.ts | 1 + shared/src/i18n/gr/transport.ts | 2 +- shared/src/i18n/gr/trip.ts | 4 + shared/src/i18n/hu/common.ts | 1 + shared/src/i18n/hu/transport.ts | 2 +- shared/src/i18n/hu/trip.ts | 4 + shared/src/i18n/id/common.ts | 1 + shared/src/i18n/id/transport.ts | 2 +- shared/src/i18n/id/trip.ts | 4 + shared/src/i18n/it/common.ts | 1 + shared/src/i18n/it/transport.ts | 2 +- shared/src/i18n/it/trip.ts | 4 + shared/src/i18n/ja/common.ts | 1 + shared/src/i18n/ja/transport.ts | 2 +- shared/src/i18n/ja/trip.ts | 4 + shared/src/i18n/ko/common.ts | 1 + shared/src/i18n/ko/transport.ts | 2 +- shared/src/i18n/ko/trip.ts | 4 + shared/src/i18n/nl/common.ts | 1 + shared/src/i18n/nl/transport.ts | 2 +- shared/src/i18n/nl/trip.ts | 4 + shared/src/i18n/pl/common.ts | 1 + shared/src/i18n/pl/transport.ts | 2 +- shared/src/i18n/pl/trip.ts | 4 + shared/src/i18n/ru/common.ts | 1 + shared/src/i18n/ru/transport.ts | 2 +- shared/src/i18n/ru/trip.ts | 4 + shared/src/i18n/sv/common.ts | 1 + shared/src/i18n/sv/transport.ts | 2 +- shared/src/i18n/sv/trip.ts | 4 + shared/src/i18n/tr/common.ts | 1 + shared/src/i18n/tr/transport.ts | 2 +- shared/src/i18n/tr/trip.ts | 4 + shared/src/i18n/uk/common.ts | 1 + shared/src/i18n/uk/transport.ts | 2 +- shared/src/i18n/uk/trip.ts | 4 + shared/src/i18n/vi/common.ts | 1 + shared/src/i18n/vi/transport.ts | 2 +- shared/src/i18n/vi/trip.ts | 4 + shared/src/i18n/zh-TW/common.ts | 1 + shared/src/i18n/zh-TW/transport.ts | 2 +- shared/src/i18n/zh-TW/trip.ts | 4 + shared/src/i18n/zh/common.ts | 1 + shared/src/i18n/zh/transport.ts | 2 +- shared/src/i18n/zh/trip.ts | 4 + 74 files changed, 441 insertions(+), 165 deletions(-) create mode 100644 client/src/components/Planner/transitDisplay.tsx diff --git a/client/src/components/Planner/DayPlanSidebar.test.tsx b/client/src/components/Planner/DayPlanSidebar.test.tsx index 0c4aefed4..828d1bffb 100644 --- a/client/src/components/Planner/DayPlanSidebar.test.tsx +++ b/client/src/components/Planner/DayPlanSidebar.test.tsx @@ -340,15 +340,16 @@ describe('DayPlanSidebar', () => { }, }, } - render() - // Line chip + transfer summary render inline in the timeline row. + const onOpenTransit = vi.fn() + render() + // Line chip + transfer summary render inline in the timeline row; the + // title uses an arrow icon, so its parts are separate text nodes. 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')) + // Clicking the row opens the journey view — not the edit form. + await user.click(screen.getByText('Fernsehturm')) expect(onEditTransport).not.toHaveBeenCalled() - expect(await screen.findByText('Itinerary')).toBeInTheDocument() - expect(screen.getByText(/Ruhleben/)).toBeInTheDocument() + expect(onOpenTransit).toHaveBeenCalledWith(expect.objectContaining({ id: 300 })) }) // ── Day info button ───────────────────────────────────────────────────── diff --git a/client/src/components/Planner/DayPlanSidebar.tsx b/client/src/components/Planner/DayPlanSidebar.tsx index f82d171d5..02b2cad30 100644 --- a/client/src/components/Planner/DayPlanSidebar.tsx +++ b/client/src/components/Planner/DayPlanSidebar.tsx @@ -37,6 +37,7 @@ import { DayPlanSidebarToolbar } from './DayPlanSidebarToolbar' import { DayPlanSidebarNoteModal } from './DayPlanSidebarNoteModal' import { DayPlanSidebarTimeConfirmModal } from './DayPlanSidebarTimeConfirmModal' import { DayPlanSidebarTransportDetailModal } from './DayPlanSidebarTransportDetailModal' +import { TransitTitle, TransitLegChips } from './transitDisplay' import { DayPlanSidebarFooter } from './DayPlanSidebarFooter' import type { Trip, Day, Place, Category, Assignment, Accommodation, Reservation, AssignmentsMap, RouteResult, RouteSegment, DayNote } from '../../types' import { getGoogleMapsUrlForPlace } from './placeGoogleMaps' @@ -2068,8 +2069,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP {spanLabel} )} - - {res.title} + + {transitMeta ? : res.title} {(() => { const { time: dispTime } = splitReservationDateTime(displayTime) @@ -2087,28 +2088,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP })()} {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 && (
diff --git a/client/src/components/Planner/ReservationsPanel.tsx b/client/src/components/Planner/ReservationsPanel.tsx index 2bc6e103d..6265505aa 100644 --- a/client/src/components/Planner/ReservationsPanel.tsx +++ b/client/src/components/Planner/ReservationsPanel.tsx @@ -12,6 +12,7 @@ import { TramFront, Footprints, } from 'lucide-react' import { openFile } from '../../utils/fileDownload' +import { TransitTitle, TransitLegChips } from './transitDisplay' import Markdown from 'react-markdown' import remarkGfm from 'remark-gfm' import remarkBreaks from 'remark-breaks' @@ -307,38 +308,6 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo ) })()} - {/* Transit itinerary chips (#1065) — the journey at a glance */} - {(() => { - if (r.type !== 'transit') return null - const meta = typeof r.metadata === 'string' ? (() => { try { return JSON.parse(r.metadata || '{}') } catch { return {} } })() : (r.metadata || {}) - const transit = meta.transit - if (!transit || !Array.isArray(transit.legs) || transit.legs.length === 0) return null - return ( -
- {transit.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} - - )} - - ))} - - {transit.transfers > 0 ? t('transit.transfers', { count: transit.transfers }) : t('transit.direct')} - -
- ) - })()} - {/* Type-specific metadata */} {(() => { const meta = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata || {}) @@ -525,6 +494,87 @@ function Section({ title, count, children, defaultOpen = true, accent, storageKe ) } +/** + * A transit journey's own card (#1065) — leg chips + journey stats instead of + * the generic booking layout. Clicking anywhere opens the journey view. + */ +function TransitJourneyCard({ r, days, onOpen, onDelete, canEdit }: { + r: Reservation + days: Day[] + onOpen: (r: Reservation) => void + onDelete: (id: number) => void + canEdit: boolean +}) { + const { t, locale } = useTranslation() + const timeFormat = useSettingsStore(st => st.settings.time_format) || '24h' + const [confirmOpen, setConfirmOpen] = useState(false) + const meta = typeof r.metadata === 'string' ? (() => { try { return JSON.parse(r.metadata || '{}') } catch { return {} } })() : (r.metadata || {}) + const transit = meta.transit && Array.isArray(meta.transit.legs) ? meta.transit : null + const { date, time } = splitReservationDateTime(r.reservation_time) + const { time: endTime } = splitReservationDateTime(r.reservation_end_time) + const day = r.day_id ? days.find(d => d.id === r.day_id) : undefined + const dateStr = date ? new Date(date + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' }) : null + const mins = transit?.duration ? Math.round(transit.duration / 60) : null + return ( +
onOpen(r)} + style={{ borderRadius: 12, border: '1px solid rgba(124,58,237,0.22)', padding: '12px 14px', display: 'flex', flexDirection: 'column', gap: 9, cursor: 'pointer', transition: 'box-shadow 0.15s ease' }} + onMouseEnter={e => e.currentTarget.style.boxShadow = '0 2px 12px rgba(0,0,0,0.06)'} + onMouseLeave={e => e.currentTarget.style.boxShadow = 'none'} + > +
+
+ +
+
+
+ +
+
+ {[ + day ? (day.title || t('dayplan.dayN', { n: day.day_number })) : null, + dateStr, + time ? `${formatTime(time, locale, timeFormat)}${endTime ? ` – ${formatTime(endTime, locale, timeFormat)}` : ''}` : null, + mins ? t('transit.min', { count: mins }) : null, + ].filter(Boolean).join(' · ')} +
+
+ {canEdit && ( + + )} +
+ {transit && ( +
+ +
+ )} + {confirmOpen && ReactDOM.createPortal( +
{ e.stopPropagation(); setConfirmOpen(false) }}> +
e.stopPropagation()}> +
{t('reservations.confirm.deleteTitle')}
+
{t('reservations.confirm.deleteBody', { name: r.title })}
+
+ + +
+
+
, + document.body + )} +
+ ) +} + interface ReservationsPanelProps { tripId: number reservations: Reservation[] @@ -718,7 +768,7 @@ export default function ReservationsPanel({ tripId, reservations, days, assignme <> {transitEntries.length > 0 && (
- {transitEntries.map(r => )} + {transitEntries.map(r => )}
)} {allPending.length > 0 && ( diff --git a/client/src/components/Planner/TransitJourneyModal.test.tsx b/client/src/components/Planner/TransitJourneyModal.test.tsx index 182002807..b94ee94fb 100644 --- a/client/src/components/Planner/TransitJourneyModal.test.tsx +++ b/client/src/components/Planner/TransitJourneyModal.test.tsx @@ -49,22 +49,39 @@ describe('TransitJourneyModal', () => { it('FE-PLANNER-TRANSITJOURNEY-001: shows summary, line badge, platform and legs', () => { render() expect(screen.getByText('U2')).toBeInTheDocument() - expect(screen.getByText('1 transfers')).toBeInTheDocument() + // stat tiles: value + caption + expect(screen.getByText('Transfers')).toBeInTheDocument() + expect(screen.getByText('Walking')).toBeInTheDocument() expect(screen.getByText(/Platform 2/)).toBeInTheDocument() expect(screen.getByText(/Ruhleben/)).toBeInTheDocument() expect(screen.getByText(/BVG/)).toBeInTheDocument() }) - it('FE-PLANNER-TRANSITJOURNEY-002: saving edited fields calls onSave with the field payload only', async () => { + it('FE-PLANNER-TRANSITJOURNEY-002: inline title rename + notes save as a minimal field payload', async () => { const user = userEvent.setup() const onSave = vi.fn().mockResolvedValue({}) render() + // The title renames inline in the header via its pencil. + await user.click(screen.getByLabelText('Edit')) const titleInput = screen.getByDisplayValue('Fernsehturm → Zoo') await user.clear(titleInput) await user.type(titleInput, 'Zum Zoo') + await user.keyboard('{Enter}') + await user.type(screen.getByPlaceholderText(/notes/i), 'Take **coffee**') await user.click(screen.getByRole('button', { name: /^Save$/ })) await waitFor(() => expect(onSave).toHaveBeenCalled()) - expect(onSave).toHaveBeenCalledWith({ title: 'Zum Zoo', status: 'confirmed', confirmation_number: null, notes: null }) + expect(onSave).toHaveBeenCalledWith({ title: 'Zum Zoo', notes: 'Take **coffee**' }) + }) + + it('FE-PLANNER-TRANSITJOURNEY-006: no status or booking-code fields; notes support a markdown preview', async () => { + const user = userEvent.setup() + render() + expect(screen.queryByText('Status')).not.toBeInTheDocument() + expect(screen.queryByText(/Booking code|Confirmation/i)).not.toBeInTheDocument() + await user.type(screen.getByPlaceholderText(/notes/i), '**bold** note') + await user.click(screen.getByRole('button', { name: 'Preview' })) + const bold = document.querySelector('.collab-note-md strong') + expect(bold?.textContent).toBe('bold') }) it('FE-PLANNER-TRANSITJOURNEY-003: change route triggers onChangeRoute', async () => { diff --git a/client/src/components/Planner/TransitJourneyModal.tsx b/client/src/components/Planner/TransitJourneyModal.tsx index cd6e0c4fd..589de151b 100644 --- a/client/src/components/Planner/TransitJourneyModal.tsx +++ b/client/src/components/Planner/TransitJourneyModal.tsx @@ -1,18 +1,21 @@ -import { useEffect, useState } from 'react' -import { ArrowRight, Footprints, Pencil, RefreshCw, TramFront, Trash2 } from 'lucide-react' +import { useEffect, useRef, useState } from 'react' +import Markdown from 'react-markdown' +import remarkGfm from 'remark-gfm' +import remarkBreaks from 'remark-breaks' +import { ArrowRight, ArrowRightLeft, Clock, Footprints, Pencil, RefreshCw, TramFront, Trash2 } from 'lucide-react' import Modal from '../shared/Modal' import ConfirmDialog from '../shared/ConfirmDialog' import { useTranslation } from '../../i18n' import { useSettingsStore } from '../../store/settingsStore' import { splitReservationDateTime, formatTime } from '../../utils/formatters' +import { TransitTitle } from './transitDisplay' import type { Reservation } from '../../types' /** * The journey view for an automated public-transit entry (#1065): a roomy modal - * that shows the full itinerary (summary + stop-by-stop legs) together with the - * editable booking fields — the generic transport form never opens for transit. - * "Change route" re-enters the transit search pre-seeded with this journey's - * origin/destination and replaces the itinerary on save. + * around the stop-by-stop itinerary. The title renames inline right in the + * header, notes get the full width with markdown support, and "Change route" + * re-enters the transit search pre-seeded with this journey's route. */ interface TransitLegMeta { @@ -31,8 +34,8 @@ interface TransitLegMeta { interface TransitJourneyModalProps { reservation: Reservation onClose: () => void - /** Partial field update (title/status/confirmation/notes) — endpoints + itinerary stay untouched. */ - onSave: (fields: { title: string; status: string; confirmation_number: string | null; notes: string | null }) => Promise + /** Partial field update — endpoints + itinerary stay untouched. */ + onSave: (fields: { title: string; notes: string | null }) => Promise onDelete: () => Promise onChangeRoute: () => void canEdit: boolean @@ -46,27 +49,28 @@ export default function TransitJourneyModal({ reservation, onClose, onSave, onDe const transit = meta.transit && Array.isArray(meta.transit.legs) ? meta.transit : null const [title, setTitle] = useState(res.title || '') - const [status, setStatus] = useState(res.status || 'confirmed') - const [confirmation, setConfirmation] = useState(res.confirmation_number || '') + const [editingTitle, setEditingTitle] = useState(false) const [notes, setNotes] = useState(res.notes || '') + const [notesTab, setNotesTab] = useState<'write' | 'preview'>('write') const [saving, setSaving] = useState(false) const [confirmDelete, setConfirmDelete] = useState(false) + const titleInputRef = useRef(null) useEffect(() => { setTitle(res.title || '') - setStatus(res.status || 'confirmed') - setConfirmation(res.confirmation_number || '') setNotes(res.notes || '') + setEditingTitle(false) }, [res.id]) - const dirty = title !== (res.title || '') || status !== (res.status || 'confirmed') - || confirmation !== (res.confirmation_number || '') || notes !== (res.notes || '') + useEffect(() => { if (editingTitle) titleInputRef.current?.focus() }, [editingTitle]) + + const dirty = title !== (res.title || '') || notes !== (res.notes || '') const save = async () => { if (!title.trim()) return setSaving(true) try { - await onSave({ title: title.trim(), status, confirmation_number: confirmation.trim() || null, notes: notes.trim() || null }) + await onSave({ title: title.trim(), notes: notes.trim() || null }) onClose() } finally { setSaving(false) } } @@ -75,8 +79,19 @@ export default function TransitJourneyModal({ reservation, onClose, onSave, onDe const { time: endTime } = splitReservationDateTime(res.reservation_end_time) const dateStr = date ? new Date(date + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'long', day: 'numeric', month: 'long', timeZone: 'UTC' }) : '' - const inputClass = 'w-full border border-edge rounded-[10px] px-[12px] py-[9px] text-[13px] font-[inherit] outline-none box-border text-content bg-surface-input' - const labelClass = 'block text-[11px] font-semibold text-content-faint mb-[5px] uppercase tracking-[0.03em]' + const fmtDuration = (seconds: number) => { + const mins = Math.round(seconds / 60) + if (mins < 60) return t('transit.min', { count: mins }) + const h = Math.floor(mins / 60) + const m = mins % 60 + return m > 0 ? `${h} h ${m} min` : `${h} h` + } + + const statTiles = transit ? [ + { Icon: Clock, value: transit.duration > 0 ? fmtDuration(transit.duration) : '—', label: t('transit.durationLabel') }, + { Icon: ArrowRightLeft, value: String(transit.transfers ?? 0), label: t('transit.transfersLabel') }, + { Icon: Footprints, value: transit.walk_seconds > 59 ? t('transit.min', { count: Math.round(transit.walk_seconds / 60) }) : '—', label: t('transit.walkLabel') }, + ] : [] return (
- {/* header: route + date/time + status badge */} + {/* header: icon + inline-renamable title + date/time */}
-
- +
+
-
{res.title}
-
+ {editingTitle ? ( + setTitle(e.target.value)} + onBlur={() => setEditingTitle(false)} + onKeyDown={e => { if (e.key === 'Enter') setEditingTitle(false); if (e.key === 'Escape') { setTitle(res.title || ''); setEditingTitle(false) } }} + className="text-content" + aria-label={t('reservations.titleLabel')} + style={{ width: '100%', border: 'none', outline: 'none', background: 'transparent', padding: 0, fontFamily: 'inherit', fontSize: 'calc(17px * var(--fs-scale-subtitle, 1))', fontWeight: 700, letterSpacing: '-0.015em', borderBottom: '1.5px solid var(--text-primary)' }} + /> + ) : ( +
+ + {canEdit && ( + + )} +
+ )} +
{[dateStr, time ? `${formatTime(time, locale, timeFormat)}${endTime ? ` – ${formatTime(endTime, locale, timeFormat)}` : ''}` : ''].filter(Boolean).join(' · ')}
-
- {(status === 'confirmed' ? t('planner.resConfirmed') : t('planner.resPending')).replace(/\s*·\s*$/, '')} -
{transit && ( <> - {/* journey summary */} -
- {transit.duration > 0 && ( - {Math.floor(transit.duration / 3600) > 0 ? `${Math.floor(transit.duration / 3600)} h ${Math.round((transit.duration % 3600) / 60)} min` : t('transit.min', { count: Math.round(transit.duration / 60) })} - )} - · - {transit.transfers > 0 ? t('transit.transfers', { count: transit.transfers }) : t('transit.direct')} - {transit.walk_seconds > 59 && ( - <> - · - {t('transit.min', { count: Math.round(transit.walk_seconds / 60) })} - - )} + {/* journey stats — three full-width tiles */} +
+ {statTiles.map(({ Icon, value, label }, i) => ( +
+
+ +
+
+
{value}
+
{label}
+
+
+ ))}
- {/* stop-by-stop itinerary — roomy */} + {/* stop-by-stop itinerary */}
{t('transit.itinerary')} @@ -207,41 +239,38 @@ export default function TransitJourneyModal({ reservation, onClose, onSave, onDe )} - {/* editable booking fields */} -
-
- - setTitle(e.target.value)} disabled={!canEdit} className={inputClass} /> + {/* notes — full width, markdown */} +
+
+ + {canEdit && ( +
+ {([['write', t('common.edit')], ['preview', t('common.preview')]] as const).map(([tab, label]) => ( + + ))} +
+ )}
-
- -
- {(['pending', 'confirmed'] as const).map(sv => ( - - ))} + {canEdit && notesTab === 'write' ? ( +