feat(planner): integrate transit into the transport system as Automated mode

The add-transport dialog gains a Manual/Automated switch: Automated embeds the
public-transit search (day picker + from/to + modes + preferences + results)
right in the dialog, and the day header's tram button opens it directly in
that mode. The standalone search modal is gone.

Saved journeys get their own roomy journey view — the stop-by-stop itinerary
(times, platforms, lines, headsigns, operators) together with the editable
booking fields, delete, and a "Change route" action that re-runs the search
pre-seeded with the journey's origin/destination and replaces the itinerary on
save. The generic transport form no longer opens for transit entries, from the
timeline or from the Transports tab, where journeys now sit in their own
"Automated public transit" section with their line badges on the card.

New i18n keys in every locale; wiki updated.
This commit is contained in:
Maurice
2026-07-02 20:41:12 +02:00
committed by Maurice
parent 3c87d37cd9
commit 5a007a1f0e
33 changed files with 715 additions and 72 deletions
@@ -86,6 +86,8 @@ interface DayPlanSidebarProps {
onAddTransport?: (dayId: number) => void
/** Opens the public-transit route search for a day (#1065). */
onPlanTransit?: (dayId: number) => void
/** Opens the journey view for a saved transit entry (#1065). */
onOpenTransit?: (reservation: Reservation) => void
onEditTransport?: (reservation: Reservation) => void
onEditReservation?: (reservation: Reservation) => void
onAddBookingToAssignment?: (dayId: number, assignmentId: number) => void
@@ -130,6 +132,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
onRouteRefresh,
onAddTransport,
onPlanTransit,
onOpenTransit,
onEditTransport,
onEditReservation,
onAddBookingToAssignment,
@@ -1004,6 +1007,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
onRouteRefresh,
onAddTransport,
onPlanTransit,
onOpenTransit,
onEditTransport,
onEditReservation,
onAddBookingToAssignment,
@@ -1166,6 +1170,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
onRouteRefresh,
onAddTransport,
onPlanTransit,
onOpenTransit,
onEditTransport,
onEditReservation,
onAddBookingToAssignment,
@@ -1971,10 +1976,14 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
<div
onClick={() => {
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 }
// A transit journey opens its own journey view — the rich
// stop-by-stop breakdown with its booking fields, never the
// generic edit form (#1065).
if (transitMeta) {
if (onOpenTransit) onOpenTransit(target)
else setTransportDetail(target)
return
}
if (!canEditDays) return
if (TRANSPORT_TYPES.has(res.type)) onEditTransport?.(target)
else onEditReservation?.(target)
@@ -9,6 +9,7 @@ import {
Plane, Hotel, Utensils, Train, Car, Ship, Bus, Sailboat, Bike, CarTaxiFront, Route, Ticket, FileText, MapPin,
Calendar, Hash, CheckCircle2, Circle, Pencil, Trash2, Plus, ChevronDown, ChevronRight, Users,
ExternalLink, BookMarked, Lightbulb, Link2, Clock, ArrowRight, AlertCircle, Download,
TramFront, Footprints,
} from 'lucide-react'
import { openFile } from '../../utils/fileDownload'
import Markdown from 'react-markdown'
@@ -37,6 +38,7 @@ const TYPE_OPTIONS = [
{ value: 'bicycle', labelKey: 'reservations.type.bicycle', Icon: Bike, color: '#84cc16' },
{ value: 'cruise', labelKey: 'reservations.type.cruise', Icon: Ship, color: '#0ea5e9' },
{ value: 'ferry', labelKey: 'reservations.type.ferry', Icon: Sailboat, color: '#0d9488' },
{ value: 'transit', labelKey: 'reservations.type.transit', Icon: TramFront, color: '#7c3aed' },
{ value: 'transport_other', labelKey: 'reservations.type.transport_other', Icon: Route, color: '#6b7280' },
{ value: 'event', labelKey: 'reservations.type.event', Icon: Ticket, color: '#f59e0b' },
{ value: 'tour', labelKey: 'reservations.type.tour', Icon: Users, color: '#10b981' },
@@ -305,6 +307,38 @@ 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 (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5, flexWrap: 'wrap' }}>
{transit.legs.map((leg: { mode?: string; line?: string | null; line_color?: string | null; line_text_color?: string | null }, li: number) => (
<span key={li} style={{ display: 'inline-flex', alignItems: 'center', gap: 5 }}>
{li > 0 && <span className="text-content-faint" style={{ fontSize: 10 }}>›</span>}
{leg.mode === 'WALK'
? <Footprints size={12} className="text-content-faint" />
: (
<span style={{
display: 'inline-flex', alignItems: 'center', borderRadius: 5, padding: '1px 7px',
fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))', fontWeight: 700,
background: leg.line_color || 'var(--bg-tertiary)',
color: leg.line_color ? (leg.line_text_color || '#fff') : 'var(--text-primary)',
}}>
{leg.line || leg.mode}
</span>
)}
</span>
))}
<span className="text-content-faint" style={{ fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))', marginLeft: 3 }}>
{transit.transfers > 0 ? t('transit.transfers', { count: transit.transfers }) : t('transit.direct')}
</span>
</div>
)
})()}
{/* Type-specific metadata */}
{(() => {
const meta = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata || {})
@@ -539,8 +573,12 @@ export default function ReservationsPanel({ tripId, reservations, days, assignme
typeFilters.size === 0 ? reservations : reservations.filter(r => typeFilters.has(r.type)),
[reservations, typeFilters])
const allPending = filtered.filter(r => r.status !== 'confirmed')
const allConfirmed = filtered.filter(r => r.status === 'confirmed')
// Automated public transit (#1065) gets its own section — journeys planned via
// the transit search live alongside manual transports without mixing in.
const transitEntries = filtered.filter(r => r.type === 'transit')
const nonTransit = filtered.filter(r => r.type !== 'transit')
const allPending = nonTransit.filter(r => r.status !== 'confirmed')
const allConfirmed = nonTransit.filter(r => r.status === 'confirmed')
const total = filtered.length
const usedTypes = useMemo(() => new Set(reservations.map(r => r.type)), [reservations])
@@ -678,6 +716,11 @@ export default function ReservationsPanel({ tripId, reservations, days, assignme
</div>
) : (
<>
{transitEntries.length > 0 && (
<Section title={t('transit.sectionTitle')} count={transitEntries.length} accent="gray" storageKey={`trek:bookings-transit-open:${tripId}`}>
{transitEntries.map(r => <ReservationCard key={r.id} r={r} tripId={tripId} onEdit={onEdit} onDelete={onDelete} files={files} onNavigateToFiles={onNavigateToFiles} assignmentLookup={assignmentLookup} canEdit={canEdit} days={days} />)}
</Section>
)}
{allPending.length > 0 && (
<Section title={t('reservations.pending')} count={allPending.length} accent="gray" storageKey={`trek:bookings-pending-open:${tripId}`}>
{allPending.map(r => <ReservationCard key={r.id} r={r} tripId={tripId} onEdit={onEdit} onDelete={onDelete} files={files} onNavigateToFiles={onNavigateToFiles} assignmentLookup={assignmentLookup} canEdit={canEdit} days={days} />)}
@@ -0,0 +1,97 @@
// FE-PLANNER-TRANSITJOURNEY-001 to 005 — the journey view for a saved transit entry.
import { render, screen, waitFor } from '../../../tests/helpers/render'
import userEvent from '@testing-library/user-event'
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
import { useAuthStore } from '../../store/authStore'
import { useSettingsStore } from '../../store/settingsStore'
import { buildUser, buildReservation } from '../../../tests/helpers/factories'
import TransitJourneyModal from './TransitJourneyModal'
function makeReservation() {
return {
...buildReservation({ id: 7, type: 'transit', title: 'Fernsehturm → Zoo', reservation_time: '2025-06-01T08:30:00', status: 'confirmed' }),
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', agency: 'BVG', duration: 1440, stops: 6, from: { name: 'Alexanderplatz', time: '08:36', track: '2' }, to: { name: 'Zoo', time: '09:00' } },
],
},
},
endpoints: [
{ role: 'from', sequence: 0, name: 'Fernsehturm', code: null, lat: 52.52, lng: 13.4, timezone: 'Europe/Berlin', local_date: null, local_time: null },
{ role: 'to', sequence: 1, name: 'Zoo', code: null, lat: 52.5, lng: 13.33, timezone: 'Europe/Berlin', local_date: null, local_time: null },
],
} as any
}
function makeProps(overrides = {}) {
return {
reservation: makeReservation(),
onClose: vi.fn(),
onSave: vi.fn().mockResolvedValue({}),
onDelete: vi.fn().mockResolvedValue({}),
onChangeRoute: vi.fn(),
canEdit: true,
...overrides,
}
}
beforeEach(() => {
resetAllStores()
vi.clearAllMocks()
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true })
seedStore(useSettingsStore, { settings: { time_format: '24h' } } as any)
})
describe('TransitJourneyModal', () => {
it('FE-PLANNER-TRANSITJOURNEY-001: shows summary, line badge, platform and legs', () => {
render(<TransitJourneyModal {...makeProps()} />)
expect(screen.getByText('U2')).toBeInTheDocument()
expect(screen.getByText('1 transfers')).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 () => {
const user = userEvent.setup()
const onSave = vi.fn().mockResolvedValue({})
render(<TransitJourneyModal {...makeProps({ onSave })} />)
const titleInput = screen.getByDisplayValue('Fernsehturm → Zoo')
await user.clear(titleInput)
await user.type(titleInput, 'Zum Zoo')
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 })
})
it('FE-PLANNER-TRANSITJOURNEY-003: change route triggers onChangeRoute', async () => {
const user = userEvent.setup()
const onChangeRoute = vi.fn()
render(<TransitJourneyModal {...makeProps({ onChangeRoute })} />)
await user.click(screen.getByRole('button', { name: /Change route/ }))
expect(onChangeRoute).toHaveBeenCalled()
})
it('FE-PLANNER-TRANSITJOURNEY-004: delete asks for confirmation, then calls onDelete', async () => {
const user = userEvent.setup()
const onDelete = vi.fn().mockResolvedValue({})
render(<TransitJourneyModal {...makeProps({ onDelete })} />)
await user.click(screen.getByRole('button', { name: /^Delete$/ }))
expect(onDelete).not.toHaveBeenCalled()
// Confirm dialog appears — confirm it.
const confirmBtns = await screen.findAllByRole('button', { name: /Delete/ })
await user.click(confirmBtns[confirmBtns.length - 1])
await waitFor(() => expect(onDelete).toHaveBeenCalled())
})
it('FE-PLANNER-TRANSITJOURNEY-005: read-only without edit rights — no delete/save/change-route', () => {
render(<TransitJourneyModal {...makeProps({ canEdit: false })} />)
expect(screen.queryByRole('button', { name: /^Delete$/ })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /Change route/ })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /^Save$/ })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: /Close/ })).toBeInTheDocument()
})
})
@@ -0,0 +1,256 @@
import { useEffect, useState } from 'react'
import { ArrowRight, 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 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.
*/
interface TransitLegMeta {
mode?: string
line?: string | null
line_color?: string | null
line_text_color?: string | null
headsign?: string | null
agency?: string | null
duration?: number
stops?: number
from?: { name?: string; time?: string | null; track?: string | null }
to?: { name?: string; time?: string | null; track?: string | null }
}
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<unknown>
onDelete: () => Promise<unknown>
onChangeRoute: () => void
canEdit: boolean
}
export default function TransitJourneyModal({ reservation, onClose, onSave, onDelete, onChangeRoute, canEdit }: TransitJourneyModalProps) {
const { t, locale } = useTranslation()
const timeFormat = useSettingsStore(st => st.settings.time_format) || '24h'
const res = reservation
const meta = typeof res.metadata === 'string' ? (() => { try { return JSON.parse(res.metadata || '{}') } catch { return {} } })() : (res.metadata || {})
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 [notes, setNotes] = useState(res.notes || '')
const [saving, setSaving] = useState(false)
const [confirmDelete, setConfirmDelete] = useState(false)
useEffect(() => {
setTitle(res.title || '')
setStatus(res.status || 'confirmed')
setConfirmation(res.confirmation_number || '')
setNotes(res.notes || '')
}, [res.id])
const dirty = title !== (res.title || '') || status !== (res.status || 'confirmed')
|| confirmation !== (res.confirmation_number || '') || 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 })
onClose()
} finally { setSaving(false) }
}
const { date, time } = splitReservationDateTime(res.reservation_time)
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]'
return (
<Modal
isOpen
onClose={onClose}
title={t('transit.journey')}
size="2xl"
footer={
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{canEdit && (
<button onClick={() => setConfirmDelete(true)} style={{
display: 'inline-flex', alignItems: 'center', gap: 5, padding: '8px 14px', borderRadius: 10,
border: '1px solid rgba(239,68,68,0.3)', background: 'rgba(239,68,68,0.06)', color: '#ef4444',
fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit',
}}>
<Trash2 size={13} /> {t('common.delete')}
</button>
)}
<div style={{ flex: 1 }} />
{canEdit && (
<button onClick={onChangeRoute} className="text-content-muted" style={{
display: 'inline-flex', alignItems: 'center', gap: 6, padding: '8px 16px', borderRadius: 10,
border: '1px solid var(--border-primary)', background: 'none',
fontSize: 'calc(12px * var(--fs-scale-body, 1))', cursor: 'pointer', fontFamily: 'inherit',
}}>
<RefreshCw size={13} /> {t('transit.changeRoute')}
</button>
)}
{canEdit ? (
<button onClick={save} disabled={saving || !title.trim() || !dirty} className="bg-[var(--text-primary)] text-[var(--bg-primary)]" style={{
padding: '8px 20px', borderRadius: 10, border: 'none',
fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
opacity: saving || !title.trim() || !dirty ? 0.5 : 1,
}}>
{saving ? t('common.saving') : t('common.save')}
</button>
) : (
<button onClick={onClose} className="bg-accent text-accent-text" style={{ padding: '8px 20px', borderRadius: 10, border: 'none', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}>
{t('common.close')}
</button>
)}
</div>
}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 18, fontFamily: 'var(--font-system)' }}>
{/* header: route + date/time + status badge */}
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
<div style={{ width: 46, height: 46, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: 14, background: '#7c3aed18' }}>
<TramFront size={22} strokeWidth={1.8} color="#7c3aed" />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="text-content" style={{ fontSize: 'calc(17px * var(--fs-scale-subtitle, 1))', fontWeight: 700, letterSpacing: '-0.015em', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{res.title}</div>
<div className="text-content-muted" style={{ fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', marginTop: 2 }}>
{[dateStr, time ? `${formatTime(time, locale, timeFormat)}${endTime ? ` – ${formatTime(endTime, locale, timeFormat)}` : ''}` : ''].filter(Boolean).join(' · ')}
</div>
</div>
<div className={status === 'confirmed' ? 'bg-[rgba(22,163,74,0.1)] text-[#16a34a]' : 'bg-[rgba(217,119,6,0.1)] text-[#d97706]'} style={{ padding: '4px 10px', borderRadius: 7, fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, flexShrink: 0 }}>
{(status === 'confirmed' ? t('planner.resConfirmed') : t('planner.resPending')).replace(/\s*·\s*$/, '')}
</div>
</div>
{transit && (
<>
{/* journey summary */}
<div className="bg-surface-tertiary text-content" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 12, padding: '11px 14px', borderRadius: 10, fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600, flexWrap: 'wrap' }}>
{transit.duration > 0 && (
<span>{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) })}</span>
)}
<span className="text-content-faint">·</span>
<span>{transit.transfers > 0 ? t('transit.transfers', { count: transit.transfers }) : t('transit.direct')}</span>
{transit.walk_seconds > 59 && (
<>
<span className="text-content-faint">·</span>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 5 }}><Footprints size={14} /> {t('transit.min', { count: Math.round(transit.walk_seconds / 60) })}</span>
</>
)}
</div>
{/* stop-by-stop itinerary — roomy */}
<div className="bg-surface-tertiary" style={{ padding: '14px 16px', borderRadius: 12 }}>
<div className="text-content-faint" style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.04em', marginBottom: 12 }}>
{t('transit.itinerary')}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{(transit.legs as TransitLegMeta[]).map((leg, i) => {
const isWalk = leg.mode === 'WALK'
const mins = leg.duration ? Math.round(leg.duration / 60) : null
return (
<div key={i} style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
<div className="text-content-muted" style={{ width: 44, flexShrink: 0, textAlign: 'right', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, paddingTop: 1 }}>
{!isWalk && leg.from?.time ? leg.from.time : ''}
</div>
{isWalk ? (
<span className="text-content-faint" style={{ display: 'inline-flex', alignItems: 'center', flexShrink: 0, paddingTop: 2 }}>
<Footprints size={14} />
</span>
) : (
<span style={{ display: 'inline-flex', alignItems: 'center', background: leg.line_color || 'var(--bg-hover)', color: leg.line_color ? (leg.line_text_color || '#fff') : 'var(--text-primary)', borderRadius: 6, padding: '2px 8px', fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', fontWeight: 700, flexShrink: 0 }}>
{leg.line || leg.mode}
</span>
)}
<div style={{ flex: 1, minWidth: 0 }}>
<div className="text-content" style={{ fontSize: 'calc(13.5px * var(--fs-scale-body, 1))', fontWeight: 600, display: 'flex', alignItems: 'center', gap: 5, flexWrap: 'wrap' }}>
{isWalk
? <span className="text-content-muted" style={{ fontWeight: 500 }}>{t('transit.walkTo', { name: leg.to?.name || '' })}</span>
: <>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>{leg.from?.name}</span>
{leg.from?.track && <span className="text-content-faint" style={{ fontWeight: 500 }}>({t('transit.platform', { track: leg.from.track })})</span>}
<ArrowRight size={12} className="text-content-faint" style={{ flexShrink: 0 }} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>{leg.to?.name}</span>
</>}
</div>
<div className="text-content-faint" style={{ fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', marginTop: 3 }}>
{[
!isWalk && leg.from?.time ? `${leg.from.time}${leg.to?.time ? ` – ${leg.to.time}` : ''}` : null,
mins ? t('transit.min', { count: mins }) : null,
!isWalk && leg.stops ? t('transit.stops', { count: leg.stops }) : null,
!isWalk && leg.headsign ? `→ ${leg.headsign}` : null,
!isWalk ? leg.agency : null,
].filter(Boolean).join(' · ')}
</div>
</div>
</div>
)
})}
</div>
</div>
</>
)}
{/* editable booking fields */}
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr', gap: 12 }}>
<div>
<label className={labelClass}>{t('reservations.titleLabel')} *</label>
<input value={title} onChange={e => setTitle(e.target.value)} disabled={!canEdit} className={inputClass} />
</div>
<div>
<label className={labelClass}>{t('reservations.status')}</label>
<div className="bg-surface-secondary" style={{ display: 'flex', borderRadius: 10, padding: 3, gap: 2 }}>
{(['pending', 'confirmed'] as const).map(sv => (
<button key={sv} type="button" disabled={!canEdit} onClick={() => setStatus(sv)}
className={status === sv ? 'bg-surface-card text-content' : 'text-content-muted'}
style={{ flex: 1, padding: '7px 4px', fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', fontWeight: 500, borderRadius: 8, border: 0, cursor: canEdit ? 'pointer' : 'default', fontFamily: 'inherit', background: status === sv ? undefined : 'transparent', whiteSpace: 'nowrap' }}>
{(sv === 'confirmed' ? t('planner.resConfirmed') : t('planner.resPending')).replace(/\s*·\s*$/, '')}
</button>
))}
</div>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 2fr', gap: 12 }}>
<div>
<label className={labelClass}>{t('reservations.confirmationCode')}</label>
<input value={confirmation} onChange={e => setConfirmation(e.target.value)} disabled={!canEdit} className={inputClass} />
</div>
<div>
<label className={labelClass}>{t('reservations.notes')}</label>
<input value={notes} onChange={e => setNotes(e.target.value)} disabled={!canEdit} placeholder={canEdit ? t('reservations.notesPlaceholder') : undefined} className={inputClass} />
</div>
</div>
{canEdit && !transit && (
<div className="text-content-faint" style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))' }}>
<Pencil size={11} /> {t('transit.noItinerary')}
</div>
)}
</div>
<ConfirmDialog
isOpen={confirmDelete}
onClose={() => setConfirmDelete(false)}
onConfirm={async () => { setConfirmDelete(false); await onDelete(); onClose() }}
title={t('reservations.confirm.deleteTitle')}
message={t('reservations.confirm.deleteBody', { name: res.title })}
/>
</Modal>
)
}
@@ -1,11 +1,12 @@
// FE-PLANNER-TRANSIT-001 to FE-PLANNER-TRANSIT-006
// FE-PLANNER-TRANSIT-001 to FE-PLANNER-TRANSIT-006 — the transit search panel
// (embedded as the TransportModal's Automated mode).
import { render, screen, waitFor } from '../../../tests/helpers/render'
import userEvent from '@testing-library/user-event'
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
import { useAuthStore } from '../../store/authStore'
import { useSettingsStore } from '../../store/settingsStore'
import { buildUser, buildDay, buildPlace } from '../../../tests/helpers/factories'
import TransitSearchModal from './TransitSearchModal'
import TransitSearchPanel from './TransitSearchPanel'
const { transitApiMock } = vi.hoisted(() => ({
transitApiMock: { geocode: vi.fn(), plan: vi.fn() },
@@ -35,8 +36,6 @@ const day = buildDay({ id: 10, trip_id: 1, date: '2025-06-01', title: 'Berlin Da
function makeProps(overrides = {}) {
return {
isOpen: true,
onClose: vi.fn(),
day,
days: [day],
places: [buildPlace({ id: 1, name: 'Fernsehturm', lat: 52.5208, lng: 13.4094 })],
@@ -65,10 +64,9 @@ beforeEach(() => {
seedStore(useSettingsStore, { settings: { time_format: '24h' } } as any)
})
describe('TransitSearchModal', () => {
describe('TransitSearchPanel', () => {
it('FE-PLANNER-TRANSIT-001: renders from/to pickers, modes and preferences', () => {
render(<TransitSearchModal {...makeProps()} />)
expect(screen.getByText('Public transit')).toBeInTheDocument()
render(<TransitSearchPanel {...makeProps()} />)
expect(screen.getAllByPlaceholderText('Search stop or station…')).toHaveLength(2)
expect(screen.getByText('Subway')).toBeInTheDocument()
expect(screen.getByText('Fewer transfers')).toBeInTheDocument()
@@ -77,7 +75,7 @@ describe('TransitSearchModal', () => {
it('FE-PLANNER-TRANSIT-002: searching lists itineraries with times, transfers and line badges', async () => {
const user = userEvent.setup()
transitApiMock.plan.mockResolvedValueOnce({ itineraries: [ITINERARY] })
render(<TransitSearchModal {...makeProps()} />)
render(<TransitSearchPanel {...makeProps()} />)
await pickFromAndTo(user)
await user.click(screen.getByRole('button', { name: /^Search$/ }))
// Local Berlin times, U2 badge, 1 transfer.
@@ -90,7 +88,7 @@ describe('TransitSearchModal', () => {
const user = userEvent.setup()
const onAdd = vi.fn().mockResolvedValue({})
transitApiMock.plan.mockResolvedValueOnce({ itineraries: [ITINERARY] })
render(<TransitSearchModal {...makeProps({ onAdd })} />)
render(<TransitSearchPanel {...makeProps({ onAdd })} />)
await pickFromAndTo(user)
await user.click(screen.getByRole('button', { name: /^Search$/ }))
await user.click(await screen.findByText(/08:30 – 09:00/))
@@ -117,7 +115,7 @@ describe('TransitSearchModal', () => {
it('FE-PLANNER-TRANSIT-004: search failure shows the empty state, not a crash', async () => {
const user = userEvent.setup()
transitApiMock.plan.mockRejectedValueOnce(new Error('boom'))
render(<TransitSearchModal {...makeProps()} />)
render(<TransitSearchPanel {...makeProps()} />)
await pickFromAndTo(user)
await user.click(screen.getByRole('button', { name: /^Search$/ }))
expect(await screen.findByText(/No connections found/)).toBeInTheDocument()
@@ -127,7 +125,7 @@ describe('TransitSearchModal', () => {
const user = userEvent.setup()
const direct = { ...ITINERARY, startTime: '2025-06-01T06:40:00Z', endTime: '2025-06-01T07:20:00Z', duration: 2400, transfers: 0 }
transitApiMock.plan.mockResolvedValueOnce({ itineraries: [ITINERARY, direct] })
render(<TransitSearchModal {...makeProps()} />)
render(<TransitSearchPanel {...makeProps()} />)
await pickFromAndTo(user)
await user.click(screen.getByRole('button', { name: /^Search$/ }))
await screen.findByText(/08:30 – 09:00/)
@@ -139,7 +137,7 @@ describe('TransitSearchModal', () => {
it('FE-PLANNER-TRANSIT-006: swap exchanges from and to', async () => {
const user = userEvent.setup()
render(<TransitSearchModal {...makeProps()} />)
render(<TransitSearchPanel {...makeProps()} />)
const [fromInput] = screen.getAllByPlaceholderText('Search stop or station…')
await user.click(fromInput)
await user.click(await screen.findByText('Fernsehturm'))
@@ -1,7 +1,6 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'
import tzlookup from 'tz-lookup'
import { ArrowLeftRight, ArrowRight, Bus, CableCar, ChevronDown, ChevronUp, Clock, Footprints, MapPin, Sailboat, Search, Train, TramFront, TrainFront } from 'lucide-react'
import Modal from '../shared/Modal'
import CustomTimePicker from '../shared/CustomTimePicker'
import { transitApi } from '../../api/client'
import { useSettingsStore } from '../../store/settingsStore'
@@ -33,7 +32,7 @@ export interface TransitItinerary {
interface TransitPlaceResult { name: string; lat: number; lng: number; type: string; area: string | null }
interface PickedPlace { name: string; lat: number; lng: number }
export interface PickedPlace { name: string; lat: number; lng: number }
// ── helpers ──────────────────────────────────────────────────────────────────
@@ -294,26 +293,30 @@ function ItineraryCard({ it, tzFrom, tzTo, is12h, expanded, onToggle, onAdd, add
)
}
// ── the modal ────────────────────────────────────────────────────────────────
// ── the search panel ─────────────────────────────────────────────────────────
// Modal-less on purpose: it renders as the "Automated transport" mode inside
// the TransportModal (and could embed anywhere else). The host owns the modal
// chrome and closes itself once onAdd resolves.
interface TransitSearchModalProps {
isOpen: boolean
onClose: () => void
interface TransitSearchPanelProps {
day: Day
days: Day[]
places: Place[]
accommodations?: Accommodation[]
/** Persist the built reservation payload; resolves when saved. */
onAdd: (payload: Record<string, unknown>) => Promise<unknown>
/** Pre-seed from/to — used by "change route" on an existing journey. */
initialFrom?: PickedPlace | null
initialTo?: PickedPlace | null
}
export default function TransitSearchModal({ isOpen, onClose, day, days, places, accommodations = [], onAdd }: TransitSearchModalProps) {
export default function TransitSearchPanel({ day, days, places, accommodations = [], onAdd, initialFrom = null, initialTo = null }: TransitSearchPanelProps) {
const { t } = useTranslation()
const toast = useToast()
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
const [from, setFrom] = useState<PickedPlace | null>(null)
const [to, setTo] = useState<PickedPlace | null>(null)
const [from, setFrom] = useState<PickedPlace | null>(initialFrom)
const [to, setTo] = useState<PickedPlace | null>(initialTo)
const [time, setTime] = useState('09:00')
const [arriveBy, setArriveBy] = useState(false)
const [activeModes, setActiveModes] = useState<Set<string>>(() => new Set(MODE_GROUPS.map(m => m.key)))
@@ -442,7 +445,6 @@ export default function TransitSearchModal({ isOpen, onClose, day, days, places,
}
await onAdd(payload)
onClose()
} catch {
toast.error(t('common.unknownError'))
} finally {
@@ -461,8 +463,7 @@ export default function TransitSearchModal({ isOpen, onClose, day, days, places,
})
return (
<Modal isOpen={isOpen} onClose={onClose} title={t('transit.title')} size="lg">
<div style={{ display: 'flex', flexDirection: 'column', gap: 14, fontFamily: 'var(--font-system)' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, fontFamily: 'var(--font-system)' }}>
{/* from / to */}
<div style={{ display: 'flex', gap: 8, alignItems: 'flex-end' }}>
<StopPicker label={t('transit.from')} value={from} onPick={setFrom} quickPicks={quickPicks} near={near} placeholder={t('transit.searchStop')} />
@@ -568,6 +569,5 @@ export default function TransitSearchModal({ isOpen, onClose, day, days, places,
</div>
)}
</div>
</Modal>
)
}
@@ -366,4 +366,28 @@ describe('TransportModal', () => {
expect(payload.metadata?.transit).toBeUndefined();
expect(payload.endpoints.map((e: { role: string }) => e.role)).toEqual(['from', 'to']);
});
// ── Manual / Automated creation switch (#1065) ─────────────────────────────
it('FE-PLANNER-TRANSMODAL-022: creating shows the Manual/Automated switch; Automated opens the transit search', async () => {
render(<TransportModal {...defaultProps} places={[]} accommodations={[]} />);
expect(screen.getByRole('button', { name: 'Manual transport' })).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: 'Automated transport' }));
// No day selected in defaultProps (days: []) — the pick-day hint shows.
expect(screen.getByText(/Pick a day/)).toBeInTheDocument();
// The manual form is gone in automated mode.
expect(screen.queryByPlaceholderText(/e\.g\. Lufthansa/i)).not.toBeInTheDocument();
});
it('FE-PLANNER-TRANSMODAL-023: initialAutomated opens straight in the transit search with the day preset', () => {
const days = [{ id: 10, trip_id: 1, day_number: 1, date: '2025-06-01', title: 'Day 1' }] as any;
render(<TransportModal {...defaultProps} days={days} selectedDayId={10} initialAutomated places={[]} accommodations={[]} />);
expect(screen.getAllByPlaceholderText('Search stop or station…')).toHaveLength(2);
});
it('FE-PLANNER-TRANSMODAL-024: editing shows no Manual/Automated switch', () => {
const res = buildReservation({ title: 'My Train', type: 'train' });
render(<TransportModal {...defaultProps} reservation={res} />);
expect(screen.queryByRole('button', { name: 'Automated transport' })).not.toBeInTheDocument();
});
});
@@ -13,11 +13,12 @@ import { useAddonStore } from '../../store/addonStore'
import { formatDate, splitReservationDateTime, resolveDayId } from '../../utils/formatters'
import { openFile } from '../../utils/fileDownload'
import apiClient from '../../api/client'
import type { Day, Reservation, ReservationEndpoint, TripFile, BudgetItem } from '../../types'
import type { Day, Place, Accommodation, Reservation, ReservationEndpoint, TripFile, BudgetItem } from '../../types'
import { parseReservationMetadata, orderedEndpoints } from '../../utils/flightLegs'
import { BookingCostsSection } from './BookingCostsSection'
import type { BookingExpenseRequest } from './BookingCostsSection.types'
import type { BookingReviewDraft } from './parsedItemToDraft'
import TransitSearchPanel, { type PickedPlace } from './TransitSearchPanel'
import { typeToCostCategory } from '@trek/shared'
const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transit', 'transport_other'] as const
@@ -96,7 +97,6 @@ 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 },
]
@@ -131,9 +131,16 @@ interface TransportModalProps {
// Pre-fill a brand-new transport booking from a parsed import item (review-
// before-save); like `reservation` for the form but stays in create mode.
prefill?: BookingReviewDraft | null
/** Data for the Automated (public transit) mode's quick picks. */
places?: Place[]
accommodations?: Accommodation[]
/** Open directly in the Automated public-transit mode (day-header tram button, "change route"). */
initialAutomated?: boolean
/** Pre-seed the transit search — used by "change route" on an existing journey. */
transitPrefill?: { from?: PickedPlace | null; to?: PickedPlace | null } | null
}
export function TransportModal({ isOpen, onClose, onSave, reservation, days, selectedDayId, files = [], onFileUpload, onFileDelete, onOpenExpense, prefill = null }: TransportModalProps) {
export function TransportModal({ isOpen, onClose, onSave, reservation, days, selectedDayId, files = [], onFileUpload, onFileDelete, onOpenExpense, prefill = null, places = [], accommodations = [], initialAutomated = false, transitPrefill = null }: TransportModalProps) {
const { t, locale } = useTranslation()
const toast = useToast()
const isBudgetEnabled = useAddonStore(s => s.isEnabled('budget'))
@@ -145,6 +152,8 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
// the post-save handler knows to open the Costs editor for the saved booking.
const expenseIntentRef = useRef<{ editItem?: BudgetItem; create?: boolean } | null>(null)
const [form, setForm] = useState({ ...defaultForm })
// Manual vs Automated (public transit search) creation mode (#1065).
const [automated, setAutomated] = useState(false)
const [isSaving, setIsSaving] = useState(false)
const [fromPick, setFromPick] = useState<EndpointPick>({})
const [toPick, setToPick] = useState<EndpointPick>({})
@@ -162,6 +171,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
// Either way the init reads the same fields — `reservation` still decides
// edit-vs-create at submit time.
const src = (reservation ?? prefill) as Reservation | null
if (src) setAutomated(initialAutomated)
// On a review-import, seed the booking's Files with the parsed source document.
setPendingFiles(!reservation && prefill?._sourceFiles ? prefill._sourceFiles : [])
if (src) {
@@ -233,6 +243,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
}
} else {
setForm({ ...defaultForm, start_day_id: selectedDayId ?? '', end_day_id: selectedDayId ?? '' })
setAutomated(initialAutomated)
setFromPick({})
setToPick({})
setWaypoints([emptyWaypoint(selectedDayId ?? ''), emptyWaypoint(selectedDayId ?? '')])
@@ -465,19 +476,62 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
<Modal
isOpen={isOpen}
onClose={onClose}
title={reservation ? t('transport.modalTitle.edit') : t('transport.modalTitle.create')}
title={automated ? t('transit.title') : reservation ? t('transport.modalTitle.edit') : t('transport.modalTitle.create')}
size="2xl"
footer={
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<button type="button" onClick={onClose} className="text-content-muted" style={{ padding: '8px 16px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', fontSize: 'calc(12px * var(--fs-scale-body, 1))', cursor: 'pointer', fontFamily: 'inherit' }}>
{t('common.cancel')}
</button>
{!automated && (
<button type="button" onClick={handleSubmit} disabled={isSaving || !form.title.trim()} className="bg-[var(--text-primary)] text-[var(--bg-primary)]" style={{ padding: '8px 20px', borderRadius: 10, border: 'none', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', opacity: isSaving || !form.title.trim() ? 0.5 : 1 }}>
{isSaving ? t('common.saving') : reservation ? t('common.update') : t('common.add')}
</button>
)}
</div>
}
>
{/* Manual vs Automated creation switch (#1065) — creating only; editing a
journey re-enters via "change route" with the switch hidden. */}
{!reservation && (
<div className="bg-surface-secondary" style={{ display: 'flex', borderRadius: 11, padding: 3, gap: 2, marginBottom: 14 }}>
{([['manual', t('transport.modeManual')], ['automated', t('transport.modeAutomated')]] as const).map(([m, label]) => {
const active = (m === 'automated') === automated
return (
<button key={m} type="button" onClick={() => setAutomated(m === 'automated')}
className={active ? 'bg-surface-card text-content' : 'text-content-muted'}
style={{ flex: 1, padding: '8px 6px', fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', fontWeight: 500, borderRadius: 8, border: 0, cursor: 'pointer', fontFamily: 'inherit', whiteSpace: 'nowrap', background: active ? undefined : 'transparent', boxShadow: active ? '0 1px 4px rgba(0,0,0,0.08)' : 'none' }}>
{label}
</button>
)
})}
</div>
)}
{automated ? (
/* ── Automated: public transit search (#1065) ── */
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div style={{ maxWidth: 280 }}>
<label className={labelClass}>{t('reservations.departureDate')}</label>
<CustomSelect value={form.start_day_id} onChange={v => set('start_day_id', v)} placeholder={t('dayplan.dayN', { n: '?' })} options={dayOptions} size="sm" />
</div>
{(() => {
const transitDay = days.find(d => d.id === Number(form.start_day_id))
if (!transitDay) return <div className="text-content-faint" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', padding: '12px 0' }}>{t('transit.pickDay')}</div>
return (
<TransitSearchPanel
day={transitDay}
days={days}
places={places}
accommodations={accommodations}
onAdd={(payload) => onSave(payload as Record<string, any> & { title: string })}
initialFrom={transitPrefill?.from ?? null}
initialTo={transitPrefill?.to ?? null}
/>
)
})()}
</div>
) : (
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
{/* Type selector */}
@@ -781,6 +835,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
)}
</form>
)}
</Modal>
)
}
+36 -24
View File
@@ -17,7 +17,7 @@ import SlidingTabs from '../components/shared/SlidingTabs'
import TripMembersModal from '../components/Trips/TripMembersModal'
import { ReservationModal } from '../components/Planner/ReservationModal'
import { TransportModal } from '../components/Planner/TransportModal'
import TransitSearchModal from '../components/Planner/TransitSearchModal'
import TransitJourneyModal from '../components/Planner/TransitJourneyModal'
import BookingImportModal from '../components/Planner/BookingImportModal'
import AirTrailImportModal from '../components/Planner/AirTrailImportModal'
// MemoriesPanel moved to Journey addon
@@ -195,7 +195,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
bookingForAssignmentId, setBookingForAssignmentId,
showTransportModal, setShowTransportModal, editingTransport, setEditingTransport,
transportModalDayId, setTransportModalDayId,
transitModalDayId, setTransitModalDayId, handleAddTransitRoute,
transportModalAutomated, setTransportModalAutomated, transitPrefill, setTransitPrefill, transitJourney, setTransitJourney,
reservationPrefill, transportPrefill, importReviewActive, advanceImportReview,
routeShown, setRouteShown, routeProfile, setRouteProfile, fitKey, setFitKey,
mobileSidebarOpen, setMobileSidebarOpen, mobilePlanScrollTopRef, mobilePlacesScrollTopRef,
@@ -398,8 +398,9 @@ export default function TripPlannerPage(): React.ReactElement | null {
externalTransportDetail={mapTransportDetail}
onExternalTransportDetailHandled={() => setMapTransportDetail(null)}
onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true) }}
onAddTransport={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setShowTransportModal(true) } : undefined}
onPlanTransit={can('day_edit', trip) ? (dayId) => setTransitModalDayId(dayId) : undefined}
onAddTransport={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(false); setShowTransportModal(true) } : undefined}
onOpenTransit={(r) => setTransitJourney(r)}
onPlanTransit={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(true); setShowTransportModal(true) } : undefined}
onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true) } : undefined}
onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true) } : undefined}
onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }}
@@ -622,7 +623,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
</div>
<div style={{ flex: 1, overflow: 'auto' }}>
{mobileSidebarOpen === 'left'
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId) }} onReorder={handleReorder} onReorderDays={handleReorderDays} onAddDay={handleAddDay} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute([r.coordinates]); setRouteInfo(r) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddTransport={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onPlanTransit={can('day_edit', trip) ? (dayId) => { setTransitModalDayId(dayId); setMobileSidebarOpen(null) } : undefined} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }} onRemoveAssignment={handleRemoveAssignment} onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} accommodations={tripAccommodations} routeShown={routeShown} routeProfile={routeProfile} onToggleRoute={() => setRouteShown(v => !v)} onSetRouteProfile={setRouteProfile} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} initialScrollTop={mobilePlanScrollTopRef.current} onScrollTopChange={(top) => { mobilePlanScrollTopRef.current = top }} showRouteToolsWhenExpanded />
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId) }} onReorder={handleReorder} onReorderDays={handleReorderDays} onAddDay={handleAddDay} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute([r.coordinates]); setRouteInfo(r) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddTransport={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(false); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onOpenTransit={(r) => { setTransitJourney(r); setMobileSidebarOpen(null) }} onPlanTransit={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(true); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }} onRemoveAssignment={handleRemoveAssignment} onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} accommodations={tripAccommodations} routeShown={routeShown} routeProfile={routeProfile} onToggleRoute={() => setRouteShown(v => !v)} onSetRouteProfile={setRouteProfile} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} initialScrollTop={mobilePlanScrollTopRef.current} onScrollTopChange={(top) => { mobilePlanScrollTopRef.current = top }} showRouteToolsWhenExpanded />
: <PlacesSidebar tripId={tripId} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onPlaceClick={(placeId) => { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { openPlaceEditor(place); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)} onBulkDeleteConfirm={(ids) => confirmDeletePlaces(ids)} onBulkChangeCategory={(ids, catId) => confirmChangeCategory(ids, catId)} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} onPlacesFilterChange={setMapPlacesFilter} pushUndo={pushUndo} initialScrollTop={mobilePlacesScrollTopRef.current} onScrollTopChange={(top) => { mobilePlacesScrollTopRef.current = top }} />
}
</div>
@@ -641,12 +642,12 @@ export default function TripPlannerPage(): React.ReactElement | null {
days={days}
assignments={assignments}
files={files}
onAdd={() => { setEditingTransport(null); setShowTransportModal(true) }}
onAdd={() => { setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(false); setShowTransportModal(true) }}
onImport={() => setShowBookingImport(true)}
bookingImportAvailable={bookingImportAvailable}
onAirTrailImport={() => setShowAirTrailImport(true)}
airTrailAvailable={airTrailAvailable}
onEdit={(r) => { setEditingTransport(r); setShowTransportModal(true) }}
onEdit={(r) => { if (r.type === 'transit') { setTransitJourney(r) } else { setEditingTransport(r); setTransportModalAutomated(false); setShowTransportModal(true) } }}
onDelete={handleDeleteReservation}
onNavigateToFiles={() => handleTabChange('dateien')}
titleKey="transport.title"
@@ -719,23 +720,34 @@ export default function TripPlannerPage(): React.ReactElement | null {
/>
<TripMembersModal isOpen={showMembersModal} onClose={() => setShowMembersModal(false)} tripId={tripId} tripTitle={trip?.title} />
<ReservationModal isOpen={showReservationModal} onClose={() => { if (importReviewActive) { advanceImportReview() } else { setShowReservationModal(false); setEditingReservation(null); setBookingForAssignmentId(null) } }} onSave={async (data) => { const r = await handleSaveReservation(data); if (importReviewActive && r) advanceImportReview(); return r }} reservation={editingReservation} prefill={reservationPrefill} days={days} places={places} assignments={assignments} selectedDayId={selectedDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} accommodations={tripAccommodations} defaultAssignmentId={bookingForAssignmentId} onOpenExpense={openBookingExpense} />
{showTransportModal && <TransportModal isOpen={showTransportModal} onClose={() => { if (importReviewActive) { advanceImportReview() } else { setShowTransportModal(false); setEditingTransport(null); setTransportModalDayId(null) } }} onSave={async (data) => { const r = await handleSaveTransport(data); if (importReviewActive && r) advanceImportReview(); return r }} reservation={editingTransport} prefill={transportPrefill} days={days} selectedDayId={transportModalDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} onOpenExpense={openBookingExpense} />}
{/* Public transit route search (#1065) */}
{transitModalDayId != null && (() => {
const transitDay = days.find(d => d.id === transitModalDayId)
if (!transitDay) return null
return (
<TransitSearchModal
isOpen
onClose={() => setTransitModalDayId(null)}
day={transitDay}
days={days}
places={places}
accommodations={tripAccommodations}
onAdd={handleAddTransitRoute}
/>
)
})()}
{showTransportModal && <TransportModal isOpen={showTransportModal} onClose={() => { if (importReviewActive) { advanceImportReview() } else { setShowTransportModal(false); setEditingTransport(null); setTransportModalDayId(null); setTransportModalAutomated(false); setTransitPrefill(null) } }} onSave={async (data) => { const r = await handleSaveTransport(data); if (importReviewActive && r) advanceImportReview(); return r }} reservation={editingTransport} prefill={transportPrefill} days={days} selectedDayId={transportModalDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} onOpenExpense={openBookingExpense} places={places} accommodations={tripAccommodations} initialAutomated={transportModalAutomated} transitPrefill={transitPrefill} />}
{/* Journey view for a saved public-transit entry (#1065) */}
{transitJourney && (
<TransitJourneyModal
reservation={transitJourney}
canEdit={can('day_edit', trip)}
onClose={() => setTransitJourney(null)}
onSave={async (fields) => { await tripActions.updateReservation(tripId, transitJourney.id, fields); setTransitJourney(null) }}
onDelete={async () => { await handleDeleteReservation(transitJourney.id); setTransitJourney(null) }}
onChangeRoute={() => {
// Re-enter the transit search seeded with this journey's route; the
// existing reservation is REPLACED on save (editingTransport drives
// handleSaveTransport's update path).
const eps = transitJourney.endpoints || []
const from = eps.find(e => e.role === 'from')
const to = eps.find(e => e.role === 'to')
setTransitPrefill({
from: from ? { name: from.name, lat: from.lat, lng: from.lng } : null,
to: to ? { name: to.name, lat: to.lat, lng: to.lng } : null,
})
setEditingTransport(transitJourney)
setTransportModalDayId(transitJourney.day_id ?? null)
setTransportModalAutomated(true)
setTransitJourney(null)
setShowTransportModal(true)
}}
/>
)}
{bookingExpense && (
<ExpenseModal
tripId={tripId}
+6 -11
View File
@@ -162,8 +162,11 @@ export function useTripPlanner() {
const [showTransportModal, setShowTransportModal] = useState<boolean>(false)
const [editingTransport, setEditingTransport] = useState<Reservation | null>(null)
const [transportModalDayId, setTransportModalDayId] = useState<number | null>(null)
// Public transit route search (#1065) — non-null while the modal is open for that day.
const [transitModalDayId, setTransitModalDayId] = useState<number | null>(null)
// Public transit (#1065): open the TransportModal in its Automated mode, seed
// the search (change-route), and show the journey view for a saved entry.
const [transportModalAutomated, setTransportModalAutomated] = useState<boolean>(false)
const [transitPrefill, setTransitPrefill] = useState<{ from?: { name: string; lat: number; lng: number } | null; to?: { name: string; lat: number; lng: number } | null } | null>(null)
const [transitJourney, setTransitJourney] = useState<Reservation | null>(null)
// The bottom-nav "+" is context-aware per tab: on the Bookings / Transports tabs
// it opens the booking / transport modal via ?create=reservation|transport
@@ -691,14 +694,6 @@ export function useTripPlanner() {
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
}
// A chosen transit itinerary is persisted as a regular transport reservation,
// so it slots into the timeline by time and inherits edit/delete/drag (#1065).
const handleAddTransitRoute = async (payload: Record<string, unknown> & { title: string }) => {
const r = await tripActions.addReservation(tripId, payload)
toast.success(t('trip.toast.reservationAdded'))
return r
}
const handleDeleteReservation = async (id) => {
try {
await tripActions.deleteReservation(tripId, id)
@@ -869,7 +864,7 @@ export function useTripPlanner() {
bookingForAssignmentId, setBookingForAssignmentId,
showTransportModal, setShowTransportModal, editingTransport, setEditingTransport,
transportModalDayId, setTransportModalDayId,
transitModalDayId, setTransitModalDayId, handleAddTransitRoute,
transportModalAutomated, setTransportModalAutomated, transitPrefill, setTransitPrefill, transitJourney, setTransitJourney,
reservationPrefill, transportPrefill, importReviewActive, startImportReview, advanceImportReview,
routeShown, setRouteShown, routeProfile, setRouteProfile, fitKey, setFitKey,
mobileSidebarOpen, setMobileSidebarOpen, mobilePlanScrollTopRef, mobilePlacesScrollTopRef,
+7
View File
@@ -69,5 +69,12 @@ const trip: TranslationStrings = {
'transit.addToDay': 'إضافة إلى اليوم',
'transit.itinerary': 'خط سير الرحلة',
'transit.attribution': 'بيانات المسارات عبر',
'transport.modeManual': 'نقل يدوي',
'transport.modeAutomated': 'نقل تلقائي',
'transit.sectionTitle': 'النقل العام التلقائي',
'transit.changeRoute': 'تغيير المسار',
'transit.journey': 'رحلة بالنقل العام',
'transit.pickDay': 'اختر يومًا للبحث عن الرحلات المتاحة.',
'transit.noItinerary': 'لا يحتوي هذا الإدخال على خط سير محفوظ — استخدم "تغيير المسار" للبحث عن أحدها.',
};
export default trip;
+7
View File
@@ -69,5 +69,12 @@ const trip: TranslationStrings = {
'transit.addToDay': 'Adicionar ao dia',
'transit.itinerary': 'Itinerário',
'transit.attribution': 'Dados de rota via',
'transport.modeManual': 'Transporte manual',
'transport.modeAutomated': 'Transporte automático',
'transit.sectionTitle': 'Transporte público automático',
'transit.changeRoute': 'Alterar trajeto',
'transit.journey': 'Viagem de transporte público',
'transit.pickDay': 'Escolha um dia para buscar conexões.',
'transit.noItinerary': 'Esta entrada não tem itinerário salvo — use "Alterar trajeto" para buscar um.',
};
export default trip;
+7
View File
@@ -69,5 +69,12 @@ const trip: TranslationStrings = {
'transit.addToDay': 'Přidat ke dni',
'transit.itinerary': 'Itinerář',
'transit.attribution': 'Data o spojení přes',
'transport.modeManual': 'Ruční doprava',
'transport.modeAutomated': 'Automatická doprava',
'transit.sectionTitle': 'Automatické spojení MHD',
'transit.changeRoute': 'Změnit trasu',
'transit.journey': 'Cesta veřejnou dopravou',
'transit.pickDay': 'Vyberte den, pro který chcete vyhledat spojení.',
'transit.noItinerary': 'Tato položka nemá uložené spojení – vyhledejte ho pomocí tlačítka „Změnit trasu“.',
};
export default trip;
+7
View File
@@ -69,5 +69,12 @@ const trip: TranslationStrings = {
'transit.addToDay': 'Zum Tag hinzufügen',
'transit.itinerary': 'Verbindung',
'transit.attribution': 'Routing-Daten via',
'transport.modeManual': 'Manueller Transport',
'transport.modeAutomated': 'Automatischer Transport',
'transit.sectionTitle': 'Automatische ÖPNV-Verbindungen',
'transit.changeRoute': 'Route ändern',
'transit.journey': 'ÖPNV-Verbindung',
'transit.pickDay': 'Wähle einen Tag, für den Verbindungen gesucht werden sollen.',
'transit.noItinerary': 'Dieser Eintrag hat keine gespeicherte Verbindung — über „Route ändern" eine suchen.',
};
export default trip;
+7
View File
@@ -69,5 +69,12 @@ const trip: TranslationStrings = {
'transit.addToDay': 'Add to day',
'transit.itinerary': 'Itinerary',
'transit.attribution': 'Routing data via',
'transport.modeManual': 'Manual transport',
'transport.modeAutomated': 'Automated transport',
'transit.sectionTitle': 'Automated public transit',
'transit.changeRoute': 'Change route',
'transit.journey': 'Public transit journey',
'transit.pickDay': 'Pick a day to search connections for.',
'transit.noItinerary': 'This entry has no stored itinerary — use "Change route" to search one.',
};
export default trip;
+7
View File
@@ -69,5 +69,12 @@ const trip: TranslationStrings = {
'transit.addToDay': 'Añadir al día',
'transit.itinerary': 'Itinerario',
'transit.attribution': 'Datos de rutas vía',
'transport.modeManual': 'Transporte manual',
'transport.modeAutomated': 'Transporte automático',
'transit.sectionTitle': 'Transporte público automático',
'transit.changeRoute': 'Cambiar ruta',
'transit.journey': 'Trayecto en transporte público',
'transit.pickDay': 'Elige un día para buscar conexiones.',
'transit.noItinerary': 'Esta entrada no tiene ningún itinerario guardado: usa «Cambiar ruta» para buscar uno.',
};
export default trip;
+7
View File
@@ -69,5 +69,12 @@ const trip: TranslationStrings = {
'transit.addToDay': 'Ajouter au jour',
'transit.itinerary': 'Itinéraire',
'transit.attribution': 'Données d\'itinéraire via',
'transport.modeManual': 'Transport manuel',
'transport.modeAutomated': 'Transport automatisé',
'transit.sectionTitle': 'Transports en commun automatisés',
'transit.changeRoute': 'Modifier l\'itinéraire',
'transit.journey': 'Trajet en transports en commun',
'transit.pickDay': 'Choisissez un jour pour rechercher des connexions.',
'transit.noItinerary': 'Cette entrée n\'a aucun itinéraire enregistré — utilisez « Modifier l\'itinéraire » pour en rechercher un.',
};
export default trip;
+7
View File
@@ -69,5 +69,12 @@ const trip: TranslationStrings = {
'transit.addToDay': 'Προσθήκη στην ημέρα',
'transit.itinerary': 'Δρομολόγιο',
'transit.attribution': 'Δεδομένα διαδρομών μέσω',
'transport.modeManual': 'Χειροκίνητη μεταφορά',
'transport.modeAutomated': 'Αυτόματη μεταφορά',
'transit.sectionTitle': 'Αυτόματες δημόσιες συγκοινωνίες',
'transit.changeRoute': 'Αλλαγή διαδρομής',
'transit.journey': 'Διαδρομή με δημόσιες συγκοινωνίες',
'transit.pickDay': 'Επιλέξτε μια ημέρα για αναζήτηση συνδέσεων.',
'transit.noItinerary': 'Αυτή η καταχώριση δεν έχει αποθηκευμένο δρομολόγιο — χρησιμοποιήστε την «Αλλαγή διαδρομής» για να αναζητήσετε ένα.',
};
export default trip;
+7
View File
@@ -69,5 +69,12 @@ const trip: TranslationStrings = {
'transit.addToDay': 'Hozzáadás a naphoz',
'transit.itinerary': 'Útiterv',
'transit.attribution': 'Útvonaladatok innen:',
'transport.modeManual': 'Kézi közlekedés',
'transport.modeAutomated': 'Automatikus közlekedés',
'transit.sectionTitle': 'Automatikus tömegközlekedés',
'transit.changeRoute': 'Útvonal módosítása',
'transit.journey': 'Tömegközlekedési útvonal',
'transit.pickDay': 'Válassz egy napot a kapcsolatok kereséséhez.',
'transit.noItinerary': 'Ehhez a bejegyzéshez nincs mentett útiterv – az „Útvonal módosítása” gombbal kereshetsz egyet.',
};
export default trip;
+7
View File
@@ -69,5 +69,12 @@ const trip: TranslationStrings = {
'transit.addToDay': 'Tambahkan ke hari',
'transit.itinerary': 'Itinerari',
'transit.attribution': 'Data rute melalui',
'transport.modeManual': 'Transportasi manual',
'transport.modeAutomated': 'Transportasi otomatis',
'transit.sectionTitle': 'Transportasi umum otomatis',
'transit.changeRoute': 'Ubah rute',
'transit.journey': 'Perjalanan transportasi umum',
'transit.pickDay': 'Pilih hari untuk mencari koneksi.',
'transit.noItinerary': 'Entri ini tidak memiliki rencana perjalanan tersimpan — gunakan "Ubah rute" untuk mencarinya.',
};
export default trip;
+7
View File
@@ -69,5 +69,12 @@ const trip: TranslationStrings = {
'transit.addToDay': 'Aggiungi al giorno',
'transit.itinerary': 'Itinerario',
'transit.attribution': 'Dati di routing tramite',
'transport.modeManual': 'Trasporto manuale',
'transport.modeAutomated': 'Trasporto automatico',
'transit.sectionTitle': 'Trasporto pubblico automatico',
'transit.changeRoute': 'Cambia percorso',
'transit.journey': 'Viaggio in trasporto pubblico',
'transit.pickDay': 'Scegli un giorno per cui cercare i collegamenti.',
'transit.noItinerary': 'Questa voce non ha un itinerario salvato: usa "Cambia percorso" per cercarne uno.',
};
export default trip;
+7
View File
@@ -69,5 +69,12 @@ const trip: TranslationStrings = {
'transit.addToDay': '日程に追加',
'transit.itinerary': '行程',
'transit.attribution': 'ルートデータ提供:',
'transport.modeManual': '手動で入力',
'transport.modeAutomated': '自動で検索',
'transit.sectionTitle': '公共交通機関の自動検索',
'transit.changeRoute': '経路を変更',
'transit.journey': '公共交通機関の経路',
'transit.pickDay': '接続を検索する日を選択してください。',
'transit.noItinerary': 'この項目には保存された行程がありません。「経路を変更」から検索してください。',
};
export default trip;
+7
View File
@@ -69,5 +69,12 @@ const trip: TranslationStrings = {
'transit.addToDay': '일정에 추가',
'transit.itinerary': '여정',
'transit.attribution': '경로 데이터 제공:',
'transport.modeManual': '수동 교통편',
'transport.modeAutomated': '자동 교통편',
'transit.sectionTitle': '자동 대중교통',
'transit.changeRoute': '경로 변경',
'transit.journey': '대중교통 경로',
'transit.pickDay': '연결편을 검색할 날짜를 선택하세요.',
'transit.noItinerary': '이 항목에는 저장된 경로가 없습니다. "경로 변경"을 사용하여 검색하세요.',
};
export default trip;
+7
View File
@@ -69,5 +69,12 @@ const trip: TranslationStrings = {
'transit.addToDay': 'Toevoegen aan dag',
'transit.itinerary': 'Reisplan',
'transit.attribution': 'Routegegevens via',
'transport.modeManual': 'Handmatig vervoer',
'transport.modeAutomated': 'Automatisch vervoer',
'transit.sectionTitle': 'Automatisch openbaar vervoer',
'transit.changeRoute': 'Route wijzigen',
'transit.journey': 'Reis met openbaar vervoer',
'transit.pickDay': 'Kies een dag om verbindingen voor te zoeken.',
'transit.noItinerary': 'Deze invoer heeft geen opgeslagen reisplan — gebruik "Route wijzigen" om er een te zoeken.',
};
export default trip;
+7
View File
@@ -69,5 +69,12 @@ const trip: TranslationStrings = {
'transit.addToDay': 'Dodaj do dnia',
'transit.itinerary': 'Plan podróży',
'transit.attribution': 'Dane tras z',
'transport.modeManual': 'Transport ręczny',
'transport.modeAutomated': 'Transport automatyczny',
'transit.sectionTitle': 'Automatyczny transport publiczny',
'transit.changeRoute': 'Zmień trasę',
'transit.journey': 'Podróż transportem publicznym',
'transit.pickDay': 'Wybierz dzień, dla którego wyszukać połączenia.',
'transit.noItinerary': 'Ten wpis nie ma zapisanej trasy — użyj opcji „Zmień trasę”, aby ją wyszukać.',
};
export default trip;
+7
View File
@@ -69,5 +69,12 @@ const trip: TranslationStrings = {
'transit.addToDay': 'Добавить в день',
'transit.itinerary': 'Маршрут',
'transit.attribution': 'Данные о маршрутах через',
'transport.modeManual': 'Транспорт вручную',
'transport.modeAutomated': 'Автоматический транспорт',
'transit.sectionTitle': 'Автоматический общественный транспорт',
'transit.changeRoute': 'Изменить маршрут',
'transit.journey': 'Поездка на общественном транспорте',
'transit.pickDay': 'Выберите день для поиска соединений.',
'transit.noItinerary': 'Для этой записи нет сохранённого маршрута — нажмите «Изменить маршрут», чтобы найти его.',
};
export default trip;
+7
View File
@@ -69,5 +69,12 @@ const trip: TranslationStrings = {
'transit.addToDay': 'Lägg till dag',
'transit.itinerary': 'Reseplan',
'transit.attribution': 'Ruttdata via',
'transport.modeManual': 'Manuell transport',
'transport.modeAutomated': 'Automatisk transport',
'transit.sectionTitle': 'Automatisk kollektivtrafik',
'transit.changeRoute': 'Ändra resväg',
'transit.journey': 'Kollektivtrafikresa',
'transit.pickDay': 'Välj en dag att söka förbindelser för.',
'transit.noItinerary': 'Den här posten har ingen sparad resplan – använd ”Ändra resväg” för att söka en.',
};
export default trip;
+7
View File
@@ -69,5 +69,12 @@ const trip: TranslationStrings = {
'transit.addToDay': 'Güne ekle',
'transit.itinerary': 'Güzergah',
'transit.attribution': 'Rota verileri:',
'transport.modeManual': 'Manuel ulaşım',
'transport.modeAutomated': 'Otomatik ulaşım',
'transit.sectionTitle': 'Otomatik toplu taşıma',
'transit.changeRoute': 'Rotayı değiştir',
'transit.journey': 'Toplu taşıma yolculuğu',
'transit.pickDay': 'Bağlantı aramak için bir gün seçin.',
'transit.noItinerary': 'Bu kaydın kayıtlı bir güzergâhı yok — bir tane aramak için "Rotayı değiştir" seçeneğini kullanın.',
};
export default trip;
+7
View File
@@ -69,5 +69,12 @@ const trip: TranslationStrings = {
'transit.addToDay': 'Додати до дня',
'transit.itinerary': 'Маршрут',
'transit.attribution': 'Дані маршрутизації через',
'transport.modeManual': 'Транспорт вручну',
'transport.modeAutomated': 'Автоматичний транспорт',
'transit.sectionTitle': 'Автоматичний громадський транспорт',
'transit.changeRoute': 'Змінити маршрут',
'transit.journey': 'Поїздка громадським транспортом',
'transit.pickDay': 'Виберіть день для пошуку сполучень.',
'transit.noItinerary': 'Для цього запису немає збереженого маршруту — натисніть «Змінити маршрут», щоб знайти його.',
};
export default trip;
+7
View File
@@ -69,5 +69,12 @@ const trip: TranslationStrings = {
'transit.addToDay': 'Thêm vào ngày',
'transit.itinerary': 'Lộ trình',
'transit.attribution': 'Dữ liệu tìm tuyến từ',
'transport.modeManual': 'Phương tiện thủ công',
'transport.modeAutomated': 'Phương tiện tự động',
'transit.sectionTitle': 'Giao thông công cộng tự động',
'transit.changeRoute': 'Đổi tuyến',
'transit.journey': 'Hành trình giao thông công cộng',
'transit.pickDay': 'Chọn một ngày để tìm chuyến kết nối.',
'transit.noItinerary': 'Mục này chưa có lộ trình được lưu — dùng “Đổi tuyến” để tìm một lộ trình.',
};
export default trip;
+7
View File
@@ -69,5 +69,12 @@ const trip: TranslationStrings = {
'transit.addToDay': '加入當日行程',
'transit.itinerary': '行程',
'transit.attribution': '路線資料來自',
'transport.modeManual': '手動交通',
'transport.modeAutomated': '自動交通',
'transit.sectionTitle': '自動大眾運輸',
'transit.changeRoute': '變更路線',
'transit.journey': '大眾運輸行程',
'transit.pickDay': '請選擇要查詢班次的日期。',
'transit.noItinerary': '此項目尚未儲存行程——請使用「變更路線」查詢。',
};
export default trip;
+7
View File
@@ -69,5 +69,12 @@ const trip: TranslationStrings = {
'transit.addToDay': '添加到当天',
'transit.itinerary': '行程',
'transit.attribution': '路线数据来自',
'transport.modeManual': '手动添加交通',
'transport.modeAutomated': '自动查询交通',
'transit.sectionTitle': '自动公共交通',
'transit.changeRoute': '更改路线',
'transit.journey': '公共交通行程',
'transit.pickDay': '请选择要查询班次的日期。',
'transit.noItinerary': '此条目尚未保存行程 — 请使用"更改路线"查询班次。',
};
export default trip;
+2 -2
View File
@@ -8,12 +8,12 @@ Open the **Transports** tab in the trip planner and click **Add**, or open the p
## Public transit search
Each day header in the plan sidebar has a **transit button** (tram icon) that opens a public-transit route search, powered by [Transitous](https://transitous.org/) — free, open data, no API key or paid provider. (The rename pencil this button replaced moved next to the day name in the day detail panel.)
The **Add transport** dialog has two modes: **Manual transport** (the classic form) and **Automated transport** — a public-transit route search powered by [Transitous](https://transitous.org/), free open data with no API key or paid provider. The **transit button** (tram icon) on each day header opens the dialog straight in the Automated mode. (The rename pencil this button replaced moved next to the day name in the day detail panel.)
- Pick **from** and **to** (stop/station search; the day's own places and hotels appear as quick picks), a **depart/arrive** time, and filter by mode: train, subway, tram, bus, ferry, cable car.
- Rank the results by **best route**, **fewer transfers**, or **less walking**.
- Each result shows local departure/arrival times, duration, transfers, walking time and the line badges in their official colors; expand it for the stop-by-stop breakdown.
- **Add to day** saves the chosen connection as a first-class **transit** entry. It slots into the day timeline at its departure time and shows its line badges, transfers and walking time right in the plan; clicking it opens the full stop-by-stop itinerary (with an Edit action for times/notes). It can be deleted and drag-reordered like any other transport, and editing keeps the stored itinerary as long as the origin and destination are unchanged.
- **Add to day** saves the chosen connection as a first-class **transit** entry. It slots into the day timeline at its departure time and shows its line badges, transfers and walking time right in the plan. Clicking it opens the **journey view**: the full stop-by-stop itinerary together with the editable booking fields (title, status, confirmation code, notes), a **Change route** action that re-runs the search and replaces the itinerary, and delete. In the Transports tab these journeys appear in their own **Automated public transit** section.
Self-hosters can point the `TRANSIT_API_URL` environment variable at their own MOTIS instance.