mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 23:46:35 +00:00
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.
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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: '🚕',
|
||||
|
||||
@@ -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<DayPlanSidebarNoteModalProps, 'noteUi' | 'noteInputRef'> & { 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
|
||||
/>
|
||||
<div className="mt-1 flex justify-between gap-2" style={fs(10.5)}>
|
||||
<span className="text-content-faint">{t('notes.markdownHint')}</span>
|
||||
<span className={`tabular-nums ${bodyLen >= BODY_MAX - 100 ? 'text-warning' : 'text-content-faint'}`}>{bodyLen}/{BODY_MAX}</span>
|
||||
<span className={`tabular-nums ${dayNoteNearLimit(bodyLen) ? 'text-warning' : 'text-content-faint'}`}>{bodyLen}/{DAY_NOTE_DETAIL_MAX}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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<DayNote, 'text' | 'time' | 'icon' | 'color'> | 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<TripStoreState, 'addDayNote' | 'updateDayNote'>,
|
||||
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<NoteUiMap>({})
|
||||
const noteInputRef = useRef<HTMLInputElement | null>(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')) }
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
<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 className={dayNoteNearLimit(detail.length) ? 'text-[color:var(--m-st-pending)]' : 'text-m-faint'}>
|
||||
{detail.length}/{DAY_NOTE_DETAIL_MAX}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user