refactor(todo): share the to-do filters and the task form between desktop and phone

The to-do filters, counts, sort order and categories move from the phone
listsModel into todoListModel. useTaskForm holds the name, the fields,
the load and the save of a task for the desktop detail and new task
panes and for MTaskSheet. Each side keeps its own guard and error
wording.
This commit is contained in:
Maurice
2026-10-08 20:52:37 +00:00
parent 5a0e03929d
commit 6dc8699bd1
11 changed files with 791 additions and 307 deletions
+19 -40
View File
@@ -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
<span style={{ flex: 1 }} />
{/* Sort order: priority or due date, neither means the manual order */}
<div role="group" aria-label={t('todo.sidebar.sortBy')} style={{ display: 'inline-flex', alignItems: 'center', gap: 2, padding: 3, borderRadius: 99, background: 'var(--bg-card)', flexShrink: 0 }}>
<button type="button" aria-pressed={sortByPrio} title={t('todo.priority')} onClick={() => { setSortByDue(false); setSortByPrio(v => !v) }} style={sortSegment(sortByPrio)}>
<button type="button" aria-pressed={sortByPrio} title={t('todo.priority')} onClick={() => toggleSort('priority')} style={sortSegment(sortByPrio)}>
<Flag size={12} />{!isMobile && t('todo.priority')}
</button>
<button type="button" aria-pressed={sortByDue} title={t('todo.detail.dueDate')} onClick={() => { setSortByPrio(false); setSortByDue(v => !v) }} style={sortSegment(sortByDue)}>
<button type="button" aria-pressed={sortByDue} title={t('todo.detail.dueDate')} onClick={() => toggleSort('due')} style={sortSegment(sortByDue)}>
<Calendar size={12} />{!isMobile && t('todo.detail.dueDate')}
</button>
</div>
@@ -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<TaskFieldValues>(() => 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
<input value={name} onChange={e => 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')} />
<TodoTaskFields values={fields} onChange={patch => setFields(f => ({ ...f, ...patch }))} categories={categories} members={members} canEdit={canEdit} />
<TodoTaskFields values={fields} onChange={patchFields} categories={categories} members={members} canEdit={canEdit} />
</div>
{/* 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<TaskFieldValues>({ 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 (
<NewTaskDialog name={name} onName={setName} fields={fields} onFields={patch => setFields(f => ({ ...f, ...patch }))}
<NewTaskDialog name={name} onName={setName} fields={fields} onFields={patchFields}
categories={categories} members={members} saving={saving} onCreate={create} onClose={onClose} />
)
@@ -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')} />
<TodoTaskFields values={fields} onChange={patch => setFields(f => ({ ...f, ...patch }))} categories={categories} members={members} />
<TodoTaskFields values={fields} onChange={patchFields} categories={categories} members={members} />
</div>
<div style={FOOTER}>
+155 -29
View File
@@ -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);
});
});
+179 -21
View File
@@ -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<number, { label: string; color: string }> = {
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<string>();
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,
};
}
@@ -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<void>((resolve) => {
finish = () => resolve();
})
);
const onError = vi.fn();
let done: Promise<void> = 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');
});
});
+37
View File
@@ -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<TaskFieldValues>(() => (item ? taskFieldsOf(item) : blankTaskFields(category)));
const [saving, setSaving] = useState(false);
const patchFields = useCallback((patch: Partial<TaskFieldValues>) => 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<unknown>, onError: (err: unknown) => void) => {
setSaving(true);
try {
await write();
} catch (err) {
onError(err);
} finally {
setSaving(false);
}
};
return { name, setName, fields, patchFields, saving, load, saveWith };
}
@@ -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<typeof import('../../api/client')>()),
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]);
});
});
+78 -47
View File
@@ -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<TodoActiveFilter>({ kind: 'smart', id: 'all' })
const [sortBy, setSortBy] = useState<TodoSort>(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<FilterType>('all')
const [selectedId, setSelectedId] = useState<number | null>(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<Member[]>([])
@@ -56,47 +125,12 @@ export function useTodoList(tripId: number, items: TodoItem[], addItemSignal: nu
}).catch(() => {})
}, [tripId])
const categories = useMemo(() => {
const cats = new Set<string>()
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,
@@ -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<number | null>(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 => (
<textarea
value={description}
onChange={e => setDescription(e.target.value)}
onChange={e => patchFields({ desc: e.target.value })}
rows={3}
{...editor}
placeholder={t('todo.descriptionPlaceholder')}
@@ -160,7 +130,7 @@ export default function MTaskSheet({ planner, open, itemId, categories, members,
<button
key={p}
type="button"
onClick={() => setPriority(p)}
onClick={() => patchFields({ priority: p })}
className={`flex flex-1 items-center justify-center gap-1 rounded-full py-[7px] text-[0.71875rem] font-bold ${
active ? (color ? '' : 'text-m-ink') : 'text-m-faint'
}`}
@@ -179,11 +149,11 @@ export default function MTaskSheet({ planner, open, itemId, categories, members,
<Eyebrow className="mb-[6px] mt-3 uppercase">{t('todo.detail.category')}</Eyebrow>
<div className="flex flex-wrap gap-[6px]">
<button type="button" onClick={() => setCategory('')} className={pillCls(category === '')}>
<button type="button" onClick={() => patchFields({ category: '' })} className={pillCls(category === '')}>
{t('todo.noCategory')}
</button>
{categories.map(cat => (
<button key={cat} type="button" onClick={() => setCategory(cat)} className={pillCls(category === cat)}>
<button key={cat} type="button" onClick={() => patchFields({ category: cat })} className={pillCls(category === cat)}>
{cat}
</button>
))}
@@ -229,11 +199,11 @@ export default function MTaskSheet({ planner, open, itemId, categories, members,
</div>
<Eyebrow className="mb-[5px] mt-3 uppercase">{t('todo.detail.dueDate')}</Eyebrow>
<CustomDatePicker value={dueDate} onChange={setDueDate} placeholder={t('todo.detail.dueDate')} />
<CustomDatePicker value={dueDate} onChange={v => patchFields({ dueDate: v })} placeholder={t('todo.detail.dueDate')} />
<Eyebrow className="mb-[6px] mt-3 uppercase">{t('todo.detail.assignedTo')}</Eyebrow>
<div className="flex flex-wrap gap-[6px]">
<button type="button" onClick={() => setAssignedUserId(null)} className={pillCls(assignedUserId == null)}>
<button type="button" onClick={() => patchFields({ assignedUserId: null })} className={pillCls(assignedUserId == null)}>
<User size={12} strokeWidth={2.2} />
{t('todo.unassigned')}
</button>
@@ -241,7 +211,7 @@ export default function MTaskSheet({ planner, open, itemId, categories, members,
const active = assignedUserId === m.id
const src = m.avatar_url || avatarSrc(m.avatar)
return (
<button key={m.id} type="button" onClick={() => setAssignedUserId(m.id)} className={pillCls(active)}>
<button key={m.id} type="button" onClick={() => patchFields({ assignedUserId: m.id })} className={pillCls(active)}>
<span className="flex h-4 w-4 flex-none items-center justify-center overflow-hidden rounded-full bg-m-act text-[0.5rem] font-bold text-m-actfg">
{src ? <img src={src} alt="" className="h-full w-full object-cover" /> : m.username[0]?.toUpperCase()}
</span>
@@ -1,28 +1,19 @@
import { useMemo, useState } from 'react'
import { useState } from 'react'
import { Calendar, Check, ChevronRight, Flag, Plus } from 'lucide-react'
import MDancingTrek from '../../../components/MDancingTrek'
import { useAuthStore } from '../../../../store/authStore'
import { useTranslation } from '../../../../i18n'
import { avatarSrc } from '../../../../utils/avatarSrc'
import { formatDate } from '../../../../utils/formatters'
import { localToday } from '../../../../components/Planner/today'
import type { TodoItem, TripMember } from '../../../../types'
import type { TripPlanner } from '../MTripShell'
import { TabScroller } from './tabChrome'
import {
PRIORITY_COLOR, PRIORITY_LABEL, filterTodoItems, filterTodoItemsByCategory, isTodoOverdue,
sortTodoRows, todoCategories, todoCategoryOpenCount, todoCounts, type TodoSmartFilter,
} from './listsModel'
import { PRIORITY_COLOR, PRIORITY_LABEL } from './listsModel'
import { TODO_SMART_FILTERS, isTodoOverdue, type TodoSmartFilter } from '../../../../components/Todo/todoListModel'
import { useTodoView } from '../../../../components/Todo/useTodoList'
import MTaskSheet from './MTaskSheet'
import MarkdownText from '../../../../components/shared/MarkdownText'
const BUILTIN_FILTERS: TodoSmartFilter[] = ['all', 'my', 'overdue', 'done']
// The rail holds two kinds of bucket: the four built-ins, addressed by id, and
// the category buckets, addressed by name — so a category a user called "all"
// or "done" gets its own bucket instead of the built-in one.
type ActiveFilter = { kind: 'smart'; id: TodoSmartFilter } | { kind: 'category'; name: string }
/**
* To-do sub-tab (spec 03 §4.5-4.7): progress card with "New task", the
* All|My|Overdue|Done + priority-sort filter row (extended with a category
@@ -36,27 +27,13 @@ export default function MTodoListTab({ planner }: { planner: TripPlanner }) {
const currentUserId = useAuthStore(s => s.user?.id) ?? null
const tripMembers = planner.tripMembers
const [active, setActive] = useState<ActiveFilter>({ kind: 'smart', id: 'all' })
const [sortBy, setSortBy] = useState<'priority' | 'due' | null>(null)
// The rail addresses a category by name, so a category a user called "all"
// or "done" gets its own bucket instead of the built-in one.
const { active, setActive, sortBy, toggleSort, today, categories, rows, counts, catCount } =
useTodoView(items, currentUserId, { rankByStatus: true, pinToday: true })
const [editingItemId, setEditingItemId] = useState<number | null>(null)
const [creatingTask, setCreatingTask] = useState(false)
// The user's calendar day, not UTC's — an overdue task must turn overdue at
// the traveller's midnight, not eight hours either side of it.
const today = useMemo(() => localToday(), [])
const categories = useMemo(() => todoCategories(items), [items])
const counts = todoCounts(items, currentUserId, today)
const rows = useMemo(
() => sortTodoRows(
active.kind === 'category'
? filterTodoItemsByCategory(items, active.name)
: filterTodoItems(items, active.id, currentUserId, today),
sortBy,
today,
),
[items, active, currentUserId, today, sortBy],
)
const pct = items.length > 0 ? Math.round((counts.done / items.length) * 100) : 0
const defaultCategoryForNew = active.kind === 'category' ? active.name : null
@@ -101,7 +78,7 @@ export default function MTodoListTab({ planner }: { planner: TripPlanner }) {
{/* ── Filters (spec §4.6) ── */}
{items.length > 0 && (
<div className="mt-[10px] flex items-center gap-[6px] overflow-x-auto whitespace-nowrap">
{BUILTIN_FILTERS.map(f => (
{TODO_SMART_FILTERS.map(f => (
<button
key={f}
type="button"
@@ -114,7 +91,7 @@ export default function MTodoListTab({ planner }: { planner: TripPlanner }) {
))}
<button
type="button"
onClick={() => setSortBy(v => v === 'priority' ? null : 'priority')}
onClick={() => toggleSort('priority')}
aria-pressed={sortBy === 'priority'}
className={filterPill(sortBy === 'priority')}
>
@@ -123,7 +100,7 @@ export default function MTodoListTab({ planner }: { planner: TripPlanner }) {
</button>
<button
type="button"
onClick={() => setSortBy(v => v === 'due' ? null : 'due')}
onClick={() => toggleSort('due')}
aria-pressed={sortBy === 'due'}
className={filterPill(sortBy === 'due')}
>
@@ -139,7 +116,7 @@ export default function MTodoListTab({ planner }: { planner: TripPlanner }) {
className={filterPill(active.kind === 'category' && active.name === cat)}
>
{cat}
<span className="font-geist text-[0.5625rem] opacity-70">{todoCategoryOpenCount(items, cat)}</span>
<span className="font-geist text-[0.5625rem] opacity-70">{catCount(cat)}</span>
</button>
))}
</div>
@@ -1,4 +1,4 @@
import type { PackingItem, TodoItem } from '../../../../types'
import type { PackingItem } from '../../../../types'
import { PACKING_PLACEHOLDER_NAME } from '../../../../components/Packing/packingListPanel.constants'
import { STATUS_COLOR } from './tabModel'
@@ -105,89 +105,6 @@ export function isPackingPlaceholder(item: Pick<PackingItem, 'name'>): boolean {
// ── To-do ────────────────────────────────────────────────────────────────
export type TodoSmartFilter = 'all' | 'my' | 'overdue' | 'done'
export type TodoFilter = TodoSmartFilter | string
export function isTodoOverdue(item: TodoItem, today: string): boolean {
return !!item.due_date && !item.checked && item.due_date < today
}
/** Same four smart filters + per-category buckets as the desktop sidebar. */
export function filterTodoItems(
items: TodoItem[],
filter: TodoFilter,
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 todoCounts badge.
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` — 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)
}
/**
* Row order (spec 03 §4.7): done sinks to the end, open-overdue floats to the
* top. Ties break by the active sort toggle: ascending priority (0/undefined
* last within its bucket), or nearest due date first with undated tasks after
* all dated ones (#2205). 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 (a.priority || 99) - (b.priority || 99)
if (sortBy === 'due') {
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 0
})
}
export function todoCategories(items: TodoItem[]): string[] {
const cats = new Set<string>()
items.forEach(i => { if (i.category) cats.add(i.category) })
// Same locale collation as the desktop hook (useTodoList) — a bare .sort() is
// byte order and files every accented name behind the whole ASCII range.
return Array.from(cats).sort((a, b) => a.localeCompare(b))
}
/** Open (non-done) item count for a given category — matches the desktop 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,
}
}
/** P1/P2/P3 colours (spec 03 §4.7) reuse the shared status-dot tokens so priority and status stay one palette. */
export const PRIORITY_COLOR: Record<number, string> = {
1: STATUS_COLOR.danger,
@@ -3,22 +3,24 @@ import {
PRIORITY_COLOR,
PRIORITY_LABEL,
PRIORITY_LEVELS,
filterTodoItems,
formatWeight,
groupPackingItems,
isLastCustomItemInCategory,
isPackingPlaceholder,
isTodoOverdue,
packingCategoryOrder,
packingItemWeight,
packingProgress,
packingStatusFiltered,
packingViewItems,
} from '../../../../src/mobile/screens/trip/tabs/listsModel';
import {
filterTodoItems,
isTodoOverdue,
sortTodoRows,
todoCategories,
todoCategoryOpenCount,
todoCounts,
} from '../../../../src/mobile/screens/trip/tabs/listsModel';
} from '../../../../src/components/Todo/todoListModel';
import { STATUS_COLOR } from '../../../../src/mobile/screens/trip/tabs/tabModel';
import { PACKING_PLACEHOLDER_NAME } from '../../../../src/components/Packing/packingListPanel.constants';
import { buildPackingItem, buildTodoItem } from '../../../helpers/factories';