From ce37e8e97cc9519a5af2a362d6d4b038a0ff4630 Mon Sep 17 00:00:00 2001 From: Maurice <61554723+mauriceboe@users.noreply.github.com> Date: Tue, 11 Aug 2026 22:14:12 +0200 Subject: [PATCH] Rich formatting and colours for notes (#1889) * Rich formatting and colours for notes Asked for in discussion #1629: notes should carry formatting, links and colour, so a warning reads differently from a recommendation. Notes have rendered Markdown for a long time. The problem was that the only way to write it was a bare textarea, so unless you already knew the syntax there was nothing to tell you it existed. Both note fields get a formatting bar - bold, italic, strikethrough, code, link, lists, quote - which edits Markdown rather than replacing it with a rich-text model, so the stored value stays plain text that the PDF, the plugin API and every existing note keep reading unchanged. Links now open in their own tab. A bare anchor navigates the planner away, which loses the open day and the map, and without rel=noopener the opened page can navigate this one - notes are content other trip members wrote. Day notes get a colour from a short shared palette. It tints the card, the border and the icon circle, and it reaches the PDF as the spine and rule rather than a fill. The dialog is two columns now, with a live preview of the card beside the picker, because a row of swatches says nothing about what a tinted note looks like next to a place. On the phone the thirty-two icon grid was most of the screen, so it is folded behind the chosen icon, which doubles as the colour preview. The detail field there got the same formatting bar and the same 2000 character ceiling as the desktop one - they write the same column. * Fix the tests that pinned the old note caps, and rework the note actions The e2e suite builds its own day_notes table by hand rather than going through schema.ts, so it never got the new column and every note insert came back 500. Three more tests wrote down the 250 character body cap and the old create() arity. While in there: the edit and delete buttons on a note card were a 10px icon in a 14px box, side by side, and they took width away from the text on every note whether you were hovering or not. They are stacked in a capsule now, 30px targets with their own hover states, floating over the right edge of the card only while the pointer is on it. The text keeps the full width the rest of the time. * Centre the day badge when there is no weather to stack under it The badge is pinned to the top of the row because with weather it is a tall stack that has to start there. Without a location there is no weather block, so all that is left is a 26px circle floating above the day title. * Render the place subtitle in the places list, and drop the form hint The row in the places sidebar printed the description raw while the same line in the day plan renders it, so a place written with the formatting bar read as _underscores_ in one list and italics in the other. Still clamped to one line - it is a list entry, not the inspector. The "Markdown supported" line under the notes field goes: the bar above it already says so. --- .../Collections/CollectionPlaceDetail.tsx | 3 +- client/src/components/PDF/TripPDF.tsx | 10 +- .../Planner/DayPlanSidebar.test.tsx | 42 ++++ .../src/components/Planner/DayPlanSidebar.tsx | 55 +++-- .../Planner/DayPlanSidebarNoteModal.tsx | 222 +++++++++++++----- .../DayPlanSidebarTransportDetailModal.tsx | 3 +- .../src/components/Planner/PlaceFormModal.tsx | 22 +- .../src/components/Planner/PlaceInspector.tsx | 7 +- .../components/Planner/PlacesSidebarRow.tsx | 12 +- .../components/Planner/ReservationsPanel.tsx | 3 +- .../components/Planner/noteSurface.test.ts | 35 +++ client/src/components/Planner/noteSurface.ts | 36 +++ .../src/components/shared/NoteColorPicker.tsx | 65 +++++ .../shared/NoteFormatToolbar.test.tsx | 113 +++++++++ .../components/shared/NoteFormatToolbar.tsx | 115 +++++++++ .../components/shared/markdownLink.test.tsx | 38 +++ client/src/components/shared/markdownLink.tsx | 22 ++ client/src/hooks/useDayNotes.ts | 11 +- client/src/index.css | 13 + .../screens/trip/plan/MPlanTimelineRows.tsx | 37 ++- .../mobile/screens/trip/sheets/MNoteSheet.tsx | 116 +++++++-- .../unit/mobile/trip/MNoteSheet.test.tsx | 80 ++++++- .../mobile/trip/MPlanTimelineRows.test.tsx | 15 +- server/src/db/migrations.ts | 9 + .../nest/day-notes/day-notes.controller.ts | 4 +- .../src/nest/day-notes/day-notes.service.ts | 27 ++- server/src/types.ts | 2 + server/tests/e2e/days.e2e.test.ts | 2 +- .../tests/unit/nest/day-notes.service.test.ts | 50 ++++ .../tests/unit/nest/days.controller.test.ts | 11 +- shared/src/day/day.schema.spec.ts | 14 +- shared/src/day/day.schema.ts | 9 +- shared/src/day/note-colors.ts | 32 +++ shared/src/i18n/ar/dayplan.ts | 24 ++ shared/src/i18n/br/dayplan.ts | 24 ++ shared/src/i18n/ca/dayplan.ts | 24 ++ shared/src/i18n/cs/dayplan.ts | 24 ++ shared/src/i18n/de/dayplan.ts | 24 ++ shared/src/i18n/en/dayplan.ts | 24 ++ shared/src/i18n/es/dayplan.ts | 24 ++ shared/src/i18n/fr/dayplan.ts | 24 ++ shared/src/i18n/gr/dayplan.ts | 24 ++ shared/src/i18n/hu/dayplan.ts | 24 ++ shared/src/i18n/id/dayplan.ts | 24 ++ shared/src/i18n/it/dayplan.ts | 24 ++ shared/src/i18n/ja/dayplan.ts | 24 ++ shared/src/i18n/ko/dayplan.ts | 24 ++ shared/src/i18n/nl/dayplan.ts | 24 ++ shared/src/i18n/pl/dayplan.ts | 24 ++ shared/src/i18n/ru/dayplan.ts | 24 ++ shared/src/i18n/sv/dayplan.ts | 24 ++ shared/src/i18n/tr/dayplan.ts | 24 ++ shared/src/i18n/uk/dayplan.ts | 24 ++ shared/src/i18n/vi/dayplan.ts | 24 ++ shared/src/i18n/zh-TW/dayplan.ts | 24 ++ shared/src/i18n/zh/dayplan.ts | 24 ++ shared/src/index.ts | 1 + 57 files changed, 1631 insertions(+), 157 deletions(-) create mode 100644 client/src/components/Planner/noteSurface.test.ts create mode 100644 client/src/components/Planner/noteSurface.ts create mode 100644 client/src/components/shared/NoteColorPicker.tsx create mode 100644 client/src/components/shared/NoteFormatToolbar.test.tsx create mode 100644 client/src/components/shared/NoteFormatToolbar.tsx create mode 100644 client/src/components/shared/markdownLink.test.tsx create mode 100644 client/src/components/shared/markdownLink.tsx create mode 100644 shared/src/day/note-colors.ts diff --git a/client/src/components/Collections/CollectionPlaceDetail.tsx b/client/src/components/Collections/CollectionPlaceDetail.tsx index 42a7991da..62df37d91 100644 --- a/client/src/components/Collections/CollectionPlaceDetail.tsx +++ b/client/src/components/Collections/CollectionPlaceDetail.tsx @@ -2,6 +2,7 @@ import React, { useEffect, useRef, useState } from 'react' import Markdown from 'react-markdown' import remarkGfm from 'remark-gfm' import remarkBreaks from 'remark-breaks' +import { markdownLinkComponents } from '../shared/markdownLink' import { X, Pencil, Copy, Trash2, MapPin, Link2, Plus, ExternalLink, Check, Tag, Tags, Camera, Loader2 } from 'lucide-react' import type { CollectionPlace, CollectionStatus, CollectionLink, CollectionLabel } from '@trek/shared' import type { Category, TranslationFn } from '../../types' @@ -306,7 +307,7 @@ export default function CollectionPlaceDetail({ )} {place.description && (
- {place.description} + {place.description}
)} {place.links && place.links.length > 0 && ( diff --git a/client/src/components/PDF/TripPDF.tsx b/client/src/components/PDF/TripPDF.tsx index f8fde0e19..789ee46c0 100644 --- a/client/src/components/PDF/TripPDF.tsx +++ b/client/src/components/PDF/TripPDF.tsx @@ -333,10 +333,14 @@ export async function downloadTripPDF({ trip, days, places, assignments = {}, ca if (item.type === 'note') { const note = item.data + // A coloured note carries its colour into print the same way a + // reservation does (#1629) — the rule and the spine, not a fill, + // because a tint costs ink and reads as grey on a mono printer. + const noteColor = note.color || '' return ` -
-
- ${noteIconSvg(note.icon)} +
+
+ ${noteIconSvg(note.icon)}
${escHtml(note.text)}
${note.time ? `
${escHtml(note.time)}
` : ''} diff --git a/client/src/components/Planner/DayPlanSidebar.test.tsx b/client/src/components/Planner/DayPlanSidebar.test.tsx index c23e760a4..c2ce18028 100644 --- a/client/src/components/Planner/DayPlanSidebar.test.tsx +++ b/client/src/components/Planner/DayPlanSidebar.test.tsx @@ -1198,6 +1198,48 @@ describe('DayPlanSidebar', () => { expect(mockDayNotesState.saveNote).toHaveBeenCalledWith(10) }) + // ── Note colours and formatting (#1629) ─────────────────────────────── + + it('FE-PLANNER-DAYPLAN-192: the note dialog offers the palette and reports the pick', async () => { + const user = userEvent.setup() + const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) + mockDayNotesState.noteUi = { + '10': { mode: 'add', text: 'Passport', time: '', icon: 'StickyNote', color: null }, + } + render() + + // Exact, not /red/i: "Ordered list" in the formatting bar matches that too. + await user.click(screen.getByRole('button', { name: 'Red' })) + + // The dialog owns no state of its own — it reports upwards, like every other field. + expect(mockDayNotesState.setNoteUi).toHaveBeenCalled() + }) + + it('FE-PLANNER-DAYPLAN-193: a note card is tinted by its colour and renders its body', () => { + const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) + mockDayNotesState.dayNotes = { + '10': [{ id: 1, day_id: 10, text: 'Ferry', time: 'book **early**', icon: 'StickyNote', color: '#dc2626', sort_order: 0 }], + } + render() + + // Markdown, not the asterisks someone typed with the formatting bar. + expect(screen.getByText('early').tagName).toBe('STRONG') + // jsdom normalises the hex inside color-mix() to rgb(). + const card = screen.getByText('Ferry').closest('.dp-row') as HTMLElement + expect(card.style.background).toContain('220, 38, 38') + }) + + it('FE-PLANNER-DAYPLAN-194: a note without a colour keeps the neutral card', () => { + const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) + mockDayNotesState.dayNotes = { + '10': [{ id: 2, day_id: 10, text: 'Lunch', time: '', icon: 'StickyNote', color: null, sort_order: 0 }], + } + render() + + const card = screen.getByText('Lunch').closest('.dp-row') as HTMLElement + expect(card.style.background).toContain('--bg-hover') + }) + // ── Note modal edit mode title ──────────────────────────────────────── it('FE-PLANNER-DAYPLAN-061: note modal shows Edit title in edit mode', () => { diff --git a/client/src/components/Planner/DayPlanSidebar.tsx b/client/src/components/Planner/DayPlanSidebar.tsx index 06b6d137b..d5da0ec50 100644 --- a/client/src/components/Planner/DayPlanSidebar.tsx +++ b/client/src/components/Planner/DayPlanSidebar.tsx @@ -12,6 +12,7 @@ import ConfirmDialog from '../shared/ConfirmDialog' import { useContextMenu, ContextMenu } from '../shared/ContextMenu' import Markdown from 'react-markdown' import remarkGfm from 'remark-gfm' +import remarkBreaks from 'remark-breaks' import WeatherWidget from '../Weather/WeatherWidget' import { useToast } from '../shared/Toast' import { getCategoryIcon } from '../shared/categoryIcons' @@ -33,6 +34,8 @@ import { formatDate, formatTime, dayTotalCost, formatMoneySum, splitReservationD import { useDayNotes } from '../../hooks/useDayNotes' import { useExchangeRates } from '../../hooks/useExchangeRates' import { RES_ICONS, getNoteIcon } from './DayPlanSidebar.constants' +import { noteSurface } from './noteSurface' +import { markdownLinkComponents } from '../shared/markdownLink' import { RouteConnector, HotelRouteConnector } from './DayPlanSidebarRouteConnector' import { usePluginDaySchedule, usePluginDayTints, dayTintBackground, dayTinted, PluginDayScheduleRow, formatScheduleMinutes } from '../Plugins/PluginDaySchedule' import { MobileAddPlaceButton } from './DayPlanSidebarMobileAddPlaceButton' @@ -1374,7 +1377,10 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP const hasWeather = !!(day.date && anyGeoPlace && wLat != null && wLng != null) return (
-
+ {/* lineHeight 1, or the digit rides the line box's leading and + sits above centre on the plain badge — visible as soon as + there is no weather block under it to distract from it. */} +
{index + 1}
{hasWeather && ( @@ -2178,6 +2187,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP // Notizkarte const note = item.data const NoteIcon = getNoteIcon(note.icon) + const noteSkin = noteSurface(note.color) const noteIdx = idx return ( @@ -2241,13 +2251,14 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP if (editBtns) editBtns.style.opacity = '0' }} style={{ + position: 'relative', display: 'flex', alignItems: 'center', gap: 8, padding: '7px 8px 7px 2px', margin: '1px 8px', borderRadius: 6, - border: '1px solid var(--border-faint)', + border: `1px solid ${noteSkin.border}`, borderTop: showDropLine ? '2px solid var(--text-primary)' : undefined, - background: 'var(--bg-hover)', + background: noteSkin.background, opacity: draggingId === `note-${note.id}` ? 0.4 : 1, transition: 'background 0.1s', cursor: 'grab', userSelect: 'none', }} @@ -2255,25 +2266,39 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP {canEditDays && !dragDisabled &&
} -
- +
+
{note.text} {note.time && ( -
{note.time}
+
+ {/* A link in a note goes to its own tab, and remarkBreaks + keeps a single newline a line break — people write notes + as lines, not as Markdown paragraphs. */} + {note.time} +
)}
- {canEditDays &&
- - -
} - {canEditDays &&
- - -
} + {/* Stacked in a capsule of their own, set off from the text: + a note card is as tall as its body, so the height is free + while the width next to the text is not, and a 10px icon + in a 14px box was barely a target. */} + {/* Floated over the text rather than sharing the row with + it: a note is mostly text, and reserving a column for + two buttons that are invisible until you hover costs + every note a shorter line for the whole time you are + not hovering. */} + {canEditDays &&
+ + +
} + {canEditDays &&
+ + +
}
) diff --git a/client/src/components/Planner/DayPlanSidebarNoteModal.tsx b/client/src/components/Planner/DayPlanSidebarNoteModal.tsx index d244ae8b1..7a87b2a44 100644 --- a/client/src/components/Planner/DayPlanSidebarNoteModal.tsx +++ b/client/src/components/Planner/DayPlanSidebarNoteModal.tsx @@ -1,82 +1,188 @@ +import { useRef } from 'react' import { createPortal } from 'react-dom' -import { NOTE_ICONS } from './DayPlanSidebar.constants' +import Markdown from 'react-markdown' +import remarkGfm from 'remark-gfm' +import remarkBreaks from 'remark-breaks' +import { NOTE_ICONS, getNoteIcon } from './DayPlanSidebar.constants' +import NoteFormatToolbar from '../shared/NoteFormatToolbar' +import NoteColorPicker from '../shared/NoteColorPicker' +import { noteSurface } from './noteSurface' +import { markdownLinkComponents } from '../shared/markdownLink' interface NoteModalUi { mode: 'add' | 'edit' icon: string text: string time: string + color?: string | null } +type NoteUiMap = Record + interface DayPlanSidebarNoteModalProps { - noteUi: Record - setNoteUi: (updater: (prev: any) => any) => void + noteUi: NoteUiMap + setNoteUi: (updater: (prev: NoteUiMap) => NoteUiMap) => void noteInputRef: React.RefObject cancelNote: (dayId: number) => void saveNote: (dayId: number) => void - t: (key: string, params?: Record) => string + t: (key: string, params?: Record) => string } +const BODY_MAX = 2000 + +/** + * Add/edit dialog for a day note (#1629). + * + * Two columns on anything wider than a phone: how the note looks on the left + * (icon, colour, and a preview of the card it will become), what it says on the + * right. The preview is the reason the colour picker is worth having — a swatch + * row tells you nothing about what a tinted card looks like next to a place. + */ export function DayPlanSidebarNoteModal({ noteUi, setNoteUi, noteInputRef, cancelNote, saveNote, t }: DayPlanSidebarNoteModalProps) { return ( <> {Object.entries(noteUi).map(([dayId, ui]) => ui && createPortal( -
cancelNote(Number(dayId))}> -
e.stopPropagation()}> -
- {ui.mode === 'add' ? t('dayplan.noteAdd') : t('dayplan.noteEdit')} -
- {/* Icon-Auswahl */} -
- {NOTE_ICONS.map(({ id, Icon }) => ( - - ))} -
- setNoteUi(prev => ({ ...prev, [dayId]: { ...prev[dayId], text: e.target.value } }))} - onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); saveNote(Number(dayId)) } if (e.key === 'Escape') cancelNote(Number(dayId)) }} - placeholder={t('dayplan.noteTitle') + ' *'} - required - className="text-content" - style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500, border: `1px solid ${!ui.text?.trim() ? 'var(--border-primary)' : 'var(--border-primary)'}`, borderRadius: 8, padding: '8px 10px', fontFamily: 'inherit', outline: 'none', width: '100%', boxSizing: 'border-box' }} - /> -