From c7998e926ac51effc42ff80427b3dbfbabc622d3 Mon Sep 17 00:00:00 2001 From: Maurice Date: Thu, 2 Jul 2026 22:04:58 +0200 Subject: [PATCH] feat(planner): markdown toolbar for journey notes + calmer transit search form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../Planner/DayPlanSidebar.test.tsx | 3 +- .../src/components/Planner/DayPlanSidebar.tsx | 2 +- .../Planner/TransitJourneyModal.test.tsx | 12 ++ .../Planner/TransitJourneyModal.tsx | 81 ++++++++++++-- .../components/Planner/TransitSearchPanel.tsx | 105 +++++++++--------- 5 files changed, 141 insertions(+), 62 deletions(-) diff --git a/client/src/components/Planner/DayPlanSidebar.test.tsx b/client/src/components/Planner/DayPlanSidebar.test.tsx index 828d1bffb..6da09aeee 100644 --- a/client/src/components/Planner/DayPlanSidebar.test.tsx +++ b/client/src/components/Planner/DayPlanSidebar.test.tsx @@ -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() diff --git a/client/src/components/Planner/DayPlanSidebar.tsx b/client/src/components/Planner/DayPlanSidebar.tsx index 02b2cad30..f78ab874e 100644 --- a/client/src/components/Planner/DayPlanSidebar.tsx +++ b/client/src/components/Planner/DayPlanSidebar.tsx @@ -2089,7 +2089,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP {transitMeta ? (
- +
) : subtitle && (
diff --git a/client/src/components/Planner/TransitJourneyModal.test.tsx b/client/src/components/Planner/TransitJourneyModal.test.tsx index b94ee94fb..a746e9878 100644 --- a/client/src/components/Planner/TransitJourneyModal.test.tsx +++ b/client/src/components/Planner/TransitJourneyModal.test.tsx @@ -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() + 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() diff --git a/client/src/components/Planner/TransitJourneyModal.tsx b/client/src/components/Planner/TransitJourneyModal.tsx index 589de151b..3295b6049 100644 --- a/client/src/components/Planner/TransitJourneyModal.tsx +++ b/client/src/components/Planner/TransitJourneyModal.tsx @@ -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(null) + const notesRef = useRef(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 */}
-
+
{canEdit && ( -
- {([['write', t('common.edit')], ['preview', t('common.preview')]] as const).map(([tab, label]) => ( - - ))} +
+ {notesTab === 'write' && ( +
+ {MD_TOOLS.map(({ Icon, label, action }) => ( + + ))} +
+ )} +
+ {([['write', t('common.edit')], ['preview', t('common.preview')]] as const).map(([tab, label]) => ( + + ))} +
)}
{canEdit && notesTab === 'write' ? (