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.
This commit is contained in:
Maurice
2026-08-11 22:14:12 +02:00
committed by GitHub
parent a5202f418b
commit ce37e8e97c
57 changed files with 1631 additions and 157 deletions
@@ -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 && (
<div className="col-detail-md collab-note-md">
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{place.description}</Markdown>
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]} components={markdownLinkComponents}>{place.description}</Markdown>
</div>
)}
{place.links && place.links.length > 0 && (
+7 -3
View File
@@ -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 `
<div class="note-card">
<div class="note-line"></div>
<span class="note-icon">${noteIconSvg(note.icon)}</span>
<div class="note-card"${noteColor ? ` style="border-left: 3px solid ${noteColor};"` : ''}>
<div class="note-line"${noteColor ? ` style="background: ${noteColor};"` : ''}></div>
<span class="note-icon"${noteColor ? ` style="color: ${noteColor};"` : ''}>${noteIconSvg(note.icon)}</span>
<div class="note-body">
<div class="note-text">${escHtml(note.text)}</div>
${note.time ? `<div class="note-time">${escHtml(note.time)}</div>` : ''}
@@ -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(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />)
// 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(<DayPlanSidebar {...makeDefaultProps({ days: [day], expandedDays: { 10: true } })} />)
// 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(<DayPlanSidebar {...makeDefaultProps({ days: [day], expandedDays: { 10: true } })} />)
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', () => {
@@ -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 (
<div style={{
flexShrink: 0, alignSelf: 'flex-start',
// With weather the badge is a tall stack and has to start at
// the top of the row; without it, it is a lone 26px circle and
// pinning it to the top leaves it floating above the day title.
flexShrink: 0, alignSelf: hasWeather ? 'flex-start' : 'center',
width: hasWeather ? 34 : 26,
borderRadius: hasWeather ? 11 : '50%',
// Selection still wins. A tinted badge mixes the tone INTO
@@ -1385,7 +1391,10 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
color: isSelected ? 'var(--accent-text)' : (dayTinted(dayTint, 'badge') ? 'var(--text-secondary)' : 'var(--text-muted)'),
display: 'flex', flexDirection: 'column', alignItems: 'center', overflow: 'hidden',
}}>
<div style={{ width: '100%', height: 26, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 700 }}>
{/* 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. */}
<div style={{ width: '100%', height: 26, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 700, lineHeight: 1 }}>
{index + 1}
</div>
{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 (
<React.Fragment key={`note-${note.id}`}>
@@ -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 && <div className="dp-grip" style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
<GripVertical size={13} strokeWidth={1.8} />
</div>}
<div style={{ width: 28, height: 28, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: '50%', background: 'var(--bg-hover)', overflow: 'hidden' }}>
<NoteIcon size={13} strokeWidth={1.8} color="var(--text-muted)" />
<div style={{ width: 28, height: 28, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: '50%', background: noteSkin.iconBackground, overflow: 'hidden' }}>
<NoteIcon size={13} strokeWidth={1.8} color={noteSkin.iconColor} />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<span style={{ fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', fontWeight: 500, color: 'var(--text-primary)', wordBreak: 'break-word' }}>
{note.text}
</span>
{note.time && (
<div className="collab-note-md" style={{ fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))', fontWeight: 400, color: 'var(--text-faint)', lineHeight: '1.3', marginTop: 2, wordBreak: 'break-word' }}><Markdown remarkPlugins={[remarkGfm]}>{note.time}</Markdown></div>
<div className="collab-note-md" style={{ fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))', fontWeight: 400, color: 'var(--text-faint)', lineHeight: '1.4', marginTop: 2, wordBreak: 'break-word' }}>
{/* 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. */}
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]} components={markdownLinkComponents}>{note.time}</Markdown>
</div>
)}
</div>
{canEditDays && <div className="note-edit-buttons" style={{ display: 'flex', gap: 1, flexShrink: 0, opacity: 0, transition: 'opacity 0.15s' }}>
<button onClick={e => openEditNote(day.id, note, e)} className="text-content-faint" style={{ background: 'none', border: 'none', padding: 2, cursor: 'pointer', display: 'flex' }}><Pencil size={10} /></button>
<button onClick={e => { e.stopPropagation(); setPendingDeleteNote({ dayId: day.id, noteId: note.id }) }} className="text-content-faint" style={{ background: 'none', border: 'none', padding: 2, cursor: 'pointer', display: 'flex' }}><Trash2 size={10} /></button>
</div>}
{canEditDays && <div className="reorder-buttons" style={{ flexShrink: 0, display: 'flex', gap: 1, transition: 'opacity 0.15s' }}>
<button onClick={e => { e.stopPropagation(); moveNote(day.id, note.id, 'up') }} disabled={noteIdx === 0} className={noteIdx === 0 ? 'text-[var(--border-primary)]' : 'text-content-faint'} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: noteIdx === 0 ? 'default' : 'pointer', display: 'flex', lineHeight: 1 }}><ChevronUp size={12} strokeWidth={2} /></button>
<button onClick={e => { e.stopPropagation(); moveNote(day.id, note.id, 'down') }} disabled={noteIdx === merged.length - 1} className={noteIdx === merged.length - 1 ? 'text-[var(--border-primary)]' : 'text-content-faint'} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: noteIdx === merged.length - 1 ? 'default' : 'pointer', display: 'flex', lineHeight: 1 }}><ChevronDown size={12} strokeWidth={2} /></button>
</div>}
{/* 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 && <div className="note-edit-buttons bg-surface-card" style={{ position: 'absolute', right: 5, top: '50%', transform: 'translateY(-50%)', display: 'flex', flexDirection: 'column', justifyContent: 'center', gap: 3, padding: 4, borderRadius: 999, border: '1px solid var(--border-faint)', boxShadow: '0 4px 14px rgba(0,0,0,0.10)', opacity: 0, transition: 'opacity 0.15s' }}>
<button onClick={e => openEditNote(day.id, note, e)} aria-label={t('dayplan.noteEdit')} title={t('dayplan.noteEdit')} className="text-content-faint note-edit-btn" style={{ background: 'none', border: 'none', width: 30, height: 30, borderRadius: 999, cursor: 'pointer', display: 'grid', placeItems: 'center', padding: 0 }}><Pencil size={15} /></button>
<button onClick={e => { e.stopPropagation(); setPendingDeleteNote({ dayId: day.id, noteId: note.id }) }} aria-label={t('dayplan.noteDelete')} title={t('dayplan.noteDelete')} className="text-content-faint note-edit-btn note-edit-btn-danger" style={{ background: 'none', border: 'none', width: 30, height: 30, borderRadius: 999, cursor: 'pointer', display: 'grid', placeItems: 'center', padding: 0 }}><Trash2 size={15} /></button>
</div>}
{canEditDays && <div className="reorder-buttons" style={{ flexShrink: 0, display: 'flex', gap: 1, transition: 'opacity 0.15s' }}>
<button onClick={e => { e.stopPropagation(); moveNote(day.id, note.id, 'up') }} disabled={noteIdx === 0} className={noteIdx === 0 ? 'text-[var(--border-primary)]' : 'text-content-faint'} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: noteIdx === 0 ? 'default' : 'pointer', display: 'flex', lineHeight: 1 }}><ChevronUp size={12} strokeWidth={2} /></button>
<button onClick={e => { e.stopPropagation(); moveNote(day.id, note.id, 'down') }} disabled={noteIdx === merged.length - 1} className={noteIdx === merged.length - 1 ? 'text-[var(--border-primary)]' : 'text-content-faint'} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: noteIdx === merged.length - 1 ? 'default' : 'pointer', display: 'flex', lineHeight: 1 }}><ChevronDown size={12} strokeWidth={2} /></button>
</div>}
</div>
</React.Fragment>
)
@@ -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<string, NoteModalUi | undefined>
interface DayPlanSidebarNoteModalProps {
noteUi: Record<string, NoteModalUi | undefined>
setNoteUi: (updater: (prev: any) => any) => void
noteUi: NoteUiMap
setNoteUi: (updater: (prev: NoteUiMap) => NoteUiMap) => void
noteInputRef: React.RefObject<HTMLInputElement>
cancelNote: (dayId: number) => void
saveNote: (dayId: number) => void
t: (key: string, params?: Record<string, any>) => string
t: (key: string, params?: Record<string, unknown>) => 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(
<div key={dayId} className="bg-[rgba(0,0,0,0.3)]" style={{
position: 'fixed', inset: 0, zIndex: 10000,
display: 'flex', alignItems: 'center', justifyContent: 'center',
backdropFilter: 'blur(3px)',
}} onClick={() => cancelNote(Number(dayId))}>
<div className="bg-surface-card" style={{
width: 340, borderRadius: 16,
boxShadow: '0 16px 48px rgba(0,0,0,0.22)', padding: '22px 22px 18px',
display: 'flex', flexDirection: 'column', gap: 12,
}} onClick={e => e.stopPropagation()}>
<div className="text-content" style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600 }}>
{ui.mode === 'add' ? t('dayplan.noteAdd') : t('dayplan.noteEdit')}
</div>
{/* Icon-Auswahl */}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 5 }}>
{NOTE_ICONS.map(({ id, Icon }) => (
<button key={id} onClick={() => setNoteUi(prev => ({ ...prev, [dayId]: { ...prev[dayId], icon: id } }))}
title={id}
className={ui.icon === id ? 'bg-surface-hover' : 'bg-transparent'}
style={{ width: 45, height: 45, borderRadius: 8, border: ui.icon === id ? '2px solid var(--text-primary)' : '2px solid var(--border-faint)', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 0 }}>
<Icon size={18} strokeWidth={1.8} color={ui.icon === id ? 'var(--text-primary)' : 'var(--text-muted)'} />
</button>
))}
</div>
<input
ref={noteInputRef}
type="text"
value={ui.text}
onChange={e => 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' }}
/>
<textarea
value={ui.time}
maxLength={250}
rows={3}
onChange={e => setNoteUi(prev => ({ ...prev, [dayId]: { ...prev[dayId], time: e.target.value } }))}
onKeyDown={e => { if (e.key === 'Escape') cancelNote(Number(dayId)) }}
placeholder={t('dayplan.noteSubtitle')}
className="text-content"
style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', border: '1px solid var(--border-primary)', borderRadius: 8, padding: '7px 10px', fontFamily: 'inherit', outline: 'none', width: '100%', boxSizing: 'border-box', resize: 'none', lineHeight: 1.4 }}
/>
<div className={(ui.time?.length || 0) >= 240 ? 'text-[#d97706]' : 'text-content-faint'} style={{ textAlign: 'right', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', marginTop: -2 }}>{ui.time?.length || 0}/250</div>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button onClick={() => cancelNote(Number(dayId))} className="text-content-muted" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', background: 'none', border: '1px solid var(--border-primary)', borderRadius: 8, padding: '6px 14px', cursor: 'pointer', fontFamily: 'inherit' }}>{t('common.cancel')}</button>
<button onClick={() => saveNote(Number(dayId))} disabled={!ui.text?.trim()} className={!ui.text?.trim() ? 'bg-[var(--border-primary)] text-content-faint' : 'bg-accent text-accent-text'} style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', border: 'none', borderRadius: 8, padding: '6px 16px', cursor: !ui.text?.trim() ? 'not-allowed' : 'pointer', fontWeight: 600, fontFamily: 'inherit', transition: 'background 0.15s, color 0.15s' }}>
{ui.mode === 'add' ? t('common.add') : t('common.save')}
</button>
</div>
</div>
</div>,
<NoteDialog key={dayId} dayId={dayId} ui={ui} setNoteUi={setNoteUi} noteInputRef={noteInputRef}
cancelNote={cancelNote} saveNote={saveNote} t={t} />,
document.body
))}
</>
)
}
function NoteDialog({ dayId, ui, setNoteUi, noteInputRef, cancelNote, saveNote, t }: Omit<DayPlanSidebarNoteModalProps, 'noteUi'> & {
dayId: string
ui: NoteModalUi
}) {
const bodyRef = useRef<HTMLTextAreaElement | null>(null)
const patch = (fields: Partial<NoteModalUi>) =>
setNoteUi(prev => ({ ...prev, [dayId]: { ...(prev[dayId] as NoteModalUi), ...fields } }))
const color = ui.color ?? null
const surface = noteSurface(color)
const PreviewIcon = getNoteIcon(ui.icon)
const canSave = Boolean(ui.text?.trim())
const bodyLen = ui.time?.length || 0
return (
<div className="bg-[rgba(0,0,0,0.3)]" style={{
position: 'fixed', inset: 0, zIndex: 10000,
display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16,
backdropFilter: 'blur(3px)',
}} onClick={() => cancelNote(Number(dayId))}>
<div className="bg-surface-card note-modal" style={{
width: '100%', maxWidth: 720, maxHeight: '90vh', overflowY: 'auto', borderRadius: 16,
boxShadow: '0 16px 48px rgba(0,0,0,0.22)', padding: '22px 22px 18px',
display: 'flex', flexDirection: 'column', gap: 16,
}} onClick={e => e.stopPropagation()}>
<div className="text-content" style={{ fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))', fontWeight: 600 }}>
{ui.mode === 'add' ? t('dayplan.noteAdd') : t('dayplan.noteEdit')}
</div>
<div className="note-modal-cols" style={{ display: 'grid', gridTemplateColumns: '210px 1fr', gap: 20, alignItems: 'start' }}>
{/* Left — how it looks. */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 14, minWidth: 0 }}>
<div>
<div className="text-content-faint" style={{ fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.09em', marginBottom: 7 }}>
{t('dayplan.noteIcon')}
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 5 }}>
{NOTE_ICONS.map(({ id, Icon }) => (
<button key={id} type="button" onClick={() => patch({ icon: id })} title={id}
aria-pressed={ui.icon === id}
className={ui.icon === id ? 'bg-surface-hover' : 'bg-transparent'}
style={{ width: 38, height: 38, borderRadius: 8, border: ui.icon === id ? '2px solid var(--text-primary)' : '2px solid var(--border-faint)', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 0 }}>
<Icon size={16} strokeWidth={1.8} color={ui.icon === id ? (color ?? 'var(--text-primary)') : 'var(--text-muted)'} />
</button>
))}
</div>
</div>
<div>
<div className="text-content-faint" style={{ fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.09em', marginBottom: 7 }}>
{t('notes.color.label')}
</div>
<NoteColorPicker value={color} onChange={c => patch({ color: c })} />
</div>
<div>
<div className="text-content-faint" style={{ fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.09em', marginBottom: 7 }}>
{t('notes.preview')}
</div>
{/* The same shape the card takes in the day plan. */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '7px 8px', borderRadius: 6, border: `1px solid ${surface.border}`, background: surface.background }}>
<div style={{ width: 28, height: 28, flexShrink: 0, display: 'grid', placeItems: 'center', borderRadius: '50%', background: surface.iconBackground }}>
<PreviewIcon size={13} strokeWidth={1.8} color={surface.iconColor} />
</div>
<span className="text-content" style={{ fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', fontWeight: 500, minWidth: 0, wordBreak: 'break-word' }}>
{ui.text?.trim() || t('dayplan.noteTitle')}
</span>
</div>
</div>
</div>
{/* Right — what it says. */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, minWidth: 0 }}>
<div>
<label className="text-content-faint" htmlFor="note-title" style={{ display: 'block', fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.09em', marginBottom: 7 }}>
{t('dayplan.noteTitle')} *
</label>
<input
id="note-title"
ref={noteInputRef}
type="text"
value={ui.text}
onChange={e => patch({ 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 bg-surface-input"
style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500, border: '1px solid var(--border-primary)', borderRadius: 8, padding: '9px 11px', fontFamily: 'inherit', outline: 'none', width: '100%', boxSizing: 'border-box' }}
/>
</div>
<div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, marginBottom: 7, flexWrap: 'wrap' }}>
<label className="text-content-faint" htmlFor="note-body" style={{ fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.09em' }}>
{t('dayplan.noteSubtitle')}
</label>
<NoteFormatToolbar textareaRef={bodyRef} onChange={v => patch({ time: v })} compact />
</div>
<textarea
id="note-body"
ref={bodyRef}
value={ui.time}
maxLength={BODY_MAX}
rows={6}
onChange={e => patch({ time: e.target.value })}
onKeyDown={e => { if (e.key === 'Escape') cancelNote(Number(dayId)) }}
placeholder={t('notes.bodyPlaceholder')}
className="text-content bg-surface-input"
style={{ fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', border: '1px solid var(--border-primary)', borderRadius: 8, padding: '9px 11px', fontFamily: 'inherit', outline: 'none', width: '100%', boxSizing: 'border-box', resize: 'vertical', lineHeight: 1.5 }}
/>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8, marginTop: 4 }}>
<span className="text-content-faint" style={{ fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))' }}>{t('notes.markdownHint')}</span>
<span className={bodyLen >= BODY_MAX - 100 ? 'text-[#d97706]' : 'text-content-faint'} style={{ fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))' }}>{bodyLen}/{BODY_MAX}</span>
</div>
</div>
{ui.time?.trim() && (
<div className="collab-note-md-full text-content-muted" style={{ borderRadius: 10, border: `1px solid ${surface.border}`, background: surface.background, padding: '10px 12px', fontSize: 'calc(12px * var(--fs-scale-body, 1))', lineHeight: 1.55, wordBreak: 'break-word', overflowWrap: 'anywhere', maxHeight: 200, overflowY: 'auto' }}>
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]} components={markdownLinkComponents}>{ui.time}</Markdown>
</div>
)}
</div>
</div>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button type="button" onClick={() => cancelNote(Number(dayId))} className="text-content-muted" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', background: 'none', border: '1px solid var(--border-primary)', borderRadius: 8, padding: '7px 15px', cursor: 'pointer', fontFamily: 'inherit' }}>{t('common.cancel')}</button>
<button type="button" onClick={() => saveNote(Number(dayId))} disabled={!canSave}
className={!canSave ? 'bg-[var(--border-primary)] text-content-faint' : 'bg-accent text-accent-text'}
style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', border: 'none', borderRadius: 8, padding: '7px 17px', cursor: !canSave ? 'not-allowed' : 'pointer', fontWeight: 600, fontFamily: 'inherit', transition: 'background 0.15s, color 0.15s' }}>
{ui.mode === 'add' ? t('common.add') : t('common.save')}
</button>
</div>
</div>
</div>
)
}
@@ -3,6 +3,7 @@ import { Ticket, FileText, ExternalLink, Footprints, ArrowRight, Pencil } from '
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkBreaks from 'remark-breaks'
import { markdownLinkComponents } from '../shared/markdownLink'
import { useSettingsStore } from '../../store/settingsStore'
import { useTripStore } from '../../store/tripStore'
import { formatTime, splitReservationDateTime } from '../../utils/formatters'
@@ -186,7 +187,7 @@ export function DayPlanSidebarTransportDetailModal({
{res.notes && (
<div className="bg-surface-tertiary" style={{ padding: '8px 10px', borderRadius: 8 }}>
<div className="text-content-faint" style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.03em', marginBottom: 3 }}>{t('reservations.notes')}</div>
<div className="collab-note-md text-content" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', wordBreak: 'break-word', overflowWrap: 'anywhere' }}><Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{res.notes}</Markdown></div>
<div className="collab-note-md text-content" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', wordBreak: 'break-word', overflowWrap: 'anywhere' }}><Markdown remarkPlugins={[remarkGfm, remarkBreaks]} components={markdownLinkComponents}>{res.notes}</Markdown></div>
</div>
)}
@@ -1,6 +1,7 @@
import { useState, useEffect, useRef, useMemo, useCallback } from 'react'
import Modal from '../shared/Modal'
import CustomSelect from '../shared/CustomSelect'
import NoteFormatToolbar from '../shared/NoteFormatToolbar'
import { mapsApi } from '../../api/client'
import { useAuthStore } from '../../store/authStore'
import { useCanDo } from '../../store/permissionsStore'
@@ -601,6 +602,8 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
// pick — otherwise the dialog would jump sideways mid-typing.
const showDetails = !isMobile && placesEnrichEnabled
const modalSize = isMobile ? 'lg' : showDetails && twoColumn ? '5xl' : showDetails || twoColumn ? '4xl' : 'lg'
const descriptionRef = useRef<HTMLTextAreaElement | null>(null)
const notesRef = useRef<HTMLTextAreaElement | null>(null)
return (
<Modal
isOpen={isOpen}
@@ -740,23 +743,32 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
{/* Description */}
<div>
<label className="block text-sm font-medium text-content-secondary mb-1">{t('places.formDescription')}</label>
<div className="flex items-center justify-between gap-2 mb-1 flex-wrap">
<label className="block text-sm font-medium text-content-secondary">{t('places.formDescription')}</label>
<NoteFormatToolbar textareaRef={descriptionRef} onChange={v => handleChange('description', v)} compact />
</div>
<textarea
ref={descriptionRef}
value={form.description}
onChange={e => handleChange('description', e.target.value)}
rows={2}
rows={3}
placeholder={t('places.formDescriptionPlaceholder')}
className="form-input" style={{ resize: 'vertical' }}
/>
</div>
{/* Notes */}
{/* Notes — Markdown, same as the description, and rendered as such in the
inspector. The bar is how anyone finds that out. */}
<div>
<label className="block text-sm font-medium text-content-secondary mb-1">{t('places.formNotes')}</label>
<div className="flex items-center justify-between gap-2 mb-1 flex-wrap">
<label className="block text-sm font-medium text-content-secondary">{t('places.formNotes')}</label>
<NoteFormatToolbar textareaRef={notesRef} onChange={v => handleChange('notes', v)} compact />
</div>
<textarea
ref={notesRef}
value={form.notes}
onChange={e => handleChange('notes', e.target.value)}
rows={2}
rows={3}
maxLength={2000}
placeholder={t('places.formNotesPlaceholder')}
className="form-input" style={{ resize: 'vertical' }}
@@ -4,6 +4,7 @@ import { openFile } from '../../utils/fileDownload'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkBreaks from 'remark-breaks'
import { markdownLinkComponents } from '../shared/markdownLink'
import { X, Clock, MapPin, ExternalLink, Phone, Banknote, Edit2, Trash2, Plus, Minus, ChevronDown, ChevronUp, FileText, Upload, File, FileImage, Star, Navigation, Map as MapIcon, Users, Mountain, TrendingUp, Bookmark, BookmarkCheck, Copy, Route } from 'lucide-react'
import PlaceAvatar from '../shared/PlaceAvatar'
import PlaceAvatarUpload from '../shared/PlaceAvatarUpload'
@@ -405,14 +406,14 @@ export default function PlaceInspector({
{/* Description / Summary */}
{(place.description || googleDetails?.summary) && (
<div className="collab-note-md bg-surface-hover text-content-muted" style={{ borderRadius: 10, overflow: 'hidden', flexShrink: 0, fontSize: 'calc(12px * var(--fs-scale-body, 1))', lineHeight: '1.5', padding: '8px 12px', wordBreak: 'break-word', overflowWrap: 'anywhere' }}>
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{place.description || googleDetails?.summary || ''}</Markdown>
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]} components={markdownLinkComponents}>{place.description || googleDetails?.summary || ''}</Markdown>
</div>
)}
{/* Notes */}
{place.notes && (
<div className="collab-note-md bg-surface-hover text-content-muted" style={{ borderRadius: 10, overflow: 'hidden', flexShrink: 0, fontSize: 'calc(12px * var(--fs-scale-body, 1))', lineHeight: '1.5', padding: '8px 12px', wordBreak: 'break-word', overflowWrap: 'anywhere' }}>
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{place.notes}</Markdown>
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]} components={markdownLinkComponents}>{place.notes}</Markdown>
</div>
)}
@@ -863,7 +864,7 @@ function PlaceReservationParticipants({ selectedAssignmentId, reservations, assi
</div>
)}
</div>
{res.notes && <div className="collab-note-md text-content-faint" style={{ padding: '0 10px 6px', fontSize: 'calc(10px * var(--fs-scale-caption, 1))', lineHeight: 1.4, wordBreak: 'break-word', overflowWrap: 'anywhere' }}><Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{res.notes}</Markdown></div>}
{res.notes && <div className="collab-note-md text-content-faint" style={{ padding: '0 10px 6px', fontSize: 'calc(10px * var(--fs-scale-caption, 1))', lineHeight: 1.4, wordBreak: 'break-word', overflowWrap: 'anywhere' }}><Markdown remarkPlugins={[remarkGfm, remarkBreaks]} components={markdownLinkComponents}>{res.notes}</Markdown></div>}
{(() => {
const meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {})
if (!meta || Object.keys(meta).length === 0) return null
@@ -1,4 +1,6 @@
import React from 'react'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { Plus, Check, Star } from 'lucide-react'
import PlaceAvatar from '../shared/PlaceAvatar'
import { getCategoryIcon } from '../shared/categoryIcons'
@@ -107,9 +109,13 @@ export const MemoPlaceRow = React.memo(function MemoPlaceRow({
</div>
{(place.description || place.address || cat?.name) && (
<div style={{ marginTop: 2 }}>
<span className="text-content-faint" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'block', lineHeight: 1.2 }}>
{place.description || place.address || cat?.name}
</span>
{/* 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. */}
<div className="collab-note-md text-content-faint" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', lineHeight: 1.2, maxHeight: '1.2em' }}>
<Markdown remarkPlugins={[remarkGfm]}>{place.description || place.address || cat?.name || ''}</Markdown>
</div>
</div>
)}
</div>
@@ -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
<div>
<div className={fieldLabelClass}>{t('reservations.notes')}</div>
<div className={`collab-note-md ${fieldValueClass}`} style={{ fontWeight: 400, lineHeight: 1.5, wordBreak: 'break-word', overflowWrap: 'anywhere' }}>
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{r.notes}</Markdown>
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]} components={markdownLinkComponents}>{r.notes}</Markdown>
</div>
</div>
)}
@@ -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))
})
})
@@ -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,
}
}
@@ -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 (
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => onChange(null)}
aria-pressed={value === null}
aria-label={t('notes.color.none')}
title={t('notes.color.none')}
className={`w-7 h-7 rounded-full grid place-items-center border-2 border-dashed transition-transform duration-150 hover:scale-110 motion-reduce:hover:scale-100 motion-reduce:transition-none focus-visible:outline-2 focus-visible:outline-[var(--accent)] ${
value === null ? `border-solid border-transparent bg-surface-hover ${selected}` : 'border-edge-secondary hover:border-edge'
}`}
>
<Ban size={12} className="text-content-faint" />
</button>
{NOTE_COLORS.map(color => (
<button
key={color}
type="button"
onClick={() => onChange(color)}
aria-pressed={value === color}
aria-label={t(`notes.color.${COLOR_NAMES[color]}`)}
title={t(`notes.color.${COLOR_NAMES[color]}`)}
className={`w-7 h-7 rounded-full ring-1 ring-inset ring-black/10 dark:ring-white/15 transition-transform duration-150 hover:scale-110 motion-reduce:hover:scale-100 motion-reduce:transition-none focus-visible:outline-2 focus-visible:outline-[var(--accent)] ${
value === color ? selected : ''
}`}
style={{ backgroundColor: color }}
/>
))}
</div>
)
}
/** Names rather than hex codes in the label, so a screen reader says something useful. */
const COLOR_NAMES: Record<string, string> = {
'#dc2626': 'red',
'#ea580c': 'orange',
'#d97706': 'amber',
'#16a34a': 'green',
'#0891b2': 'cyan',
'#2563eb': 'blue',
'#9333ea': 'purple',
}
@@ -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<HTMLTextAreaElement | null>(null)
const [value, setValue] = useState(initial)
return (
<>
<NoteFormatToolbar textareaRef={ref} onChange={v => { setValue(v); onValue?.(v) }} />
<textarea ref={ref} aria-label="body" value={value} onChange={e => setValue(e.target.value)} />
</>
)
}
const field = () => screen.getByLabelText('body') as HTMLTextAreaElement
const select = (from: number, to: number) => {
const ta = field()
ta.focus()
ta.setSelectionRange(from, to)
}
describe('NoteFormatToolbar', () => {
it('FE-NOTEBAR-001: wraps the selection and leaves the selection on the text, not the markers', async () => {
const user = userEvent.setup()
render(<Harness initial="closes at 5pm" />)
select(0, 6)
await user.click(screen.getByRole('button', { name: /bold/i }))
expect(field().value).toBe('**closes** at 5pm')
// "closes", not "**closes**" — a second format nests instead of wrapping syntax.
// The selection is restored in a rAF, after the field holds the new text.
await waitFor(() => expect(field().value.slice(field().selectionStart, field().selectionEnd)).toBe('closes'))
})
it('FE-NOTEBAR-002: an empty selection inserts the markers and puts the caret between them', async () => {
const user = userEvent.setup()
render(<Harness initial="note" />)
select(4, 4)
await user.click(screen.getByRole('button', { name: /italic/i }))
expect(field().value).toBe('note__')
await waitFor(() => expect(field().selectionStart).toBe(5))
expect(field().selectionEnd).toBe(5)
})
it('FE-NOTEBAR-003: a list prefix applies to the line the caret is on, not the whole field', async () => {
const user = userEvent.setup()
render(<Harness initial={'first\nsecond'} />)
select(8, 8) // inside "second"
await user.click(screen.getByRole('button', { name: /bulleted list/i }))
expect(field().value).toBe('first\n- second')
})
it('FE-NOTEBAR-004: pressing the same line button again removes the prefix', async () => {
const user = userEvent.setup()
render(<Harness initial="- already a list" />)
select(5, 5)
await user.click(screen.getByRole('button', { name: /bulleted list/i }))
expect(field().value).toBe('already a list')
})
it('FE-NOTEBAR-005: the link button leaves a placeholder URL to type over', async () => {
const user = userEvent.setup()
render(<Harness initial="opening hours" />)
select(0, 13)
await user.click(screen.getByRole('button', { name: /^link$/i }))
expect(field().value).toBe('[opening hours](https://)')
})
it('FE-NOTEBAR-006: the buttons do not take focus away from the field', async () => {
const user = userEvent.setup()
render(<Harness initial="text" />)
select(0, 4)
await user.click(screen.getByRole('button', { name: /bold/i }))
// If the button had taken focus, the selection would be gone and the wrap
// would have landed at position 0 instead of around the word.
expect(field().value).toBe('**text**')
await waitFor(() => expect(document.activeElement).toBe(field()))
})
it('FE-NOTEBAR-007: does nothing when the field is not mounted', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
function Detached() {
const ref = useRef<HTMLTextAreaElement | null>(null)
return <NoteFormatToolbar textareaRef={ref} onChange={onChange} />
}
render(<Detached />)
await user.click(screen.getByRole('button', { name: /bold/i }))
expect(onChange).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,115 @@
import { useRef } from 'react'
import { Bold, Italic, Strikethrough, List, ListOrdered, Link2, Quote, Code } from 'lucide-react'
import { useTranslation } from '../../i18n'
type FormatAction =
| { type: 'wrap'; before: string; after: string }
| { type: 'line'; prefix: string }
interface Props {
/** The field being formatted. Must be mounted before a button is pressed. */
textareaRef: React.RefObject<HTMLTextAreaElement | null>
/** Receives the whole new value — the field stays controlled by its owner. */
onChange: (value: string) => void
/** Rendered smaller, for the narrow note dialog. */
compact?: boolean
/** Mobile sheets run on their own token family, scoped to `.m-root`. */
variant?: 'desktop' | 'mobile'
}
const ACTIONS: Array<{ icon: typeof Bold; labelKey: string; action: FormatAction }> = [
{ icon: Bold, labelKey: 'notes.format.bold', action: { type: 'wrap', before: '**', after: '**' } },
{ icon: Italic, labelKey: 'notes.format.italic', action: { type: 'wrap', before: '_', after: '_' } },
{ icon: Strikethrough, labelKey: 'notes.format.strike', action: { type: 'wrap', before: '~~', after: '~~' } },
{ icon: Code, labelKey: 'notes.format.code', action: { type: 'wrap', before: '`', after: '`' } },
{ icon: Link2, labelKey: 'notes.format.link', action: { type: 'wrap', before: '[', after: '](https://)' } },
{ icon: List, labelKey: 'notes.format.list', action: { type: 'line', prefix: '- ' } },
{ icon: ListOrdered, labelKey: 'notes.format.orderedList', action: { type: 'line', prefix: '1. ' } },
{ icon: Quote, labelKey: 'notes.format.quote', action: { type: 'line', prefix: '> ' } },
]
/**
* Formatting bar for the note fields (#1629).
*
* Notes have rendered Markdown for a long time, but only ever offered a bare
* textarea to write it in, so unless you already knew the syntax there was no
* way to find out it existed. This is that discovery surface.
*
* It edits Markdown rather than being a WYSIWYG editor on purpose: the stored
* value stays plain text that the PDF export, the plugin API and every existing
* note keep reading unchanged.
*
* Split from Journey's MarkdownToolbar, which is bound to the journal's own
* `--journal-*` variables and hardcodes English labels; this one runs on theme
* tokens and goes through i18n, so it can sit in the planner.
*/
export default function NoteFormatToolbar({ textareaRef, onChange, compact, variant = 'desktop' }: Props) {
const { t } = useTranslation()
// Applied after the re-render, when the field holds the new text.
const pendingSelection = useRef<[number, number] | null>(null)
const apply = (action: FormatAction) => {
const ta = textareaRef.current
if (!ta) return
const { selectionStart: start, selectionEnd: end, value } = ta
const selected = value.slice(start, end)
let next: string
let caret: [number, number]
if (action.type === 'wrap') {
next = value.slice(0, start) + action.before + selected + action.after + value.slice(end)
caret = selected
// Keep the selection on the text, not on the markers around it, so a
// second click on another button nests instead of wrapping the syntax.
? [start + action.before.length, end + action.before.length]
: [start + action.before.length, start + action.before.length]
} else {
const lineStart = value.lastIndexOf('\n', start - 1) + 1
const alreadyPrefixed = value.startsWith(action.prefix, lineStart)
next = alreadyPrefixed
? value.slice(0, lineStart) + value.slice(lineStart + action.prefix.length)
: value.slice(0, lineStart) + action.prefix + value.slice(lineStart)
const shift = alreadyPrefixed ? -action.prefix.length : action.prefix.length
caret = [Math.max(lineStart, start + shift), Math.max(lineStart, end + shift)]
}
pendingSelection.current = caret
onChange(next)
requestAnimationFrame(() => {
const el = textareaRef.current
const sel = pendingSelection.current
if (!el || !sel) return
el.focus()
el.setSelectionRange(sel[0], sel[1])
pendingSelection.current = null
})
}
const size = compact ? 26 : 30
const buttonCls = variant === 'mobile'
? 'grid place-items-center rounded-[10px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] text-m-muted active:bg-m-act active:text-m-actfg'
: 'grid place-items-center rounded-lg text-content-muted hover:text-content hover:bg-surface-hover transition-colors'
return (
<div className="flex flex-wrap items-center gap-[2px]" role="toolbar" aria-label={t('notes.format.toolbar')}>
{ACTIONS.map(({ icon: Icon, labelKey, action }) => (
<button
key={labelKey}
type="button"
title={t(labelKey)}
aria-label={t(labelKey)}
// Buttons in a toolbar above a field must not steal focus, or the
// selection they are about to format is gone before the click lands.
onMouseDown={e => e.preventDefault()}
onClick={() => apply(action)}
className={buttonCls}
style={{ width: size, height: size, cursor: 'pointer', padding: 0, ...(variant === 'mobile' ? {} : { border: 0, background: 'none' }) }}
>
<Icon size={compact ? 13 : 15} strokeWidth={1.9} />
</button>
))}
</div>
)
}
@@ -0,0 +1,38 @@
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { markdownLinkComponents } from './markdownLink'
describe('markdownLinkComponents', () => {
it('FE-MDLINK-001: a link in a note opens in its own tab instead of navigating the planner away', () => {
render(
<Markdown remarkPlugins={[remarkGfm]} components={markdownLinkComponents}>
{'[opening hours](https://example.com/hours)'}
</Markdown>
)
const link = screen.getByRole('link', { name: 'opening hours' })
expect(link).toHaveAttribute('href', 'https://example.com/hours')
expect(link).toHaveAttribute('target', '_blank')
})
it('FE-MDLINK-002: the opened page gets no handle on the app', () => {
render(
<Markdown remarkPlugins={[remarkGfm]} components={markdownLinkComponents}>
{'[booking](https://example.com)'}
</Markdown>
)
// Without noopener the target can navigate this window through window.opener,
// and a note is content other trip members wrote.
expect(screen.getByRole('link', { name: 'booking' }).getAttribute('rel')).toContain('noopener')
})
it('FE-MDLINK-003: a bare URL typed into a note becomes a link too', () => {
render(
<Markdown remarkPlugins={[remarkGfm]} components={markdownLinkComponents}>
{'tickets: https://example.com/tickets'}
</Markdown>
)
expect(screen.getByRole('link', { name: 'https://example.com/tickets' })).toHaveAttribute('target', '_blank')
})
})
@@ -0,0 +1,22 @@
import type { Components } from 'react-markdown'
/**
* How a link inside a note behaves (#1629).
*
* react-markdown renders a bare `<a href>`, which navigates the SPA away from
* the trip: the planner state, the open day, the map viewport are all gone, and
* the way back is the browser's back button. A note is a place to park a
* booking confirmation or an opening-hours page, so the link opens in its own
* tab instead.
*
* `rel` is not decoration. Without `noopener` the opened page gets a handle on
* this one through `window.opener` and can navigate it somewhere else, and a
* note is user-supplied content that other trip members read.
*/
export const markdownLinkComponents: Components = {
a: ({ children, href, ...rest }) => (
<a {...rest} href={href} target="_blank" rel="noopener noreferrer nofollow">
{children}
</a>
),
}
+7 -4
View File
@@ -10,6 +10,9 @@ interface NoteUiState {
text: string
time: string
icon: string
/** One of NOTE_COLORS, or null for the neutral card (#1629). Absent on a
* caller that predates colours, which then leaves the stored one alone. */
color?: string | null
sortOrder?: number
}
@@ -28,13 +31,13 @@ export function useDayNotes(tripId: number | string) {
const openAddNote = (dayId: number, getMergedItems: (dayId: number) => MergedItem[], expandDay?: (dayId: number) => void) => {
const merged = getMergedItems(dayId)
const maxKey = merged.length > 0 ? Math.max(...merged.map((i) => i.sortKey)) : -1
setNoteUi((prev) => ({ ...prev, [dayId]: { mode: 'add', text: '', time: '', icon: 'FileText', sortOrder: maxKey + 1 } }))
setNoteUi((prev) => ({ ...prev, [dayId]: { mode: 'add', text: '', time: '', icon: 'FileText', color: null, sortOrder: maxKey + 1 } }))
expandDay?.(dayId)
setTimeout(() => noteInputRef.current?.focus(), 50)
}
const openEditNote = (dayId: number, note: DayNote) => {
setNoteUi((prev) => ({ ...prev, [dayId]: { mode: 'edit', noteId: note.id, text: note.text, time: note.time || '', icon: note.icon || 'FileText' } }))
setNoteUi((prev) => ({ ...prev, [dayId]: { mode: 'edit', noteId: note.id, text: note.text, time: note.time || '', icon: note.icon || 'FileText', color: note.color ?? null } }))
setTimeout(() => noteInputRef.current?.focus(), 50)
}
@@ -47,9 +50,9 @@ export function useDayNotes(tripId: number | string) {
if (!ui?.text?.trim()) return
try {
if (ui.mode === 'add') {
await tripStore.addDayNote(tripId, dayId, { text: ui.text.trim(), time: ui.time || null, icon: ui.icon || 'FileText', sort_order: ui.sortOrder })
await tripStore.addDayNote(tripId, dayId, { text: ui.text.trim(), time: ui.time || null, icon: ui.icon || 'FileText', color: ui.color ?? null, sort_order: ui.sortOrder })
} else {
await tripStore.updateDayNote(tripId, dayId, ui.noteId!, { text: ui.text.trim(), time: ui.time || null, icon: ui.icon || 'FileText' })
await tripStore.updateDayNote(tripId, dayId, ui.noteId!, { text: ui.text.trim(), time: ui.time || null, icon: ui.icon || 'FileText', color: ui.color })
}
cancelNote(dayId)
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
+13
View File
@@ -1193,6 +1193,19 @@ img[alt="TREK"] {
.collab-note-md-full th, .collab-note-md-full td { border: 1px solid var(--border-primary); padding: 4px 8px; font-size: 0.9em; }
.collab-note-md-full hr { border: none; border-top: 1px solid var(--border-primary); margin: 0.8em 0; }
/* Note card actions: stacked beside the card, and big enough to aim at. */
.note-edit-btn {
transition: background-color 0.12s ease, color 0.12s ease;
}
.note-edit-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.note-edit-btn-danger:hover {
background: rgba(220, 38, 38, 0.13);
color: #dc2626;
}
/* Day-plan header action grid (edit / +transport / note / collapse) */
.dp-day-actions button {
color: var(--text-faint);
@@ -1,8 +1,13 @@
import { BedDouble, Car, ChevronDown, ChevronUp, Clock, Footprints, Pencil, Route, Ticket, X, Zap } from 'lucide-react'
import type { ReactNode, MouseEvent } from 'react'
import type { ReactNode, MouseEvent, CSSProperties } from 'react'
import PlaceAvatar from '../../../../components/shared/PlaceAvatar'
import { getCategoryIcon } from '../../../../components/shared/categoryIcons'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkBreaks from 'remark-breaks'
import { RES_ICONS, getNoteIcon } from '../../../../components/Planner/DayPlanSidebar.constants'
import { noteSurface } from '../../../../components/Planner/noteSurface'
import { markdownLinkComponents } from '../../../../components/shared/markdownLink'
import { getDisplayTimeForDay, getSpanPhase } from '../../../../utils/dayMerge'
import { formatTime, splitReservationDateTime } from '../../../../utils/formatters'
import { transportSubtitle, type TransitMeta, type TransportEntry } from './planTimelineModel'
@@ -80,9 +85,12 @@ function ActionCircle({ label, onClick, faint = false, children }: {
}
/** 30px go-mode avatar ring (photo for places, icon circle for transports/notes). */
function AvatarRing({ children, className = '' }: { children: ReactNode; className?: string }) {
function AvatarRing({ children, className = '', style }: { children: ReactNode; className?: string; style?: CSSProperties }) {
return (
<span className={`flex h-[30px] w-[30px] flex-none items-center justify-center overflow-hidden rounded-full border-[1.5px] border-[color:var(--m-avbr)] bg-[color:var(--m-ic)] ${className}`}>
<span
className={`flex h-[30px] w-[30px] flex-none items-center justify-center overflow-hidden rounded-full border-[1.5px] border-[color:var(--m-avbr)] bg-[color:var(--m-ic)] ${className}`}
style={style}
>
{children}
</span>
)
@@ -457,11 +465,12 @@ export function NoteRow({ note, chrome, reorder, onEdit }: {
onEdit: () => void
}) {
const Icon = getNoteIcon(note.icon)
const skin = noteSurface(note.color)
// The time column is a free detail line; only a leading HH:MM is a real time.
const { time: noteTime, detail } = splitNoteTime(note.time)
const time = noteTime ? fmtTime(noteTime, chrome) : ''
const [title, ...rest] = note.text.split('\n')
const sub = [rest.join(' ').trim(), detail].filter(Boolean).join(' · ')
const titleExtra = rest.join(' ').trim()
return (
<div
@@ -469,17 +478,27 @@ export function NoteRow({ note, chrome, reorder, onEdit }: {
className={`my-[2px] flex items-center gap-2.5 ${chrome.editing ? 'cursor-pointer' : ''}`}
>
{!chrome.editing && (
<AvatarRing>
<Icon size={14} strokeWidth={2} className="text-m-muted" />
<AvatarRing style={note.color ? { background: skin.iconBackground, borderColor: skin.border } : undefined}>
<Icon size={14} strokeWidth={2} style={{ color: skin.iconColor }} />
</AvatarRing>
)}
<div className="min-w-0 flex-1 rounded-[13px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-[11px] py-[7px]">
<div
className="min-w-0 flex-1 rounded-[13px] border px-[11px] py-[7px]"
style={{ borderColor: skin.border, background: note.color ? skin.background : 'var(--m-ic)' }}
>
<div className="flex items-center gap-1.5">
{time && <span className={TIME_CHIP}>{time}</span>}
<span className="min-w-0 text-[0.875rem] font-semibold">{title}</span>
</div>
{sub && (
<div className="mt-px font-geist text-[0.71875rem] leading-[1.4] text-m-muted">{sub}</div>
{titleExtra && (
<div className="mt-px font-geist text-[0.71875rem] leading-[1.4] text-m-muted">{titleExtra}</div>
)}
{detail && (
// Rendered, not raw: a note written with the formatting bar would
// otherwise read as `**asterisks**` on the phone.
<div className="collab-note-md mt-px font-geist text-[0.71875rem] leading-[1.45] text-m-muted [overflow-wrap:anywhere]">
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]} components={markdownLinkComponents}>{detail}</Markdown>
</div>
)}
</div>
{chrome.editing && <span className="flex flex-none items-center gap-1.5">{reorder}</span>}
@@ -1,6 +1,10 @@
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import MSheet from '../../../components/MSheet'
import { NOTE_ICONS } from '../../../../components/Planner/DayPlanSidebar.constants'
import { ChevronDown } from 'lucide-react'
import { NOTE_COLORS } from '@trek/shared'
import { NOTE_ICONS, getNoteIcon } from '../../../../components/Planner/DayPlanSidebar.constants'
import { noteSurface } from '../../../../components/Planner/noteSurface'
import NoteFormatToolbar from '../../../../components/shared/NoteFormatToolbar'
import { useTripStore } from '../../../../store/tripStore'
import { Eyebrow, FIELD_AREA_CLS, FIELD_CLS, FormSheetFooter, FormSheetHeader } from './PlSheetChrome'
import type { DayNote } from '../../../../types'
@@ -19,7 +23,7 @@ export interface MNoteSheetProps {
onClose: () => void
}
const DETAIL_MAX = 250
const DETAIL_MAX = 2000
/**
* Day-note sheet: the demo's icon grid over title + detail. Persists through
@@ -31,8 +35,13 @@ export default function MNoteSheet({ planner, open, payload, onClose }: MNoteShe
const { t, toast, tripId, selectedDayId, tripActions } = planner
const [icon, setIcon] = useState('FileText')
const [color, setColor] = useState<string | null>(null)
// Thirty-two icons in a six-wide grid is most of a phone screen, so the grid
// is folded behind the chosen one, which doubles as the colour preview.
const [iconOpen, setIconOpen] = useState(false)
const [title, setTitle] = useState('')
const [detail, setDetail] = useState('')
const detailRef = useRef<HTMLTextAreaElement | null>(null)
const [isSaving, setIsSaving] = useState(false)
// Open-time snapshot — the payload disappears with shell.sheet on close, but
// the sheet still shows through its exit animation.
@@ -47,6 +56,8 @@ export default function MNoteSheet({ planner, open, payload, onClose }: MNoteShe
if (!open) return
setSheetPayload(payload)
setIcon(payload?.note?.icon || 'FileText')
setColor(payload?.note?.color ?? null)
setIconOpen(false)
setTitle(payload?.note?.text || '')
setDetail(payload?.note?.time || '')
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -54,13 +65,15 @@ export default function MNoteSheet({ planner, open, payload, onClose }: MNoteShe
const note = sheetPayload?.note ?? null
const dayId = sheetPayload?.dayId ?? selectedDayId
const skin = noteSurface(color)
const ChosenIcon = getNoteIcon(icon)
const handleSubmit = async () => {
if (!title.trim() || !dayId || isSaving) return
setIsSaving(true)
try {
if (note) {
await tripActions.updateDayNote(tripId, dayId, note.id, { text: title.trim(), time: detail || null, icon })
await tripActions.updateDayNote(tripId, dayId, note.id, { text: title.trim(), time: detail || null, icon, color })
} else {
// Append at the end of the day timeline: after the last assignment or note.
const state = useTripStore.getState()
@@ -73,6 +86,7 @@ export default function MNoteSheet({ planner, open, payload, onClose }: MNoteShe
text: title.trim(),
time: detail || null,
icon,
color,
sort_order: maxKey + 1,
})
}
@@ -103,23 +117,72 @@ export default function MNoteSheet({ planner, open, payload, onClose }: MNoteShe
/>
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] pb-[6px] pt-1">
<div className="grid grid-cols-6 gap-[7px]">
{NOTE_ICONS.map(({ id, Icon }) => (
<Eyebrow className="mb-[7px] uppercase">{t('notes.appearance')}</Eyebrow>
<div className="flex items-center gap-[10px]">
{/* The chosen icon in the chosen colour: the picker and the preview
are the same control, which is the only way both fit above the
fold on a phone. */}
<button
type="button"
onClick={() => setIconOpen(v => !v)}
aria-expanded={iconOpen}
aria-label={t('dayplan.noteIcon')}
className="relative flex h-[46px] w-[46px] flex-none items-center justify-center rounded-[14px] border"
style={{ borderColor: skin.border, background: skin.iconBackground }}
>
<ChosenIcon size={19} strokeWidth={1.9} style={{ color: skin.iconColor }} />
<ChevronDown
size={11}
strokeWidth={2.4}
className={`absolute bottom-[3px] right-[3px] text-m-faint transition-transform duration-200 ${iconOpen ? 'rotate-180' : ''}`}
/>
</button>
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-[7px]">
<button
key={id}
type="button"
onClick={() => setIcon(id)}
aria-label={id}
aria-pressed={icon === id}
className={`flex h-[42px] items-center justify-center rounded-[12px] border border-[color:var(--m-rowbr)] ${
icon === id ? 'bg-m-act text-m-actfg' : 'bg-[color:var(--m-ic)] text-m-muted'
onClick={() => setColor(null)}
aria-pressed={color === null}
aria-label={t('notes.color.none')}
className={`h-[26px] w-[26px] rounded-full border-2 border-dashed border-[color:var(--m-rowbr)] ${
color === null ? 'ring-2 ring-[color:var(--m-act)] ring-offset-2 ring-offset-[color:var(--m-card)]' : ''
}`}
>
<Icon size={17} strokeWidth={1.8} />
</button>
))}
/>
{NOTE_COLORS.map(c => (
<button
key={c}
type="button"
onClick={() => setColor(c)}
aria-pressed={color === c}
aria-label={c}
className={`h-[26px] w-[26px] rounded-full ${
color === c ? 'ring-2 ring-[color:var(--m-act)] ring-offset-2 ring-offset-[color:var(--m-card)]' : ''
}`}
style={{ background: c }}
/>
))}
</div>
</div>
{iconOpen && (
<div className="mt-[9px] grid grid-cols-6 gap-[7px]">
{NOTE_ICONS.map(({ id, Icon }) => (
<button
key={id}
type="button"
onClick={() => { setIcon(id); setIconOpen(false) }}
aria-label={id}
aria-pressed={icon === id}
className={`flex h-[42px] items-center justify-center rounded-[12px] border border-[color:var(--m-rowbr)] ${
icon === id ? 'bg-m-act text-m-actfg' : 'bg-[color:var(--m-ic)] text-m-muted'
}`}
>
<Icon size={17} strokeWidth={1.8} />
</button>
))}
</div>
)}
<Eyebrow className="mb-[5px] mt-[14px] uppercase">{t('dayplan.noteTitle')} *</Eyebrow>
<input
type="text"
@@ -130,21 +193,24 @@ export default function MNoteSheet({ planner, open, payload, onClose }: MNoteShe
className={FIELD_CLS}
/>
<Eyebrow className="mb-[5px] mt-3 uppercase">{t('dayplan.noteSubtitle')}</Eyebrow>
<div className="mb-[6px] mt-3 flex items-center justify-between gap-2">
<Eyebrow className="uppercase">{t('dayplan.noteSubtitle')}</Eyebrow>
<NoteFormatToolbar textareaRef={detailRef} onChange={setDetail} compact variant="mobile" />
</div>
<textarea
ref={detailRef}
value={detail}
onChange={e => setDetail(e.target.value)}
rows={3}
rows={4}
maxLength={DETAIL_MAX}
placeholder={t('dayplan.noteSubtitle')}
placeholder={t('notes.bodyPlaceholder')}
className={FIELD_AREA_CLS}
/>
<div
className={`mt-1 flex justify-end font-geist text-[0.59375rem] ${
detail.length >= DETAIL_MAX - 10 ? 'text-[color:var(--m-st-pending)]' : 'text-m-faint'
}`}
>
{detail.length}/{DETAIL_MAX}
<div className="mt-1 flex items-center justify-between gap-2 font-geist text-[0.59375rem]">
<span className="text-m-faint">{t('notes.markdownHint')}</span>
<span className={detail.length >= DETAIL_MAX - 100 ? 'text-[color:var(--m-st-pending)]' : 'text-m-faint'}>
{detail.length}/{DETAIL_MAX}
</span>
</div>
</div>
@@ -15,6 +15,7 @@ const NOTE = {
text: 'Buy museum tickets',
time: '09:30 at the kiosk',
icon: 'Ticket',
color: null,
sort_order: 2,
} as unknown as DayNote
@@ -36,6 +37,14 @@ function typeTitle(value: string) {
fireEvent.change(screen.getByPlaceholderText('dayplan.noteTitle *'), { target: { value } })
}
// The icon grid is folded away behind the chosen icon (#1629) — a phone cannot
// spare 290px for 32 icons — so reaching one means opening the grid first.
const openIconGrid = () => fireEvent.click(screen.getByRole('button', { name: 'dayplan.noteIcon' }))
const pickIcon = (id: string) => {
openIconGrid()
fireEvent.click(screen.getByRole('button', { name: id }))
}
describe('MNoteSheet', () => {
beforeEach(() => {
resetAllStores()
@@ -51,13 +60,16 @@ describe('MNoteSheet', () => {
expect(screen.getByRole('dialog', { name: 'dayplan.noteAdd' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.add' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'common.delete' })).not.toBeInTheDocument()
openIconGrid()
expect(screen.getByRole('button', { name: 'FileText' })).toHaveAttribute('aria-pressed', 'true')
expect(screen.getByRole('button', { name: 'Train' })).toHaveAttribute('aria-pressed', 'false')
})
it('FE-MOB-NOTESH-003: picking an icon moves the pressed state', () => {
renderSheet()
fireEvent.click(screen.getByRole('button', { name: 'Coffee' }))
pickIcon('Coffee')
// Choosing closes the grid, so the assertion reopens it.
openIconGrid()
expect(screen.getByRole('button', { name: 'Coffee' })).toHaveAttribute('aria-pressed', 'true')
expect(screen.getByRole('button', { name: 'FileText' })).toHaveAttribute('aria-pressed', 'false')
})
@@ -78,9 +90,9 @@ describe('MNoteSheet', () => {
dayNotes: { '7': [{ sort_order: 5 }] as unknown as DayNote[] },
})
const { planner, onClose } = renderSheet()
fireEvent.click(screen.getByRole('button', { name: 'Plane' }))
pickIcon('Plane')
typeTitle(' Check in online ')
fireEvent.change(screen.getByPlaceholderText('dayplan.noteSubtitle'), { target: { value: '18:00 at the gate' } })
fireEvent.change(screen.getByPlaceholderText('notes.bodyPlaceholder'), { target: { value: '18:00 at the gate' } })
fireEvent.click(screen.getByRole('button', { name: 'common.add' }))
await waitFor(() => expect(planner.tripActions.addDayNote).toHaveBeenCalledTimes(1))
@@ -88,6 +100,7 @@ describe('MNoteSheet', () => {
text: 'Check in online',
time: '18:00 at the gate',
icon: 'Plane',
color: null,
sort_order: 6,
})
await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1))
@@ -103,6 +116,7 @@ describe('MNoteSheet', () => {
text: 'Sunrise walk',
time: null,
icon: 'FileText',
color: null,
sort_order: 0,
})
})
@@ -129,7 +143,8 @@ describe('MNoteSheet', () => {
renderSheet({ payload: { dayId: 7, note: NOTE } })
expect(screen.getByRole('dialog', { name: 'dayplan.noteEdit' })).toBeInTheDocument()
expect(screen.getByPlaceholderText('dayplan.noteTitle *')).toHaveValue('Buy museum tickets')
expect(screen.getByPlaceholderText('dayplan.noteSubtitle')).toHaveValue('09:30 at the kiosk')
expect(screen.getByPlaceholderText('notes.bodyPlaceholder')).toHaveValue('09:30 at the kiosk')
openIconGrid()
expect(screen.getByRole('button', { name: 'Ticket' })).toHaveAttribute('aria-pressed', 'true')
expect(screen.getByRole('button', { name: 'common.save' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.delete' })).toBeInTheDocument()
@@ -145,6 +160,7 @@ describe('MNoteSheet', () => {
text: 'Buy museum tickets online',
time: '09:30 at the kiosk',
icon: 'Ticket',
color: null,
})
expect(planner.tripActions.addDayNote).not.toHaveBeenCalled()
await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1))
@@ -191,16 +207,59 @@ describe('MNoteSheet', () => {
expect(onClose).not.toHaveBeenCalled()
})
it('FE-MOB-NOTESH-020: the icon grid stays folded until asked for (#1629)', () => {
renderSheet()
// 32 icons in a six-wide grid is most of a phone screen.
expect(screen.queryByRole('button', { name: 'Train' })).toBeNull()
openIconGrid()
expect(screen.getByRole('button', { name: 'Train' })).toBeInTheDocument()
// Choosing one closes it again.
fireEvent.click(screen.getByRole('button', { name: 'Train' }))
expect(screen.queryByRole('button', { name: 'Coffee' })).toBeNull()
})
it('FE-MOB-NOTESH-021: a colour is saved with the note (#1629)', async () => {
const planner = buildPlanner({ tripId: 3, selectedDayId: 7 })
render(<MNoteSheet planner={planner} open payload={{ dayId: 7 }} onClose={vi.fn()} />)
fireEvent.change(screen.getByPlaceholderText('dayplan.noteTitle *'), { target: { value: 'Passport' } })
fireEvent.click(screen.getByRole('button', { name: '#dc2626' }))
fireEvent.click(screen.getByRole('button', { name: 'common.add' }))
await waitFor(() => expect(planner.tripActions.addDayNote).toHaveBeenCalledWith(3, 7, expect.objectContaining({
color: '#dc2626',
})))
})
it('FE-MOB-NOTESH-022: an existing colour is loaded and can be cleared again (#1629)', async () => {
const planner = buildPlanner({ tripId: 3, selectedDayId: 7 })
const note = { id: 88, day_id: 7, text: 'Ferry', time: '', icon: 'FileText', color: '#16a34a' } as unknown as DayNote
render(<MNoteSheet planner={planner} open payload={{ dayId: 7, note }} onClose={vi.fn()} />)
expect(screen.getByRole('button', { name: '#16a34a' })).toHaveAttribute('aria-pressed', 'true')
fireEvent.click(screen.getByRole('button', { name: 'notes.color.none' }))
fireEvent.click(screen.getByRole('button', { name: 'common.save' }))
await waitFor(() => expect(planner.tripActions.updateDayNote).toHaveBeenCalledWith(3, 7, 88, expect.objectContaining({
color: null,
})))
})
it('FE-MOB-NOTESH-015: counts the detail characters and warns near the limit', () => {
renderSheet()
const detail = screen.getByPlaceholderText('dayplan.noteSubtitle')
expect(screen.getByText('0/250')).toBeInTheDocument()
const detail = screen.getByPlaceholderText('notes.bodyPlaceholder')
// The ceiling matches the desktop dialog since #1629 — a formatted note needs
// the room, and the two fields write the same column.
expect(screen.getByText('0/2000')).toBeInTheDocument()
fireEvent.change(detail, { target: { value: 'a'.repeat(100) } })
const relaxed = screen.getByText('100/250')
const relaxed = screen.getByText('100/2000')
expect(relaxed.className).toContain('text-m-faint')
fireEvent.change(detail, { target: { value: 'a'.repeat(241) } })
const warned = screen.getByText('241/250')
fireEvent.change(detail, { target: { value: 'a'.repeat(1950) } })
const warned = screen.getByText('1950/2000')
expect(warned.className).toContain('--m-st-pending')
})
@@ -239,7 +298,8 @@ describe('MNoteSheet', () => {
rerender(<MNoteSheet planner={planner} open payload={second} onClose={onClose} />)
expect(screen.getByPlaceholderText('dayplan.noteTitle *')).toHaveValue('Return the car')
expect(screen.getByPlaceholderText('dayplan.noteSubtitle')).toHaveValue('')
expect(screen.getByPlaceholderText('notes.bodyPlaceholder')).toHaveValue('')
openIconGrid()
expect(screen.getByRole('button', { name: 'Car' })).toHaveAttribute('aria-pressed', 'true')
})
@@ -474,12 +474,23 @@ describe('NoteRow', () => {
const base = { chrome: chrome(), reorder: REORDER, onEdit: vi.fn() }
it('FE-MOB-PLROW-034: splits the leading time off and joins the rest into the subtitle', () => {
it('FE-MOB-PLROW-034: splits the leading time off and keeps the rest of the note below it', () => {
render(<NoteRow {...base} note={note()} />)
expect(screen.getByText('09:30')).toBeInTheDocument()
expect(screen.getByText('Buy museum tickets')).toBeInTheDocument()
expect(screen.getByText('Cash only · at the kiosk')).toBeInTheDocument()
// Two separate blocks since #1629: the extra title lines are plain text, the
// detail is rendered Markdown, so they cannot be joined into one string.
expect(screen.getByText('Cash only')).toBeInTheDocument()
expect(screen.getByText('at the kiosk')).toBeInTheDocument()
})
it('FE-MOB-PLROW-039: renders the detail as Markdown and tints the row with the note colour (#1629)', () => {
render(<NoteRow {...base} note={note({ text: 'Ferry', time: 'book **early**', color: '#dc2626' } as never)} />)
expect(screen.getByText('early').tagName).toBe('STRONG')
const card = screen.getByText('Ferry').closest('div[style]') as HTMLElement
expect(card.style.background).toContain('220, 38, 38')
})
it('FE-MOB-PLROW-035: a free-text time stays in the subtitle and shows no time chip', () => {
+9
View File
@@ -3856,6 +3856,15 @@ function runMigrations(db: Database.Database): void {
WHERE note LIKE 'TICKETJSON:%'
`);
},
// Note colours (#1629). A day note is a label as much as a reminder — "watch
// out", "must see", "already booked" — and a wall of identical grey cards
// makes that impossible to see at a glance. NULL keeps the neutral card
// every existing note has today.
() => {
const hasColor = db.prepare("SELECT 1 FROM pragma_table_info('day_notes') WHERE name = 'color'").get();
if (!hasColor) db.exec('ALTER TABLE day_notes ADD COLUMN color TEXT');
},
];
if (currentVersion < migrations.length) {
@@ -57,7 +57,7 @@ export class DayNotesController {
if (!body.text?.trim()) {
throw new HttpException({ error: 'Text required' }, 400);
}
const note = this.notes.create(dayId, tripId, body.text, body.time, body.icon, body.sort_order);
const note = this.notes.create(dayId, tripId, body.text, body.time, body.icon, body.sort_order, body.color);
this.notes.broadcast(tripId, 'dayNote:created', { dayId: Number(dayId), note }, socketId);
return { note };
}
@@ -76,7 +76,7 @@ export class DayNotesController {
if (!current) {
throw new HttpException({ error: 'Note not found' }, 404);
}
const note = this.notes.update(id, current as never, { text: body.text, time: body.time, icon: body.icon, sort_order: body.sort_order });
const note = this.notes.update(id, current as never, { text: body.text, time: body.time, icon: body.icon, sort_order: body.sort_order, color: body.color });
this.notes.broadcast(tripId, 'dayNote:updated', { dayId: Number(dayId), note }, socketId);
return { note };
}
+21 -6
View File
@@ -1,5 +1,5 @@
import { Injectable } from '@nestjs/common';
import type { TrekWsPayload, TrekWsTripEventName } from '@trek/shared';
import { NOTE_COLORS, type TrekWsPayload, type TrekWsTripEventName } from '@trek/shared';
import { RealtimeService } from '../realtime/realtime.service';
import { PermissionsService } from '../permissions/permissions.service';
import type { DayNote, User } from '../../types';
@@ -11,6 +11,20 @@ import { DatabaseService, type TripAccess } from '../database/database.service';
* coercions). Trip access rides DatabaseService.canAccessTrip; the 'day_edit'
* permission reuses the legacy check.
*/
/**
* Only a colour the palette actually offers reaches the column (#1629).
*
* The Zod contract can only say "a short string" — it has no way to express the
* palette without pinning the UI's choices into the wire format — so the check
* that matters lives here. Anything else is stored as "no colour", which is the
* neutral card, rather than being rejected: a bad colour is not a reason to lose
* the note someone just wrote.
*/
export function normalizeNoteColor(color: string | null | undefined): string | null {
if (!color) return null;
return (NOTE_COLORS as readonly string[]).includes(color) ? color : null;
}
@Injectable()
export class DayNotesService {
constructor(
@@ -46,21 +60,22 @@ export class DayNotesService {
return this.dbs.get<DayNote>('SELECT * FROM day_notes WHERE id = ? AND day_id = ? AND trip_id = ?', id, dayId, tripId);
}
create(dayId: string | number, tripId: string | number, text: string, time?: string | null, icon?: string | null, sortOrder?: number) {
create(dayId: string | number, tripId: string | number, text: string, time?: string | null, icon?: string | null, sortOrder?: number, color?: string | null) {
const result = this.dbs.run(
'INSERT INTO day_notes (day_id, trip_id, text, time, icon, sort_order) VALUES (?, ?, ?, ?, ?, ?)',
dayId, tripId, text.trim(), time || null, icon || '📝', sortOrder ?? 9999,
'INSERT INTO day_notes (day_id, trip_id, text, time, icon, sort_order, color) VALUES (?, ?, ?, ?, ?, ?, ?)',
dayId, tripId, text.trim(), time || null, icon || '📝', sortOrder ?? 9999, normalizeNoteColor(color),
);
return this.dbs.get('SELECT * FROM day_notes WHERE id = ?', result.lastInsertRowid);
}
update(id: string | number, current: DayNote, fields: { text?: string; time?: string | null; icon?: string | null; sort_order?: number }) {
update(id: string | number, current: DayNote, fields: { text?: string; time?: string | null; icon?: string | null; sort_order?: number; color?: string | null }) {
this.dbs.run(
'UPDATE day_notes SET text = ?, time = ?, icon = ?, sort_order = ? WHERE id = ?',
'UPDATE day_notes SET text = ?, time = ?, icon = ?, sort_order = ?, color = ? WHERE id = ?',
fields.text !== undefined ? fields.text.trim() : current.text,
fields.time !== undefined ? fields.time : current.time,
fields.icon !== undefined ? fields.icon : current.icon,
fields.sort_order !== undefined ? fields.sort_order : current.sort_order,
fields.color !== undefined ? normalizeNoteColor(fields.color) : (current.color ?? null),
id,
);
return this.dbs.get('SELECT * FROM day_notes WHERE id = ?', id);
+2
View File
@@ -238,6 +238,8 @@ export interface DayNote {
text: string;
time?: string | null;
icon: string;
/** One of NOTE_COLORS, or null for the neutral card (#1629). */
color?: string | null;
sort_order: number;
created_at?: string;
}
+1 -1
View File
@@ -43,7 +43,7 @@ const { db } = vi.hoisted(() => {
PRIMARY KEY (assignment_id, user_id));`);
tmp.exec(`CREATE TABLE day_notes (id INTEGER PRIMARY KEY AUTOINCREMENT, day_id INTEGER NOT NULL,
trip_id INTEGER NOT NULL, text TEXT NOT NULL, time TEXT, icon TEXT DEFAULT '📝',
sort_order REAL DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP);`);
color TEXT, sort_order REAL DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP);`);
// Reorder/insert touch accommodations + reservation restamping.
tmp.exec(`CREATE TABLE day_accommodations (id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL,
place_id INTEGER, start_day_id INTEGER, end_day_id INTEGER, check_in TEXT, check_in_end TEXT,
@@ -153,6 +153,56 @@ describe('create', () => {
});
});
// ── colour (#1629) ────────────────────────────────────────────────────────────
describe('note colours', () => {
it('DAYNOTE-SVC-017: a palette colour is stored as given', () => {
const { trip, day } = seedTripAndDay();
const note = svc.create(day.id, trip.id, 'Passport check', null, null, 0, '#dc2626') as DayNote;
expect(note.color).toBe('#dc2626');
});
it('DAYNOTE-SVC-018: no colour is the default, and stays NULL rather than empty string', () => {
const { trip, day } = seedTripAndDay();
expect((svc.create(day.id, trip.id, 'a') as DayNote).color).toBeNull();
expect((svc.create(day.id, trip.id, 'b', null, null, 0, null) as DayNote).color).toBeNull();
expect((svc.create(day.id, trip.id, 'c', null, null, 0, '') as DayNote).color).toBeNull();
});
it('DAYNOTE-SVC-019: a colour outside the palette is dropped, not stored', () => {
const { trip, day } = seedTripAndDay();
// Anything can reach this: the Zod contract can only say "a short string".
expect((svc.create(day.id, trip.id, 'a', null, null, 0, '#123456') as DayNote).color).toBeNull();
expect((svc.create(day.id, trip.id, 'b', null, null, 0, 'red') as DayNote).color).toBeNull();
expect((svc.create(day.id, trip.id, 'c', null, null, 0, 'javascript:x') as DayNote).color).toBeNull();
});
it('DAYNOTE-SVC-020: the note survives a bad colour instead of being rejected', () => {
const { trip, day } = seedTripAndDay();
const note = svc.create(day.id, trip.id, 'Ferry at six', '18:00', null, 0, 'nonsense') as DayNote;
expect(note.text).toBe('Ferry at six');
expect(note.time).toBe('18:00');
});
it('DAYNOTE-SVC-021: update changes the colour and can clear it again', () => {
const { trip, day } = seedTripAndDay();
const note = svc.create(day.id, trip.id, 'Museum', null, null, 0, '#2563eb') as DayNote;
expect((svc.update(note.id, note, { color: '#16a34a' }) as DayNote).color).toBe('#16a34a');
expect((svc.update(note.id, svc.getNote(note.id, day.id, trip.id)!, { color: null }) as DayNote).color).toBeNull();
});
it('DAYNOTE-SVC-022: an update that says nothing about the colour keeps it', () => {
const { trip, day } = seedTripAndDay();
const note = svc.create(day.id, trip.id, 'Museum', null, null, 0, '#9333ea') as DayNote;
const updated = svc.update(note.id, note, { text: 'Museum, book ahead' }) as DayNote;
expect(updated.text).toBe('Museum, book ahead');
expect(updated.color).toBe('#9333ea');
});
});
// ── list ──────────────────────────────────────────────────────────────────────
describe('list', () => {
@@ -118,12 +118,15 @@ describe('DayNotesController (parity with the legacy /api/.../days/:dayId/notes
// (day-notes.dto.ts over the @trek/shared schemas) — like the old guard it
// rejects before the trip-access check. The pipe path is exercised end-to-end
// in tests/e2e/days.e2e.test.ts; here we pin the caps on the DTO schemas.
it('DTO schemas carry the legacy length caps and moveDayNote nulls', () => {
it('DTO schemas carry the length caps and moveDayNote nulls', () => {
expect(DayNoteCreateDto.schema.safeParse({ text: 'x'.repeat(501) }).success).toBe(false);
expect(DayNoteCreateDto.schema.safeParse({ text: 'ok', time: 'y'.repeat(251) }).success).toBe(false);
// The body cap moved to 2000 with #1629 (formatted Markdown); the title did not.
expect(DayNoteCreateDto.schema.safeParse({ text: 'ok', time: 'y'.repeat(2001) }).success).toBe(false);
expect(DayNoteCreateDto.schema.safeParse({ text: 'ok', time: 'y'.repeat(2000) }).success).toBe(true);
expect(DayNoteCreateDto.schema.safeParse({ text: 'ok', time: null, icon: null }).success).toBe(true);
expect(DayNoteUpdateDto.schema.safeParse({ time: 'y'.repeat(251) }).success).toBe(false);
expect(DayNoteUpdateDto.schema.safeParse({ time: 'y'.repeat(2001) }).success).toBe(false);
expect(DayNoteUpdateDto.schema.safeParse({ time: null }).success).toBe(true);
expect(DayNoteUpdateDto.schema.safeParse({ color: '#dc2626' }).success).toBe(true);
});
// Trip access and day_edit are TripAccessGuard's now (trip-access.guard.test.ts);
@@ -134,7 +137,7 @@ describe('DayNotesController (parity with the legacy /api/.../days/:dayId/notes
const create = vi.fn().mockReturnValue({ id: 7 }); const broadcast = vi.fn();
const svc = notesSvc({ dayExists: vi.fn().mockReturnValue(true), create, broadcast } as Partial<DayNotesService>);
expect(new DayNotesController(svc).create(user, '5', '3', { text: 'Lunch', time: '12:00' }, 'sock')).toEqual({ note: { id: 7 } });
expect(create).toHaveBeenCalledWith('3', '5', 'Lunch', '12:00', undefined, undefined);
expect(create).toHaveBeenCalledWith('3', '5', 'Lunch', '12:00', undefined, undefined, undefined);
expect(broadcast).toHaveBeenCalledWith('5', 'dayNote:created', { dayId: 3, note: { id: 7 } }, 'sock');
});
+12 -2
View File
@@ -10,18 +10,28 @@ describe('dayCreateRequestSchema', () => {
});
describe('dayNoteCreateRequestSchema', () => {
it('requires non-empty text capped at 500, time capped at 250', () => {
it('requires non-empty text capped at 500, body capped at 2000', () => {
expect(dayNoteCreateRequestSchema.safeParse({ text: 'Lunch' }).success).toBe(true);
expect(dayNoteCreateRequestSchema.safeParse({ text: '' }).success).toBe(false);
expect(dayNoteCreateRequestSchema.safeParse({ text: 'x'.repeat(501) }).success).toBe(false);
// Raised from 250 with #1629: the field carries formatted Markdown now.
expect(dayNoteCreateRequestSchema.safeParse({ text: 'ok', time: 'y'.repeat(2000) }).success).toBe(true);
expect(
dayNoteCreateRequestSchema.safeParse({
text: 'ok',
time: 'y'.repeat(251),
time: 'y'.repeat(2001),
}).success,
).toBe(false);
});
it('takes a note colour, and only as a short string', () => {
expect(dayNoteCreateRequestSchema.safeParse({ text: 'ok', color: '#dc2626' }).success).toBe(true);
expect(dayNoteCreateRequestSchema.safeParse({ text: 'ok', color: null }).success).toBe(true);
// The palette itself is enforced in the service — the contract only keeps
// the column from taking arbitrary length.
expect(dayNoteCreateRequestSchema.safeParse({ text: 'ok', color: 'x'.repeat(10) }).success).toBe(false);
});
it('accepts null time/icon (moveDayNote re-sends the nullable entity fields)', () => {
expect(dayNoteCreateRequestSchema.safeParse({ text: 'ok', time: null, icon: null }).success).toBe(true);
});
+7 -2
View File
@@ -24,6 +24,8 @@ export const dayNoteSchema = z.object({
text: z.string(),
time: z.string().nullable().optional(),
icon: z.string().nullable().optional(),
/** One of NOTE_COLORS, or null for the neutral card (#1629). */
color: z.string().nullable().optional(),
sort_order: z.number().optional(),
created_at: z.string().optional(),
});
@@ -83,16 +85,19 @@ export type DayTransportRequest = z.infer<typeof dayTransportRequestSchema>;
// null time on update means "clear" (the legacy raw-body route accepted both).
export const dayNoteCreateRequestSchema = z.object({
text: z.string().min(1).max(500),
time: z.string().max(250).nullable().optional(),
time: z.string().max(2000).nullable().optional(),
icon: z.string().nullable().optional(),
/** One of NOTE_COLORS, or null for the neutral card (#1629). */
color: z.string().max(9).nullable().optional(),
sort_order: z.number().optional(),
});
export type DayNoteCreateRequest = z.infer<typeof dayNoteCreateRequestSchema>;
export const dayNoteUpdateRequestSchema = z.object({
text: z.string().max(500).optional(),
time: z.string().max(250).nullable().optional(),
time: z.string().max(2000).nullable().optional(),
icon: z.string().nullable().optional(),
color: z.string().max(9).nullable().optional(),
sort_order: z.number().optional(),
});
export type DayNoteUpdateRequest = z.infer<typeof dayNoteUpdateRequestSchema>;
+32
View File
@@ -0,0 +1,32 @@
/**
* Note colour palette (#1629) — shared so the picker, the note card and the PDF
* hand out the exact same set.
*
* These are drawn on UI surfaces rather than on map tiles, which is why they are
* not TRACK_COLORS: a note is rendered as a tint of its colour behind body text
* in both themes, so each entry has to stay legible at ~12% opacity on a white
* card and at ~18% on a dark one. The 500/600 tier does that; the 700s used for
* tracks go muddy once tinted.
*
* The set is small on purpose. A note colour is a label ("watch out", "must
* see", "booked"), and a palette of twenty makes people pick by hue instead of
* by meaning. Seven distinct hues, none of them adjacent, plus "no colour" as
* the default.
*/
export const NOTE_COLORS = [
'#dc2626', // red-600 — warnings
'#ea580c', // orange-600
'#d97706', // amber-600
'#16a34a', // green-600 — done, confirmed
'#0891b2', // cyan-600
'#2563eb', // blue-600 — information
'#9333ea', // purple-600
] as const;
export type NoteColor = (typeof NOTE_COLORS)[number];
/** True for a value the palette actually offers, or for "no colour at all". */
export function isNoteColor(value: unknown): boolean {
if (value === null || value === undefined || value === '') return true;
return typeof value === 'string' && (NOTE_COLORS as readonly string[]).includes(value);
}
+24
View File
@@ -15,8 +15,32 @@ const dayplan: TranslationStrings = {
'dayplan.editNote': 'تعديل الملاحظة',
'dayplan.noteAdd': 'إضافة ملاحظة',
'dayplan.noteEdit': 'تعديل الملاحظة',
'dayplan.noteDelete': 'حذف الملاحظة',
'dayplan.noteTitle': 'ملاحظة',
'dayplan.noteSubtitle': 'ملاحظة يومية',
'dayplan.noteIcon': 'أيقونة',
'notes.appearance': 'المظهر',
'notes.preview': 'معاينة',
'notes.bodyPlaceholder': 'تفاصيل وروابط وتذكيرات…',
'notes.markdownHint': 'يدعم Markdown',
'notes.format.toolbar': 'التنسيق',
'notes.format.bold': 'غامق',
'notes.format.italic': 'مائل',
'notes.format.strike': 'يتوسطه خط',
'notes.format.code': 'كود',
'notes.format.link': 'رابط',
'notes.format.list': 'قائمة نقطية',
'notes.format.orderedList': 'قائمة مرقمة',
'notes.format.quote': 'اقتباس',
'notes.color.label': 'اللون',
'notes.color.none': 'بلا لون',
'notes.color.red': 'أحمر',
'notes.color.orange': 'برتقالي',
'notes.color.amber': 'كهرماني',
'notes.color.green': 'أخضر',
'notes.color.cyan': 'سماوي',
'notes.color.blue': 'أزرق',
'notes.color.purple': 'بنفسجي',
'dayplan.totalCost': 'إجمالي التكلفة',
'dayplan.days': 'الأيام',
'dayplan.dayN': 'اليوم {n}',
+24
View File
@@ -7,8 +7,32 @@ const dayplan: TranslationStrings = {
'dayplan.editNote': 'Editar nota',
'dayplan.noteAdd': 'Adicionar nota',
'dayplan.noteEdit': 'Editar nota',
'dayplan.noteDelete': 'Excluir nota',
'dayplan.noteTitle': 'Nota',
'dayplan.noteSubtitle': 'Nota do dia',
'dayplan.noteIcon': 'Ícone',
'notes.appearance': 'Aparência',
'notes.preview': 'Pré-visualização',
'notes.bodyPlaceholder': 'Detalhes, links, lembretes…',
'notes.markdownHint': 'Markdown suportado',
'notes.format.toolbar': 'Formatação',
'notes.format.bold': 'Negrito',
'notes.format.italic': 'Itálico',
'notes.format.strike': 'Tachado',
'notes.format.code': 'Código',
'notes.format.link': 'Link',
'notes.format.list': 'Lista com marcadores',
'notes.format.orderedList': 'Lista numerada',
'notes.format.quote': 'Citação',
'notes.color.label': 'Cor',
'notes.color.none': 'Sem cor',
'notes.color.red': 'Vermelho',
'notes.color.orange': 'Laranja',
'notes.color.amber': 'Âmbar',
'notes.color.green': 'Verde',
'notes.color.cyan': 'Ciano',
'notes.color.blue': 'Azul',
'notes.color.purple': 'Roxo',
'dayplan.totalCost': 'Custo total',
'dayplan.days': 'Dias',
'dayplan.dayN': 'Dia {n}',
+24
View File
@@ -7,8 +7,32 @@ const dayplan: TranslationStrings = {
'dayplan.editNote': 'Edita la nota',
'dayplan.noteAdd': 'Afegeix una nota',
'dayplan.noteEdit': 'Edita la nota',
'dayplan.noteDelete': 'Elimina la nota',
'dayplan.noteTitle': 'Nota',
'dayplan.noteSubtitle': 'Nota diària',
'dayplan.noteIcon': 'Icona',
'notes.appearance': 'Aparença',
'notes.preview': 'Vista prèvia',
'notes.bodyPlaceholder': 'Detalls, enllaços, recordatoris…',
'notes.markdownHint': 'Admet Markdown',
'notes.format.toolbar': 'Format',
'notes.format.bold': 'Negreta',
'notes.format.italic': 'Cursiva',
'notes.format.strike': 'Ratllat',
'notes.format.code': 'Codi',
'notes.format.link': 'Enllaç',
'notes.format.list': 'Llista amb pics',
'notes.format.orderedList': 'Llista numerada',
'notes.format.quote': 'Cita',
'notes.color.label': 'Color',
'notes.color.none': 'Sense color',
'notes.color.red': 'Vermell',
'notes.color.orange': 'Taronja',
'notes.color.amber': 'Ambre',
'notes.color.green': 'Verd',
'notes.color.cyan': 'Cian',
'notes.color.blue': 'Blau',
'notes.color.purple': 'Lila',
'dayplan.totalCost': 'Cost total',
'dayplan.days': 'Dies',
'dayplan.dayN': 'Dia {n}',
+24
View File
@@ -7,8 +7,32 @@ const dayplan: TranslationStrings = {
'dayplan.editNote': 'Upravit poznámku',
'dayplan.noteAdd': 'Přidat poznámku',
'dayplan.noteEdit': 'Upravit poznámku',
'dayplan.noteDelete': 'Smazat poznámku',
'dayplan.noteTitle': 'Poznámka',
'dayplan.noteSubtitle': 'Poznámka ke dni',
'dayplan.noteIcon': 'Ikona',
'notes.appearance': 'Vzhled',
'notes.preview': 'Náhled',
'notes.bodyPlaceholder': 'Podrobnosti, odkazy, připomínky…',
'notes.markdownHint': 'Podporuje Markdown',
'notes.format.toolbar': 'Formátování',
'notes.format.bold': 'Tučné',
'notes.format.italic': 'Kurzíva',
'notes.format.strike': 'Přeškrtnuté',
'notes.format.code': 'Kód',
'notes.format.link': 'Odkaz',
'notes.format.list': 'Odrážkový seznam',
'notes.format.orderedList': 'Číslovaný seznam',
'notes.format.quote': 'Citace',
'notes.color.label': 'Barva',
'notes.color.none': 'Bez barvy',
'notes.color.red': 'Červená',
'notes.color.orange': 'Oranžová',
'notes.color.amber': 'Jantarová',
'notes.color.green': 'Zelená',
'notes.color.cyan': 'Azurová',
'notes.color.blue': 'Modrá',
'notes.color.purple': 'Fialová',
'dayplan.totalCost': 'Celkové náklady',
'dayplan.days': 'Dny',
'dayplan.dayN': 'Den {n}',
+24
View File
@@ -18,8 +18,32 @@ const dayplan: TranslationStrings = {
'dayplan.editNote': 'Notiz bearbeiten',
'dayplan.noteAdd': 'Notiz hinzufügen',
'dayplan.noteEdit': 'Notiz bearbeiten',
'dayplan.noteDelete': 'Notiz löschen',
'dayplan.noteTitle': 'Notiz',
'dayplan.noteSubtitle': 'Tagesnotiz',
'dayplan.noteIcon': 'Symbol',
'notes.appearance': 'Darstellung',
'notes.preview': 'Vorschau',
'notes.bodyPlaceholder': 'Details, Links, Erinnerungen …',
'notes.markdownHint': 'Markdown wird unterstützt',
'notes.format.toolbar': 'Formatierung',
'notes.format.bold': 'Fett',
'notes.format.italic': 'Kursiv',
'notes.format.strike': 'Durchgestrichen',
'notes.format.code': 'Code',
'notes.format.link': 'Link',
'notes.format.list': 'Aufzählung',
'notes.format.orderedList': 'Nummerierte Liste',
'notes.format.quote': 'Zitat',
'notes.color.label': 'Farbe',
'notes.color.none': 'Keine Farbe',
'notes.color.red': 'Rot',
'notes.color.orange': 'Orange',
'notes.color.amber': 'Bernstein',
'notes.color.green': 'Grün',
'notes.color.cyan': 'Türkis',
'notes.color.blue': 'Blau',
'notes.color.purple': 'Lila',
'dayplan.totalCost': 'Gesamtkosten',
'dayplan.days': 'Tage',
'dayplan.dayN': 'Tag {n}',
+24
View File
@@ -18,8 +18,32 @@ const dayplan: TranslationStrings = {
'dayplan.editNote': 'Edit Note',
'dayplan.noteAdd': 'Add Note',
'dayplan.noteEdit': 'Edit Note',
'dayplan.noteDelete': 'Delete Note',
'dayplan.noteTitle': 'Note',
'dayplan.noteSubtitle': 'Daily Note',
'dayplan.noteIcon': 'Icon',
'notes.appearance': 'Appearance',
'notes.preview': 'Preview',
'notes.bodyPlaceholder': 'Details, links, reminders…',
'notes.markdownHint': 'Markdown supported',
'notes.format.toolbar': 'Formatting',
'notes.format.bold': 'Bold',
'notes.format.italic': 'Italic',
'notes.format.strike': 'Strikethrough',
'notes.format.code': 'Code',
'notes.format.link': 'Link',
'notes.format.list': 'Bulleted list',
'notes.format.orderedList': 'Numbered list',
'notes.format.quote': 'Quote',
'notes.color.label': 'Colour',
'notes.color.none': 'No colour',
'notes.color.red': 'Red',
'notes.color.orange': 'Orange',
'notes.color.amber': 'Amber',
'notes.color.green': 'Green',
'notes.color.cyan': 'Cyan',
'notes.color.blue': 'Blue',
'notes.color.purple': 'Purple',
'dayplan.totalCost': 'Total Cost',
'dayplan.days': 'Days',
'dayplan.dayN': 'Day {n}',
+24
View File
@@ -7,8 +7,32 @@ const dayplan: TranslationStrings = {
'dayplan.editNote': 'Editar nota',
'dayplan.noteAdd': 'Añadir nota',
'dayplan.noteEdit': 'Editar nota',
'dayplan.noteDelete': 'Eliminar nota',
'dayplan.noteTitle': 'Nota',
'dayplan.noteSubtitle': 'Nota diaria',
'dayplan.noteIcon': 'Icono',
'notes.appearance': 'Apariencia',
'notes.preview': 'Vista previa',
'notes.bodyPlaceholder': 'Detalles, enlaces, recordatorios…',
'notes.markdownHint': 'Compatible con Markdown',
'notes.format.toolbar': 'Formato',
'notes.format.bold': 'Negrita',
'notes.format.italic': 'Cursiva',
'notes.format.strike': 'Tachado',
'notes.format.code': 'Código',
'notes.format.link': 'Enlace',
'notes.format.list': 'Lista con viñetas',
'notes.format.orderedList': 'Lista numerada',
'notes.format.quote': 'Cita',
'notes.color.label': 'Color',
'notes.color.none': 'Sin color',
'notes.color.red': 'Rojo',
'notes.color.orange': 'Naranja',
'notes.color.amber': 'Ámbar',
'notes.color.green': 'Verde',
'notes.color.cyan': 'Cian',
'notes.color.blue': 'Azul',
'notes.color.purple': 'Morado',
'dayplan.totalCost': 'Coste total',
'dayplan.days': 'Días',
'dayplan.dayN': 'Día {n}',
+24
View File
@@ -7,8 +7,32 @@ const dayplan: TranslationStrings = {
'dayplan.editNote': 'Modifier la note',
'dayplan.noteAdd': 'Ajouter une note',
'dayplan.noteEdit': 'Modifier la note',
'dayplan.noteDelete': 'Supprimer la note',
'dayplan.noteTitle': 'Note',
'dayplan.noteSubtitle': 'Note du jour',
'dayplan.noteIcon': 'Icône',
'notes.appearance': 'Apparence',
'notes.preview': 'Aperçu',
'notes.bodyPlaceholder': 'Détails, liens, rappels…',
'notes.markdownHint': 'Markdown pris en charge',
'notes.format.toolbar': 'Mise en forme',
'notes.format.bold': 'Gras',
'notes.format.italic': 'Italique',
'notes.format.strike': 'Barré',
'notes.format.code': 'Code',
'notes.format.link': 'Lien',
'notes.format.list': 'Liste à puces',
'notes.format.orderedList': 'Liste numérotée',
'notes.format.quote': 'Citation',
'notes.color.label': 'Couleur',
'notes.color.none': 'Aucune couleur',
'notes.color.red': 'Rouge',
'notes.color.orange': 'Orange',
'notes.color.amber': 'Ambre',
'notes.color.green': 'Vert',
'notes.color.cyan': 'Cyan',
'notes.color.blue': 'Bleu',
'notes.color.purple': 'Violet',
'dayplan.totalCost': 'Coût total',
'dayplan.days': 'Jours',
'dayplan.dayN': 'Jour {n}',
+24
View File
@@ -18,8 +18,32 @@ const dayplan: TranslationStrings = {
'dayplan.editNote': 'Επεξεργασία Σημείωσης',
'dayplan.noteAdd': 'Προσθήκη Σημείωσης',
'dayplan.noteEdit': 'Επεξεργασία Σημείωσης',
'dayplan.noteDelete': 'Διαγραφή σημείωσης',
'dayplan.noteTitle': 'Σημείωση',
'dayplan.noteSubtitle': 'Ημερήσια Σημείωση',
'dayplan.noteIcon': 'Εικονίδιο',
'notes.appearance': 'Εμφάνιση',
'notes.preview': 'Προεπισκόπηση',
'notes.bodyPlaceholder': 'Λεπτομέρειες, σύνδεσμοι, υπενθυμίσεις…',
'notes.markdownHint': 'Υποστηρίζεται Markdown',
'notes.format.toolbar': 'Μορφοποίηση',
'notes.format.bold': 'Έντονα',
'notes.format.italic': 'Πλάγια',
'notes.format.strike': 'Διαγράμμιση',
'notes.format.code': 'Κώδικας',
'notes.format.link': 'Σύνδεσμος',
'notes.format.list': 'Λίστα με κουκκίδες',
'notes.format.orderedList': 'Αριθμημένη λίστα',
'notes.format.quote': 'Παράθεση',
'notes.color.label': 'Χρώμα',
'notes.color.none': 'Χωρίς χρώμα',
'notes.color.red': 'Κόκκινο',
'notes.color.orange': 'Πορτοκαλί',
'notes.color.amber': 'Κεχριμπαρένιο',
'notes.color.green': 'Πράσινο',
'notes.color.cyan': 'Κυανό',
'notes.color.blue': 'Μπλε',
'notes.color.purple': 'Μοβ',
'dayplan.totalCost': 'Συνολικό Κόστος',
'dayplan.days': 'Ημέρες',
'dayplan.dayN': 'Ημέρα {n}',
+24
View File
@@ -7,8 +7,32 @@ const dayplan: TranslationStrings = {
'dayplan.editNote': 'Jegyzet szerkesztése',
'dayplan.noteAdd': 'Jegyzet hozzáadása',
'dayplan.noteEdit': 'Jegyzet szerkesztése',
'dayplan.noteDelete': 'Megjegyzés törlése',
'dayplan.noteTitle': 'Jegyzet',
'dayplan.noteSubtitle': 'Napi jegyzet',
'dayplan.noteIcon': 'Ikon',
'notes.appearance': 'Megjelenés',
'notes.preview': 'Előnézet',
'notes.bodyPlaceholder': 'Részletek, hivatkozások, emlékeztetők…',
'notes.markdownHint': 'Markdown támogatott',
'notes.format.toolbar': 'Formázás',
'notes.format.bold': 'Félkövér',
'notes.format.italic': 'Dőlt',
'notes.format.strike': 'Áthúzott',
'notes.format.code': 'Kód',
'notes.format.link': 'Hivatkozás',
'notes.format.list': 'Felsorolás',
'notes.format.orderedList': 'Számozott lista',
'notes.format.quote': 'Idézet',
'notes.color.label': 'Szín',
'notes.color.none': 'Nincs szín',
'notes.color.red': 'Piros',
'notes.color.orange': 'Narancs',
'notes.color.amber': 'Borostyán',
'notes.color.green': 'Zöld',
'notes.color.cyan': 'Ciánkék',
'notes.color.blue': 'Kék',
'notes.color.purple': 'Lila',
'dayplan.totalCost': 'Összköltség',
'dayplan.days': 'nap',
'dayplan.dayN': '{n}. nap',
+24
View File
@@ -16,8 +16,32 @@ const dayplan: TranslationStrings = {
'dayplan.editNote': 'Edit Catatan',
'dayplan.noteAdd': 'Tambah Catatan',
'dayplan.noteEdit': 'Edit Catatan',
'dayplan.noteDelete': 'Hapus catatan',
'dayplan.noteTitle': 'Catatan',
'dayplan.noteSubtitle': 'Catatan Harian',
'dayplan.noteIcon': 'Ikon',
'notes.appearance': 'Tampilan',
'notes.preview': 'Pratinjau',
'notes.bodyPlaceholder': 'Detail, tautan, pengingat…',
'notes.markdownHint': 'Mendukung Markdown',
'notes.format.toolbar': 'Pemformatan',
'notes.format.bold': 'Tebal',
'notes.format.italic': 'Miring',
'notes.format.strike': 'Coret',
'notes.format.code': 'Kode',
'notes.format.link': 'Tautan',
'notes.format.list': 'Daftar berpoin',
'notes.format.orderedList': 'Daftar bernomor',
'notes.format.quote': 'Kutipan',
'notes.color.label': 'Warna',
'notes.color.none': 'Tanpa warna',
'notes.color.red': 'Merah',
'notes.color.orange': 'Oranye',
'notes.color.amber': 'Amber',
'notes.color.green': 'Hijau',
'notes.color.cyan': 'Sian',
'notes.color.blue': 'Biru',
'notes.color.purple': 'Ungu',
'dayplan.totalCost': 'Total Biaya',
'dayplan.days': 'Hari',
'dayplan.dayN': 'Hari {n}',
+24
View File
@@ -7,8 +7,32 @@ const dayplan: TranslationStrings = {
'dayplan.editNote': 'Modifica nota',
'dayplan.noteAdd': 'Aggiungi nota',
'dayplan.noteEdit': 'Modifica nota',
'dayplan.noteDelete': 'Elimina nota',
'dayplan.noteTitle': 'Nota',
'dayplan.noteSubtitle': 'Nota giornaliera',
'dayplan.noteIcon': 'Icona',
'notes.appearance': 'Aspetto',
'notes.preview': 'Anteprima',
'notes.bodyPlaceholder': 'Dettagli, link, promemoria…',
'notes.markdownHint': 'Markdown supportato',
'notes.format.toolbar': 'Formattazione',
'notes.format.bold': 'Grassetto',
'notes.format.italic': 'Corsivo',
'notes.format.strike': 'Barrato',
'notes.format.code': 'Codice',
'notes.format.link': 'Link',
'notes.format.list': 'Elenco puntato',
'notes.format.orderedList': 'Elenco numerato',
'notes.format.quote': 'Citazione',
'notes.color.label': 'Colore',
'notes.color.none': 'Nessun colore',
'notes.color.red': 'Rosso',
'notes.color.orange': 'Arancione',
'notes.color.amber': 'Ambra',
'notes.color.green': 'Verde',
'notes.color.cyan': 'Ciano',
'notes.color.blue': 'Blu',
'notes.color.purple': 'Viola',
'dayplan.totalCost': 'Costo totale',
'dayplan.days': 'Giorni',
'dayplan.dayN': 'Giorno {n}',
+24
View File
@@ -18,8 +18,32 @@ const dayplan: TranslationStrings = {
'dayplan.editNote': 'メモを編集',
'dayplan.noteAdd': 'メモを追加',
'dayplan.noteEdit': 'メモを編集',
'dayplan.noteDelete': 'メモを削除',
'dayplan.noteTitle': 'メモ',
'dayplan.noteSubtitle': '日別メモ',
'dayplan.noteIcon': 'アイコン',
'notes.appearance': '外観',
'notes.preview': 'プレビュー',
'notes.bodyPlaceholder': '詳細、リンク、メモなど…',
'notes.markdownHint': 'Markdown に対応',
'notes.format.toolbar': '書式',
'notes.format.bold': '太字',
'notes.format.italic': '斜体',
'notes.format.strike': '取り消し線',
'notes.format.code': 'コード',
'notes.format.link': 'リンク',
'notes.format.list': '箇条書き',
'notes.format.orderedList': '番号付きリスト',
'notes.format.quote': '引用',
'notes.color.label': '色',
'notes.color.none': '色なし',
'notes.color.red': '赤',
'notes.color.orange': 'オレンジ',
'notes.color.amber': 'アンバー',
'notes.color.green': '緑',
'notes.color.cyan': 'シアン',
'notes.color.blue': '青',
'notes.color.purple': '紫',
'dayplan.totalCost': '合計費用',
'dayplan.days': '日',
'dayplan.dayN': '{n}日目',
+24
View File
@@ -18,8 +18,32 @@ const dayplan: TranslationStrings = {
'dayplan.editNote': '메모 편집',
'dayplan.noteAdd': '메모 추가',
'dayplan.noteEdit': '메모 편집',
'dayplan.noteDelete': '메모 삭제',
'dayplan.noteTitle': '메모',
'dayplan.noteSubtitle': '일별 메모',
'dayplan.noteIcon': '아이콘',
'notes.appearance': '모양',
'notes.preview': '미리 보기',
'notes.bodyPlaceholder': '세부 정보, 링크, 알림…',
'notes.markdownHint': '마크다운 지원',
'notes.format.toolbar': '서식',
'notes.format.bold': '굵게',
'notes.format.italic': '기울임',
'notes.format.strike': '취소선',
'notes.format.code': '코드',
'notes.format.link': '링크',
'notes.format.list': '글머리 기호 목록',
'notes.format.orderedList': '번호 매기기 목록',
'notes.format.quote': '인용',
'notes.color.label': '색상',
'notes.color.none': '색상 없음',
'notes.color.red': '빨강',
'notes.color.orange': '주황',
'notes.color.amber': '호박색',
'notes.color.green': '초록',
'notes.color.cyan': '청록',
'notes.color.blue': '파랑',
'notes.color.purple': '보라',
'dayplan.totalCost': '총 비용',
'dayplan.days': '일',
'dayplan.dayN': '{n}일차',
+24
View File
@@ -7,8 +7,32 @@ const dayplan: TranslationStrings = {
'dayplan.editNote': 'Notitie bewerken',
'dayplan.noteAdd': 'Notitie toevoegen',
'dayplan.noteEdit': 'Notitie bewerken',
'dayplan.noteDelete': 'Notitie verwijderen',
'dayplan.noteTitle': 'Notitie',
'dayplan.noteSubtitle': 'Dagnotitie',
'dayplan.noteIcon': 'Pictogram',
'notes.appearance': 'Weergave',
'notes.preview': 'Voorbeeld',
'notes.bodyPlaceholder': 'Details, links, herinneringen…',
'notes.markdownHint': 'Markdown wordt ondersteund',
'notes.format.toolbar': 'Opmaak',
'notes.format.bold': 'Vet',
'notes.format.italic': 'Cursief',
'notes.format.strike': 'Doorhalen',
'notes.format.code': 'Code',
'notes.format.link': 'Link',
'notes.format.list': 'Opsomming',
'notes.format.orderedList': 'Genummerde lijst',
'notes.format.quote': 'Citaat',
'notes.color.label': 'Kleur',
'notes.color.none': 'Geen kleur',
'notes.color.red': 'Rood',
'notes.color.orange': 'Oranje',
'notes.color.amber': 'Amber',
'notes.color.green': 'Groen',
'notes.color.cyan': 'Cyaan',
'notes.color.blue': 'Blauw',
'notes.color.purple': 'Paars',
'dayplan.totalCost': 'Totale kosten',
'dayplan.days': 'Dagen',
'dayplan.dayN': 'Dag {n}',
+24
View File
@@ -17,8 +17,32 @@ const dayplan: TranslationStrings = {
'dayplan.editNote': 'Edytuj notatkę',
'dayplan.noteAdd': 'Dodaj notatkę',
'dayplan.noteEdit': 'Edytuj notatkę',
'dayplan.noteDelete': 'Usuń notatkę',
'dayplan.noteTitle': 'Notatka',
'dayplan.noteSubtitle': 'Notatka dnia',
'dayplan.noteIcon': 'Ikona',
'notes.appearance': 'Wygląd',
'notes.preview': 'Podgląd',
'notes.bodyPlaceholder': 'Szczegóły, linki, przypomnienia…',
'notes.markdownHint': 'Obsługiwany Markdown',
'notes.format.toolbar': 'Formatowanie',
'notes.format.bold': 'Pogrubienie',
'notes.format.italic': 'Kursywa',
'notes.format.strike': 'Przekreślenie',
'notes.format.code': 'Kod',
'notes.format.link': 'Link',
'notes.format.list': 'Lista punktowana',
'notes.format.orderedList': 'Lista numerowana',
'notes.format.quote': 'Cytat',
'notes.color.label': 'Kolor',
'notes.color.none': 'Bez koloru',
'notes.color.red': 'Czerwony',
'notes.color.orange': 'Pomarańczowy',
'notes.color.amber': 'Bursztynowy',
'notes.color.green': 'Zielony',
'notes.color.cyan': 'Cyjan',
'notes.color.blue': 'Niebieski',
'notes.color.purple': 'Fioletowy',
'dayplan.totalCost': 'Łączny koszt',
'dayplan.days': 'Dni',
'dayplan.dayN': 'Dzień {n}',
+24
View File
@@ -7,8 +7,32 @@ const dayplan: TranslationStrings = {
'dayplan.editNote': 'Редактировать заметку',
'dayplan.noteAdd': 'Добавить заметку',
'dayplan.noteEdit': 'Редактировать заметку',
'dayplan.noteDelete': 'Удалить заметку',
'dayplan.noteTitle': 'Заметка',
'dayplan.noteSubtitle': 'Заметка на день',
'dayplan.noteIcon': 'Значок',
'notes.appearance': 'Вид',
'notes.preview': 'Предпросмотр',
'notes.bodyPlaceholder': 'Детали, ссылки, напоминания…',
'notes.markdownHint': 'Поддерживается Markdown',
'notes.format.toolbar': 'Форматирование',
'notes.format.bold': 'Полужирный',
'notes.format.italic': 'Курсив',
'notes.format.strike': 'Зачёркнутый',
'notes.format.code': 'Код',
'notes.format.link': 'Ссылка',
'notes.format.list': 'Маркированный список',
'notes.format.orderedList': 'Нумерованный список',
'notes.format.quote': 'Цитата',
'notes.color.label': 'Цвет',
'notes.color.none': 'Без цвета',
'notes.color.red': 'Красный',
'notes.color.orange': 'Оранжевый',
'notes.color.amber': 'Янтарный',
'notes.color.green': 'Зелёный',
'notes.color.cyan': 'Голубой',
'notes.color.blue': 'Синий',
'notes.color.purple': 'Фиолетовый',
'dayplan.totalCost': 'Общая стоимость',
'dayplan.days': 'Дни',
'dayplan.dayN': 'День {n}',
+24
View File
@@ -19,8 +19,32 @@ const dayplan: TranslationStrings = {
'dayplan.editNote': 'Redigera notering',
'dayplan.noteAdd': 'Lägg till notering',
'dayplan.noteEdit': 'Redigera notering',
'dayplan.noteDelete': 'Ta bort anteckning',
'dayplan.noteTitle': 'Notering',
'dayplan.noteSubtitle': 'Daglig notering',
'dayplan.noteIcon': 'Ikon',
'notes.appearance': 'Utseende',
'notes.preview': 'Förhandsvisning',
'notes.bodyPlaceholder': 'Detaljer, länkar, påminnelser…',
'notes.markdownHint': 'Markdown stöds',
'notes.format.toolbar': 'Formatering',
'notes.format.bold': 'Fet',
'notes.format.italic': 'Kursiv',
'notes.format.strike': 'Genomstruken',
'notes.format.code': 'Kod',
'notes.format.link': 'Länk',
'notes.format.list': 'Punktlista',
'notes.format.orderedList': 'Numrerad lista',
'notes.format.quote': 'Citat',
'notes.color.label': 'Färg',
'notes.color.none': 'Ingen färg',
'notes.color.red': 'Röd',
'notes.color.orange': 'Orange',
'notes.color.amber': 'Bärnsten',
'notes.color.green': 'Grön',
'notes.color.cyan': 'Cyan',
'notes.color.blue': 'Blå',
'notes.color.purple': 'Lila',
'dayplan.totalCost': 'Totala kostnaden',
'dayplan.days': 'Dagar',
'dayplan.dayN': 'Dag {n}',
+24
View File
@@ -18,8 +18,32 @@ const dayplan: TranslationStrings = {
'dayplan.editNote': 'Notu Düzenle',
'dayplan.noteAdd': 'Not Ekle',
'dayplan.noteEdit': 'Notu Düzenle',
'dayplan.noteDelete': 'Notu sil',
'dayplan.noteTitle': 'Not',
'dayplan.noteSubtitle': 'Günlük Not',
'dayplan.noteIcon': 'Simge',
'notes.appearance': 'Görünüm',
'notes.preview': 'Önizleme',
'notes.bodyPlaceholder': 'Ayrıntılar, bağlantılar, hatırlatmalar…',
'notes.markdownHint': 'Markdown destekleniyor',
'notes.format.toolbar': 'Biçimlendirme',
'notes.format.bold': 'Kalın',
'notes.format.italic': 'İtalik',
'notes.format.strike': 'Üstü çizili',
'notes.format.code': 'Kod',
'notes.format.link': 'Bağlantı',
'notes.format.list': 'Madde işaretli liste',
'notes.format.orderedList': 'Numaralı liste',
'notes.format.quote': 'Alıntı',
'notes.color.label': 'Renk',
'notes.color.none': 'Renk yok',
'notes.color.red': 'Kırmızı',
'notes.color.orange': 'Turuncu',
'notes.color.amber': 'Kehribar',
'notes.color.green': 'Yeşil',
'notes.color.cyan': 'Camgöbeği',
'notes.color.blue': 'Mavi',
'notes.color.purple': 'Mor',
'dayplan.totalCost': 'Toplam Maliyet',
'dayplan.days': 'Günler',
'dayplan.dayN': '{n}. gün',
+24
View File
@@ -7,8 +7,32 @@ const dayplan: TranslationStrings = {
'dayplan.editNote': 'Редагувати нотатку',
'dayplan.noteAdd': 'Додати нотатку',
'dayplan.noteEdit': 'Редагувати нотатку',
'dayplan.noteDelete': 'Видалити нотатку',
'dayplan.noteTitle': 'Нотатка',
'dayplan.noteSubtitle': 'Нотатка на день',
'dayplan.noteIcon': 'Значок',
'notes.appearance': 'Вигляд',
'notes.preview': 'Попередній перегляд',
'notes.bodyPlaceholder': 'Деталі, посилання, нагадування…',
'notes.markdownHint': 'Підтримується Markdown',
'notes.format.toolbar': 'Форматування',
'notes.format.bold': 'Жирний',
'notes.format.italic': 'Курсив',
'notes.format.strike': 'Закреслений',
'notes.format.code': 'Код',
'notes.format.link': 'Посилання',
'notes.format.list': 'Маркований список',
'notes.format.orderedList': 'Нумерований список',
'notes.format.quote': 'Цитата',
'notes.color.label': 'Колір',
'notes.color.none': 'Без кольору',
'notes.color.red': 'Червоний',
'notes.color.orange': 'Помаранчевий',
'notes.color.amber': 'Бурштиновий',
'notes.color.green': 'Зелений',
'notes.color.cyan': 'Блакитний',
'notes.color.blue': 'Синій',
'notes.color.purple': 'Фіолетовий',
'dayplan.collapseAll': 'Згорнути всі дні',
'dayplan.expandAll': 'Розгорнути всі дні',
'dayplan.totalCost': 'Загальна вартість',
+24
View File
@@ -18,8 +18,32 @@ const dayplan: TranslationStrings = {
'dayplan.editNote': 'Chỉnh sửa ghi chú',
'dayplan.noteAdd': 'Thêm ghi chú',
'dayplan.noteEdit': 'Chỉnh sửa ghi chú',
'dayplan.noteDelete': 'Xóa ghi chú',
'dayplan.noteTitle': 'Ghi chú',
'dayplan.noteSubtitle': 'Ghi chú hàng ngày',
'dayplan.noteIcon': 'Biểu tượng',
'notes.appearance': 'Giao diện',
'notes.preview': 'Xem trước',
'notes.bodyPlaceholder': 'Chi tiết, liên kết, nhắc nhở…',
'notes.markdownHint': 'Hỗ trợ Markdown',
'notes.format.toolbar': 'Định dạng',
'notes.format.bold': 'Đậm',
'notes.format.italic': 'Nghiêng',
'notes.format.strike': 'Gạch ngang',
'notes.format.code': 'Mã',
'notes.format.link': 'Liên kết',
'notes.format.list': 'Danh sách dấu đầu dòng',
'notes.format.orderedList': 'Danh sách đánh số',
'notes.format.quote': 'Trích dẫn',
'notes.color.label': 'Màu',
'notes.color.none': 'Không màu',
'notes.color.red': 'Đỏ',
'notes.color.orange': 'Cam',
'notes.color.amber': 'Hổ phách',
'notes.color.green': 'Xanh lá',
'notes.color.cyan': 'Lục lam',
'notes.color.blue': 'Xanh dương',
'notes.color.purple': 'Tím',
'dayplan.totalCost': 'Tổng chi phí',
'dayplan.days': 'Ngày',
'dayplan.dayN': 'Ngày {n}',
+24
View File
@@ -7,8 +7,32 @@ const dayplan: TranslationStrings = {
'dayplan.editNote': '編輯備註',
'dayplan.noteAdd': '新增備註',
'dayplan.noteEdit': '編輯備註',
'dayplan.noteDelete': '刪除備註',
'dayplan.noteTitle': '備註',
'dayplan.noteSubtitle': '每日備註',
'dayplan.noteIcon': '圖示',
'notes.appearance': '外觀',
'notes.preview': '預覽',
'notes.bodyPlaceholder': '詳情、連結、提醒…',
'notes.markdownHint': '支援 Markdown',
'notes.format.toolbar': '格式',
'notes.format.bold': '粗體',
'notes.format.italic': '斜體',
'notes.format.strike': '刪除線',
'notes.format.code': '程式碼',
'notes.format.link': '連結',
'notes.format.list': '項目符號清單',
'notes.format.orderedList': '編號清單',
'notes.format.quote': '引用',
'notes.color.label': '顏色',
'notes.color.none': '無顏色',
'notes.color.red': '紅色',
'notes.color.orange': '橙色',
'notes.color.amber': '琥珀色',
'notes.color.green': '綠色',
'notes.color.cyan': '青色',
'notes.color.blue': '藍色',
'notes.color.purple': '紫色',
'dayplan.totalCost': '總費用',
'dayplan.days': '天',
'dayplan.dayN': '第 {n} 天',
+24
View File
@@ -7,8 +7,32 @@ const dayplan: TranslationStrings = {
'dayplan.editNote': '编辑备注',
'dayplan.noteAdd': '添加备注',
'dayplan.noteEdit': '编辑备注',
'dayplan.noteDelete': '删除备注',
'dayplan.noteTitle': '备注',
'dayplan.noteSubtitle': '每日备注',
'dayplan.noteIcon': '图标',
'notes.appearance': '外观',
'notes.preview': '预览',
'notes.bodyPlaceholder': '详情、链接、提醒…',
'notes.markdownHint': '支持 Markdown',
'notes.format.toolbar': '格式',
'notes.format.bold': '加粗',
'notes.format.italic': '斜体',
'notes.format.strike': '删除线',
'notes.format.code': '代码',
'notes.format.link': '链接',
'notes.format.list': '项目符号列表',
'notes.format.orderedList': '编号列表',
'notes.format.quote': '引用',
'notes.color.label': '颜色',
'notes.color.none': '无颜色',
'notes.color.red': '红色',
'notes.color.orange': '橙色',
'notes.color.amber': '琥珀色',
'notes.color.green': '绿色',
'notes.color.cyan': '青色',
'notes.color.blue': '蓝色',
'notes.color.purple': '紫色',
'dayplan.totalCost': '总费用',
'dayplan.days': '天',
'dayplan.dayN': '第 {n} 天',
+1
View File
@@ -30,6 +30,7 @@ export * from './reservation/reservation.schema';
export * from './reservation/ki-reservation.schema';
export * from './airtrail/airtrail.schema';
export * from './day/day.schema';
export * from './day/note-colors';
export * from './assignment/assignment.schema';
export * from './place/place.schema';
export * from './place/track-colors';