feat(planner): markdown toolbar for journey notes + calmer transit search form

The journey notes gain a proper markdown toolbar (bold, italic, strike,
heading, list, checklist, link, code) that wraps the selection or prefixes
the current lines. The transit search options settle into one calm card:
depart/arrive + time + date and the ranking preference share the top row,
the mode filters and the search button share the bottom row, with the mode
chips restyled from heavy filled pills to quiet toggles. The day-plan row
drops the transfer count — the leg chips already tell the story.
This commit is contained in:
Maurice
2026-07-02 22:04:58 +02:00
committed by Maurice
parent 04415be3bc
commit c7998e926a
5 changed files with 141 additions and 62 deletions
@@ -345,7 +345,8 @@ describe('DayPlanSidebar', () => {
// 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()
// Transfer counts stay out of the compact row — the chips say it all.
expect(screen.queryByText(/1 transfers/)).not.toBeInTheDocument()
// Clicking the row opens the journey view — not the edit form.
await user.click(screen.getByText('Fernsehturm'))
expect(onEditTransport).not.toHaveBeenCalled()
@@ -2089,7 +2089,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
</div>
{transitMeta ? (
<div style={{ display: 'flex', alignItems: 'center', marginTop: 3 }}>
<TransitLegChips legs={transitMeta.legs} transfers={transitMeta.transfers} size="sm" t={t} />
<TransitLegChips legs={transitMeta.legs} 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' }}>
@@ -84,6 +84,18 @@ describe('TransitJourneyModal', () => {
expect(bold?.textContent).toBe('bold')
})
it('FE-PLANNER-TRANSITJOURNEY-007: the markdown toolbar wraps the note text', async () => {
const user = userEvent.setup()
render(<TransitJourneyModal {...makeProps()} />)
const area = screen.getByPlaceholderText(/notes/i) as HTMLTextAreaElement
await user.type(area, 'coffee')
area.setSelectionRange(0, 6)
await user.click(screen.getByRole('button', { name: 'Bold' }))
expect(area.value).toBe('**coffee**')
await user.click(screen.getByRole('button', { name: 'Checklist' }))
expect((screen.getByPlaceholderText(/notes/i) as HTMLTextAreaElement).value).toMatch(/^- \[ \] /)
})
it('FE-PLANNER-TRANSITJOURNEY-003: change route triggers onChangeRoute', async () => {
const user = userEvent.setup()
const onChangeRoute = vi.fn()
@@ -2,7 +2,7 @@ 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 { ArrowRight, ArrowRightLeft, Bold, Clock, Code, Footprints, Heading2, Italic, Link2, List, ListChecks, Pencil, RefreshCw, Strikethrough, TramFront, Trash2 } from 'lucide-react'
import Modal from '../shared/Modal'
import ConfirmDialog from '../shared/ConfirmDialog'
import { useTranslation } from '../../i18n'
@@ -55,6 +55,51 @@ export default function TransitJourneyModal({ reservation, onClose, onSave, onDe
const [saving, setSaving] = useState(false)
const [confirmDelete, setConfirmDelete] = useState(false)
const titleInputRef = useRef<HTMLInputElement | null>(null)
const notesRef = useRef<HTMLTextAreaElement | null>(null)
// Markdown toolbar: wrap the selection / prefix the current lines, then
// restore focus and a sensible cursor.
const applyMd = (action: { wrap?: [string, string]; linePrefix?: string }) => {
const el = notesRef.current
if (!el) return
const start = el.selectionStart ?? 0
const end = el.selectionEnd ?? 0
const value = notes
let next = value
let selStart = start
let selEnd = end
if (action.wrap) {
const [pre, post] = action.wrap
const selected = value.slice(start, end)
next = value.slice(0, start) + pre + selected + post + value.slice(end)
selStart = start + pre.length
selEnd = selStart + selected.length
} else if (action.linePrefix) {
const prefix = action.linePrefix
const lineStart = value.lastIndexOf('\n', start - 1) + 1
const block = value.slice(lineStart, end)
const prefixed = block.split('\n').map(l => prefix + l).join('\n')
next = value.slice(0, lineStart) + prefixed + value.slice(end)
selStart = start + prefix.length
selEnd = end + (prefixed.length - block.length)
}
setNotes(next)
requestAnimationFrame(() => {
el.focus()
el.setSelectionRange(selStart, selEnd)
})
}
const MD_TOOLS: { Icon: typeof Bold; label: string; action: { wrap?: [string, string]; linePrefix?: string } }[] = [
{ Icon: Bold, label: 'Bold', action: { wrap: ['**', '**'] } },
{ Icon: Italic, label: 'Italic', action: { wrap: ['*', '*'] } },
{ Icon: Strikethrough, label: 'Strikethrough', action: { wrap: ['~~', '~~'] } },
{ Icon: Heading2, label: 'Heading', action: { linePrefix: '## ' } },
{ Icon: List, label: 'List', action: { linePrefix: '- ' } },
{ Icon: ListChecks, label: 'Checklist', action: { linePrefix: '- [ ] ' } },
{ Icon: Link2, label: 'Link', action: { wrap: ['[', '](https://)'] } },
{ Icon: Code, label: 'Code', action: { wrap: ['`', '`'] } },
]
useEffect(() => {
setTitle(res.title || '')
@@ -241,22 +286,38 @@ export default function TransitJourneyModal({ reservation, onClose, onSave, onDe
{/* notes — full width, markdown */}
<div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 6 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 6, gap: 8, flexWrap: 'wrap' }}>
<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 style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{notesTab === 'write' && (
<div className="bg-surface-secondary" style={{ display: 'flex', borderRadius: 8, padding: 2, gap: 1 }}>
{MD_TOOLS.map(({ Icon, label, action }) => (
<button key={label} type="button" onClick={() => applyMd(action)} title={label} aria-label={label}
className="text-content-muted"
style={{ width: 26, height: 24, display: 'grid', placeItems: 'center', borderRadius: 6, border: 0, background: 'transparent', cursor: 'pointer' }}
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-card)' }}
onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}>
<Icon size={13} strokeWidth={2} />
</button>
))}
</div>
)}
<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>
{canEdit && notesTab === 'write' ? (
<textarea
ref={notesRef}
value={notes}
onChange={e => setNotes(e.target.value)}
placeholder={t('reservations.notesPlaceholder')}
@@ -479,60 +479,65 @@ export default function TransitSearchPanel({ day, days, places, accommodations =
<StopPicker label={t('transit.to')} value={to} onPick={setTo} quickPicks={quickPicks} near={near} placeholder={t('transit.searchStop')} />
</div>
{/* time + depart/arrive */}
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
<div className="bg-surface-secondary" style={{ display: 'flex', borderRadius: 9, padding: 3 }}>
<button onClick={() => setArriveBy(false)} style={segBtn(!arriveBy)}>{t('transit.depart')}</button>
<button onClick={() => setArriveBy(true)} style={segBtn(arriveBy)}>{t('transit.arrive')}</button>
{/* search options — one calm card: when + how on top, modes + go below */}
<div className="bg-surface-tertiary" style={{ borderRadius: 14, padding: '12px 14px', display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10, flexWrap: 'wrap' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<div className="bg-surface-secondary" style={{ display: 'flex', borderRadius: 9, padding: 3 }}>
<button onClick={() => setArriveBy(false)} style={segBtn(!arriveBy)}>{t('transit.depart')}</button>
<button onClick={() => setArriveBy(true)} style={segBtn(arriveBy)}>{t('transit.arrive')}</button>
</div>
<div style={{ width: 110 }}>
<CustomTimePicker value={time} onChange={setTime} />
</div>
{day.date && (
<span className="text-content-faint" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', display: 'inline-flex', alignItems: 'center', gap: 5 }}>
<Clock size={12} />
{new Date(day.date + 'T00:00:00Z').toLocaleDateString(undefined, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })}
</span>
)}
</div>
<div className="bg-surface-secondary" style={{ display: 'flex', borderRadius: 9, padding: 3 }}>
<button onClick={() => setPref('best')} style={segBtn(pref === 'best')}>{t('transit.pref.best')}</button>
<button onClick={() => setPref('transfers')} style={segBtn(pref === 'transfers')}>{t('transit.pref.transfers')}</button>
<button onClick={() => setPref('walking')} style={segBtn(pref === 'walking')}>{t('transit.pref.walking')}</button>
</div>
</div>
<div style={{ width: 110 }}>
<CustomTimePicker value={time} onChange={setTime} />
</div>
{day.date && (
<span className="text-content-faint" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', display: 'inline-flex', alignItems: 'center', gap: 5 }}>
<Clock size={12} />
{new Date(day.date + 'T00:00:00Z').toLocaleDateString(undefined, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })}
</span>
)}
</div>
{/* mode chips */}
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{MODE_GROUPS.map(m => {
const active = activeModes.has(m.key)
return (
<button key={m.key} onClick={() => toggleMode(m.key)}
style={{
display: 'inline-flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 99,
border: '1.5px solid', fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', fontWeight: 600,
cursor: 'pointer', fontFamily: 'inherit', transition: 'all 0.12s',
background: active ? 'var(--text-primary)' : 'transparent',
borderColor: active ? 'var(--text-primary)' : 'var(--border-primary)',
color: active ? 'var(--bg-primary)' : 'var(--text-muted)',
}}>
<m.Icon size={12} strokeWidth={2} />
{t(m.labelKey)}
</button>
)
})}
</div>
<div style={{ height: 1, background: 'var(--border-faint)' }} />
{/* preference + search */}
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
<div className="bg-surface-secondary" style={{ display: 'flex', borderRadius: 9, padding: 3 }}>
<button onClick={() => setPref('best')} style={segBtn(pref === 'best')}>{t('transit.pref.best')}</button>
<button onClick={() => setPref('transfers')} style={segBtn(pref === 'transfers')}>{t('transit.pref.transfers')}</button>
<button onClick={() => setPref('walking')} style={segBtn(pref === 'walking')}>{t('transit.pref.walking')}</button>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10, flexWrap: 'wrap' }}>
<div style={{ display: 'flex', gap: 5, flexWrap: 'wrap' }}>
{MODE_GROUPS.map(m => {
const active = activeModes.has(m.key)
return (
<button key={m.key} onClick={() => toggleMode(m.key)}
className={active ? 'bg-surface-card text-content' : 'text-content-faint'}
style={{
display: 'inline-flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 99,
border: '1px solid', fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', fontWeight: 500,
cursor: 'pointer', fontFamily: 'inherit', transition: 'all 0.12s',
background: active ? undefined : 'transparent',
borderColor: active ? 'var(--border-primary)' : 'transparent',
boxShadow: active ? '0 1px 3px rgba(0,0,0,0.06)' : 'none',
opacity: active ? 1 : 0.75,
}}>
<m.Icon size={12} strokeWidth={2} />
{t(m.labelKey)}
</button>
)
})}
</div>
<button
onClick={search}
disabled={!from || !to || loading}
className="bg-accent text-accent-text"
style={{ display: 'inline-flex', alignItems: 'center', gap: 6, border: 'none', borderRadius: 10, padding: '9px 18px', fontWeight: 600, fontSize: 'calc(13px * var(--fs-scale-body, 1))', cursor: (!from || !to || loading) ? 'default' : 'pointer', fontFamily: 'inherit', opacity: (!from || !to || loading) ? 0.55 : 1, flexShrink: 0 }}
>
<Search size={14} strokeWidth={2.2} />
{loading ? t('transit.searching') : t('transit.search')}
</button>
</div>
<button
onClick={search}
disabled={!from || !to || loading}
className="bg-accent text-accent-text"
style={{ marginLeft: 'auto', display: 'inline-flex', alignItems: 'center', gap: 6, border: 'none', borderRadius: 10, padding: '9px 18px', fontWeight: 600, fontSize: 'calc(13px * var(--fs-scale-body, 1))', cursor: (!from || !to || loading) ? 'default' : 'pointer', fontFamily: 'inherit', opacity: (!from || !to || loading) ? 0.55 : 1 }}
>
<Search size={14} strokeWidth={2.2} />
{loading ? t('transit.searching') : t('transit.search')}
</button>
</div>
{/* results */}