feat(planner): polish the transit journey UI and fix its tab placement

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.
This commit is contained in:
Maurice
2026-07-02 21:41:31 +02:00
committed by Maurice
parent 5a007a1f0e
commit 06b94c14a5
74 changed files with 441 additions and 165 deletions
@@ -340,15 +340,16 @@ describe('DayPlanSidebar', () => {
},
},
}
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], reservations: [res as any], onEditTransport })} />)
// Line chip + transfer summary render inline in the timeline row.
const onOpenTransit = vi.fn()
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], reservations: [res as any], onEditTransport, onOpenTransit })} />)
// 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 ─────────────────────────────────────────────────────
@@ -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}
</span>
)}
<span style={{ fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', fontWeight: 500, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{res.title}
<span style={{ fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', fontWeight: 500, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', minWidth: 0 }}>
{transitMeta ? <TransitTitle title={res.title} iconSize={11} /> : res.title}
</span>
{(() => {
const { time: dispTime } = splitReservationDateTime(displayTime)
@@ -2087,28 +2088,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
})()}
</div>
{transitMeta ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginTop: 3, flexWrap: 'wrap' }}>
{transitMeta.legs.map((leg: { mode?: string; line?: string | null; line_color?: string | null; line_text_color?: string | null }, li: number) => (
<React.Fragment key={li}>
{li > 0 && <span style={{ fontSize: 9, color: 'var(--text-faint)' }}>›</span>}
{leg.mode === 'WALK'
? <Footprints size={10} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
: (
<span style={{
display: 'inline-flex', alignItems: 'center', borderRadius: 4, padding: '0 5px',
fontSize: 'calc(9.5px * var(--fs-scale-caption, 1))', fontWeight: 700, lineHeight: '15px',
background: leg.line_color || 'var(--bg-tertiary)',
color: leg.line_color ? (leg.line_text_color || '#fff') : 'var(--text-primary)',
}}>
{leg.line || leg.mode}
</span>
)}
</React.Fragment>
))}
<span style={{ fontSize: 'calc(9.5px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', marginLeft: 2 }}>
{transitMeta.transfers > 0 ? t('transit.transfers', { count: transitMeta.transfers }) : t('transit.direct')}
{transitMeta.walk_seconds > 59 && <> · <Footprints size={9} style={{ display: 'inline', verticalAlign: '-1px' }} /> {t('transit.min', { count: Math.round(transitMeta.walk_seconds / 60) })}</>}
</span>
<div style={{ display: 'flex', alignItems: 'center', marginTop: 3 }}>
<TransitLegChips legs={transitMeta.legs} transfers={transitMeta.transfers} size="sm" t={t} />
</div>
) : subtitle && (
<div style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', marginTop: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
@@ -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 (
<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 || {})
@@ -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 (
<div
className="bg-surface-card"
onClick={() => 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'}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{ width: 34, height: 34, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: 10, background: 'rgba(124,58,237,0.1)' }}>
<TramFront size={16} strokeWidth={1.8} color="#7c3aed" />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="text-content" style={{ fontSize: 'calc(13.5px * var(--fs-scale-body, 1))', fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
<TransitTitle title={r.title} iconSize={12} />
</div>
<div className="text-content-faint" style={{ fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', marginTop: 2 }}>
{[
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(' · ')}
</div>
</div>
{canEdit && (
<button
onClick={e => { e.stopPropagation(); setConfirmOpen(true) }}
title={t('common.delete')}
className="bg-transparent text-content-faint"
style={{ appearance: 'none', border: 'none', width: 26, height: 26, borderRadius: 6, display: 'grid', placeItems: 'center', cursor: 'pointer', flexShrink: 0 }}
onMouseEnter={e => { e.currentTarget.style.background = 'rgba(239,68,68,0.08)'; e.currentTarget.style.color = '#ef4444' }}
onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = 'var(--text-faint)' }}
>
<Trash2 size={13} />
</button>
)}
</div>
{transit && (
<div style={{ paddingLeft: 44 }}>
<TransitLegChips legs={transit.legs} transfers={transit.transfers} size="md" t={t} />
</div>
)}
{confirmOpen && ReactDOM.createPortal(
<div className="bg-[rgba(0,0,0,0.35)]" style={{ position: 'fixed', inset: 0, zIndex: 3000, display: 'flex', alignItems: 'center', justifyContent: 'center' }} onClick={e => { e.stopPropagation(); setConfirmOpen(false) }}>
<div className="bg-surface-card" style={{ borderRadius: 14, padding: 20, width: 340, boxShadow: '0 16px 48px rgba(0,0,0,0.22)' }} onClick={e => e.stopPropagation()}>
<div className="text-content" style={{ fontWeight: 600, fontSize: 'calc(14px * var(--fs-scale-body, 1))', marginBottom: 6 }}>{t('reservations.confirm.deleteTitle')}</div>
<div className="text-content-muted" style={{ fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', marginBottom: 14 }}>{t('reservations.confirm.deleteBody', { name: r.title })}</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<button onClick={e => { e.stopPropagation(); setConfirmOpen(false) }} className="text-content-muted" style={{ padding: '7px 14px', borderRadius: 9, border: '1px solid var(--border-primary)', background: 'none', fontSize: 'calc(12px * var(--fs-scale-body, 1))', cursor: 'pointer', fontFamily: 'inherit' }}>{t('common.cancel')}</button>
<button onClick={e => { e.stopPropagation(); setConfirmOpen(false); onDelete(r.id) }} style={{ padding: '7px 14px', borderRadius: 9, border: 'none', background: '#ef4444', color: '#fff', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}>{t('common.delete')}</button>
</div>
</div>
</div>,
document.body
)}
</div>
)
}
interface ReservationsPanelProps {
tripId: number
reservations: Reservation[]
@@ -718,7 +768,7 @@ export default function ReservationsPanel({ tripId, reservations, days, assignme
<>
{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} />)}
{transitEntries.map(r => <TransitJourneyCard key={r.id} r={r} days={days} onOpen={onEdit} onDelete={onDelete} canEdit={canEdit} />)}
</Section>
)}
{allPending.length > 0 && (
@@ -49,22 +49,39 @@ 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()
// 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(<TransitJourneyModal {...makeProps({ onSave })} />)
// 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(<TransitJourneyModal {...makeProps()} />)
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 () => {
@@ -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<unknown>
/** Partial field update — endpoints + itinerary stay untouched. */
onSave: (fields: { title: string; notes: string | null }) => Promise<unknown>
onDelete: () => Promise<unknown>
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<HTMLInputElement | null>(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 (
<Modal
@@ -122,40 +137,57 @@ export default function TransitJourneyModal({ reservation, onClose, onSave, onDe
}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 18, fontFamily: 'var(--font-system)' }}>
{/* header: route + date/time + status badge */}
{/* header: icon + inline-renamable title + date/time */}
<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 style={{ width: 48, height: 48, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: 14, background: '#7c3aed18' }}>
<TramFront size={23} 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 }}>
{editingTitle ? (
<input
ref={titleInputRef}
value={title}
onChange={e => 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)' }}
/>
) : (
<div className="text-content" style={{ display: 'flex', alignItems: 'center', gap: 7, fontSize: 'calc(17px * var(--fs-scale-subtitle, 1))', fontWeight: 700, letterSpacing: '-0.015em', minWidth: 0 }}>
<span style={{ minWidth: 0, overflow: 'hidden' }}><TransitTitle title={title} iconSize={15} /></span>
{canEdit && (
<button onClick={() => setEditingTitle(true)} aria-label={t('common.edit')} title={t('common.edit')} className="text-content-faint" style={{ border: 'none', background: 'none', padding: 3, cursor: 'pointer', display: 'flex', flexShrink: 0 }}>
<Pencil size={13} strokeWidth={1.8} />
</button>
)}
</div>
)}
<div className="text-content-muted" style={{ fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', marginTop: 3 }}>
{[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>
</>
)}
{/* journey stats — three full-width tiles */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 10 }}>
{statTiles.map(({ Icon, value, label }, i) => (
<div key={i} className="bg-surface-tertiary" style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '13px 16px', borderRadius: 12 }}>
<div className="bg-surface-card" style={{ width: 34, height: 34, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: 10 }}>
<Icon size={16} strokeWidth={1.9} className="text-content-muted" />
</div>
<div style={{ minWidth: 0 }}>
<div className="text-content" style={{ fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))', fontWeight: 700, letterSpacing: '-0.01em', whiteSpace: 'nowrap' }}>{value}</div>
<div className="text-content-faint" style={{ fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.04em' }}>{label}</div>
</div>
</div>
))}
</div>
{/* stop-by-stop itinerary — roomy */}
{/* stop-by-stop itinerary */}
<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')}
@@ -207,41 +239,38 @@ export default function TransitJourneyModal({ reservation, onClose, onSave, onDe
</>
)}
{/* 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} />
{/* notes — full width, markdown */}
<div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 6 }}>
<label className="text-content-faint" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.03em' }}>{t('reservations.notes')}</label>
{canEdit && (
<div className="bg-surface-secondary" style={{ display: 'flex', borderRadius: 8, padding: 2, gap: 2 }}>
{([['write', t('common.edit')], ['preview', t('common.preview')]] as const).map(([tab, label]) => (
<button key={tab} type="button" onClick={() => setNotesTab(tab)}
className={notesTab === tab ? 'bg-surface-card text-content' : 'text-content-muted'}
style={{ padding: '4px 12px', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 500, borderRadius: 6, border: 0, cursor: 'pointer', fontFamily: 'inherit', background: notesTab === tab ? undefined : 'transparent' }}>
{label}
</button>
))}
</div>
)}
</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>
))}
{canEdit && notesTab === 'write' ? (
<textarea
value={notes}
onChange={e => setNotes(e.target.value)}
placeholder={t('reservations.notesPlaceholder')}
className="w-full border border-edge rounded-[10px] px-[12px] py-[10px] text-[13px] font-[inherit] outline-none box-border text-content bg-surface-input"
style={{ minHeight: 130, resize: 'vertical', lineHeight: 1.55 }}
/>
) : (
<div className="bg-surface-tertiary" style={{ borderRadius: 10, padding: '12px 14px', minHeight: canEdit ? 130 : undefined }}>
{notes.trim()
? <div className="collab-note-md text-content" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', wordBreak: 'break-word', overflowWrap: 'anywhere' }}><Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{notes}</Markdown></div>
: <span className="text-content-faint" style={{ fontSize: 'calc(12.5px * var(--fs-scale-body, 1))' }}>{t('reservations.notesPlaceholder')}</span>}
</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
@@ -510,14 +510,23 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
{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 style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
{/* search header: what this is + the day it plans for */}
<div className="bg-surface-tertiary" style={{ display: 'flex', alignItems: 'center', gap: 14, padding: '14px 16px', borderRadius: 14, flexWrap: 'wrap' }}>
<div style={{ width: 42, height: 42, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: 12, background: '#7c3aed18' }}>
<TramFront size={20} strokeWidth={1.8} color="#7c3aed" />
</div>
<div style={{ flex: 1, minWidth: 180 }}>
<div className="text-content" style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 700, letterSpacing: '-0.01em' }}>{t('transit.title')}</div>
<div className="text-content-faint" style={{ fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', marginTop: 1 }}>{t('transit.searchHint')}</div>
</div>
<div style={{ width: 210, flexShrink: 0 }}>
<CustomSelect value={form.start_day_id} onChange={v => set('start_day_id', v)} placeholder={t('dayplan.dayN', { n: '?' })} options={dayOptions} size="sm" />
</div>
</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>
if (!transitDay) return <div className="text-content-faint" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', padding: '4px 2px 12px' }}>{t('transit.pickDay')}</div>
return (
<TransitSearchPanel
day={transitDay}
@@ -0,0 +1,79 @@
import React from 'react'
import { Footprints, MoveRight } from 'lucide-react'
/**
* Shared display bits for public-transit entries (#1065) — the timeline row,
* the Transports-tab card and the journey modal all render the same language:
* "A → B" titles with a real arrow icon, and the leg sequence as chips where
* walks carry their minutes (🚶 3 › ⟨U2⟩ › 🚶 3) instead of a detached summary.
*/
export interface TransitLegDisplay {
mode?: string
line?: string | null
line_color?: string | null
line_text_color?: string | null
duration?: number
}
/** Renders "From → To" titles with an arrow icon instead of the text glyph. */
export function TransitTitle({ title, iconSize = 12 }: { title: string; iconSize?: number }) {
const parts = title.split(' → ')
if (parts.length < 2) return <>{title}</>
return (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 5, minWidth: 0, maxWidth: '100%' }}>
{parts.map((p, i) => (
<React.Fragment key={i}>
{i > 0 && <MoveRight size={iconSize} style={{ flexShrink: 0, opacity: 0.55 }} />}
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p}</span>
</React.Fragment>
))}
</span>
)
}
/**
* The leg sequence as chips. Walk legs show their minutes right at the foot
* icon (sub-minute walks are dropped); transit legs are line badges in their
* colors. Optionally appends "· N transfers" — never a redundant "direct".
*/
export function TransitLegChips({ legs, transfers, size = 'sm', t }: {
legs: TransitLegDisplay[]
transfers?: number
size?: 'sm' | 'md'
t: (k: string, p?: Record<string, string | number>) => string
}) {
const badgeFont = size === 'sm' ? 'calc(9.5px * var(--fs-scale-caption, 1))' : 'calc(10.5px * var(--fs-scale-caption, 1))'
const walkIcon = size === 'sm' ? 10 : 12
const shown = legs.filter(l => l.mode !== 'WALK' || (l.duration || 0) >= 60)
return (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: size === 'sm' ? 4 : 5, flexWrap: 'wrap' }}>
{shown.map((leg, i) => (
<React.Fragment key={i}>
{i > 0 && <span className="text-content-faint" style={{ fontSize: size === 'sm' ? 9 : 10 }}>›</span>}
{leg.mode === 'WALK' ? (
<span className="text-content-faint" style={{ display: 'inline-flex', alignItems: 'center', gap: 2, fontSize: badgeFont, fontWeight: 600 }}>
<Footprints size={walkIcon} />
{Math.round((leg.duration || 0) / 60)}
</span>
) : (
<span style={{
display: 'inline-flex', alignItems: 'center', borderRadius: size === 'sm' ? 4 : 5,
padding: size === 'sm' ? '0 5px' : '1px 7px', lineHeight: size === 'sm' ? '15px' : undefined,
fontSize: badgeFont, 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>
)}
</React.Fragment>
))}
{typeof transfers === 'number' && transfers > 0 && (
<span className="text-content-faint" style={{ fontSize: badgeFont, marginLeft: 2 }}>
· {t('transit.transfers', { count: transfers })}
</span>
)}
</span>
)
}
@@ -92,7 +92,7 @@ export function useTripPlanner() {
}).catch(() => {})
}, [])
const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transport_other'])
const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transit', 'transport_other'])
const TRIP_TABS = [
{ id: 'plan', label: t('trip.tabs.plan'), icon: Map },
+1
View File
@@ -7,6 +7,7 @@ const common: TranslationStrings = {
'common.cancel': 'إلغاء',
'common.clear': 'مسح',
'common.delete': 'حذف',
'common.preview': 'معاينة',
'common.edit': 'تعديل',
'common.add': 'إضافة',
'common.loading': 'جارٍ التحميل...',
+1 -1
View File
@@ -5,6 +5,6 @@ const transport: TranslationStrings = {
'transport.modalTitle.create': 'إضافة وسيلة نقل',
'transport.modalTitle.edit': 'تعديل وسيلة النقل',
'transport.title': 'المواصلات',
'transport.addManual': 'نقل يدوي',
'transport.addManual': 'وسيلة نقل',
};
export default transport;
+4
View File
@@ -76,5 +76,9 @@ const trip: TranslationStrings = {
'transit.journey': 'رحلة بالنقل العام',
'transit.pickDay': 'اختر يومًا للبحث عن الرحلات المتاحة.',
'transit.noItinerary': 'لا يحتوي هذا الإدخال على خط سير محفوظ — استخدم "تغيير المسار" للبحث عن أحدها.',
'transit.durationLabel': 'المدة',
'transit.transfersLabel': 'التحويلات',
'transit.walkLabel': 'المشي',
'transit.searchHint': 'ابحث عن رحلات فعلية وأضفها مباشرة إلى اليوم — البيانات عبر Transitous.',
};
export default trip;
+1
View File
@@ -7,6 +7,7 @@ const common: TranslationStrings = {
'common.cancel': 'Cancelar',
'common.clear': 'Limpar',
'common.delete': 'Excluir',
'common.preview': 'Prévia',
'common.edit': 'Editar',
'common.add': 'Adicionar',
'common.loading': 'Carregando...',
+1 -1
View File
@@ -5,6 +5,6 @@ const transport: TranslationStrings = {
'transport.modalTitle.create': 'Adicionar transporte',
'transport.modalTitle.edit': 'Editar transporte',
'transport.title': 'Transportes',
'transport.addManual': 'Transporte Manual',
'transport.addManual': 'Transporte',
};
export default transport;
+4
View File
@@ -76,5 +76,9 @@ const trip: TranslationStrings = {
'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.',
'transit.durationLabel': 'Duração',
'transit.transfersLabel': 'Baldeações',
'transit.walkLabel': 'A pé',
'transit.searchHint': 'Busque conexões reais e adicione direto ao dia — dados via Transitous.',
};
export default trip;
+1
View File
@@ -7,6 +7,7 @@ const common: TranslationStrings = {
'common.cancel': 'Zrušit',
'common.clear': 'Vymazat',
'common.delete': 'Smazat',
'common.preview': 'Náhled',
'common.edit': 'Upravit',
'common.add': 'Přidat',
'common.loading': 'Načítání...',
+1 -1
View File
@@ -5,6 +5,6 @@ const transport: TranslationStrings = {
'transport.modalTitle.create': 'Přidat dopravu',
'transport.modalTitle.edit': 'Upravit dopravu',
'transport.title': 'Doprava',
'transport.addManual': 'Ruční doprava',
'transport.addManual': 'Doprava',
};
export default transport;
+4
View File
@@ -76,5 +76,9 @@ const trip: TranslationStrings = {
'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“.',
'transit.durationLabel': 'Doba trvání',
'transit.transfersLabel': 'Přestupy',
'transit.walkLabel': 'Chůze',
'transit.searchHint': 'Vyhledejte reálná spojení a přidejte je rovnou do dne — data přes Transitous.',
};
export default trip;
+1
View File
@@ -7,6 +7,7 @@ const common: TranslationStrings = {
'common.cancel': 'Abbrechen',
'common.clear': 'Löschen',
'common.delete': 'Löschen',
'common.preview': 'Vorschau',
'common.edit': 'Bearbeiten',
'common.add': 'Hinzufügen',
'common.loading': 'Laden...',
+1 -1
View File
@@ -5,6 +5,6 @@ const transport: TranslationStrings = {
'transport.modalTitle.create': 'Transport hinzufügen',
'transport.modalTitle.edit': 'Transport bearbeiten',
'transport.title': 'Transporte',
'transport.addManual': 'Manuelles Transportmittel',
'transport.addManual': 'Transport',
};
export default transport;
+4
View File
@@ -76,5 +76,9 @@ const trip: TranslationStrings = {
'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.',
'transit.durationLabel': 'Dauer',
'transit.transfersLabel': 'Umstiege',
'transit.walkLabel': 'Fußweg',
'transit.searchHint': 'Echte Verbindungen suchen und direkt zum Tag hinzufügen — Daten via Transitous.',
};
export default trip;
+1
View File
@@ -7,6 +7,7 @@ const common: TranslationStrings = {
'common.cancel': 'Cancel',
'common.clear': 'Clear',
'common.delete': 'Delete',
'common.preview': 'Preview',
'common.edit': 'Edit',
'common.add': 'Add',
'common.loading': 'Loading...',
+1 -1
View File
@@ -5,6 +5,6 @@ const transport: TranslationStrings = {
'transport.modalTitle.create': 'Add transport',
'transport.modalTitle.edit': 'Edit transport',
'transport.title': 'Transports',
'transport.addManual': 'Manual Transport',
'transport.addManual': 'Transport',
};
export default transport;
+4
View File
@@ -76,5 +76,9 @@ const trip: TranslationStrings = {
'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.',
'transit.durationLabel': 'Duration',
'transit.transfersLabel': 'Transfers',
'transit.walkLabel': 'Walking',
'transit.searchHint': 'Search real connections and add them straight to the day — data via Transitous.',
};
export default trip;
+1
View File
@@ -7,6 +7,7 @@ const common: TranslationStrings = {
'common.cancel': 'Cancelar',
'common.clear': 'Borrar',
'common.delete': 'Eliminar',
'common.preview': 'Vista previa',
'common.edit': 'Editar',
'common.add': 'Añadir',
'common.loading': 'Cargando...',
+1 -1
View File
@@ -5,6 +5,6 @@ const transport: TranslationStrings = {
'transport.modalTitle.create': 'Añadir transporte',
'transport.modalTitle.edit': 'Editar transporte',
'transport.title': 'Transportes',
'transport.addManual': 'Transporte manual',
'transport.addManual': 'Transporte',
};
export default transport;
+4
View File
@@ -76,5 +76,9 @@ const trip: TranslationStrings = {
'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.',
'transit.durationLabel': 'Duración',
'transit.transfersLabel': 'Transbordos',
'transit.walkLabel': 'A pie',
'transit.searchHint': 'Busca conexiones reales y añádelas directamente al día — datos vía Transitous.',
};
export default trip;
+1
View File
@@ -7,6 +7,7 @@ const common: TranslationStrings = {
'common.cancel': 'Annuler',
'common.clear': 'Effacer',
'common.delete': 'Supprimer',
'common.preview': 'Aperçu',
'common.edit': 'Modifier',
'common.add': 'Ajouter',
'common.loading': 'Chargement…',
+1 -1
View File
@@ -5,6 +5,6 @@ const transport: TranslationStrings = {
'transport.modalTitle.create': 'Ajouter un transport',
'transport.modalTitle.edit': 'Modifier le transport',
'transport.title': 'Transports',
'transport.addManual': 'Transport manuel',
'transport.addManual': 'Transport',
};
export default transport;
+4
View File
@@ -76,5 +76,9 @@ const trip: TranslationStrings = {
'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.',
'transit.durationLabel': 'Durée',
'transit.transfersLabel': 'Correspondances',
'transit.walkLabel': 'Marche',
'transit.searchHint': 'Recherchez de vraies liaisons et ajoutez-les directement au jour — données via Transitous.',
};
export default trip;
+1
View File
@@ -7,6 +7,7 @@ const common: TranslationStrings = {
'common.cancel': 'Ακύρωση',
'common.clear': 'Εκκαθάριση',
'common.delete': 'Διαγραφή',
'common.preview': 'Προεπισκόπηση',
'common.edit': 'Επεξεργασία',
'common.add': 'Προσθήκη',
'common.loading': 'Φόρτωση...',
+1 -1
View File
@@ -5,6 +5,6 @@ const transport: TranslationStrings = {
'transport.modalTitle.create': 'Προσθήκη μεταφοράς',
'transport.modalTitle.edit': 'Επεξεργασία μεταφοράς',
'transport.title': 'Μεταφορές',
'transport.addManual': 'Χειροκίνητη Μεταφορά',
'transport.addManual': 'Μεταφορά',
};
export default transport;
+4
View File
@@ -76,5 +76,9 @@ const trip: TranslationStrings = {
'transit.journey': 'Διαδρομή με δημόσιες συγκοινωνίες',
'transit.pickDay': 'Επιλέξτε μια ημέρα για αναζήτηση συνδέσεων.',
'transit.noItinerary': 'Αυτή η καταχώριση δεν έχει αποθηκευμένο δρομολόγιο — χρησιμοποιήστε την «Αλλαγή διαδρομής» για να αναζητήσετε ένα.',
'transit.durationLabel': 'Διάρκεια',
'transit.transfersLabel': 'Μετεπιβιβάσεις',
'transit.walkLabel': 'Περπάτημα',
'transit.searchHint': 'Αναζητήστε πραγματικές συνδέσεις και προσθέστε τις απευθείας στην ημέρα — δεδομένα μέσω Transitous.',
};
export default trip;
+1
View File
@@ -7,6 +7,7 @@ const common: TranslationStrings = {
'common.cancel': 'Mégse',
'common.clear': 'Törlés',
'common.delete': 'Törlés',
'common.preview': 'Előnézet',
'common.edit': 'Szerkesztés',
'common.add': 'Hozzáadás',
'common.loading': 'Betöltés...',
+1 -1
View File
@@ -5,6 +5,6 @@ const transport: TranslationStrings = {
'transport.modalTitle.create': 'Közlekedés hozzáadása',
'transport.modalTitle.edit': 'Közlekedés szerkesztése',
'transport.title': 'Közlekedés',
'transport.addManual': 'Kézi közlekedés',
'transport.addManual': 'Közlekedés',
};
export default transport;
+4
View File
@@ -76,5 +76,9 @@ const trip: TranslationStrings = {
'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.',
'transit.durationLabel': 'Időtartam',
'transit.transfersLabel': 'Átszállások',
'transit.walkLabel': 'Gyaloglás',
'transit.searchHint': 'Keress valós összeköttetéseket, és add hozzá őket közvetlenül a naphoz – adatok a Transitous révén.',
};
export default trip;
+1
View File
@@ -7,6 +7,7 @@ const common: TranslationStrings = {
'common.cancel': 'Batal',
'common.clear': 'Hapus',
'common.delete': 'Hapus',
'common.preview': 'Pratinjau',
'common.edit': 'Sunting',
'common.add': 'Tambah',
'common.loading': 'Memuat...',
+1 -1
View File
@@ -5,6 +5,6 @@ const transport: TranslationStrings = {
'transport.modalTitle.create': 'Tambah transportasi',
'transport.modalTitle.edit': 'Edit transportasi',
'transport.title': 'Transportasi',
'transport.addManual': 'Transportasi Manual',
'transport.addManual': 'Transportasi',
};
export default transport;
+4
View File
@@ -76,5 +76,9 @@ const trip: TranslationStrings = {
'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.',
'transit.durationLabel': 'Durasi',
'transit.transfersLabel': 'Transit',
'transit.walkLabel': 'Jalan kaki',
'transit.searchHint': 'Cari koneksi nyata dan tambahkan langsung ke hari itu — data melalui Transitous.',
};
export default trip;
+1
View File
@@ -7,6 +7,7 @@ const common: TranslationStrings = {
'common.cancel': 'Annulla',
'common.clear': 'Cancella',
'common.delete': 'Elimina',
'common.preview': 'Anteprima',
'common.edit': 'Modifica',
'common.add': 'Aggiungi',
'common.loading': 'Caricamento...',
+1 -1
View File
@@ -5,6 +5,6 @@ const transport: TranslationStrings = {
'transport.modalTitle.create': 'Aggiungi trasporto',
'transport.modalTitle.edit': 'Modifica trasporto',
'transport.title': 'Trasporti',
'transport.addManual': 'Trasporto manuale',
'transport.addManual': 'Trasporto',
};
export default transport;
+4
View File
@@ -76,5 +76,9 @@ const trip: TranslationStrings = {
'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.',
'transit.durationLabel': 'Durata',
'transit.transfersLabel': 'Cambi',
'transit.walkLabel': 'A piedi',
'transit.searchHint': 'Cerca collegamenti reali e aggiungili direttamente alla giornata — dati via Transitous.',
};
export default trip;
+1
View File
@@ -7,6 +7,7 @@ const common: TranslationStrings = {
'common.cancel': 'キャンセル',
'common.clear': 'クリア',
'common.delete': '削除',
'common.preview': 'プレビュー',
'common.edit': '編集',
'common.add': '追加',
'common.loading': '読み込み中…',
+1 -1
View File
@@ -5,6 +5,6 @@ const transport: TranslationStrings = {
'transport.modalTitle.create': '移動手段を追加',
'transport.modalTitle.edit': '移動手段を編集',
'transport.title': '移動手段',
'transport.addManual': '手動で追加',
'transport.addManual': '交通',
};
export default transport;
+4
View File
@@ -76,5 +76,9 @@ const trip: TranslationStrings = {
'transit.journey': '公共交通機関の経路',
'transit.pickDay': '接続を検索する日を選択してください。',
'transit.noItinerary': 'この項目には保存された行程がありません。「経路を変更」から検索してください。',
'transit.durationLabel': '所要時間',
'transit.transfersLabel': '乗り換え',
'transit.walkLabel': '徒歩',
'transit.searchHint': '実際の経路を検索して、そのまま日程に追加できます — データは Transitous 提供。',
};
export default trip;
+1
View File
@@ -7,6 +7,7 @@ const common: TranslationStrings = {
'common.cancel': '취소',
'common.clear': '지우기',
'common.delete': '삭제',
'common.preview': '미리보기',
'common.edit': '편집',
'common.add': '추가',
'common.loading': '로딩 중...',
+1 -1
View File
@@ -5,6 +5,6 @@ const transport: TranslationStrings = {
'transport.modalTitle.create': '교통 추가',
'transport.modalTitle.edit': '교통 편집',
'transport.title': '교통',
'transport.addManual': '직접 교통 입력',
'transport.addManual': '교통',
};
export default transport;
+4
View File
@@ -76,5 +76,9 @@ const trip: TranslationStrings = {
'transit.journey': '대중교통 경로',
'transit.pickDay': '연결편을 검색할 날짜를 선택하세요.',
'transit.noItinerary': '이 항목에는 저장된 경로가 없습니다. "경로 변경"을 사용하여 검색하세요.',
'transit.durationLabel': '소요 시간',
'transit.transfersLabel': '환승',
'transit.walkLabel': '도보',
'transit.searchHint': '실제 연결편을 검색하고 바로 일정에 추가하세요 — 데이터 제공: Transitous.',
};
export default trip;
+1
View File
@@ -7,6 +7,7 @@ const common: TranslationStrings = {
'common.cancel': 'Annuleren',
'common.clear': 'Wissen',
'common.delete': 'Verwijderen',
'common.preview': 'Voorbeeld',
'common.edit': 'Bewerken',
'common.add': 'Toevoegen',
'common.loading': 'Laden...',
+1 -1
View File
@@ -5,6 +5,6 @@ const transport: TranslationStrings = {
'transport.modalTitle.create': 'Vervoer toevoegen',
'transport.modalTitle.edit': 'Vervoer bewerken',
'transport.title': 'Transport',
'transport.addManual': 'Handmatig transport',
'transport.addManual': 'Vervoer',
};
export default transport;
+4
View File
@@ -76,5 +76,9 @@ const trip: TranslationStrings = {
'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.',
'transit.durationLabel': 'Duur',
'transit.transfersLabel': 'Overstappen',
'transit.walkLabel': 'Lopen',
'transit.searchHint': 'Zoek echte verbindingen en voeg ze direct toe aan de dag — gegevens via Transitous.',
};
export default trip;
+1
View File
@@ -7,6 +7,7 @@ const common: TranslationStrings = {
'common.cancel': 'Anuluj',
'common.clear': 'Wyczyść',
'common.delete': 'Usuń',
'common.preview': 'Podgląd',
'common.edit': 'Edytuj',
'common.add': 'Dodaj',
'common.loading': 'Ładowanie...',
+1 -1
View File
@@ -5,6 +5,6 @@ const transport: TranslationStrings = {
'transport.modalTitle.create': 'Dodaj transport',
'transport.modalTitle.edit': 'Edytuj transport',
'transport.title': 'Transport',
'transport.addManual': 'Ręczny transport',
'transport.addManual': 'Transport',
};
export default transport;
+4
View File
@@ -76,5 +76,9 @@ const trip: TranslationStrings = {
'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ć.',
'transit.durationLabel': 'Czas trwania',
'transit.transfersLabel': 'Przesiadki',
'transit.walkLabel': 'Pieszo',
'transit.searchHint': 'Wyszukaj prawdziwe połączenia i dodaj je od razu do dnia — dane z Transitous.',
};
export default trip;
+1
View File
@@ -7,6 +7,7 @@ const common: TranslationStrings = {
'common.cancel': 'Отмена',
'common.clear': 'Очистить',
'common.delete': 'Удалить',
'common.preview': 'Предпросмотр',
'common.edit': 'Редактировать',
'common.add': 'Добавить',
'common.loading': 'Загрузка...',
+1 -1
View File
@@ -5,6 +5,6 @@ const transport: TranslationStrings = {
'transport.modalTitle.create': 'Добавить транспорт',
'transport.modalTitle.edit': 'Изменить транспорт',
'transport.title': 'Транспорт',
'transport.addManual': 'Ручной транспорт',
'transport.addManual': 'Транспорт',
};
export default transport;
+4
View File
@@ -76,5 +76,9 @@ const trip: TranslationStrings = {
'transit.journey': 'Поездка на общественном транспорте',
'transit.pickDay': 'Выберите день для поиска соединений.',
'transit.noItinerary': 'Для этой записи нет сохранённого маршрута — нажмите «Изменить маршрут», чтобы найти его.',
'transit.durationLabel': 'Длительность',
'transit.transfersLabel': 'Пересадки',
'transit.walkLabel': 'Пешком',
'transit.searchHint': 'Ищите реальные маршруты и добавляйте их прямо в день — данные от Transitous.',
};
export default trip;
+1
View File
@@ -19,6 +19,7 @@ const common: TranslationStrings = {
'common.cancel': 'Avbryt',
'common.clear': 'Rensa',
'common.delete': 'Ta bort',
'common.preview': 'Förhandsvisning',
'common.edit': 'Redigera',
'common.add': 'Lägg till',
'common.loading': 'Laddar...',
+1 -1
View File
@@ -5,6 +5,6 @@ const transport: TranslationStrings = {
'transport.modalTitle.create': 'Lägg till transport',
'transport.modalTitle.edit': 'Redigera transport',
'transport.title': 'Transporter',
'transport.addManual': 'Manuell Transport',
'transport.addManual': 'Transport',
};
export default transport;
+4
View File
@@ -76,5 +76,9 @@ const trip: TranslationStrings = {
'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.',
'transit.durationLabel': 'Restid',
'transit.transfersLabel': 'Byten',
'transit.walkLabel': 'Gång',
'transit.searchHint': 'Sök verkliga förbindelser och lägg till dem direkt i dagen – data via Transitous.',
};
export default trip;
+1
View File
@@ -7,6 +7,7 @@ const common: TranslationStrings = {
'common.cancel': 'İptal',
'common.clear': 'Temizle',
'common.delete': 'Sil',
'common.preview': 'Önizleme',
'common.edit': 'Düzenle',
'common.add': 'Ekle',
'common.loading': 'Yükleniyor...',
+1 -1
View File
@@ -5,6 +5,6 @@ const transport: TranslationStrings = {
'transport.modalTitle.create': 'Ulaşım ekle',
'transport.modalTitle.edit': 'Ulaşımı düzenle',
'transport.title': 'Ulaşım',
'transport.addManual': 'Manuel Ulaşım',
'transport.addManual': 'Ulaşım',
};
export default transport;
+4
View File
@@ -76,5 +76,9 @@ const trip: TranslationStrings = {
'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.',
'transit.durationLabel': 'Süre',
'transit.transfersLabel': 'Aktarma',
'transit.walkLabel': 'Yürüyüş',
'transit.searchHint': 'Gerçek bağlantıları ara ve doğrudan güne ekle — veriler Transitous üzerinden.',
};
export default trip;
+1
View File
@@ -7,6 +7,7 @@ const common: TranslationStrings = {
'common.cancel': 'Скасувати',
'common.clear': 'Очистити',
'common.delete': 'Видалити',
'common.preview': 'Перегляд',
'common.edit': 'Редагувати',
'common.add': 'Додати',
'common.loading': 'Завантаження...',
+1 -1
View File
@@ -5,6 +5,6 @@ const transport: TranslationStrings = {
'transport.modalTitle.create': 'Додати транспорт',
'transport.modalTitle.edit': 'Змінити транспорт',
'transport.title': 'Транспорт',
'transport.addManual': 'Ручний транспорт',
'transport.addManual': 'Транспорт',
};
export default transport;
+4
View File
@@ -76,5 +76,9 @@ const trip: TranslationStrings = {
'transit.journey': 'Поїздка громадським транспортом',
'transit.pickDay': 'Виберіть день для пошуку сполучень.',
'transit.noItinerary': 'Для цього запису немає збереженого маршруту — натисніть «Змінити маршрут», щоб знайти його.',
'transit.durationLabel': 'Тривалість',
'transit.transfersLabel': 'Пересадки',
'transit.walkLabel': 'Пішки',
'transit.searchHint': 'Шукайте справжні маршрути та додавайте їх одразу до дня — дані від Transitous.',
};
export default trip;
+1
View File
@@ -19,6 +19,7 @@ const common: TranslationStrings = {
'common.cancel': 'Hủy',
'common.clear': 'Xóa',
'common.delete': 'Xóa bỏ',
'common.preview': 'Xem trước',
'common.edit': 'Chỉnh sửa',
'common.add': 'Thêm',
'common.loading': 'Đang tải...',
+1 -1
View File
@@ -5,6 +5,6 @@ const transport: TranslationStrings = {
'transport.modalTitle.create': 'Thêm phương tiện di chuyển',
'transport.modalTitle.edit': 'Chỉnh sửa phương tiện đi lại',
'transport.title': 'Di chuyển',
'transport.addManual': 'Thêm di chuyển',
'transport.addManual': 'Phương tiện',
};
export default transport;
+4
View File
@@ -76,5 +76,9 @@ const trip: TranslationStrings = {
'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.',
'transit.durationLabel': 'Thời lượng',
'transit.transfersLabel': 'Chuyển tuyến',
'transit.walkLabel': 'Đi bộ',
'transit.searchHint': 'Tìm các kết nối thực tế và thêm thẳng vào ngày — dữ liệu qua Transitous.',
};
export default trip;
+1
View File
@@ -7,6 +7,7 @@ const common: TranslationStrings = {
'common.cancel': '取消',
'common.clear': '清除',
'common.delete': '刪除',
'common.preview': '預覽',
'common.edit': '編輯',
'common.add': '新增',
'common.loading': '載入中...',
+1 -1
View File
@@ -5,6 +5,6 @@ const transport: TranslationStrings = {
'transport.modalTitle.create': '新增交通',
'transport.modalTitle.edit': '編輯交通',
'transport.title': '交通',
'transport.addManual': '手動新增交通',
'transport.addManual': '交通',
};
export default transport;
+4
View File
@@ -76,5 +76,9 @@ const trip: TranslationStrings = {
'transit.journey': '大眾運輸行程',
'transit.pickDay': '請選擇要查詢班次的日期。',
'transit.noItinerary': '此項目尚未儲存行程——請使用「變更路線」查詢。',
'transit.durationLabel': '行程時間',
'transit.transfersLabel': '轉乘次數',
'transit.walkLabel': '步行',
'transit.searchHint': '搜尋實際交通連線,並直接加入當天行程 — 資料由 Transitous 提供。',
};
export default trip;
+1
View File
@@ -7,6 +7,7 @@ const common: TranslationStrings = {
'common.cancel': '取消',
'common.clear': '清除',
'common.delete': '删除',
'common.preview': '预览',
'common.edit': '编辑',
'common.add': '添加',
'common.loading': '加载中...',
+1 -1
View File
@@ -5,6 +5,6 @@ const transport: TranslationStrings = {
'transport.modalTitle.create': '添加交通',
'transport.modalTitle.edit': '编辑交通',
'transport.title': '交通',
'transport.addManual': '手动添加交通',
'transport.addManual': '交通',
};
export default transport;
+4
View File
@@ -76,5 +76,9 @@ const trip: TranslationStrings = {
'transit.journey': '公共交通行程',
'transit.pickDay': '请选择要查询班次的日期。',
'transit.noItinerary': '此条目尚未保存行程 — 请使用"更改路线"查询班次。',
'transit.durationLabel': '时长',
'transit.transfersLabel': '换乘',
'transit.walkLabel': '步行',
'transit.searchHint': '搜索真实班次并直接添加到当天行程 — 数据来自 Transitous。',
};
export default trip;