mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
feat(todo): sort the task list by due date (#2205)
The list held its manual order, so an item due tomorrow sat behind one created earlier but due later. The sort-by section gains a due-date toggle next to the priority one, on desktop and in the phone tab: nearest deadline first, undated tasks after all dated ones, ties keep the manual order. One sort at a time, and manual drag-reorder pauses while either is active, exactly like the priority sort.
This commit is contained in:
@@ -256,6 +256,24 @@ describe('TodoListPanel', () => {
|
|||||||
expect(html.indexOf('High Prio')).toBeLessThan(html.indexOf('Low Prio'));
|
expect(html.indexOf('High Prio')).toBeLessThan(html.indexOf('Low Prio'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-TODO-073: Sort by due date puts the nearest deadline first, undated tasks last (#2205)', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const items = [
|
||||||
|
buildTodoItem({ name: 'Due Later', due_date: '2099-07-17', checked: 0 }),
|
||||||
|
buildTodoItem({ name: 'Due Tomorrow', due_date: '2099-07-16', checked: 0 }),
|
||||||
|
buildTodoItem({ name: 'No Due Date', checked: 0 }),
|
||||||
|
];
|
||||||
|
render(<TodoListPanel tripId={1} items={items} />);
|
||||||
|
const sortBtn = screen.getAllByRole('button').find(
|
||||||
|
b => b.textContent?.includes('Due date') || b.getAttribute('title') === 'Due date'
|
||||||
|
);
|
||||||
|
expect(sortBtn).toBeTruthy();
|
||||||
|
await user.click(sortBtn!);
|
||||||
|
const html = document.body.innerHTML;
|
||||||
|
expect(html.indexOf('Due Tomorrow')).toBeLessThan(html.indexOf('Due Later'));
|
||||||
|
expect(html.indexOf('Due Later')).toBeLessThan(html.indexOf('No Due Date'));
|
||||||
|
});
|
||||||
|
|
||||||
it('FE-COMP-TODO-019: Detail pane shows task name and allows editing', async () => {
|
it('FE-COMP-TODO-019: Detail pane shows task name and allows editing', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const items = [buildTodoItem({ id: 11, name: 'Edit Me', checked: 0 })];
|
const items = [buildTodoItem({ id: 11, name: 'Edit Me', checked: 0 })];
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ export default function TodoListPanel({ tripId, items, addItemSignal = 0 }: { tr
|
|||||||
const {
|
const {
|
||||||
canEdit, t, formatDate, toggleTodoItem, reorderTodoItems,
|
canEdit, t, formatDate, toggleTodoItem, reorderTodoItems,
|
||||||
isMobile, filter, setFilter, selectedId, setSelectedId,
|
isMobile, filter, setFilter, selectedId, setSelectedId,
|
||||||
isAddingNew, setIsAddingNew, sortByPrio, setSortByPrio,
|
isAddingNew, setIsAddingNew, sortByPrio, setSortByPrio, sortByDue, setSortByDue,
|
||||||
addingCategory, setAddingCategory, newCategoryName, setNewCategoryName,
|
addingCategory, setAddingCategory, newCategoryName, setNewCategoryName,
|
||||||
members, categories, today, filtered, selectedItem,
|
members, categories, today, filtered, selectedItem,
|
||||||
totalCount, doneCount, overdueCount, myCount,
|
totalCount, doneCount, overdueCount, myCount,
|
||||||
@@ -89,7 +89,7 @@ export default function TodoListPanel({ tripId, items, addItemSignal = 0 }: { tr
|
|||||||
// full item order so unfiltered tasks keep their place.
|
// full item order so unfiltered tasks keep their place.
|
||||||
const [dragId, setDragId] = useState<number | null>(null)
|
const [dragId, setDragId] = useState<number | null>(null)
|
||||||
const [overId, setOverId] = useState<number | null>(null)
|
const [overId, setOverId] = useState<number | null>(null)
|
||||||
const canReorder = canEdit && !sortByPrio
|
const canReorder = canEdit && !sortByPrio && !sortByDue
|
||||||
|
|
||||||
const handleReorderDrop = (targetId: number) => {
|
const handleReorderDrop = (targetId: number) => {
|
||||||
const from = dragId
|
const from = dragId
|
||||||
@@ -162,7 +162,7 @@ export default function TodoListPanel({ tripId, items, addItemSignal = 0 }: { tr
|
|||||||
{!isMobile && <div style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', padding: '16px 12px 4px', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
{!isMobile && <div style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', padding: '16px 12px 4px', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||||
{t('todo.sidebar.sortBy')}
|
{t('todo.sidebar.sortBy')}
|
||||||
</div>}
|
</div>}
|
||||||
<button type="button" onClick={() => setSortByPrio(v => !v)}
|
<button type="button" onClick={() => { setSortByDue(false); setSortByPrio(v => !v) }}
|
||||||
title={isMobile ? t('todo.priority') : undefined}
|
title={isMobile ? t('todo.priority') : undefined}
|
||||||
style={{
|
style={{
|
||||||
display: 'flex', alignItems: 'center', justifyContent: isMobile ? 'center' : 'flex-start',
|
display: 'flex', alignItems: 'center', justifyContent: isMobile ? 'center' : 'flex-start',
|
||||||
@@ -177,6 +177,21 @@ export default function TodoListPanel({ tripId, items, addItemSignal = 0 }: { tr
|
|||||||
<Flag size={isMobile ? 18 : 15} style={{ flexShrink: 0, opacity: 0.7 }} />
|
<Flag size={isMobile ? 18 : 15} style={{ flexShrink: 0, opacity: 0.7 }} />
|
||||||
{!isMobile && <span style={{ flex: 1, textAlign: 'left' }}>{t('todo.priority')}</span>}
|
{!isMobile && <span style={{ flex: 1, textAlign: 'left' }}>{t('todo.priority')}</span>}
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" onClick={() => { setSortByPrio(false); setSortByDue(v => !v) }}
|
||||||
|
title={isMobile ? t('todo.detail.dueDate') : undefined}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: isMobile ? 'center' : 'flex-start',
|
||||||
|
gap: isMobile ? 0 : 8, width: '100%', padding: isMobile ? '8px 0' : '7px 12px',
|
||||||
|
border: 'none', borderRadius: 8, cursor: 'pointer', fontFamily: 'inherit', fontSize: 'calc(13px * var(--fs-scale-body, 1))',
|
||||||
|
background: sortByDue ? '#f59e0b12' : 'transparent',
|
||||||
|
color: sortByDue ? '#f59e0b' : 'var(--text-secondary)',
|
||||||
|
fontWeight: sortByDue ? 600 : 400, transition: 'all 0.1s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { if (!sortByDue) e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||||
|
onMouseLeave={e => { if (!sortByDue) e.currentTarget.style.background = 'transparent' }}>
|
||||||
|
<Calendar size={isMobile ? 18 : 15} style={{ flexShrink: 0, opacity: 0.7 }} />
|
||||||
|
{!isMobile && <span style={{ flex: 1, textAlign: 'left' }}>{t('todo.detail.dueDate')}</span>}
|
||||||
|
</button>
|
||||||
|
|
||||||
{/* Categories */}
|
{/* Categories */}
|
||||||
{!isMobile && <div style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', padding: '16px 12px 4px', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
{!isMobile && <div style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', padding: '16px 12px 4px', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ export function useTodoList(tripId: number, items: TodoItem[], addItemSignal: nu
|
|||||||
lastHandledAddSignal.current = addItemSignal
|
lastHandledAddSignal.current = addItemSignal
|
||||||
}, [addItemSignal])
|
}, [addItemSignal])
|
||||||
const [sortByPrio, setSortByPrio] = useState(false)
|
const [sortByPrio, setSortByPrio] = useState(false)
|
||||||
|
const [sortByDue, setSortByDue] = useState(false)
|
||||||
const [addingCategory, setAddingCategory] = useState(false)
|
const [addingCategory, setAddingCategory] = useState(false)
|
||||||
const [newCategoryName, setNewCategoryName] = useState('')
|
const [newCategoryName, setNewCategoryName] = useState('')
|
||||||
const [members, setMembers] = useState<Member[]>([])
|
const [members, setMembers] = useState<Member[]>([])
|
||||||
@@ -86,8 +87,15 @@ export function useTodoList(tripId: number, items: TodoItem[], addItemSignal: nu
|
|||||||
const bp = b.priority || 99
|
const bp = b.priority || 99
|
||||||
return ap - bp
|
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
|
return result
|
||||||
}, [items, filter, currentUserId, today, sortByPrio])
|
}, [items, filter, currentUserId, today, sortByPrio, sortByDue])
|
||||||
|
|
||||||
const selectedItem = items.find(i => i.id === selectedId) || null
|
const selectedItem = items.find(i => i.id === selectedId) || null
|
||||||
const totalCount = items.length
|
const totalCount = items.length
|
||||||
@@ -109,7 +117,7 @@ export function useTodoList(tripId: number, items: TodoItem[], addItemSignal: nu
|
|||||||
return {
|
return {
|
||||||
canEdit, t, formatDate, toggleTodoItem, reorderTodoItems,
|
canEdit, t, formatDate, toggleTodoItem, reorderTodoItems,
|
||||||
isMobile, filter, setFilter, selectedId, setSelectedId,
|
isMobile, filter, setFilter, selectedId, setSelectedId,
|
||||||
isAddingNew, setIsAddingNew, sortByPrio, setSortByPrio,
|
isAddingNew, setIsAddingNew, sortByPrio, setSortByPrio, sortByDue, setSortByDue,
|
||||||
addingCategory, setAddingCategory, newCategoryName, setNewCategoryName,
|
addingCategory, setAddingCategory, newCategoryName, setNewCategoryName,
|
||||||
members, categories, today, filtered, selectedItem,
|
members, categories, today, filtered, selectedItem,
|
||||||
totalCount, doneCount, overdueCount, myCount,
|
totalCount, doneCount, overdueCount, myCount,
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export default function MTodoListTab({ planner }: { planner: TripPlanner }) {
|
|||||||
const tripMembers = planner.tripMembers
|
const tripMembers = planner.tripMembers
|
||||||
|
|
||||||
const [active, setActive] = useState<ActiveFilter>({ kind: 'smart', id: 'all' })
|
const [active, setActive] = useState<ActiveFilter>({ kind: 'smart', id: 'all' })
|
||||||
const [sortByPriority, setSortByPriority] = useState(false)
|
const [sortBy, setSortBy] = useState<'priority' | 'due' | null>(null)
|
||||||
const [editingItemId, setEditingItemId] = useState<number | null>(null)
|
const [editingItemId, setEditingItemId] = useState<number | null>(null)
|
||||||
const [creatingTask, setCreatingTask] = useState(false)
|
const [creatingTask, setCreatingTask] = useState(false)
|
||||||
|
|
||||||
@@ -50,10 +50,10 @@ export default function MTodoListTab({ planner }: { planner: TripPlanner }) {
|
|||||||
active.kind === 'category'
|
active.kind === 'category'
|
||||||
? filterTodoItemsByCategory(items, active.name)
|
? filterTodoItemsByCategory(items, active.name)
|
||||||
: filterTodoItems(items, active.id, currentUserId, today),
|
: filterTodoItems(items, active.id, currentUserId, today),
|
||||||
sortByPriority,
|
sortBy,
|
||||||
today,
|
today,
|
||||||
),
|
),
|
||||||
[items, active, currentUserId, today, sortByPriority],
|
[items, active, currentUserId, today, sortBy],
|
||||||
)
|
)
|
||||||
|
|
||||||
const pct = items.length > 0 ? Math.round((counts.done / items.length) * 100) : 0
|
const pct = items.length > 0 ? Math.round((counts.done / items.length) * 100) : 0
|
||||||
@@ -113,13 +113,22 @@ export default function MTodoListTab({ planner }: { planner: TripPlanner }) {
|
|||||||
))}
|
))}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setSortByPriority(v => !v)}
|
onClick={() => setSortBy(v => v === 'priority' ? null : 'priority')}
|
||||||
aria-pressed={sortByPriority}
|
aria-pressed={sortBy === 'priority'}
|
||||||
className={filterPill(sortByPriority)}
|
className={filterPill(sortBy === 'priority')}
|
||||||
>
|
>
|
||||||
<Flag size={11} strokeWidth={2.2} />
|
<Flag size={11} strokeWidth={2.2} />
|
||||||
{t('todo.priority')}
|
{t('todo.priority')}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSortBy(v => v === 'due' ? null : 'due')}
|
||||||
|
aria-pressed={sortBy === 'due'}
|
||||||
|
className={filterPill(sortBy === 'due')}
|
||||||
|
>
|
||||||
|
<Calendar size={11} strokeWidth={2.2} />
|
||||||
|
{t('todo.detail.dueDate')}
|
||||||
|
</button>
|
||||||
{categories.length > 0 && <span className="h-4 w-px flex-none bg-[color:var(--m-rowbr)]" />}
|
{categories.length > 0 && <span className="h-4 w-px flex-none bg-[color:var(--m-rowbr)]" />}
|
||||||
{categories.map(cat => (
|
{categories.map(cat => (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -138,16 +138,22 @@ export function filterTodoItemsByCategory(items: TodoItem[], category: string):
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Row order (spec 03 §4.7): done sinks to the end, open-overdue floats to the
|
* Row order (spec 03 §4.7): done sinks to the end, open-overdue floats to the
|
||||||
* top, and — only while the priority toggle is on — ties break by ascending
|
* top. Ties break by the active sort toggle: ascending priority (0/undefined
|
||||||
* priority (0/undefined sorts last within its bucket).
|
* 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[], sortByPriority: boolean, today: string): TodoItem[] {
|
export function sortTodoRows(items: TodoItem[], sortBy: 'priority' | 'due' | null, today: string): TodoItem[] {
|
||||||
const rank = (i: TodoItem) => (i.checked ? 2 : isTodoOverdue(i, today) ? 0 : 1)
|
const rank = (i: TodoItem) => (i.checked ? 2 : isTodoOverdue(i, today) ? 0 : 1)
|
||||||
return [...items].sort((a, b) => {
|
return [...items].sort((a, b) => {
|
||||||
const byRank = rank(a) - rank(b)
|
const byRank = rank(a) - rank(b)
|
||||||
if (byRank !== 0) return byRank
|
if (byRank !== 0) return byRank
|
||||||
if (!sortByPriority) return 0
|
if (sortBy === 'priority') return (a.priority || 99) - (b.priority || 99)
|
||||||
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
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -181,6 +181,27 @@ describe('MTodoListTab', () => {
|
|||||||
expect(rowOrder(['Low', 'High'])).toEqual(['High', 'Low'])
|
expect(rowOrder(['Low', 'High'])).toEqual(['High', 'Low'])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('FE-MOB-TODO-020: toggles the due-date sort and puts the nearest deadline first (#2205)', () => {
|
||||||
|
setup([
|
||||||
|
todo({ id: 1, name: 'Later', due_date: '2099-07-17' }),
|
||||||
|
todo({ id: 2, name: 'Sooner', due_date: '2099-07-16' }),
|
||||||
|
todo({ id: 3, name: 'Undated' }),
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(rowOrder(['Later', 'Sooner', 'Undated'])).toEqual(['Later', 'Sooner', 'Undated'])
|
||||||
|
|
||||||
|
const sortButton = screen.getByRole('button', { name: /todo.detail.dueDate/ })
|
||||||
|
expect(sortButton).toHaveAttribute('aria-pressed', 'false')
|
||||||
|
fireEvent.click(sortButton)
|
||||||
|
|
||||||
|
expect(sortButton).toHaveAttribute('aria-pressed', 'true')
|
||||||
|
expect(rowOrder(['Later', 'Sooner', 'Undated'])).toEqual(['Sooner', 'Later', 'Undated'])
|
||||||
|
|
||||||
|
// one sort at a time: flipping priority on releases the due toggle
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /todo.priority/ }))
|
||||||
|
expect(sortButton).toHaveAttribute('aria-pressed', 'false')
|
||||||
|
})
|
||||||
|
|
||||||
it('FE-MOB-TODO-009: floats overdue rows above the other open ones', () => {
|
it('FE-MOB-TODO-009: floats overdue rows above the other open ones', () => {
|
||||||
setup([
|
setup([
|
||||||
todo({ id: 1, name: 'Open' }),
|
todo({ id: 1, name: 'Open' }),
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import { STATUS_COLOR } from '../../../../src/mobile/screens/trip/tabs/tabModel'
|
|||||||
import { PACKING_PLACEHOLDER_NAME } from '../../../../src/components/Packing/packingListPanel.constants';
|
import { PACKING_PLACEHOLDER_NAME } from '../../../../src/components/Packing/packingListPanel.constants';
|
||||||
import { buildPackingItem, buildTodoItem } from '../../../helpers/factories';
|
import { buildPackingItem, buildTodoItem } from '../../../helpers/factories';
|
||||||
|
|
||||||
// FE-MOB-LSTM-001 to FE-MOB-LSTM-020
|
// FE-MOB-LSTM-001 to FE-MOB-LSTM-021
|
||||||
|
|
||||||
const TODAY = '2026-07-15';
|
const TODAY = '2026-07-15';
|
||||||
|
|
||||||
@@ -160,7 +160,7 @@ describe('listsModel — to-do', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('FE-MOB-LSTM-014: sortTodoRows floats overdue rows up and sinks done rows', () => {
|
it('FE-MOB-LSTM-014: sortTodoRows floats overdue rows up and sinks done rows', () => {
|
||||||
const sorted = sortTodoRows(items, false, TODAY);
|
const sorted = sortTodoRows(items, null, TODAY);
|
||||||
expect(sorted.map(i => i.id)).toEqual([3, 1, 2, 4]);
|
expect(sorted.map(i => i.id)).toEqual([3, 1, 2, 4]);
|
||||||
// the input array stays untouched
|
// the input array stays untouched
|
||||||
expect(items.map(i => i.id)).toEqual([1, 2, 3, 4]);
|
expect(items.map(i => i.id)).toEqual([1, 2, 3, 4]);
|
||||||
@@ -172,8 +172,19 @@ describe('listsModel — to-do', () => {
|
|||||||
const none = buildTodoItem({ id: 12, priority: 0 });
|
const none = buildTodoItem({ id: 12, priority: 0 });
|
||||||
const list = [p3, none, p1];
|
const list = [p3, none, p1];
|
||||||
|
|
||||||
expect(sortTodoRows(list, false, TODAY).map(i => i.id)).toEqual([10, 12, 11]);
|
expect(sortTodoRows(list, null, TODAY).map(i => i.id)).toEqual([10, 12, 11]);
|
||||||
expect(sortTodoRows(list, true, TODAY).map(i => i.id)).toEqual([11, 10, 12]);
|
expect(sortTodoRows(list, 'priority', TODAY).map(i => i.id)).toEqual([11, 10, 12]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-MOB-LSTM-021: sortTodoRows orders by nearest due date, undated last, creation order untouched otherwise (#2205)', () => {
|
||||||
|
const inTwoDays = buildTodoItem({ id: 20, due_date: '2026-07-17' });
|
||||||
|
const tomorrow = buildTodoItem({ id: 21, due_date: '2026-07-16' });
|
||||||
|
const undated = buildTodoItem({ id: 22 });
|
||||||
|
const list = [inTwoDays, undated, tomorrow];
|
||||||
|
|
||||||
|
// the issue's literal repro: created first, due later, must sort behind
|
||||||
|
expect(sortTodoRows(list, 'due', TODAY).map(i => i.id)).toEqual([21, 20, 22]);
|
||||||
|
expect(sortTodoRows(list, null, TODAY).map(i => i.id)).toEqual([20, 22, 21]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-MOB-LSTM-016: todoCategories returns distinct categories alphabetically', () => {
|
it('FE-MOB-LSTM-016: todoCategories returns distinct categories alphabetically', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user