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. */}
+
+ {/* 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(
-
-
- {place.description || place.address || cat?.name}
-
+ {/* Rendered, like the same line in the day plan: the description is
+ Markdown everywhere else, and printing it raw here was the one
+ place a formatted place read as `_underscores_`. Still clamped to
+ one line — the row is a list entry, not the inspector. */}
+
diff --git a/client/src/components/Planner/ReservationsPanel.tsx b/client/src/components/Planner/ReservationsPanel.tsx
index c37d14f1a..24adba0d0 100644
--- a/client/src/components/Planner/ReservationsPanel.tsx
+++ b/client/src/components/Planner/ReservationsPanel.tsx
@@ -16,6 +16,7 @@ import { TransitTitle, TransitLegChips, TransitMetaBadges, fmtTransitDuration }
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkBreaks from 'remark-breaks'
+import { markdownLinkComponents } from '../shared/markdownLink'
import type { Reservation, Day, TripFile, AssignmentsMap } from '../../types'
import type { ViewContribution } from '../../api/client'
import { usePluginViewContributions, PluginCardFooter } from '../Plugins/PluginContributions'
@@ -406,7 +407,7 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
{t('reservations.notes')}
- {r.notes}
+ {r.notes}
)}
diff --git a/client/src/components/Planner/noteSurface.test.ts b/client/src/components/Planner/noteSurface.test.ts
new file mode 100644
index 000000000..d69611653
--- /dev/null
+++ b/client/src/components/Planner/noteSurface.test.ts
@@ -0,0 +1,35 @@
+import { describe, it, expect } from 'vitest'
+import { noteSurface } from './noteSurface'
+
+describe('noteSurface', () => {
+ it('FE-NOTESURFACE-001: no colour keeps the neutral card the notes have always had', () => {
+ const s = noteSurface(null)
+ expect(s.background).toBe('var(--bg-hover)')
+ expect(s.iconColor).toBe('var(--text-muted)')
+ // Nothing colour-derived leaks in: the neutral card must survive a theme swap.
+ expect(Object.values(s).every(v => !v.includes('color-mix'))).toBe(true)
+ })
+
+ it('FE-NOTESURFACE-002: empty string and undefined are "no colour", not a broken mix', () => {
+ expect(noteSurface('')).toEqual(noteSurface(null))
+ expect(noteSurface(undefined)).toEqual(noteSurface(null))
+ })
+
+ it('FE-NOTESURFACE-003: a colour tints the card and fills the icon with the colour itself', () => {
+ const s = noteSurface('#dc2626')
+ expect(s.iconColor).toBe('#dc2626')
+ // Mixed against transparent, not against a fixed white or grey — the card
+ // has to sit on whatever surface it lands on, in either theme.
+ expect(s.background).toBe('color-mix(in srgb, #dc2626 11%, transparent)')
+ expect(s.border).toBe('color-mix(in srgb, #dc2626 38%, transparent)')
+ expect(s.iconBackground).toBe('color-mix(in srgb, #dc2626 22%, transparent)')
+ })
+
+ it('FE-NOTESURFACE-004: the card stays a wash while the icon carries the full colour', () => {
+ const s = noteSurface('#2563eb')
+ const pct = (v: string) => Number(/(\d+)%/.exec(v)![1])
+ // Text is read on top of the background, so it must be the lightest of the three.
+ expect(pct(s.background)).toBeLessThan(pct(s.iconBackground))
+ expect(pct(s.iconBackground)).toBeLessThan(pct(s.border))
+ })
+})
diff --git a/client/src/components/Planner/noteSurface.ts b/client/src/components/Planner/noteSurface.ts
new file mode 100644
index 000000000..97862c1ca
--- /dev/null
+++ b/client/src/components/Planner/noteSurface.ts
@@ -0,0 +1,36 @@
+/**
+ * The four surfaces a note colour produces (#1629).
+ *
+ * One place decides how a colour becomes a card, so the preview in the dialog,
+ * the card in the day plan and the block in the inspector cannot drift apart.
+ *
+ * Tints rather than fills: a note sits in a list of places and must not shout
+ * louder than the trip itself, and body text has to stay readable on top. The
+ * percentages are `color-mix` against `transparent`, so the card picks up
+ * whatever surface it is dropped on and works in both themes without a second
+ * palette. `currentColor` is deliberately not used — the icon takes the full
+ * colour while the card behind it takes a wash of the same hue.
+ */
+export interface NoteSurface {
+ background: string
+ border: string
+ iconBackground: string
+ iconColor: string
+}
+
+const NEUTRAL: NoteSurface = {
+ background: 'var(--bg-hover)',
+ border: 'var(--border-faint)',
+ iconBackground: 'var(--bg-hover)',
+ iconColor: 'var(--text-muted)',
+}
+
+export function noteSurface(color: string | null | undefined): NoteSurface {
+ if (!color) return NEUTRAL
+ return {
+ background: `color-mix(in srgb, ${color} 11%, transparent)`,
+ border: `color-mix(in srgb, ${color} 38%, transparent)`,
+ iconBackground: `color-mix(in srgb, ${color} 22%, transparent)`,
+ iconColor: color,
+ }
+}
diff --git a/client/src/components/shared/NoteColorPicker.tsx b/client/src/components/shared/NoteColorPicker.tsx
new file mode 100644
index 000000000..81a3ece9f
--- /dev/null
+++ b/client/src/components/shared/NoteColorPicker.tsx
@@ -0,0 +1,65 @@
+import { Ban } from 'lucide-react'
+import { NOTE_COLORS } from '@trek/shared'
+import { useTranslation } from '../../i18n'
+
+interface Props {
+ /** The picked colour, or null for the neutral card. */
+ value: string | null
+ onChange: (color: string | null) => void
+}
+
+/**
+ * Swatch row for a note's colour (#1629).
+ *
+ * The palette is deliberately short and lives in @trek/shared, so the picker,
+ * the card and the PDF cannot drift apart. The first cell clears the colour
+ * rather than being a colour of its own — "no colour" is the default state, not
+ * an eighth option.
+ */
+export default function NoteColorPicker({ value, onChange }: Props) {
+ const { t } = useTranslation()
+ const selected = 'outline outline-2 outline-[var(--accent)] outline-offset-2 scale-110'
+
+ return (
+
+
+
+ {NOTE_COLORS.map(color => (
+
+ )
+}
+
+/** Names rather than hex codes in the label, so a screen reader says something useful. */
+const COLOR_NAMES: Record = {
+ '#dc2626': 'red',
+ '#ea580c': 'orange',
+ '#d97706': 'amber',
+ '#16a34a': 'green',
+ '#0891b2': 'cyan',
+ '#2563eb': 'blue',
+ '#9333ea': 'purple',
+}
diff --git a/client/src/components/shared/NoteFormatToolbar.test.tsx b/client/src/components/shared/NoteFormatToolbar.test.tsx
new file mode 100644
index 000000000..13b3becc8
--- /dev/null
+++ b/client/src/components/shared/NoteFormatToolbar.test.tsx
@@ -0,0 +1,113 @@
+import { describe, it, expect, vi } from 'vitest'
+import { screen, waitFor } from '@testing-library/react'
+import { render } from '../../../tests/helpers/render'
+import userEvent from '@testing-library/user-event'
+import { useRef, useState } from 'react'
+import NoteFormatToolbar from './NoteFormatToolbar'
+
+/**
+ * The toolbar edits somebody else's controlled field, so every test drives it
+ * through a real textarea rather than asserting on a callback in isolation —
+ * the selection handling is the part that breaks, and it only exists in the DOM.
+ */
+function Harness({ initial = '', onValue }: { initial?: string; onValue?: (v: string) => void }) {
+ const ref = useRef(null)
+ const [value, setValue] = useState(initial)
+ return (
+ <>
+ { setValue(v); onValue?.(v) }} />
+