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 => (