feat(planner): badge meta rows + inline itinerary expansion for transit

Dot-joined meta text becomes quiet badge chips everywhere transit facts are
listed: the journey modal's per-leg line (time, duration, stops, headsign
with an arrow icon, operator de-emphasised), the search results' leg details,
and the Transports-tab journey card (day, date, time span, duration — the
transfer count is gone from the card).

The day-plan transit row swaps the map-connections toggle for an expander:
the chevron folds the stop-by-stop itinerary out right inside the timeline —
times, line badges, stations with platform and stop counts — sized for the
sidebar.
This commit is contained in:
Maurice
2026-07-02 22:49:01 +02:00
committed by Maurice
parent c7998e926a
commit c1eaa91e50
6 changed files with 207 additions and 35 deletions
@@ -353,6 +353,38 @@ describe('DayPlanSidebar', () => {
expect(onOpenTransit).toHaveBeenCalledWith(expect.objectContaining({ id: 300 }))
})
it('FE-PLANNER-DAYPLAN-105: the transit row folds its itinerary out inline (#1065)', async () => {
const user = userEvent.setup()
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
const res = {
...buildReservation({ id: 301, type: 'transit', title: 'A → B', reservation_time: '2025-06-01T08:30:00', day_id: 10 }),
metadata: {
transit: {
provider: 'transitous', duration: 1800, transfers: 1, walk_seconds: 240,
legs: [
{ mode: 'WALK', duration: 240, from: { name: 'Start' }, to: { name: 'Alexanderplatz' } },
{ mode: 'SUBWAY', line: 'U2', line_color: '#FF3300', headsign: 'Ruhleben', duration: 1440, stops: 6, from: { name: 'Alexanderplatz', time: '08:36', track: '2' }, to: { name: 'Zoo', time: '09:00' } },
],
},
},
endpoints: [
{ role: 'from', sequence: 0, name: 'A', code: null, lat: 1, lng: 2, timezone: null, local_date: null, local_time: null },
{ role: 'to', sequence: 1, name: 'B', code: null, lat: 3, lng: 4, timezone: null, local_date: null, local_time: null },
],
}
const onToggleConnection = vi.fn()
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], reservations: [res as any], onOpenTransit: vi.fn(), onToggleConnection, visibleConnectionIds: [] })} />)
// No map-connections toggle on transit rows — the expander replaces it.
expect(screen.queryByTitle(/connections/i)).not.toBeInTheDocument()
// Collapsed: no stop names beyond the chips.
expect(screen.queryByText('Alexanderplatz')).not.toBeInTheDocument()
await user.click(screen.getByLabelText('Expand'))
expect(await screen.findByText('Alexanderplatz')).toBeInTheDocument()
expect(screen.getByText(/Platform 2/)).toBeInTheDocument()
await user.click(screen.getByLabelText('Collapse'))
expect(screen.queryByText('Alexanderplatz')).not.toBeInTheDocument()
})
// ── Day info button ─────────────────────────────────────────────────────
it('FE-PLANNER-DAYPLAN-018: clicking day header calls onDayDetail', async () => {
@@ -37,7 +37,7 @@ import { DayPlanSidebarToolbar } from './DayPlanSidebarToolbar'
import { DayPlanSidebarNoteModal } from './DayPlanSidebarNoteModal'
import { DayPlanSidebarTimeConfirmModal } from './DayPlanSidebarTimeConfirmModal'
import { DayPlanSidebarTransportDetailModal } from './DayPlanSidebarTransportDetailModal'
import { TransitTitle, TransitLegChips } from './transitDisplay'
import { TransitTitle, TransitLegChips, TransitItineraryInline } 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'
@@ -187,6 +187,8 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
const [pdfHover, setPdfHover] = useState(false)
const [icsHover, setIcsHover] = useState(false)
const [hoveredAssignmentId, setHoveredAssignmentId] = useState<number | null>(null)
// Transit rows fold their itinerary out inline (#1065).
const [expandedTransitIds, setExpandedTransitIds] = useState<Set<number>>(new Set())
const [dropTargetKey, _setDropTargetKey] = useState(null)
const dropTargetRef = useRef(null)
const setDropTargetKey = (key) => { dropTargetRef.current = key; _setDropTargetKey(key) }
@@ -1010,6 +1012,8 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
onPlanTransit,
onOpenTransit,
onEditTransport,
expandedTransitIds,
setExpandedTransitIds,
onEditReservation,
onAddBookingToAssignment,
initialScrollTop,
@@ -1173,6 +1177,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
onPlanTransit,
onOpenTransit,
onEditTransport,
expandedTransitIds,
setExpandedTransitIds,
onEditReservation,
onAddBookingToAssignment,
initialScrollTop,
@@ -2097,7 +2103,34 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
</div>
)}
</div>
{onToggleConnection && (!res.__leg || res.__leg.index === 0) && (res.endpoints || []).length >= 2 && (() => {
{transitMeta && (() => {
const expanded = expandedTransitIds.has(res.id)
return (
<button
type="button"
onClick={e => {
e.stopPropagation()
setExpandedTransitIds(prev => {
const next = new Set(prev)
if (next.has(res.id)) next.delete(res.id); else next.add(res.id)
return next
})
}}
title={t(expanded ? 'common.collapse' : 'common.expand')}
aria-label={t(expanded ? 'common.collapse' : 'common.expand')}
style={{
flexShrink: 0, appearance: 'none', width: 26, height: 26, borderRadius: 6,
display: 'grid', placeItems: 'center', cursor: 'pointer', border: 'none',
background: 'transparent', color: 'var(--text-faint)',
}}
onMouseEnter={e => { e.currentTarget.style.color = 'var(--text-primary)' }}
onMouseLeave={e => { e.currentTarget.style.color = 'var(--text-faint)' }}
>
{expanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</button>
)
})()}
{!transitMeta && onToggleConnection && (!res.__leg || res.__leg.index === 0) && (res.endpoints || []).length >= 2 && (() => {
const active = visibleConnectionIds.includes(res.id)
return (
<button
@@ -2121,6 +2154,11 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
)
})()}
</div>
{transitMeta && expandedTransitIds.has(res.id) && (
<div style={{ margin: '2px 8px 4px', padding: '9px 10px 9px 12px', borderRadius: 6, border: `1px solid ${color}26`, background: `${color}05` }}>
<TransitItineraryInline legs={transitMeta.legs} t={t} />
</div>
)}
{routeLegs[day.id]?.[res.id] && <RouteConnector seg={routeLegs[day.id]![res.id]} profile={routeProfile} />}
</React.Fragment>
)
@@ -12,7 +12,7 @@ import {
TramFront, Footprints,
} from 'lucide-react'
import { openFile } from '../../utils/fileDownload'
import { TransitTitle, TransitLegChips } from './transitDisplay'
import { TransitTitle, TransitLegChips, TransitMetaBadges, fmtTransitDuration } from './transitDisplay'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkBreaks from 'remark-breaks'
@@ -531,13 +531,13 @@ function TransitJourneyCard({ r, days, onOpen, onDelete, canEdit }: {
<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 style={{ marginTop: 4 }}>
<TransitMetaBadges size="sm" items={[
{ text: day ? (day.title || t('dayplan.dayN', { n: day.day_number })) : '' },
{ icon: Calendar, text: dateStr || '' },
{ icon: Clock, text: time ? `${formatTime(time, locale, timeFormat)}${endTime ? ` – ${formatTime(endTime, locale, timeFormat)}` : ''}` : '' },
{ text: transit?.duration ? fmtTransitDuration(transit.duration, t) : '' },
]} />
</div>
</div>
{canEdit && (
@@ -555,7 +555,7 @@ function TransitJourneyCard({ r, days, onOpen, onDelete, canEdit }: {
</div>
{transit && (
<div style={{ paddingLeft: 44 }}>
<TransitLegChips legs={transit.legs} transfers={transit.transfers} size="md" t={t} />
<TransitLegChips legs={transit.legs} size="md" t={t} />
</div>
)}
{confirmOpen && ReactDOM.createPortal(
@@ -2,13 +2,13 @@ import { useEffect, useRef, useState } from 'react'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkBreaks from 'remark-breaks'
import { ArrowRight, ArrowRightLeft, Bold, Clock, Code, Footprints, Heading2, Italic, Link2, List, ListChecks, Pencil, RefreshCw, Strikethrough, TramFront, Trash2 } from 'lucide-react'
import { ArrowRight, ArrowRightLeft, Bold, Clock, Code, Footprints, Heading2, Italic, Link2, List, ListChecks, MoveRight, Pencil, RefreshCw, Strikethrough, 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 { TransitTitle, TransitMetaBadges, fmtTransitDuration } from './transitDisplay'
import type { Reservation } from '../../types'
/**
@@ -124,16 +124,8 @@ 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 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: Clock, value: transit.duration > 0 ? fmtTransitDuration(transit.duration, t) : '—', 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') },
] : []
@@ -266,14 +258,14 @@ export default function TransitJourneyModal({ reservation, onClose, onSave, onDe
<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 style={{ marginTop: 5 }}>
<TransitMetaBadges items={[
{ icon: Clock, text: !isWalk && leg.from?.time ? `${leg.from.time}${leg.to?.time ? ` – ${leg.to.time}` : ''}` : '' },
{ text: mins ? t('transit.min', { count: mins }) : '' },
{ text: !isWalk && leg.stops ? t('transit.stops', { count: leg.stops }) : '' },
{ icon: MoveRight, text: !isWalk && leg.headsign ? leg.headsign : '' },
{ text: !isWalk && leg.agency ? leg.agency : '', dim: true },
]} />
</div>
</div>
</div>
@@ -2,6 +2,7 @@ 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 CustomTimePicker from '../shared/CustomTimePicker'
import { TransitMetaBadges } from './transitDisplay'
import { transitApi } from '../../api/client'
import { useSettingsStore } from '../../store/settingsStore'
import { useToast } from '../shared/Toast'
@@ -246,14 +247,20 @@ function ItineraryCard({ it, tzFrom, tzTo, is12h, expanded, onToggle, onAdd, add
{leg.mode === 'WALK' ? t('transit.walkTo', { name: leg.to.name }) : leg.from.name}
{leg.from.track && leg.mode !== 'WALK' && <span className="text-content-faint" style={{ fontWeight: 500 }}> · {t('transit.platform', { track: leg.from.track })}</span>}
</div>
<div className="text-content-muted" style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 3, fontSize: 'calc(12px * var(--fs-scale-body, 1))', flexWrap: 'wrap' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 4, flexWrap: 'wrap' }}>
{leg.mode === 'WALK' ? (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}><Footprints size={12} />{fmtDuration(leg.duration, t)}{leg.distance ? ` · ${leg.distance >= 1000 ? `${(leg.distance / 1000).toFixed(1)} km` : `${leg.distance} m`}` : ''}</span>
<TransitMetaBadges size="sm" items={[
{ icon: Footprints, text: fmtDuration(leg.duration, t) },
{ text: leg.distance ? (leg.distance >= 1000 ? `${(leg.distance / 1000).toFixed(1)} km` : `${leg.distance} m`) : '' },
]} />
) : (
<>
<LineBadge leg={leg} />
{leg.headsign && <span style={{ display: 'inline-flex', alignItems: 'center', gap: 3 }}><ArrowRight size={11} />{leg.headsign}</span>}
<span className="text-content-faint">{fmtDuration(leg.duration, t)}{leg.intermediateStops > 0 ? ` · ${t('transit.stops', { count: leg.intermediateStops })}` : ''}</span>
<TransitMetaBadges size="sm" items={[
{ icon: ArrowRight, text: leg.headsign || '' },
{ text: fmtDuration(leg.duration, t) },
{ text: leg.intermediateStops > 0 ? t('transit.stops', { count: leg.intermediateStops }) : '' },
]} />
</>
)}
</div>
@@ -1,5 +1,5 @@
import React from 'react'
import { Footprints, MoveRight } from 'lucide-react'
import { Footprints, MoveRight, type LucideIcon } from 'lucide-react'
/**
* Shared display bits for public-transit entries (#1065) — the timeline row,
@@ -14,6 +14,109 @@ export interface TransitLegDisplay {
line_color?: string | null
line_text_color?: string | null
duration?: number
headsign?: string | null
stops?: number
from?: { name?: string; time?: string | null; track?: string | null }
to?: { name?: string; time?: string | null; track?: string | null }
}
/**
* The itinerary folded out right inside the day-plan row (#1065): one compact
* line per leg — time, badge or foot icon, stations — sized for the sidebar.
*/
export function TransitItineraryInline({ legs, t }: {
legs: TransitLegDisplay[]
t: (k: string, p?: Record<string, string | number>) => string
}) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 7 }}>
{legs.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: 7 }}>
<span className="text-content-muted" style={{ width: 34, flexShrink: 0, textAlign: 'right', fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, paddingTop: 1 }}>
{!isWalk && leg.from?.time ? leg.from.time : ''}
</span>
{isWalk ? (
<Footprints size={11} className="text-content-faint" style={{ flexShrink: 0, marginTop: 2 }} />
) : (
<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', flexShrink: 0,
background: leg.line_color || 'var(--bg-tertiary)',
color: leg.line_color ? (leg.line_text_color || '#fff') : 'var(--text-primary)',
}}>
{leg.line || leg.mode}
</span>
)}
<div style={{ flex: 1, minWidth: 0 }}>
<div className="text-content" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 500, display: 'flex', alignItems: 'center', gap: 4, minWidth: 0 }}>
{isWalk
? <span className="text-content-muted">{t('transit.walkTo', { name: leg.to?.name || '' })}</span>
: <>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{leg.from?.name}</span>
<MoveRight size={10} className="text-content-faint" style={{ flexShrink: 0 }} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{leg.to?.name}</span>
</>}
</div>
<div className="text-content-faint" style={{ fontSize: 'calc(9.5px * var(--fs-scale-caption, 1))', marginTop: 1 }}>
{[
mins ? t('transit.min', { count: mins }) : null,
!isWalk && leg.stops ? t('transit.stops', { count: leg.stops }) : null,
!isWalk && leg.from?.track ? t('transit.platform', { track: leg.from.track }) : null,
].filter(Boolean).join(' · ')}
</div>
</div>
</div>
)
})}
</div>
)
}
/** "1 h 9 min" / "42 min" from seconds — shared by all transit surfaces. */
export function fmtTransitDuration(seconds: number, t: (k: string, p?: Record<string, string | number>) => string): string {
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`
}
export interface TransitMetaItem {
icon?: LucideIcon
text: string
/** De-emphasised (operator names and the like). */
dim?: boolean
}
/**
* A quiet badge row replacing dot-joined meta text ("21:06 - 22:15 - 69 min -
* 14 stops ..."): each fact becomes a small chip, optional icon in front.
*/
export function TransitMetaBadges({ items, size = 'md' }: { items: TransitMetaItem[]; size?: 'sm' | 'md' }) {
const font = size === 'sm' ? 'calc(10px * var(--fs-scale-caption, 1))' : 'calc(10.5px * var(--fs-scale-caption, 1))'
return (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 5, flexWrap: 'wrap' }}>
{items.filter(i => i.text).map(({ icon: Icon, text, dim }, i) => (
<span
key={i}
className={dim ? 'bg-surface-card text-content-faint' : 'bg-surface-card text-content-muted'}
style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
padding: size === 'sm' ? '1px 6px' : '2px 8px', borderRadius: 6,
fontSize: font, fontWeight: 500, whiteSpace: 'nowrap',
border: '1px solid var(--border-faint)',
}}
>
{Icon && <Icon size={size === 'sm' ? 9 : 10} strokeWidth={2} />}
{text}
</span>
))}
</span>
)
}
/** Renders "From → To" titles with an arrow icon instead of the text glyph. */