feat(planner): walk legs as centred dividers + journey-card note line

Walk segments in the journey modal and the day-plan inline itinerary
collapse from two lines into a single centred divider — dashed rules left
and right, the walk in the middle (foot icon, destination, minutes). Leg
meta badges sit tighter under their titles. The Transports-tab journey card
shows a dimmed first-line note preview, and the journey modal now reads the
reservation from the live store, so an update is visible the moment the
entry reopens.
This commit is contained in:
Maurice
2026-07-02 23:05:19 +02:00
committed by Maurice
parent c1eaa91e50
commit 9a823c0bc2
4 changed files with 64 additions and 51 deletions
@@ -9,7 +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,
TramFront, Footprints, StickyNote,
} from 'lucide-react'
import { openFile } from '../../utils/fileDownload'
import { TransitTitle, TransitLegChips, TransitMetaBadges, fmtTransitDuration } from './transitDisplay'
@@ -531,7 +531,7 @@ 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 style={{ marginTop: 4 }}>
<div style={{ marginTop: 2 }}>
<TransitMetaBadges size="sm" items={[
{ text: day ? (day.title || t('dayplan.dayN', { n: day.day_number })) : '' },
{ icon: Calendar, text: dateStr || '' },
@@ -558,6 +558,12 @@ function TransitJourneyCard({ r, days, onOpen, onDelete, canEdit }: {
<TransitLegChips legs={transit.legs} size="md" t={t} />
</div>
)}
{r.notes && (
<div className="text-content-faint" style={{ paddingLeft: 44, display: 'flex', alignItems: 'center', gap: 5, fontSize: 'calc(11px * var(--fs-scale-caption, 1))', minWidth: 0 }}>
<StickyNote size={11} style={{ flexShrink: 0 }} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.notes.split('\n')[0]}</span>
</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()}>
@@ -8,7 +8,7 @@ import ConfirmDialog from '../shared/ConfirmDialog'
import { useTranslation } from '../../i18n'
import { useSettingsStore } from '../../store/settingsStore'
import { splitReservationDateTime, formatTime } from '../../utils/formatters'
import { TransitTitle, TransitMetaBadges, fmtTransitDuration } from './transitDisplay'
import { TransitTitle, TransitMetaBadges, TransitWalkDivider, fmtTransitDuration } from './transitDisplay'
import type { Reservation } from '../../types'
/**
@@ -231,40 +231,30 @@ export default function TransitJourneyModal({ reservation, onClose, onSave, onDe
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{(transit.legs as TransitLegMeta[]).map((leg, i) => {
const isWalk = leg.mode === 'WALK'
if (leg.mode === 'WALK') return <TransitWalkDivider key={i} leg={leg} t={t} />
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 : ''}
{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>
)}
<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>
</>}
<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 style={{ marginTop: 5 }}>
<div style={{ marginTop: 3 }}>
<TransitMetaBadges items={[
{ icon: Clock, text: !isWalk && leg.from?.time ? `${leg.from.time}${leg.to?.time ? ` – ${leg.to.time}` : ''}` : '' },
{ icon: Clock, text: 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 },
{ text: leg.stops ? t('transit.stops', { count: leg.stops }) : '' },
{ icon: MoveRight, text: leg.headsign || '' },
{ text: leg.agency || '', dim: true },
]} />
</div>
</div>
@@ -20,6 +20,31 @@ export interface TransitLegDisplay {
to?: { name?: string; time?: string | null; track?: string | null }
}
/**
* A walk leg as a one-line centred divider — dashed rules left and right,
* the walk itself in the middle (🚶 Walk to X · 4 min). Used by the journey
* modal and the day-plan inline itinerary.
*/
export function TransitWalkDivider({ leg, t, size = 'md' }: {
leg: TransitLegDisplay
t: (k: string, p?: Record<string, string | number>) => string
size?: 'sm' | 'md'
}) {
const mins = leg.duration ? Math.round(leg.duration / 60) : null
const ruleStyle: React.CSSProperties = { flex: 1, borderTop: '1px dashed var(--border-primary)', opacity: 0.6, minWidth: 12 }
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: size === 'sm' ? '1px 0' : '2px 0' }}>
<span style={ruleStyle} />
<span className="text-content-faint" style={{ display: 'inline-flex', alignItems: 'center', gap: 5, fontSize: size === 'sm' ? 'calc(10px * var(--fs-scale-caption, 1))' : 'calc(11.5px * var(--fs-scale-caption, 1))', fontWeight: 500, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: '75%' }}>
<Footprints size={size === 'sm' ? 11 : 12} style={{ flexShrink: 0 }} />
{t('transit.walkTo', { name: leg.to?.name || '' })}{mins ? ` · ${t('transit.min', { count: mins })}` : ''}
</span>
<span style={ruleStyle} />
</div>
)
}
/**
* 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.
@@ -31,40 +56,32 @@ export function TransitItineraryInline({ legs, t }: {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 7 }}>
{legs.map((leg, i) => {
const isWalk = leg.mode === 'WALK'
if (leg.mode === 'WALK') return <TransitWalkDivider key={i} leg={leg} t={t} size="sm" />
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 : ''}
{leg.from?.time || ''}
</span>
<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>
{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>
</>}
<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,
leg.stops ? t('transit.stops', { count: leg.stops }) : null,
leg.from?.track ? t('transit.platform', { track: leg.from.track }) : null,
].filter(Boolean).join(' · ')}
</div>
</div>
+1 -1
View File
@@ -724,7 +724,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
{/* Journey view for a saved public-transit entry (#1065) */}
{transitJourney && (
<TransitJourneyModal
reservation={transitJourney}
reservation={reservations.find(r => r.id === transitJourney.id) ?? transitJourney}
canEdit={can('day_edit', trip)}
onClose={() => setTransitJourney(null)}
onSave={async (fields) => { await tripActions.updateReservation(tripId, transitJourney.id, fields); setTransitJourney(null) }}