diff --git a/client/src/components/Todo/TodoListPanel.tsx b/client/src/components/Todo/TodoListPanel.tsx index 4abd1f67d..01302dceb 100644 --- a/client/src/components/Todo/TodoListPanel.tsx +++ b/client/src/components/Todo/TodoListPanel.tsx @@ -1,4 +1,4 @@ -import { Fragment, useId, useState, useEffect, type CSSProperties } from 'react' +import { Fragment, useId, useState, useEffect, useEffectEvent, type CSSProperties } from 'react' import { createPortal } from 'react-dom' import { useTripStore } from '../../store/tripStore' import { useCanDo } from '../../store/permissionsStore' @@ -7,8 +7,9 @@ import { useTranslation } from '../../i18n' import { Check, Plus, Flag, X, Calendar, User, AlertCircle, Inbox, CheckCheck, Trash2, ListPlus, ListTodo } from 'lucide-react' import type { TodoItem } from '../../types' -import { katColor, taskInputStyle, type FilterType, type Member, type TaskFieldValues } from './todoListModel' +import { katColor, taskInputStyle, taskUpdatePayload, type FilterType, type Member, type TaskFieldValues } from './todoListModel' import { useTodoList } from './useTodoList' +import { useTaskForm } from './useTaskForm' import TodoRow from './TodoRow' import TodoTaskFields from './TodoTaskFields' import { usePluginViewContributions, PluginCardFooter } from '../Plugins/PluginContributions' @@ -85,7 +86,7 @@ export default function TodoListPanel({ tripId, items, addItemSignal = 0 }: { tr const { canEdit, t, formatDate, toggleTodoItem, reorderTodoItems, isMobile, filter, setFilter, selectedId, setSelectedId, - isAddingNew, setIsAddingNew, sortByPrio, setSortByPrio, sortByDue, setSortByDue, + isAddingNew, setIsAddingNew, sortByPrio, sortByDue, toggleSort, addingCategory, setAddingCategory, newCategoryName, setNewCategoryName, members, categories, today, filtered, selectedItem, totalCount, doneCount, overdueCount, myCount, @@ -208,10 +209,10 @@ export default function TodoListPanel({ tripId, items, addItemSignal = 0 }: { tr {/* Sort order: priority or due date, neither means the manual order */}
- -
@@ -346,14 +347,6 @@ const primaryButton = (enabled: boolean): CSSProperties => ({ // ── Detail Pane (right side) ────────────────────────────────────────────── -/** A task's editable fields as the form holds them: empty strings where the task has nothing. */ -function fieldsOf(item: TodoItem): TaskFieldValues { - return { - desc: item.description || '', priority: item.priority || 0, category: item.category || '', - dueDate: item.due_date || '', assignedUserId: item.assigned_user_id, - } -} - function DetailPane({ item, tripId, categories, members, onClose, variant = 'side' }: { item: TodoItem; tripId: number; categories: string[]; members: Member[]; onClose: () => void; variant?: PaneVariant; @@ -365,17 +358,12 @@ function DetailPane({ item, tripId, categories, members, onClose, variant = 'sid const toast = useToast() const { t } = useTranslation() - const [name, setName] = useState(item.name) - const [fields, setFields] = useState(() => fieldsOf(item)) - const [saving, setSaving] = useState(false) + const { name, setName, fields, patchFields, saving, load, saveWith } = useTaskForm(item) // Sync when selected item changes + const syncFromItem = useEffectEvent(() => load(item)) useEffect(() => { - setName(item.name) - setFields({ - desc: item.description || '', priority: item.priority || 0, category: item.category || '', - dueDate: item.due_date || '', assignedUserId: item.assigned_user_id, - }) + syncFromItem() }, [item.id, item.name, item.description, item.due_date, item.category, item.assigned_user_id, item.priority]) const hasChanges = name !== item.name || fields.desc !== (item.description || '') || @@ -384,15 +372,10 @@ function DetailPane({ item, tripId, categories, members, onClose, variant = 'sid const save = async () => { if (!name.trim() || !hasChanges) return - setSaving(true) - try { - await updateTodoItem(tripId, item.id, { - name: name.trim(), description: fields.desc || null, - due_date: fields.dueDate || null, category: fields.category || null, - assigned_user_id: fields.assignedUserId, priority: fields.priority, - }) - } catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.error')) } - setSaving(false) + await saveWith( + () => updateTodoItem(tripId, item.id, taskUpdatePayload(name, fields)), + (err: unknown) => toast.error(err instanceof Error ? err.message : t('common.error')), + ) } const handleDelete = async () => { @@ -421,7 +404,7 @@ function DetailPane({ item, tripId, categories, members, onClose, variant = 'sid setName(e.target.value)} disabled={!canEdit} style={{ ...nameFieldStyle, textDecoration: done ? 'line-through' : 'none', color: done ? 'var(--text-faint)' : 'var(--text-primary)' }} placeholder={t('todo.namePlaceholder')} /> - setFields(f => ({ ...f, ...patch }))} categories={categories} members={members} canEdit={canEdit} /> + {/* Always in sight: the pane scrolls between its head and this foot. */} @@ -455,14 +438,11 @@ function NewTaskPane({ tripId, categories, members, defaultCategory, onCreated, const toast = useToast() const { t } = useTranslation() - const [name, setName] = useState('') - const [fields, setFields] = useState({ desc: '', priority: 0, category: defaultCategory || '', dueDate: '', assignedUserId: null }) - const [saving, setSaving] = useState(false) + const { name, setName, fields, patchFields, saving, saveWith } = useTaskForm(null, defaultCategory) const create = async () => { if (!name.trim()) return - setSaving(true) - try { + await saveWith(async () => { const trimmedCategory = fields.category.trim() const item = await addTodoItem(tripId, { name: name.trim(), description: fields.desc || null, priority: fields.priority, @@ -470,12 +450,11 @@ function NewTaskPane({ tripId, categories, members, defaultCategory, onCreated, assigned_user_id: fields.assignedUserId, }) if (item?.id) onCreated(item.id) - } catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.error')) } - setSaving(false) + }, (err: unknown) => toast.error(err instanceof Error ? err.message : t('common.error'))) } if (variant === 'dialog') return ( - setFields(f => ({ ...f, ...patch }))} + ) @@ -488,7 +467,7 @@ function NewTaskPane({ tripId, categories, members, defaultCategory, onCreated, onKeyDown={e => { if (e.key === 'Enter' && name.trim()) void create() }} style={{ ...taskInputStyle, fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))', fontWeight: 600 }} placeholder={t('todo.namePlaceholder')} /> - setFields(f => ({ ...f, ...patch }))} categories={categories} members={members} /> +
diff --git a/client/src/components/Todo/todoListModel.test.ts b/client/src/components/Todo/todoListModel.test.ts index e8ab22bc9..8a8f14386 100644 --- a/client/src/components/Todo/todoListModel.test.ts +++ b/client/src/components/Todo/todoListModel.test.ts @@ -1,46 +1,172 @@ -// FE-W4TDM-001 to FE-W4TDM-006 -import { describe, it, expect } from 'vitest' -import { KAT_COLORS, PRIO_CONFIG, katColor } from './todoListModel' +// FE-W4TDM-001 to FE-W4TDM-013 +import { describe, expect, it } from 'vitest'; +import { buildTodoItem } from '../../../tests/helpers/factories'; +import { + KAT_COLORS, + PRIO_CONFIG, + TODO_SMART_FILTERS, + blankTaskFields, + compareTodoDue, + compareTodoPriority, + filterTodoItems, + filterTodoItemsByCategory, + isTodoSmartFilter, + katColor, + taskFieldsOf, + taskUpdatePayload, +} from './todoListModel'; describe('katColor', () => { it('FE-W4TDM-001: gives a known category the palette colour at its index', () => { - const cats = ['Docs', 'Gear', 'Food'] + const cats = ['Docs', 'Gear', 'Food']; - expect(katColor('Docs', cats)).toBe(KAT_COLORS[0]) - expect(katColor('Gear', cats)).toBe(KAT_COLORS[1]) - expect(katColor('Food', cats)).toBe(KAT_COLORS[2]) - }) + expect(katColor('Docs', cats)).toBe(KAT_COLORS[0]); + expect(katColor('Gear', cats)).toBe(KAT_COLORS[1]); + expect(katColor('Food', cats)).toBe(KAT_COLORS[2]); + }); it('FE-W4TDM-002: wraps around when there are more categories than colours', () => { - const cats = Array.from({ length: 12 }, (_, i) => `c${i}`) + const cats = Array.from({ length: 12 }, (_, i) => `c${i}`); - expect(katColor('c10', cats)).toBe(KAT_COLORS[0]) - expect(katColor('c11', cats)).toBe(KAT_COLORS[1]) - }) + expect(katColor('c10', cats)).toBe(KAT_COLORS[0]); + expect(katColor('c11', cats)).toBe(KAT_COLORS[1]); + }); it('FE-W4TDM-003: hashes an unlisted category into the palette', () => { - const color = katColor('Ad-hoc', []) + const color = katColor('Ad-hoc', []); - expect(KAT_COLORS).toContain(color) - expect(katColor('Ad-hoc', [])).toBe(color) - }) + expect(KAT_COLORS).toContain(color); + expect(katColor('Ad-hoc', [])).toBe(color); + }); it('FE-W4TDM-004: gives different unlisted categories different colours', () => { - const seen = new Set(['Alpha', 'Bravo', 'Charlie', 'Delta'].map(c => katColor(c, []))) - expect(seen.size).toBeGreaterThan(1) - }) + const seen = new Set(['Alpha', 'Bravo', 'Charlie', 'Delta'].map((c) => katColor(c, []))); + expect(seen.size).toBeGreaterThan(1); + }); it('FE-W4TDM-005: handles an empty category name without going out of range', () => { - expect(KAT_COLORS).toContain(katColor('', [])) - }) -}) + expect(KAT_COLORS).toContain(katColor('', [])); + }); +}); describe('PRIO_CONFIG', () => { it('FE-W4TDM-006: maps the three priorities to labels and colours', () => { - expect(PRIO_CONFIG[1]).toEqual({ label: 'P1', color: '#ef4444' }) - expect(PRIO_CONFIG[2].label).toBe('P2') - expect(PRIO_CONFIG[3].label).toBe('P3') - expect(PRIO_CONFIG[4]).toBeUndefined() - expect(KAT_COLORS).toHaveLength(10) - }) -}) + expect(PRIO_CONFIG[1]).toEqual({ label: 'P1', color: '#ef4444' }); + expect(PRIO_CONFIG[2].label).toBe('P2'); + expect(PRIO_CONFIG[3].label).toBe('P3'); + expect(PRIO_CONFIG[4]).toBeUndefined(); + expect(KAT_COLORS).toHaveLength(10); + }); +}); + +describe('todo order shared by both lists', () => { + it('FE-W4TDM-007: compareTodoPriority puts P1 first and tasks without a priority last', () => { + const p2 = buildTodoItem({ id: 1, priority: 2 }); + const none = buildTodoItem({ id: 2, priority: 0 }); + const p1 = buildTodoItem({ id: 3, priority: 1 }); + expect([p2, none, p1].sort(compareTodoPriority).map((i) => i.id)).toEqual([3, 1, 2]); + expect(compareTodoPriority(p2, buildTodoItem({ priority: 2 }))).toBe(0); + }); + + it('FE-W4TDM-008: compareTodoDue puts the nearest date first and undated tasks last, ties untouched', () => { + const later = buildTodoItem({ id: 1, due_date: '2026-07-20' }); + const undated = buildTodoItem({ id: 2 }); + const sooner = buildTodoItem({ id: 3, due_date: '2026-07-16' }); + const sameDay = buildTodoItem({ id: 4, due_date: '2026-07-20' }); + const undated2 = buildTodoItem({ id: 5 }); + expect([later, undated, sooner, sameDay, undated2].sort(compareTodoDue).map((i) => i.id)).toEqual([3, 1, 4, 2, 5]); + expect(compareTodoDue(undated, undated2)).toBe(0); + expect(compareTodoDue(undated, sooner)).toBe(1); + expect(compareTodoDue(sooner, undated)).toBe(-1); + }); + + it('FE-W4TDM-009: filterTodoItems reads a name it does not know as a category, as the desktop sidebar does', () => { + const items = [buildTodoItem({ id: 1, category: 'my' }), buildTodoItem({ id: 2, category: 'Gear' })]; + expect(filterTodoItems(items, 'Gear', 7, '2026-07-15').map((i) => i.id)).toEqual([2]); + expect(filterTodoItemsByCategory(items, 'my').map((i) => i.id)).toEqual([1]); + }); +}); + +describe('task form fields', () => { + it('FE-W4TDM-010: taskFieldsOf reads a saved task with empty strings for what it lacks', () => { + expect( + taskFieldsOf( + buildTodoItem({ description: null, priority: 0, category: null, due_date: null, assigned_user_id: null }) + ) + ).toEqual({ + desc: '', + priority: 0, + category: '', + dueDate: '', + assignedUserId: null, + }); + expect( + taskFieldsOf( + buildTodoItem({ + description: 'Ask', + priority: 2, + category: 'Docs', + due_date: '2026-07-01', + assigned_user_id: 4, + }) + ) + ).toEqual({ + desc: 'Ask', + priority: 2, + category: 'Docs', + dueDate: '2026-07-01', + assignedUserId: 4, + }); + }); + + it('FE-W4TDM-011: blankTaskFields files a new task in the given list, or in none', () => { + expect(blankTaskFields('Docs')).toEqual({ + desc: '', + priority: 0, + category: 'Docs', + dueDate: '', + assignedUserId: null, + }); + expect(blankTaskFields(null).category).toBe(''); + expect(blankTaskFields().category).toBe(''); + }); + + it('FE-W4TDM-012: taskUpdatePayload trims the name and sends every emptied field as null', () => { + expect( + taskUpdatePayload(' Visa ', { desc: '', priority: 0, category: '', dueDate: '', assignedUserId: null }) + ).toEqual({ + name: 'Visa', + description: null, + due_date: null, + category: null, + assigned_user_id: null, + priority: 0, + }); + expect( + taskUpdatePayload('Visa', { + desc: 'Form', + priority: 1, + category: 'Docs', + dueDate: '2026-07-01', + assignedUserId: 3, + }) + ).toEqual({ + name: 'Visa', + description: 'Form', + due_date: '2026-07-01', + category: 'Docs', + assigned_user_id: 3, + priority: 1, + }); + }); +}); + +describe('built-in filters', () => { + it('FE-W4TDM-013: lists the four built-ins and tells them apart from category names', () => { + expect(TODO_SMART_FILTERS).toEqual(['all', 'my', 'overdue', 'done']); + for (const f of TODO_SMART_FILTERS) expect(isTodoSmartFilter(f)).toBe(true); + expect(isTodoSmartFilter('Docs')).toBe(false); + expect(isTodoSmartFilter('All')).toBe(false); + expect(isTodoSmartFilter('')).toBe(false); + }); +}); diff --git a/client/src/components/Todo/todoListModel.ts b/client/src/components/Todo/todoListModel.ts index b9fc8e4a2..e500718a0 100644 --- a/client/src/components/Todo/todoListModel.ts +++ b/client/src/components/Todo/todoListModel.ts @@ -1,42 +1,200 @@ -import type { CSSProperties } from 'react' +import type { TodoUpdateItemRequest } from '@trek/shared'; +import type { CSSProperties } from 'react'; +import type { TodoItem } from '../../types'; -// Pure constants + helpers + types for the todo list. No React, no side effects. +// Pure constants + helpers + types for the todo list, shared by the desktop +// panel and the phone tab. No React, no side effects. export const KAT_COLORS = [ - '#3b82f6', '#a855f7', '#ec4899', '#22c55e', '#f97316', - '#06b6d4', '#ef4444', '#eab308', '#8b5cf6', '#14b8a6', -] + '#3b82f6', + '#a855f7', + '#ec4899', + '#22c55e', + '#f97316', + '#06b6d4', + '#ef4444', + '#eab308', + '#8b5cf6', + '#14b8a6', +]; export const PRIO_CONFIG: Record = { 1: { label: 'P1', color: '#ef4444' }, 2: { label: 'P2', color: '#f59e0b' }, 3: { label: 'P3', color: '#3b82f6' }, -} +}; export function katColor(kat: string, allCategories: string[]) { - const idx = allCategories.indexOf(kat) - if (idx >= 0) return KAT_COLORS[idx % KAT_COLORS.length] - let h = 0 - for (let i = 0; i < kat.length; i++) h = ((h << 5) - h + kat.codePointAt(i)) | 0 - return KAT_COLORS[Math.abs(h) % KAT_COLORS.length] + const idx = allCategories.indexOf(kat); + if (idx >= 0) return KAT_COLORS[idx % KAT_COLORS.length]; + // A 32 bit accumulator: each store wraps the hash the way it always has. + const h = new Int32Array(1); + for (let i = 0; i < kat.length; i++) h[0] = (h[0] << 5) - h[0] + kat.codePointAt(i); + return KAT_COLORS[Math.abs(h[0]) % KAT_COLORS.length]; } -export type FilterType = 'all' | 'my' | 'overdue' | 'done' | string +export type FilterType = 'all' | 'my' | 'overdue' | 'done' | string; -export interface Member { id: number; username: string; avatar: string | null; is_guest?: boolean } +/** The four built-in buckets; any other filter value is a category name. */ +export type TodoSmartFilter = 'all' | 'my' | 'overdue' | 'done'; + +/** The built-in buckets in the order both views list them. */ +export const TODO_SMART_FILTERS: readonly TodoSmartFilter[] = ['all', 'my', 'overdue', 'done']; + +/** Whether a filter value names a built-in bucket rather than a category. */ +export function isTodoSmartFilter(f: string): f is TodoSmartFilter { + return (TODO_SMART_FILTERS as readonly string[]).includes(f); +} + +export interface Member { + id: number; + username: string; + avatar: string | null; + is_guest?: boolean; +} /** A task's editable fields as its form holds them: empty strings where the task has nothing. */ export interface TaskFieldValues { - desc: string - priority: number - category: string - dueDate: string - assignedUserId: number | null + desc: string; + priority: number; + category: string; + dueDate: string; + assignedUserId: number | null; } /** The label and field look of the task form, shared by the detail pane and the new-task dialog. */ -export const taskLabelClass = 'mb-1.5 block text-caption font-semibold text-content-muted' +export const taskLabelClass = 'mb-1.5 block text-caption font-semibold text-content-muted'; export const taskInputStyle: CSSProperties = { - width: '100%', fontSize: 'calc(13px * var(--fs-scale-body, 1))', padding: '9px 11px', border: '1px solid var(--border-primary)', - borderRadius: 10, background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none', + width: '100%', + fontSize: 'calc(13px * var(--fs-scale-body, 1))', + padding: '9px 11px', + border: '1px solid var(--border-primary)', + borderRadius: 10, + background: 'var(--bg-input)', + color: 'var(--text-primary)', + fontFamily: 'inherit', + outline: 'none', +}; + +// ── Filters, counts and order ─────────────────────────────────────────────── + +export function isTodoOverdue(item: TodoItem, today: string): boolean { + return !!item.due_date && !item.checked && item.due_date < today; +} + +/** The four smart filters, and any other value as a category bucket. */ +export function filterTodoItems( + items: TodoItem[], + filter: FilterType, + currentUserId: number | null, + today: string +): TodoItem[] { + if (filter === 'all') return items.filter((i) => !i.checked); + if (filter === 'done') return items.filter((i) => !!i.checked); + // No resolved user means nothing is "mine", matching the "my" count. + if (filter === 'my') + return currentUserId ? items.filter((i) => !i.checked && i.assigned_user_id === currentUserId) : []; + if (filter === 'overdue') return items.filter((i) => isTodoOverdue(i, today)); + return items.filter((i) => i.category === filter); +} + +/** + * Category bucket, addressed by name instead of through `filterTodoItems`, so a + * category literally called 'all'/'my'/'overdue'/'done' keeps its own rows + * rather than collapsing into the smart filter of the same id. + */ +export function filterTodoItemsByCategory(items: TodoItem[], category: string): TodoItem[] { + return items.filter((i) => i.category === category); +} + +/** Ascending priority, with tasks that have none after every prioritised one. */ +export function compareTodoPriority(a: TodoItem, b: TodoItem): number { + return (a.priority || 99) - (b.priority || 99); +} + +/** Nearest due date first, undated tasks after all dated ones; ties keep their order (#2205). */ +export function compareTodoDue(a: TodoItem, b: TodoItem): number { + if (!a.due_date) return b.due_date ? 1 : 0; + if (!b.due_date) return -1; + return a.due_date < b.due_date ? -1 : a.due_date > b.due_date ? 1 : 0; +} + +/** + * The phone's row order: done sinks to the end, open-overdue floats to the top. + * Ties break by the active sort toggle, priority or due date; with no toggle the + * manual order stands. + */ +export function sortTodoRows(items: TodoItem[], sortBy: 'priority' | 'due' | null, today: string): TodoItem[] { + const rank = (i: TodoItem) => (i.checked ? 2 : isTodoOverdue(i, today) ? 0 : 1); + return [...items].sort((a, b) => { + const byRank = rank(a) - rank(b); + if (byRank !== 0) return byRank; + if (sortBy === 'priority') return compareTodoPriority(a, b); + if (sortBy === 'due') return compareTodoDue(a, b); + return 0; + }); +} + +export function todoCategories(items: TodoItem[]): string[] { + const cats = new Set(); + items.forEach((i) => { + if (i.category) cats.add(i.category); + }); + // Category names are free text the user types, and TREK ships 23 locales: a + // bare .sort() is byte order, which files every accented name behind the whole + // ASCII range ("Übernachtung" after "Zelt"). + return Array.from(cats).sort((a, b) => a.localeCompare(b)); +} + +/** Open (non-done) item count for a given category, the sidebar badge. */ +export function todoCategoryOpenCount(items: TodoItem[], category: string): number { + return items.filter((i) => i.category === category && !i.checked).length; +} + +export interface TodoCounts { + total: number; + open: number; + done: number; + overdue: number; + my: number; +} + +export function todoCounts(items: TodoItem[], currentUserId: number | null, today: string): TodoCounts { + return { + total: items.length, + open: items.filter((i) => !i.checked).length, + done: items.filter((i) => !!i.checked).length, + overdue: items.filter((i) => isTodoOverdue(i, today)).length, + my: currentUserId ? items.filter((i) => !i.checked && i.assigned_user_id === currentUserId).length : 0, + }; +} + +// ── The task form ─────────────────────────────────────────────────────────── + +/** A saved task's fields as the form holds them. */ +export function taskFieldsOf(item: TodoItem): TaskFieldValues { + return { + desc: item.description || '', + priority: item.priority || 0, + category: item.category || '', + dueDate: item.due_date || '', + assignedUserId: item.assigned_user_id, + }; +} + +/** A new task's fields, filed in `category` when one is given. */ +export function blankTaskFields(category?: string | null): TaskFieldValues { + return { desc: '', priority: 0, category: category || '', dueDate: '', assignedUserId: null }; +} + +/** What saving an edited task sends: the name trimmed, every emptied field as null. */ +export function taskUpdatePayload(name: string, fields: TaskFieldValues): TodoUpdateItemRequest { + return { + name: name.trim(), + description: fields.desc || null, + due_date: fields.dueDate || null, + category: fields.category || null, + assigned_user_id: fields.assignedUserId, + priority: fields.priority, + }; } diff --git a/client/src/components/Todo/useTaskForm.test.ts b/client/src/components/Todo/useTaskForm.test.ts new file mode 100644 index 000000000..3308380a1 --- /dev/null +++ b/client/src/components/Todo/useTaskForm.test.ts @@ -0,0 +1,117 @@ +import { act, renderHook } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { buildTodoItem } from '../../../tests/helpers/factories'; +import { useTaskForm } from './useTaskForm'; + +// FE-TODO-TASKFORM-001 to FE-TODO-TASKFORM-007 + +const VISA = buildTodoItem({ + id: 5, + name: 'Visa', + description: 'Fill the form', + priority: 2, + category: 'Docs', + due_date: '2026-07-01', + assigned_user_id: 3, +}); + +describe('useTaskForm', () => { + it('FE-TODO-TASKFORM-001: starts on a saved task, the way the desktop detail pane opens', () => { + const { result } = renderHook(() => useTaskForm(VISA)); + expect(result.current.name).toBe('Visa'); + expect(result.current.fields).toEqual({ + desc: 'Fill the form', + priority: 2, + category: 'Docs', + dueDate: '2026-07-01', + assignedUserId: 3, + }); + expect(result.current.saving).toBe(false); + }); + + it('FE-TODO-TASKFORM-002: starts blank in the given list for a new task, or in none', () => { + const inList = renderHook(() => useTaskForm(null, 'Docs')).result.current; + expect(inList.name).toBe(''); + expect(inList.fields).toEqual({ desc: '', priority: 0, category: 'Docs', dueDate: '', assignedUserId: null }); + expect(renderHook(() => useTaskForm(null)).result.current.fields.category).toBe(''); + }); + + it('FE-TODO-TASKFORM-003: patchFields changes only the fields it is given', () => { + const { result } = renderHook(() => useTaskForm(VISA)); + act(() => result.current.patchFields({ priority: 1 })); + act(() => result.current.patchFields({ category: 'Travel', assignedUserId: null })); + expect(result.current.fields).toEqual({ + desc: 'Fill the form', + priority: 1, + category: 'Travel', + dueDate: '2026-07-01', + assignedUserId: null, + }); + act(() => result.current.setName('Passport')); + expect(result.current.name).toBe('Passport'); + }); + + it('FE-TODO-TASKFORM-004: load puts the form on a task, or back on a blank one in a list', () => { + const { result } = renderHook(() => useTaskForm(null)); + const loadBefore = result.current.load; + act(() => result.current.load(VISA)); + expect(result.current.name).toBe('Visa'); + expect(result.current.fields.category).toBe('Docs'); + act(() => result.current.load(null, 'Home')); + expect(result.current.name).toBe(''); + expect(result.current.fields).toEqual({ + desc: '', + priority: 0, + category: 'Home', + dueDate: '', + assignedUserId: null, + }); + act(() => result.current.load(null)); + expect(result.current.fields.category).toBe(''); + // Stable, so an effect may list it without running again on every render. + expect(result.current.load).toBe(loadBefore); + }); + + it('FE-TODO-TASKFORM-005: saveWith raises saving while the write is out and drops it after', async () => { + const { result } = renderHook(() => useTaskForm(VISA)); + let finish = () => {}; + const write = vi.fn( + () => + new Promise((resolve) => { + finish = () => resolve(); + }) + ); + const onError = vi.fn(); + let done: Promise = Promise.resolve(); + act(() => { + done = result.current.saveWith(write, onError); + }); + expect(write).toHaveBeenCalledTimes(1); + expect(result.current.saving).toBe(true); + await act(async () => { + finish(); + await done; + }); + expect(result.current.saving).toBe(false); + expect(onError).not.toHaveBeenCalled(); + }); + + it('FE-TODO-TASKFORM-006: a failed write goes to onError with what it threw, and saving drops', async () => { + const { result } = renderHook(() => useTaskForm(VISA)); + const boom = new Error('No permission'); + const onError = vi.fn(); + await act(async () => { + await result.current.saveWith(() => Promise.reject(boom), onError); + }); + expect(onError).toHaveBeenCalledWith(boom); + expect(result.current.saving).toBe(false); + }); + + it('FE-TODO-TASKFORM-007: the form keeps what was typed when the task it started on changes', () => { + const { result, rerender } = renderHook(({ item }) => useTaskForm(item), { initialProps: { item: VISA } }); + act(() => result.current.setName('Visa form')); + rerender({ item: { ...VISA, name: 'Renamed elsewhere' } }); + expect(result.current.name).toBe('Visa form'); + }); +}); diff --git a/client/src/components/Todo/useTaskForm.ts b/client/src/components/Todo/useTaskForm.ts new file mode 100644 index 000000000..1da035f72 --- /dev/null +++ b/client/src/components/Todo/useTaskForm.ts @@ -0,0 +1,37 @@ +import { useCallback, useState } from 'react'; +import type { TodoItem } from '../../types'; +import { blankTaskFields, taskFieldsOf, type TaskFieldValues } from './todoListModel'; + +/** + * The task form behind the desktop detail pane, the desktop new-task pane and the + * phone task sheet: the name and fields being edited, and whether a save is out. + * It starts on `item`, or on a blank task in `category`. Each surface keeps its + * own guard, the payload of a new task and the wording of a failure. + */ +export function useTaskForm(item: TodoItem | null, category?: string | null) { + const [name, setName] = useState(() => item?.name ?? ''); + const [fields, setFields] = useState(() => (item ? taskFieldsOf(item) : blankTaskFields(category))); + const [saving, setSaving] = useState(false); + + const patchFields = useCallback((patch: Partial) => setFields((f) => ({ ...f, ...patch })), []); + + /** Puts the form back on a task, or on a blank one in `nextCategory`. */ + const load = useCallback((next: TodoItem | null, nextCategory?: string | null) => { + setName(next ? next.name : ''); + setFields(next ? taskFieldsOf(next) : blankTaskFields(nextCategory)); + }, []); + + /** Runs one write with `saving` raised, and hands a failure to `onError`. */ + const saveWith = async (write: () => Promise, onError: (err: unknown) => void) => { + setSaving(true); + try { + await write(); + } catch (err) { + onError(err); + } finally { + setSaving(false); + } + }; + + return { name, setName, fields, patchFields, saving, load, saveWith }; +} diff --git a/client/src/components/Todo/useTodoList.test.ts b/client/src/components/Todo/useTodoList.test.ts new file mode 100644 index 000000000..63b8e9713 --- /dev/null +++ b/client/src/components/Todo/useTodoList.test.ts @@ -0,0 +1,170 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'; + +import { buildTodoItem } from '../../../tests/helpers/factories'; +import apiClient from '../../api/client'; +import { useTodoList, useTodoView } from './useTodoList'; + +// FE-TODO-VIEW-001 to FE-TODO-VIEW-010 + +vi.mock('../../api/client', async (importOriginal) => ({ + ...(await importOriginal()), + default: { get: vi.fn() }, +})); + +const TODAY = '2026-07-10'; + +// In manual order: an open undated P3, a done task, an open overdue P2, an open +// task due later with P1 and mine, and an open task filed in a category named "all". +const PLAIN = buildTodoItem({ id: 1, name: 'Plain', priority: 3 }); +const DONE = buildTodoItem({ id: 2, name: 'Done', checked: 1, category: 'Docs' }); +const LATE = buildTodoItem({ id: 3, name: 'Late', priority: 2, due_date: '2026-07-01', category: 'Docs' }); +const SOON = buildTodoItem({ id: 4, name: 'Soon', priority: 1, due_date: '2026-07-20', assigned_user_id: 7 }); +const NAMED_ALL = buildTodoItem({ id: 5, name: 'Named all', category: 'all' }); +const ITEMS = [PLAIN, DONE, LATE, SOON, NAMED_ALL]; + +const ids = (rows: { id: number }[]) => rows.map((r) => r.id); + +beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(new Date(2026, 6, 10, 12, 0, 0)); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('useTodoView', () => { + it('FE-TODO-VIEW-001: starts on the open tasks in manual order, with no sort and the counts of the list', () => { + const { result } = renderHook(() => useTodoView(ITEMS, 7)); + + expect(result.current.active).toEqual({ kind: 'smart', id: 'all' }); + expect(result.current.filter).toBe('all'); + expect(result.current.sortBy).toBeNull(); + expect(result.current.today).toBe(TODAY); + expect(ids(result.current.rows)).toEqual([1, 3, 4, 5]); + expect(result.current.counts).toEqual({ total: 5, open: 4, done: 1, overdue: 1, my: 1 }); + expect(result.current.categories).toEqual(['all', 'Docs']); + expect(result.current.catCount('Docs')).toBe(1); + }); + + it('FE-TODO-VIEW-002: the desktop string filter sends a built-in id to the built-in and any other name to its category', () => { + const { result } = renderHook(() => useTodoView(ITEMS, 7)); + + act(() => result.current.setFilter('Docs')); + expect(result.current.active).toEqual({ kind: 'category', name: 'Docs' }); + expect(result.current.filter).toBe('Docs'); + expect(ids(result.current.rows)).toEqual([2, 3]); + + act(() => result.current.setFilter('my')); + expect(result.current.active).toEqual({ kind: 'smart', id: 'my' }); + expect(ids(result.current.rows)).toEqual([4]); + + // A category called "all" picked through the string lands on the built-in. + act(() => result.current.setFilter('all')); + expect(result.current.active).toEqual({ kind: 'smart', id: 'all' }); + expect(ids(result.current.rows)).toEqual([1, 3, 4, 5]); + }); + + it('FE-TODO-VIEW-003: the phone addresses a category by name, so one called "all" keeps its own rows', () => { + const { result } = renderHook(() => useTodoView(ITEMS, 7, { rankByStatus: true })); + + act(() => result.current.setActive({ kind: 'category', name: 'all' })); + expect(result.current.filter).toBe('all'); + expect(ids(result.current.rows)).toEqual([5]); + }); + + it('FE-TODO-VIEW-004: toggleSort switches between priority and due date, and a second press goes back to manual', () => { + const { result } = renderHook(() => useTodoView(ITEMS, 7)); + + act(() => result.current.toggleSort('priority')); + expect(result.current.sortBy).toBe('priority'); + act(() => result.current.toggleSort('due')); + expect(result.current.sortBy).toBe('due'); + act(() => result.current.toggleSort('due')); + expect(result.current.sortBy).toBeNull(); + }); + + it('FE-TODO-VIEW-005: the desktop order sorts the filtered rows by the toggle alone', () => { + const { result } = renderHook(() => useTodoView(ITEMS, 7)); + act(() => result.current.setFilter('Docs')); + // Manual order keeps the done task first. + expect(ids(result.current.rows)).toEqual([2, 3]); + + act(() => result.current.setFilter('all')); + act(() => result.current.toggleSort('priority')); + expect(ids(result.current.rows)).toEqual([4, 3, 1, 5]); + + act(() => result.current.toggleSort('due')); + expect(ids(result.current.rows)).toEqual([3, 4, 1, 5]); + }); + + it('FE-TODO-VIEW-006: the phone order sinks done tasks, floats overdue ones and lets the toggle break ties', () => { + const { result } = renderHook(() => useTodoView(ITEMS, 7, { rankByStatus: true })); + act(() => result.current.setActive({ kind: 'category', name: 'Docs' })); + expect(ids(result.current.rows)).toEqual([3, 2]); + + act(() => result.current.setActive({ kind: 'smart', id: 'all' })); + expect(ids(result.current.rows)).toEqual([3, 1, 4, 5]); + + act(() => result.current.toggleSort('priority')); + expect(ids(result.current.rows)).toEqual([3, 4, 1, 5]); + }); + + it('FE-TODO-VIEW-007: the desktop reads today on every render, so a task turns overdue after midnight', () => { + const due = buildTodoItem({ id: 9, due_date: TODAY }); + const { result, rerender } = renderHook(() => useTodoView([due], null)); + expect(result.current.counts.overdue).toBe(0); + + vi.setSystemTime(new Date(2026, 6, 11, 0, 5, 0)); + rerender(); + expect(result.current.today).toBe('2026-07-11'); + expect(result.current.counts.overdue).toBe(1); + }); + + it('FE-TODO-VIEW-008: the phone pins today when the tab mounts', () => { + const due = buildTodoItem({ id: 9, due_date: TODAY }); + const { result, rerender } = renderHook(() => useTodoView([due], null, { pinToday: true })); + + vi.setSystemTime(new Date(2026, 6, 11, 0, 5, 0)); + rerender(); + expect(result.current.today).toBe(TODAY); + expect(result.current.counts.overdue).toBe(0); + }); + + it('FE-TODO-VIEW-009: with no user nothing counts or shows as mine', () => { + const { result } = renderHook(() => useTodoView(ITEMS, null)); + act(() => result.current.setFilter('my')); + expect(result.current.rows).toEqual([]); + expect(result.current.counts.my).toBe(0); + }); +}); + +describe('useTodoList', () => { + it('FE-TODO-VIEW-010: runs the desktop view state on the user the member load names', async () => { + (apiClient.get as Mock).mockResolvedValue({ + data: { owner: { id: 1, username: 'owner', avatar: null }, members: [], current_user_id: 7 }, + }); + const { result } = renderHook(() => useTodoList(1, ITEMS, 0)); + + await waitFor(() => expect(result.current.myCount).toBe(1)); + expect(apiClient.get).toHaveBeenCalledWith('/trips/1/members'); + expect(ids(result.current.filtered)).toEqual([1, 3, 4, 5]); + expect(result.current.totalCount).toBe(5); + expect(result.current.doneCount).toBe(1); + expect(result.current.overdueCount).toBe(1); + + act(() => result.current.toggleSort('priority')); + expect(result.current.sortByPrio).toBe(true); + expect(result.current.sortByDue).toBe(false); + expect(ids(result.current.filtered)).toEqual([4, 3, 1, 5]); + + act(() => result.current.toggleSort('due')); + expect(result.current.sortByPrio).toBe(false); + expect(result.current.sortByDue).toBe(true); + + act(() => result.current.setFilter('my')); + expect(result.current.filter).toBe('my'); + expect(ids(result.current.filtered)).toEqual([4]); + }); +}); diff --git a/client/src/components/Todo/useTodoList.ts b/client/src/components/Todo/useTodoList.ts index e74b5d04f..f5a9d0a1c 100644 --- a/client/src/components/Todo/useTodoList.ts +++ b/client/src/components/Todo/useTodoList.ts @@ -7,9 +7,81 @@ import apiClient from '../../api/client' import { formatDate as fmtDate } from '../../utils/formatters' import type { TodoItem } from '../../types' import { localToday } from '../Planner/today' -import type { FilterType, Member } from './todoListModel' +import { + compareTodoDue, compareTodoPriority, filterTodoItems, filterTodoItemsByCategory, isTodoSmartFilter, sortTodoRows, + todoCategories, todoCategoryOpenCount, todoCounts, type FilterType, type Member, type TodoSmartFilter, +} from './todoListModel' import { useIsPhone } from '../../mobile/useIsPhone' +/** + * The bucket the list shows: one of the four built-ins, addressed by id, or a + * category, addressed by name, so a category a user called "all" or "done" + * can have its own bucket instead of the built-in one. + */ +type TodoActiveFilter = { kind: 'smart'; id: TodoSmartFilter } | { kind: 'category'; name: string } + +type TodoSort = 'priority' | 'due' | null + +export interface TodoViewOptions { + /** + * The phone's row order: done tasks sink to the end and open overdue ones + * float to the top, the sort toggle only breaks ties. The desktop list keeps + * the filtered order and sorts it by the toggle alone. + */ + rankByStatus?: boolean + /** + * Read "today" once when the view mounts, as the phone tab does. The desktop + * list reads it on every render, so its overdue cut-off moves at midnight. + */ + pinToday?: boolean +} + +/** + * The to-do view state both views share: the active bucket, the sort toggle, + * "today" and what is derived from them (categories, rows, counts). The + * desktop panel gets it through useTodoList, the phone tab calls it with its + * own options. + */ +export function useTodoView(items: TodoItem[], currentUserId: number | null, options: TodoViewOptions = {}) { + const { rankByStatus = false, pinToday = false } = options + const [active, setActive] = useState({ kind: 'smart', id: 'all' }) + const [sortBy, setSortBy] = useState(null) + + // due_date is a bare calendar date, so "today" has to be the user's calendar + // day: toISOString() hands over the UTC one and shifts the overdue cut-off. + const [mountDay] = useState(() => localToday()) + const today = pinToday ? mountDay : localToday() + + const categories = useMemo(() => todoCategories(items), [items]) + + const rows = useMemo(() => { + const result = active.kind === 'category' + ? filterTodoItemsByCategory(items, active.name) + : filterTodoItems(items, active.id, currentUserId, today) + if (rankByStatus) return sortTodoRows(result, sortBy, today) + if (sortBy === 'priority') return [...result].sort(compareTodoPriority) + // Ties keep the manual order, the stable sort leaves them untouched (#2205). + if (sortBy === 'due') return [...result].sort(compareTodoDue) + return result + }, [items, active, currentUserId, today, sortBy, rankByStatus]) + + const counts = todoCounts(items, currentUserId, today) + + // The desktop sidebar addresses every bucket by one string, so a category + // that shares a built-in's id lands on the built-in, as it always has there. + const filter: FilterType = active.kind === 'smart' ? active.id : active.name + const setFilter = (f: FilterType) => + setActive(isTodoSmartFilter(f) ? { kind: 'smart', id: f } : { kind: 'category', name: f }) + + // A second press on the active sort goes back to the manual order. + const toggleSort = (key: 'priority' | 'due') => setSortBy(v => (v === key ? null : key)) + + // Open (non-done) count of a category + const catCount = (cat: string) => todoCategoryOpenCount(items, cat) + + return { active, setActive, filter, setFilter, sortBy, toggleSort, today, categories, rows, counts, catCount } +} + /** * Todo list logic — store actions, member load, the filter/selection/add-new * view state and the derived buckets (filtered list + counts) + handlers. @@ -27,7 +99,6 @@ export function useTodoList(tripId: number, items: TodoItem[], addItemSignal: nu const isMobile = useIsPhone() - const [filter, setFilter] = useState('all') const [selectedId, setSelectedId] = useState(null) const [isAddingNew, setIsAddingNew] = useState(false) const lastHandledAddSignal = useRef(addItemSignal) @@ -39,8 +110,6 @@ export function useTodoList(tripId: number, items: TodoItem[], addItemSignal: nu } lastHandledAddSignal.current = addItemSignal }, [addItemSignal]) - const [sortByPrio, setSortByPrio] = useState(false) - const [sortByDue, setSortByDue] = useState(false) const [addingCategory, setAddingCategory] = useState(false) const [newCategoryName, setNewCategoryName] = useState('') const [members, setMembers] = useState([]) @@ -56,47 +125,12 @@ export function useTodoList(tripId: number, items: TodoItem[], addItemSignal: nu }).catch(() => {}) }, [tripId]) - const categories = useMemo(() => { - const cats = new Set() - items.forEach(i => { if (i.category) cats.add(i.category) }) - // Category names are free text the user types, and TREK ships 23 locales — a - // bare .sort() is byte order, which files every accented name behind the whole - // ASCII range ("Übernachtung" after "Zelt"). - return Array.from(cats).sort((a, b) => a.localeCompare(b)) - }, [items]) - - // due_date is a bare calendar date, so "today" has to be the user's calendar - // day: toISOString() hands over the UTC one and shifts the overdue cut-off. - const today = localToday() - - const filtered = useMemo(() => { - let result: TodoItem[] - if (filter === 'all') result = items.filter(i => !i.checked) - else if (filter === 'done') result = items.filter(i => !!i.checked) - // No resolved user means nothing is "mine" — matching the myCount badge. - else if (filter === 'my') result = currentUserId ? items.filter(i => !i.checked && i.assigned_user_id === currentUserId) : [] - else if (filter === 'overdue') result = items.filter(i => !i.checked && i.due_date && i.due_date < today) - else result = items.filter(i => i.category === filter) - if (sortByPrio) result = [...result].sort((a, b) => { - const ap = a.priority || 99 - const bp = b.priority || 99 - return ap - bp - }) - // Nearest deadline first, undated tasks after all dated ones; ties keep the - // manual order, the stable sort leaves them untouched (#2205). - else if (sortByDue) result = [...result].sort((a, b) => { - if (!a.due_date) return b.due_date ? 1 : 0 - if (!b.due_date) return -1 - return a.due_date < b.due_date ? -1 : a.due_date > b.due_date ? 1 : 0 - }) - return result - }, [items, filter, currentUserId, today, sortByPrio, sortByDue]) + const { filter, setFilter, sortBy, toggleSort, today, categories, rows: filtered, counts, catCount } = useTodoView(items, currentUserId) + const sortByPrio = sortBy === 'priority' + const sortByDue = sortBy === 'due' const selectedItem = items.find(i => i.id === selectedId) || null - const totalCount = items.length - const doneCount = items.filter(i => !!i.checked).length - const overdueCount = items.filter(i => !i.checked && i.due_date && i.due_date < today).length - const myCount = currentUserId ? items.filter(i => !i.checked && i.assigned_user_id === currentUserId).length : 0 + const { total: totalCount, done: doneCount, overdue: overdueCount, my: myCount } = counts const addCategory = () => { const name = newCategoryName.trim() @@ -106,13 +140,10 @@ export function useTodoList(tripId: number, items: TodoItem[], addItemSignal: nu .catch(err => toast.error(err instanceof Error ? err.message : t('common.error'))) } - // Get category count (non-done items) - const catCount = (cat: string) => items.filter(i => i.category === cat && !i.checked).length - return { canEdit, t, formatDate, toggleTodoItem, reorderTodoItems, isMobile, filter, setFilter, selectedId, setSelectedId, - isAddingNew, setIsAddingNew, sortByPrio, setSortByPrio, sortByDue, setSortByDue, + isAddingNew, setIsAddingNew, sortByPrio, sortByDue, toggleSort, addingCategory, setAddingCategory, newCategoryName, setNewCategoryName, members, categories, today, filtered, selectedItem, totalCount, doneCount, overdueCount, myCount, diff --git a/client/src/mobile/screens/trip/tabs/MTaskSheet.tsx b/client/src/mobile/screens/trip/tabs/MTaskSheet.tsx index 3b26b32a1..639a558ee 100644 --- a/client/src/mobile/screens/trip/tabs/MTaskSheet.tsx +++ b/client/src/mobile/screens/trip/tabs/MTaskSheet.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from 'react' import { Check, Flag, Plus, User } from 'lucide-react' -import type { TodoCreateItemRequest, TodoUpdateItemRequest } from '@trek/shared' +import type { TodoCreateItemRequest } from '@trek/shared' import MSheet from '../../../components/MSheet' import { CustomDatePicker } from '../../../../components/shared/CustomDateTimePicker' import MarkdownEditable from '../../../../components/shared/MarkdownEditable' @@ -9,6 +9,8 @@ import { avatarSrc } from '../../../../utils/avatarSrc' import type { TodoItem, TripMember } from '../../../../types' import type { TripPlanner } from '../MTripShell' import { PRIORITY_COLOR, PRIORITY_LABEL, PRIORITY_LEVELS } from './listsModel' +import { useTaskForm } from '../../../../components/Todo/useTaskForm' +import { taskUpdatePayload } from '../../../../components/Todo/todoListModel' export interface MTaskSheetProps { planner: TripPlanner @@ -33,40 +35,21 @@ export default function MTaskSheet({ planner, open, itemId, categories, members, if (liveItem) heldRef.current = liveItem const item = itemId != null ? (liveItem ?? heldRef.current) : null - const [name, setName] = useState('') - const [description, setDescription] = useState('') - const [dueDate, setDueDate] = useState('') - const [category, setCategory] = useState('') - const [assignedUserId, setAssignedUserId] = useState(null) - const [priority, setPriority] = useState(0) + const { name, setName, fields, patchFields, saving, load, saveWith } = useTaskForm(null) + const { desc: description, dueDate, category, assignedUserId, priority } = fields const [addingCategory, setAddingCategory] = useState(false) const [categoryDraft, setCategoryDraft] = useState('') - const [saving, setSaving] = useState(false) useEffect(() => { if (!open) return - if (item) { - setName(item.name) - setDescription(item.description || '') - setDueDate(item.due_date || '') - setCategory(item.category || '') - setAssignedUserId(item.assigned_user_id) - setPriority(item.priority || 0) - } else { - setName('') - setDescription('') - setDueDate('') - setCategory(defaultCategory || '') - setAssignedUserId(null) - setPriority(0) - } + load(item, defaultCategory) setAddingCategory(false) setCategoryDraft('') - }, [open, item, defaultCategory]) + }, [open, item, defaultCategory, load]) const confirmCategory = () => { const trimmed = categoryDraft.trim() - if (trimmed) setCategory(trimmed) + if (trimmed) patchFields({ category: trimmed }) setCategoryDraft('') setAddingCategory(false) } @@ -84,18 +67,9 @@ export default function MTaskSheet({ planner, open, itemId, categories, members, const handleSave = async () => { const trimmedName = name.trim() if (!trimmedName || saving) return - setSaving(true) - try { + await saveWith(async () => { if (item) { - const payload: TodoUpdateItemRequest = { - name: trimmedName, - description: description || null, - due_date: dueDate || null, - category: category || null, - assigned_user_id: assignedUserId, - priority, - } - await tripActions.updateTodoItem(tripId, item.id, payload) + await tripActions.updateTodoItem(tripId, item.id, taskUpdatePayload(name, fields)) } else { const payload: TodoCreateItemRequest = { name: trimmedName, @@ -108,11 +82,7 @@ export default function MTaskSheet({ planner, open, itemId, categories, members, await tripActions.addTodoItem(tripId, payload) } onClose() - } catch { - toast.error(t('common.error')) - } finally { - setSaving(false) - } + }, () => toast.error(t('common.error'))) } const pillCls = (active: boolean) => @@ -142,7 +112,7 @@ export default function MTaskSheet({ planner, open, itemId, categories, members, renderEditor={editor => (