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' ? ( +