From d01d9df93a8483b04dedb945824513cb4b84470a Mon Sep 17 00:00:00 2001 From: Maurice Date: Thu, 8 Oct 2026 21:17:01 +0000 Subject: [PATCH] refactor(planner): share the day note draft and save between desktop and phone The desktop note dialog and the phone note sheet fill their editor with dayNoteDraft and save through writeDayNote, so both trim the title, store an empty detail as null and default the icon the same way. Both count the detail line against one shared limit. --- .../Planner/DayPlanSidebar.constants.test.ts | 13 +++ .../Planner/DayPlanSidebar.constants.ts | 6 ++ .../Planner/DayPlanSidebarNoteModal.tsx | 8 +- client/src/hooks/useDayNotes.test.ts | 85 +++++++++++++++++++ client/src/hooks/useDayNotes.ts | 37 ++++++-- .../mobile/screens/trip/sheets/MNoteSheet.tsx | 30 +++---- 6 files changed, 148 insertions(+), 31 deletions(-) create mode 100644 client/src/components/Planner/DayPlanSidebar.constants.test.ts create mode 100644 client/src/hooks/useDayNotes.test.ts 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}