From a8548b045ba590657d63348619c5f80d7180018f Mon Sep 17 00:00:00 2001 From: Chris Date: Sun, 14 Dec 2025 01:13:57 +0200 Subject: [PATCH] Introduce sort utils (#709) * Introduce sort utils * Fix test issues --- backend/app.js | 2 +- backend/routes/backup.js | 14 +- .../tasks/queries/metrics-computation.js | 70 +++++++++- .../routes/tasks/queries/metrics-queries.js | 72 +++++++--- .../tests/integration/tasks-metrics.test.js | 98 ++++++++++++++ frontend/components/Task/TaskHeader.tsx | 41 +++++- frontend/components/Task/TasksToday.tsx | 127 +++++++++++++----- frontend/components/Task/TodayPlan.tsx | 83 +++--------- frontend/utils/taskSortUtils.ts | 60 +++++++++ 9 files changed, 434 insertions(+), 133 deletions(-) create mode 100644 backend/tests/integration/tasks-metrics.test.js create mode 100644 frontend/utils/taskSortUtils.ts diff --git a/backend/app.js b/backend/app.js index 6e4be3e..a48772b 100644 --- a/backend/app.js +++ b/backend/app.js @@ -185,7 +185,7 @@ const registerApiRoutes = (basePath) => { app.use(basePath, require('./routes/quotes')); app.use(basePath, require('./routes/task-events')); app.use(basePath, require('./routes/task-attachments')); - app.use(basePath, require('./routes/backup')); + app.use(`${basePath}/backup`, require('./routes/backup')); app.use(`${basePath}/search`, require('./routes/search')); app.use(`${basePath}/views`, require('./routes/views')); app.use(`${basePath}/notifications`, require('./routes/notifications')); diff --git a/backend/routes/backup.js b/backend/routes/backup.js index a214be8..a5a1977 100644 --- a/backend/routes/backup.js +++ b/backend/routes/backup.js @@ -77,7 +77,7 @@ const upload = multer({ } }, }); -router.post('/backup/export', async (req, res) => { +router.post('/export', async (req, res) => { try { const userId = getAuthenticatedUserId(req); if (!userId) { @@ -106,7 +106,7 @@ router.post('/backup/export', async (req, res) => { } }); -router.post('/backup/import', upload.single('backup'), async (req, res) => { +router.post('/import', upload.single('backup'), async (req, res) => { try { const userId = getAuthenticatedUserId(req); if (!userId) { @@ -167,7 +167,7 @@ router.post('/backup/import', upload.single('backup'), async (req, res) => { } }); -router.post('/backup/validate', upload.single('backup'), async (req, res) => { +router.post('/validate', upload.single('backup'), async (req, res) => { try { const userId = getAuthenticatedUserId(req); if (!userId) { @@ -238,7 +238,7 @@ router.post('/backup/validate', upload.single('backup'), async (req, res) => { } }); -router.get('/backup/list', async (req, res) => { +router.get('/list', async (req, res) => { try { const userId = getAuthenticatedUserId(req); if (!userId) { @@ -260,7 +260,7 @@ router.get('/backup/list', async (req, res) => { } }); -router.get('/backup/:uid/download', async (req, res) => { +router.get('/:uid/download', async (req, res) => { try { const userId = getAuthenticatedUserId(req); if (!userId) { @@ -301,7 +301,7 @@ router.get('/backup/:uid/download', async (req, res) => { } }); -router.post('/backup/:uid/restore', async (req, res) => { +router.post('/:uid/restore', async (req, res) => { try { const userId = getAuthenticatedUserId(req); if (!userId) { @@ -338,7 +338,7 @@ router.post('/backup/:uid/restore', async (req, res) => { } }); -router.delete('/backup/:uid', async (req, res) => { +router.delete('/:uid', async (req, res) => { try { const userId = getAuthenticatedUserId(req); if (!userId) { diff --git a/backend/routes/tasks/queries/metrics-computation.js b/backend/routes/tasks/queries/metrics-computation.js index 5414797..8ae8ad9 100644 --- a/backend/routes/tasks/queries/metrics-computation.js +++ b/backend/routes/tasks/queries/metrics-computation.js @@ -16,6 +16,61 @@ const { fetchTasksCompletedToday, } = require('./metrics-queries'); +const MAX_SUGGESTED_TASKS = 10; + +const getPriorityValue = (priority) => { + const priorityOrder = { + high: 3, + medium: 2, + low: 1, + }; + + if (priority === null || priority === undefined) { + return 0; + } + + if (typeof priority === 'number') { + // Normalize numeric priority (assuming 2=high,1=medium,0=low) + if (priority >= 2) return priorityOrder.high; + if (priority === 1) return priorityOrder.medium; + if (priority === 0) return priorityOrder.low; + return 0; + } + + const normalized = String(priority).toLowerCase(); + return priorityOrder[normalized] || 0; +}; + +const multiCriteriaTaskSort = (a, b) => { + // 1. Priority + const priorityDiff = + getPriorityValue(b.priority) - getPriorityValue(a.priority); + if (priorityDiff !== 0) { + return priorityDiff; + } + + // 2. Due date (earlier first, null/undefined last) + const getDueDateValue = (task) => { + if (!task.due_date) return Infinity; + const due = + task.due_date instanceof Date + ? task.due_date + : new Date(task.due_date); + const time = due.getTime(); + return Number.isNaN(time) ? Infinity : time; + }; + + const dueDiff = getDueDateValue(a) - getDueDateValue(b); + if (dueDiff !== 0) { + return dueDiff; + } + + // 3. Project (group similar project tasks) + const projectA = (a.project_id || '').toString(); + const projectB = (b.project_id || '').toString(); + return projectA.localeCompare(projectB); +}; + async function computeSuggestedTasks( visibleTasksWhere, userId, @@ -60,14 +115,23 @@ async function computeSuggestedTasks( const somedayFallbackTasks = await fetchSomedayFallbackTasks( userId, usedTaskIds, - somedayTaskIds, - 12 - combinedTasks.length + somedayTaskIds ); combinedTasks = [...combinedTasks, ...somedayFallbackTasks]; } - return combinedTasks; + const now = Date.now(); + const filteredTasks = combinedTasks.filter((task) => { + if (!task.defer_until) return true; + const deferUntil = new Date(task.defer_until).getTime(); + if (Number.isNaN(deferUntil)) return true; + return deferUntil <= now; + }); + + filteredTasks.sort(multiCriteriaTaskSort); + + return filteredTasks.slice(0, MAX_SUGGESTED_TASKS); } async function computeWeeklyCompletions(userId, userTimezone) { diff --git a/backend/routes/tasks/queries/metrics-queries.js b/backend/routes/tasks/queries/metrics-queries.js index 8f43938..26cf399 100644 --- a/backend/routes/tasks/queries/metrics-queries.js +++ b/backend/routes/tasks/queries/metrics-queries.js @@ -177,15 +177,16 @@ async function fetchSomedayTaskIds(userId) { async function fetchNonProjectTasks( visibleTasksWhere, excludedTaskIds, - somedayTaskIds + somedayTaskIds, + limit = null ) { - return await Task.findAll({ + const exclusionIds = [...excludedTaskIds, ...somedayTaskIds]; + const queryOptions = { where: { ...visibleTasksWhere, status: { [Op.in]: [Task.STATUS.NOT_STARTED, Task.STATUS.WAITING], }, - id: { [Op.notIn]: [...excludedTaskIds, ...somedayTaskIds] }, [Op.or]: [{ project_id: null }, { project_id: '' }], parent_task_id: null, recurring_parent_id: null, @@ -196,22 +197,32 @@ async function fetchNonProjectTasks( ['due_date', 'ASC'], ['project_id', 'ASC'], ], - limit: 6, - }); + }; + + if (exclusionIds.length > 0) { + queryOptions.where.id = { [Op.notIn]: exclusionIds }; + } + + if (limit && Number.isInteger(limit)) { + queryOptions.limit = limit; + } + + return await Task.findAll(queryOptions); } async function fetchProjectTasks( visibleTasksWhere, excludedTaskIds, - somedayTaskIds + somedayTaskIds, + limit = null ) { - return await Task.findAll({ + const exclusionIds = [...excludedTaskIds, ...somedayTaskIds]; + const queryOptions = { where: { ...visibleTasksWhere, status: { [Op.in]: [Task.STATUS.NOT_STARTED, Task.STATUS.WAITING], }, - id: { [Op.notIn]: [...excludedTaskIds, ...somedayTaskIds] }, project_id: { [Op.not]: null, [Op.ne]: '' }, parent_task_id: null, recurring_parent_id: null, @@ -222,26 +233,35 @@ async function fetchProjectTasks( ['due_date', 'ASC'], ['project_id', 'ASC'], ], - limit: 6, - }); + }; + + if (exclusionIds.length > 0) { + queryOptions.where.id = { [Op.notIn]: exclusionIds }; + } + + if (limit && Number.isInteger(limit)) { + queryOptions.limit = limit; + } + + return await Task.findAll(queryOptions); } async function fetchSomedayFallbackTasks( userId, usedTaskIds, somedayTaskIds, - limit + limit = null ) { - return await Task.findAll({ + if (somedayTaskIds.length === 0) { + return []; + } + + const queryOptions = { where: { user_id: userId, status: { [Op.in]: [Task.STATUS.NOT_STARTED, Task.STATUS.WAITING], }, - id: { - [Op.notIn]: usedTaskIds, - [Op.in]: somedayTaskIds, - }, parent_task_id: null, recurring_parent_id: null, }, @@ -251,8 +271,24 @@ async function fetchSomedayFallbackTasks( ['due_date', 'ASC'], ['project_id', 'ASC'], ], - limit: limit, - }); + }; + + if (usedTaskIds.length > 0) { + queryOptions.where.id = { + [Op.notIn]: usedTaskIds, + [Op.in]: somedayTaskIds, + }; + } else { + queryOptions.where.id = { + [Op.in]: somedayTaskIds, + }; + } + + if (limit && Number.isInteger(limit)) { + queryOptions.limit = limit; + } + + return await Task.findAll(queryOptions); } async function fetchTasksCompletedToday(userId, userTimezone) { diff --git a/backend/tests/integration/tasks-metrics.test.js b/backend/tests/integration/tasks-metrics.test.js new file mode 100644 index 0000000..e8c494a --- /dev/null +++ b/backend/tests/integration/tasks-metrics.test.js @@ -0,0 +1,98 @@ +const { Task, Project } = require('../../models'); +const { createTestUser } = require('../helpers/testUtils'); +const { + getTaskMetrics, +} = require('../../routes/tasks/queries/metrics-computation'); + +const dayFromNow = (days) => new Date(Date.now() + days * 24 * 60 * 60 * 1000); + +describe('Task Metrics Suggested Tasks', () => { + let user; + + const createTask = async (overrides = {}) => { + const { priority, status, ...rest } = overrides; + return await Task.create({ + name: rest.name || 'Suggested task', + user_id: user.id, + status: + typeof status === 'string' + ? Task.getStatusValue(status) + : (status ?? Task.STATUS.NOT_STARTED), + today: false, + priority: + typeof priority === 'string' + ? Task.getPriorityValue(priority) + : (priority ?? Task.PRIORITY.LOW), + parent_task_id: null, + recurring_parent_id: null, + ...rest, + }); + }; + + beforeEach(async () => { + user = await createTestUser({ email: 'metrics@example.com' }); + }); + + it('orders suggested tasks by priority, due date, and project', async () => { + const projectAlpha = await Project.create({ + name: 'Alpha Project', + user_id: user.id, + }); + const projectBeta = await Project.create({ + name: 'Beta Project', + user_id: user.id, + }); + + await createTask({ + name: 'High Due Later', + priority: 'high', + due_date: dayFromNow(3), + project_id: projectBeta.id, + }); + await createTask({ + name: 'High Due Soon', + priority: 'high', + due_date: dayFromNow(1), + project_id: projectAlpha.id, + }); + await createTask({ + name: 'Medium Alpha', + priority: 'medium', + due_date: dayFromNow(4), + project_id: projectAlpha.id, + }); + await createTask({ + name: 'Medium Beta', + priority: 'medium', + due_date: dayFromNow(4), + project_id: projectBeta.id, + }); + + const metrics = await getTaskMetrics(user.id, 'UTC'); + const names = metrics.suggested_tasks.map((task) => task.name); + expect(names).toEqual([ + 'High Due Soon', + 'High Due Later', + 'Medium Alpha', + 'Medium Beta', + ]); + }); + + it('excludes tasks deferred into the future from suggested results', async () => { + await createTask({ name: 'Ready Task', priority: 'high' }); + await createTask({ + name: 'Deferred Past Task', + defer_until: dayFromNow(-1), + }); + await createTask({ + name: 'Deferred Future Task', + defer_until: dayFromNow(2), + }); + + const metrics = await getTaskMetrics(user.id, 'UTC'); + const names = metrics.suggested_tasks.map((task) => task.name); + expect(names).toContain('Ready Task'); + expect(names).toContain('Deferred Past Task'); + expect(names).not.toContain('Deferred Future Task'); + }); +}); diff --git a/frontend/components/Task/TaskHeader.tsx b/frontend/components/Task/TaskHeader.tsx index 87e1e52..c9af5a9 100644 --- a/frontend/components/Task/TaskHeader.tsx +++ b/frontend/components/Task/TaskHeader.tsx @@ -155,6 +155,26 @@ const TaskHeader: React.FC = ({ }); }; + const formatDeferUntil = (deferUntil: string): string | null => { + const date = new Date(deferUntil); + if (Number.isNaN(date.getTime())) { + return null; + } + + const datePart = date.toLocaleDateString(undefined, { + weekday: 'short', + month: 'short', + day: 'numeric', + }); + + const timePart = date.toLocaleTimeString(undefined, { + hour: 'numeric', + minute: '2-digit', + }); + + return `${datePart} • ${timePart}`; + }; + const formatRecurrence = (recurrenceType: string) => { switch (recurrenceType) { case 'daily': @@ -183,13 +203,18 @@ const TaskHeader: React.FC = ({ } }; - // Check if task has metadata (project, tags, due_date, recurrence_type, or recurring_parent_id) + const formattedDeferUntil = task.defer_until + ? formatDeferUntil(task.defer_until) + : null; + + // Check if task has metadata (project, tags, due_date, recurrence_type, recurring_parent_id, or defer_until) const hasMetadata = (project && !hideProjectName) || (task.tags && task.tags.length > 0) || task.due_date || (task.recurrence_type && task.recurrence_type !== 'none') || - task.recurring_parent_id; + task.recurring_parent_id || + !!formattedDeferUntil; const isTaskCompleted = task.status === 'done' || @@ -512,6 +537,12 @@ const TaskHeader: React.FC = ({ )} + {formattedDeferUntil && ( +
+ + {formattedDeferUntil} +
+ )} )} @@ -1083,6 +1114,12 @@ const TaskHeader: React.FC = ({ )} + {formattedDeferUntil && ( +
+ + {formattedDeferUntil} +
+ )} {onToggleCompletion && ( diff --git a/frontend/components/Task/TasksToday.tsx b/frontend/components/Task/TasksToday.tsx index cacf4ae..91f55e9 100644 --- a/frontend/components/Task/TasksToday.tsx +++ b/frontend/components/Task/TasksToday.tsx @@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next'; import i18n from 'i18next'; import { useNavigate } from 'react-router-dom'; import { getLocalesPath, getApiPath } from '../../config/paths'; +import { sortTasksByPriorityDueDateProject } from '../../utils/taskSortUtils'; import { ClipboardDocumentListIcon, ArrowPathIcon, @@ -163,6 +164,9 @@ const TasksToday: React.FC = () => { // Client-side pagination for Overdue tasks (since backend returns all) const [overdueDisplayLimit, setOverdueDisplayLimit] = useState(20); + // Client-side pagination for Suggested tasks + const [suggestedDisplayLimit, setSuggestedDisplayLimit] = useState(20); + // Client-side pagination for Completed Today tasks (since backend returns all) const [completedTodayDisplayLimit, setCompletedTodayDisplayLimit] = useState(20); @@ -177,6 +181,20 @@ const TasksToday: React.FC = () => { [metrics.tasks_completed_today] ); + // Sort tasks using multi-criteria sorting (Priority → Due Date → Project) for consistency + const sortedSuggestedTasks = useMemo( + () => sortTasksByPriorityDueDateProject(metrics.suggested_tasks || []), + [metrics.suggested_tasks] + ); + const sortedDueTodayTasks = useMemo( + () => sortTasksByPriorityDueDateProject(metrics.tasks_due_today || []), + [metrics.tasks_due_today] + ); + const sortedOverdueTasks = useMemo( + () => sortTasksByPriorityDueDateProject(metrics.tasks_overdue || []), + [metrics.tasks_overdue] + ); + // Helper function to get completion trend vs average const getCompletionTrend = () => { const todayCount = metrics.tasks_completed_today.length; @@ -1501,8 +1519,8 @@ const TasksToday: React.FC = () => {
{ {/* Overdue Tasks - Displayed first */} {isSettingsLoaded && todaySettings.showDueToday && - metrics.tasks_overdue.length > 0 && ( + sortedOverdueTasks.length > 0 && (
{
- {metrics.tasks_overdue.length} + {sortedOverdueTasks.length} {isOverdueCollapsed ? ( @@ -1540,7 +1558,7 @@ const TasksToday: React.FC = () => { {!isOverdueCollapsed && ( <> { {/* Load More Buttons for Overdue Tasks */} {overdueDisplayLimit < - metrics.tasks_overdue.length && ( + sortedOverdueTasks.length && (
@@ -1730,7 +1746,7 @@ const TasksToday: React.FC = () => { {/* Due Today Tasks */} {isSettingsLoaded && todaySettings.showDueToday && - metrics.tasks_due_today.length > 0 && ( + sortedDueTodayTasks.length > 0 && (
{
- {metrics.tasks_due_today.length} + {sortedDueTodayTasks.length} {isDueTodayCollapsed ? ( @@ -1754,7 +1770,7 @@ const TasksToday: React.FC = () => { {!isDueTodayCollapsed && ( <> { {/* Load More Buttons for Due Today Tasks */} {dueTodayDisplayLimit < - metrics.tasks_due_today.length && ( + sortedDueTodayTasks.length && (
@@ -1833,7 +1846,7 @@ const TasksToday: React.FC = () => {
) : todaySettings.showSuggestions && - metrics.suggested_tasks.length > 0 ? ( + sortedSuggestedTasks.length > 0 ? (
{
- {metrics.suggested_tasks.length} + {sortedSuggestedTasks.length} {isSuggestedCollapsed ? ( @@ -1854,16 +1867,62 @@ const TasksToday: React.FC = () => {
{!isSuggestedCollapsed && ( - + <> + + + {suggestedDisplayLimit < + sortedSuggestedTasks.length && ( +
+ + +
+ )} + +
+ {t( + 'tasks.showingItems', + 'Showing {{current}} of {{total}} tasks', + { + current: Math.min( + suggestedDisplayLimit, + sortedSuggestedTasks.length + ), + total: sortedSuggestedTasks.length, + } + )} +
+ )}
) : null} @@ -1975,7 +2034,7 @@ const TasksToday: React.FC = () => { {metrics.tasks_due_today.length === 0 && metrics.tasks_in_progress.length === 0 && - metrics.suggested_tasks.length === 0 && + sortedSuggestedTasks.length === 0 && (metrics.today_plan_tasks || []).length > 0 && (

{t('tasks.noTasksAvailable')} diff --git a/frontend/components/Task/TodayPlan.tsx b/frontend/components/Task/TodayPlan.tsx index 36178ba..178568b 100644 --- a/frontend/components/Task/TodayPlan.tsx +++ b/frontend/components/Task/TodayPlan.tsx @@ -4,6 +4,7 @@ import { CalendarDaysIcon } from '@heroicons/react/24/outline'; import TaskList from './TaskList'; import { Task } from '../../entities/Task'; import { Project } from '../../entities/Project'; +import { sortTasksByPriorityDueDateProject } from '../../utils/taskSortUtils'; interface TodayPlanProps { todayPlanTasks: Task[] | undefined; @@ -27,79 +28,25 @@ const TodayPlan: React.FC = ({ // Handle undefined or null todayPlanTasks const safeTodayPlanTasks = todayPlanTasks || []; - // Sort tasks to move in-progress tasks to the top + // Sort tasks to move in-progress tasks to the top, then apply multi-criteria sorting const sortedTasks = React.useMemo(() => { if (safeTodayPlanTasks.length === 0) return []; - return [...safeTodayPlanTasks].sort((a, b) => { - const aInProgress = a.status === 'in_progress' || a.status === 1; - const bInProgress = b.status === 'in_progress' || b.status === 1; + // Separate in-progress and non-in-progress tasks + const inProgressTasks = safeTodayPlanTasks.filter( + (task) => task.status === 'in_progress' || task.status === 1 + ); + const otherTasks = safeTodayPlanTasks.filter( + (task) => task.status !== 'in_progress' && task.status !== 1 + ); - // If both are in progress, sort by multi-criteria - if (aInProgress && bInProgress) { - // 1. Priority (High → Medium → Low → None) - const priorityOrder = { high: 3, medium: 2, low: 1 }; - const aPriority = - priorityOrder[a.priority as keyof typeof priorityOrder] || - 0; - const bPriority = - priorityOrder[b.priority as keyof typeof priorityOrder] || - 0; - if (aPriority !== bPriority) { - return bPriority - aPriority; // Higher priority first - } + // Sort each group using multi-criteria sorting + const sortedInProgress = + sortTasksByPriorityDueDateProject(inProgressTasks); + const sortedOthers = sortTasksByPriorityDueDateProject(otherTasks); - // 2. Due date (earlier first, null/undefined last) - const aDueDate = a.due_date - ? new Date(a.due_date).getTime() - : Infinity; - const bDueDate = b.due_date - ? new Date(b.due_date).getTime() - : Infinity; - if (aDueDate !== bDueDate) { - return aDueDate - bDueDate; - } - - // 3. Project (tasks with same priority and due date grouped by project) - const aProject = a.project_id || ''; - const bProject = b.project_id || ''; - return aProject.toString().localeCompare(bProject.toString()); - } - - // If both are not in progress, sort by multi-criteria - if (!aInProgress && !bInProgress) { - // 1. Priority (High → Medium → Low → None) - const priorityOrder = { high: 3, medium: 2, low: 1 }; - const aPriority = - priorityOrder[a.priority as keyof typeof priorityOrder] || - 0; - const bPriority = - priorityOrder[b.priority as keyof typeof priorityOrder] || - 0; - if (aPriority !== bPriority) { - return bPriority - aPriority; // Higher priority first - } - - // 2. Due date (earlier first, null/undefined last) - const aDueDate = a.due_date - ? new Date(a.due_date).getTime() - : Infinity; - const bDueDate = b.due_date - ? new Date(b.due_date).getTime() - : Infinity; - if (aDueDate !== bDueDate) { - return aDueDate - bDueDate; - } - - // 3. Project (tasks with same priority and due date grouped by project) - const aProject = a.project_id || ''; - const bProject = b.project_id || ''; - return aProject.toString().localeCompare(bProject.toString()); - } - - // Put in-progress tasks first - return aInProgress ? -1 : 1; - }); + // Return in-progress tasks first, followed by others + return [...sortedInProgress, ...sortedOthers]; }, [safeTodayPlanTasks]); if (sortedTasks.length === 0) { diff --git a/frontend/utils/taskSortUtils.ts b/frontend/utils/taskSortUtils.ts new file mode 100644 index 0000000..bc233d4 --- /dev/null +++ b/frontend/utils/taskSortUtils.ts @@ -0,0 +1,60 @@ +import { Task } from '../entities/Task'; + +interface SortOptions { + excludeFutureDeferred?: boolean; +} + +/** + * Multi-criteria sorting for tasks in Today view sections. + * Sorting order: + * 1. Priority (High → Medium → Low → None) + * 2. Due date (earlier first, null/undefined last) + * 3. Project (tasks with same priority and due date grouped by project) + * + * This function is used to ensure consistent task ordering across all Today sections + * (Planned, Due Today, Overdue, Suggested) as per issue #653. + */ +export const sortTasksByPriorityDueDateProject = ( + tasks: Task[], + options: SortOptions = {} +): Task[] => { + if (!tasks || tasks.length === 0) return []; + + const shouldExcludeDeferred = options.excludeFutureDeferred; + const now = Date.now(); + + const filteredTasks = shouldExcludeDeferred + ? tasks.filter((task) => { + if (!task.defer_until) return true; + const deferUntil = new Date(task.defer_until).getTime(); + if (Number.isNaN(deferUntil)) return true; + return deferUntil <= now; + }) + : tasks; + + if (filteredTasks.length === 0) return []; + + return [...filteredTasks].sort((a, b) => { + // 1. Priority (High → Medium → Low → None) + const priorityOrder = { high: 3, medium: 2, low: 1 }; + const aPriority = + priorityOrder[a.priority as keyof typeof priorityOrder] || 0; + const bPriority = + priorityOrder[b.priority as keyof typeof priorityOrder] || 0; + if (aPriority !== bPriority) { + return bPriority - aPriority; // Higher priority first + } + + // 2. Due date (earlier first, null/undefined last) + const aDueDate = a.due_date ? new Date(a.due_date).getTime() : Infinity; + const bDueDate = b.due_date ? new Date(b.due_date).getTime() : Infinity; + if (aDueDate !== bDueDate) { + return aDueDate - bDueDate; + } + + // 3. Project (tasks with same priority and due date grouped by project) + const aProject = a.project_id || ''; + const bProject = b.project_id || ''; + return aProject.toString().localeCompare(bProject.toString()); + }); +};