diff --git a/client/src/components/Planner/DayPlanSidebar.constants.test.ts b/client/src/components/Planner/DayPlanSidebar.constants.test.ts new file mode 100644 index 000000000..044ee632d --- /dev/null +++ b/client/src/components/Planner/DayPlanSidebar.constants.test.ts @@ -0,0 +1,13 @@ +// FE-PLANNER-NOTELIMIT-001: the detail limit both day note editors count against. +import { describe, expect, it } from 'vitest'; + +import { DAY_NOTE_DETAIL_MAX, dayNoteNearLimit } from './DayPlanSidebar.constants'; + +describe('day note detail limit', () => { + it('FE-PLANNER-NOTELIMIT-001: the counter warns from a hundred characters before the limit', () => { + expect(DAY_NOTE_DETAIL_MAX).toBe(2000); + expect(dayNoteNearLimit(1899)).toBe(false); + expect(dayNoteNearLimit(1900)).toBe(true); + expect(dayNoteNearLimit(2000)).toBe(true); + }); +}); diff --git a/client/src/components/Planner/DayPlanSidebar.constants.ts b/client/src/components/Planner/DayPlanSidebar.constants.ts index 981fc94c4..b1bfb30d8 100644 --- a/client/src/components/Planner/DayPlanSidebar.constants.ts +++ b/client/src/components/Planner/DayPlanSidebar.constants.ts @@ -44,6 +44,12 @@ export const NOTE_ICONS = [ const NOTE_ICON_MAP = Object.fromEntries(NOTE_ICONS.map(({ id, Icon }) => [id, Icon])) export function getNoteIcon(iconId) { return NOTE_ICON_MAP[iconId] || FileText } +/** The most a day note's detail line may hold, in the desktop dialog and the phone sheet alike. */ +export const DAY_NOTE_DETAIL_MAX = 2000 + +/** Whether a detail line is close enough to the limit for the counter to warn. */ +export const dayNoteNearLimit = (length: number): boolean => length >= DAY_NOTE_DETAIL_MAX - 100 + export const TYPE_ICONS = { flight: '✈️', hotel: '🏨', restaurant: '🍽️', train: '🚆', car: '🚗', cruise: '🚢', bus: '🚌', ferry: '⛴️', cable_car: '🚡', bicycle: '🚲', taxi: '🚕', diff --git a/client/src/components/Planner/DayPlanSidebarNoteModal.tsx b/client/src/components/Planner/DayPlanSidebarNoteModal.tsx index 2cb64bca6..efc3ef440 100644 --- a/client/src/components/Planner/DayPlanSidebarNoteModal.tsx +++ b/client/src/components/Planner/DayPlanSidebarNoteModal.tsx @@ -2,7 +2,7 @@ import { useId, useRef } from 'react' import Markdown from 'react-markdown' import remarkGfm from 'remark-gfm' import remarkBreaks from 'remark-breaks' -import { NOTE_ICONS, getNoteIcon } from './DayPlanSidebar.constants' +import { DAY_NOTE_DETAIL_MAX, NOTE_ICONS, dayNoteNearLimit, getNoteIcon } from './DayPlanSidebar.constants' import NoteFormatToolbar from '../shared/NoteFormatToolbar' import NoteColorPicker from '../shared/NoteColorPicker' import { noteSurface } from './noteSurface' @@ -42,8 +42,6 @@ interface DayPlanSidebarNoteModalProps { type NoteDialogProps = Omit & { dayId: string; ui: NoteModalUi | undefined } -const BODY_MAX = 2000 - /** * Add/edit dialog for a day note (#1629), one per day whose note is open. * @@ -166,7 +164,7 @@ function OpenNoteDialog({ dayId, ui, setNoteUi, cancelNote, saveNote, onRequestD id={bodyId} ref={bodyRef} value={ui.time} - maxLength={BODY_MAX} + maxLength={DAY_NOTE_DETAIL_MAX} rows={6} onChange={e => patch({ time: e.target.value })} placeholder={t('notes.bodyPlaceholder')} @@ -174,7 +172,7 @@ function OpenNoteDialog({ dayId, ui, setNoteUi, cancelNote, saveNote, onRequestD />
{t('notes.markdownHint')} - = BODY_MAX - 100 ? 'text-warning' : 'text-content-faint'}`}>{bodyLen}/{BODY_MAX} + {bodyLen}/{DAY_NOTE_DETAIL_MAX}
diff --git a/client/src/hooks/useDayNotes.test.ts b/client/src/hooks/useDayNotes.test.ts new file mode 100644 index 000000000..8c2e55465 --- /dev/null +++ b/client/src/hooks/useDayNotes.test.ts @@ -0,0 +1,85 @@ +// FE-HOOK-DAYNOTEWRITE-001 to -005: the note draft and the note write the desktop +// note dialog and the phone note sheet share. +import { describe, expect, it, vi } from 'vitest'; + +import { buildDayNote } from '../../tests/helpers/factories'; +import { dayNoteDraft, writeDayNote } from './useDayNotes'; + +function actions() { + return { + addDayNote: vi.fn(async () => buildDayNote()), + updateDayNote: vi.fn(async () => buildDayNote()), + }; +} + +describe('dayNoteDraft', () => { + it('FE-HOOK-DAYNOTEWRITE-001: a new note starts blank with the default icon and no colour', () => { + expect(dayNoteDraft()).toEqual({ text: '', time: '', icon: 'FileText', color: null }); + expect(dayNoteDraft(null)).toEqual({ text: '', time: '', icon: 'FileText', color: null }); + }); + + it('FE-HOOK-DAYNOTEWRITE-002: an existing note fills the editor, missing parts as their defaults', () => { + const note = buildDayNote({ text: 'Tickets', time: '09:30 kiosk', icon: 'Ticket', color: '#e11d48' }); + expect(dayNoteDraft(note)).toEqual({ text: 'Tickets', time: '09:30 kiosk', icon: 'Ticket', color: '#e11d48' }); + expect(dayNoteDraft(buildDayNote({ text: 'Plain', time: null, icon: null }))).toEqual({ + text: 'Plain', + time: '', + icon: 'FileText', + color: null, + }); + }); +}); + +describe('writeDayNote', () => { + it('FE-HOOK-DAYNOTEWRITE-003: a new note is added trimmed, at its place in the day, without a colour as null', async () => { + const a = actions(); + await writeDayNote( + a, + 4, + 9, + { add: true, sortOrder: 3 }, + { text: ' Lunch ', time: '', icon: '', color: undefined } + ); + expect(a.addDayNote).toHaveBeenCalledWith(4, 9, { + text: 'Lunch', + time: null, + icon: 'FileText', + color: null, + sort_order: 3, + }); + expect(a.updateDayNote).not.toHaveBeenCalled(); + }); + + it('FE-HOOK-DAYNOTEWRITE-004: an existing note is updated, and an undefined colour leaves it alone', async () => { + const a = actions(); + await writeDayNote(a, 4, 9, { add: false, noteId: 12 }, { text: 'Museum', time: '10:00', icon: 'Landmark' }); + expect(a.updateDayNote).toHaveBeenCalledWith(4, 9, 12, { + text: 'Museum', + time: '10:00', + icon: 'Landmark', + color: undefined, + }); + await writeDayNote( + a, + 4, + 9, + { add: false, noteId: 12 }, + { text: 'Museum', time: '', icon: 'Landmark', color: null } + ); + expect(a.updateDayNote).toHaveBeenLastCalledWith(4, 9, 12, { + text: 'Museum', + time: null, + icon: 'Landmark', + color: null, + }); + expect(a.addDayNote).not.toHaveBeenCalled(); + }); + + it('FE-HOOK-DAYNOTEWRITE-005: a refused write throws for the editor to report', async () => { + const a = actions(); + a.addDayNote.mockRejectedValueOnce(new Error('too long')); + await expect( + writeDayNote(a, 4, 9, { add: true }, { text: 'x', time: '', icon: 'FileText', color: null }) + ).rejects.toThrow('too long'); + }); +}); diff --git a/client/src/hooks/useDayNotes.ts b/client/src/hooks/useDayNotes.ts index 9bc20d0b6..5710cc02b 100644 --- a/client/src/hooks/useDayNotes.ts +++ b/client/src/hooks/useDayNotes.ts @@ -1,5 +1,5 @@ import { useState, useRef } from 'react' -import { useTripStore } from '../store/tripStore' +import { useTripStore, type TripStoreState } from '../store/tripStore' import { useToast } from '../components/shared/Toast' import { useTranslation } from '../i18n' import type { MergedItem, DayNotesMap, DayNote } from '../types' @@ -20,6 +20,31 @@ interface NoteUiMap { [dayId: string]: NoteUiState } +/** A note's editable fields as the editor holds them; a new note starts blank, with the default icon and no colour. */ +export function dayNoteDraft(note?: Pick | null) { + return { text: note?.text || '', time: note?.time || '', icon: note?.icon || 'FileText', color: note?.color ?? null } +} + +/** Where a saved note goes: a new one at a place in the day's order, or an existing one. */ +export type DayNoteTarget = { add: true; sortOrder?: number } | { add: false; noteId: number } + +/** + * Saves a day note from either editor, the desktop dialog or the phone sheet. The + * title is trimmed and an empty detail line is stored as null. On an existing note + * an undefined `color` leaves its colour as it is. Throws when the write is refused. + */ +export async function writeDayNote( + actions: Pick, + tripId: number | string, + dayId: number, + target: DayNoteTarget, + draft: { text: string; time: string; icon: string; color?: string | null }, +) { + const fields = { text: draft.text.trim(), time: draft.time || null, icon: draft.icon || 'FileText' } + if ('noteId' in target) await actions.updateDayNote(tripId, dayId, target.noteId, { ...fields, color: draft.color }) + else await actions.addDayNote(tripId, dayId, { ...fields, color: draft.color ?? null, sort_order: target.sortOrder }) +} + export function useDayNotes(tripId: number | string) { const [noteUi, setNoteUi] = useState({}) const noteInputRef = useRef(null) @@ -31,13 +56,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', color: null, sortOrder: maxKey + 1 } })) + setNoteUi((prev) => ({ ...prev, [dayId]: { mode: 'add', ...dayNoteDraft(), 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', color: note.color ?? null } })) + setNoteUi((prev) => ({ ...prev, [dayId]: { mode: 'edit', noteId: note.id, ...dayNoteDraft(note) } })) setTimeout(() => noteInputRef.current?.focus(), 50) } @@ -49,11 +74,7 @@ export function useDayNotes(tripId: number | string) { const ui = noteUi[dayId] 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', 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', color: ui.color }) - } + await writeDayNote(tripStore, tripId, dayId, ui.mode === 'add' ? { add: true, sortOrder: ui.sortOrder } : { add: false, noteId: ui.noteId! }, ui) cancelNote(dayId) } catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) } } diff --git a/client/src/mobile/screens/trip/sheets/MNoteSheet.tsx b/client/src/mobile/screens/trip/sheets/MNoteSheet.tsx index ae8a51156..219fd75c7 100644 --- a/client/src/mobile/screens/trip/sheets/MNoteSheet.tsx +++ b/client/src/mobile/screens/trip/sheets/MNoteSheet.tsx @@ -2,10 +2,11 @@ import { useEffect, useRef, useState } from 'react' import MSheet from '../../../components/MSheet' import { ChevronDown } from 'lucide-react' import { NOTE_COLORS } from '@trek/shared' -import { NOTE_ICONS, getNoteIcon } from '../../../../components/Planner/DayPlanSidebar.constants' +import { DAY_NOTE_DETAIL_MAX, NOTE_ICONS, dayNoteNearLimit, getNoteIcon } from '../../../../components/Planner/DayPlanSidebar.constants' import { noteSurface } from '../../../../components/Planner/noteSurface' import NoteFormatToolbar from '../../../../components/shared/NoteFormatToolbar' import { useTripStore } from '../../../../store/tripStore' +import { dayNoteDraft, writeDayNote } from '../../../../hooks/useDayNotes' import { Eyebrow, FIELD_AREA_CLS, FIELD_CLS, FormSheetFooter, FormSheetHeader } from './PlSheetChrome' import type { DayNote } from '../../../../types' import type { TripPlanner } from '../MTripShell' @@ -23,8 +24,6 @@ export interface MNoteSheetProps { onClose: () => void } -const DETAIL_MAX = 2000 - /** * Day-note sheet: the demo's icon grid over title + detail. Persists through * the trip store's day-note actions — `text` is the title, the `time` column @@ -54,12 +53,13 @@ export default function MNoteSheet({ planner, open, payload, onClose }: MNoteShe const payloadDayId = payload?.dayId useEffect(() => { if (!open) return + const draft = dayNoteDraft(payload?.note) setSheetPayload(payload) - setIcon(payload?.note?.icon || 'FileText') - setColor(payload?.note?.color ?? null) + setIcon(draft.icon) + setColor(draft.color) setIconOpen(false) - setTitle(payload?.note?.text || '') - setDetail(payload?.note?.time || '') + setTitle(draft.text) + setDetail(draft.time) // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, payloadNoteId, payloadDayId]) @@ -73,7 +73,7 @@ export default function MNoteSheet({ planner, open, payload, onClose }: MNoteShe setIsSaving(true) try { if (note) { - await tripActions.updateDayNote(tripId, dayId, note.id, { text: title.trim(), time: detail || null, icon, color }) + await writeDayNote(tripActions, tripId, dayId, { add: false, noteId: note.id }, { text: title, time: detail, icon, color }) } else { // Append at the end of the day timeline: after the last assignment or note. const state = useTripStore.getState() @@ -82,13 +82,7 @@ export default function MNoteSheet({ planner, open, payload, onClose }: MNoteShe ...(state.assignments[String(dayId)] ?? []).map(a => a.order_index ?? 0), ...(state.dayNotes[String(dayId)] ?? []).map(n => n.sort_order ?? 0), ) - await tripActions.addDayNote(tripId, dayId, { - text: title.trim(), - time: detail || null, - icon, - color, - sort_order: maxKey + 1, - }) + await writeDayNote(tripActions, tripId, dayId, { add: true, sortOrder: maxKey + 1 }, { text: title, time: detail, icon, color }) } onClose() } catch (err: unknown) { @@ -202,14 +196,14 @@ export default function MNoteSheet({ planner, open, payload, onClose }: MNoteShe value={detail} onChange={e => setDetail(e.target.value)} rows={4} - maxLength={DETAIL_MAX} + maxLength={DAY_NOTE_DETAIL_MAX} placeholder={t('notes.bodyPlaceholder')} className={FIELD_AREA_CLS} />
{t('notes.markdownHint')} - = DETAIL_MAX - 100 ? 'text-[color:var(--m-st-pending)]' : 'text-m-faint'}> - {detail.length}/{DETAIL_MAX} + + {detail.length}/{DAY_NOTE_DETAIL_MAX}