mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 14:36:31 +00:00
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:
@@ -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}>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user