Introduce sort utils (#709)

* Introduce sort utils

* Fix test issues
This commit is contained in:
Chris 2025-12-14 01:13:57 +02:00 committed by GitHub
parent 269197e3db
commit a8548b045b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 434 additions and 133 deletions

View file

@ -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'));

View file

@ -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) {

View file

@ -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) {

View file

@ -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) {

View file

@ -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');
});
});

View file

@ -155,6 +155,26 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
});
};
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<TaskHeaderProps> = ({
}
};
// 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<TaskHeaderProps> = ({
</span>
</div>
)}
{formattedDeferUntil && (
<div className="flex items-center">
<CalendarDaysIcon className="h-3 w-3 mr-1" />
<span>{formattedDeferUntil}</span>
</div>
)}
</div>
)}
</div>
@ -1083,6 +1114,12 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
</span>
</div>
)}
{formattedDeferUntil && (
<div className="flex items-center whitespace-nowrap">
<CalendarDaysIcon className="h-3 w-3 mr-1" />
<span>{formattedDeferUntil}</span>
</div>
)}
</div>
{onToggleCompletion && (

View file

@ -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 = () => {
<div className="mb-4">
<NextTaskSuggestion
metrics={{
tasks_due_today: metrics.tasks_due_today,
suggested_tasks: metrics.suggested_tasks,
tasks_due_today: sortedDueTodayTasks,
suggested_tasks: sortedSuggestedTasks,
tasks_in_progress: metrics.tasks_in_progress,
today_plan_tasks: plannedTasks,
}}
@ -1516,7 +1534,7 @@ const TasksToday: React.FC = () => {
{/* Overdue Tasks - Displayed first */}
{isSettingsLoaded &&
todaySettings.showDueToday &&
metrics.tasks_overdue.length > 0 && (
sortedOverdueTasks.length > 0 && (
<div className="mb-6" data-testid="overdue-section">
<div
className="flex items-center justify-between cursor-pointer mt-6 mb-2 pb-2 border-b border-gray-200 dark:border-gray-700"
@ -1528,7 +1546,7 @@ const TasksToday: React.FC = () => {
</h3>
<div className="flex items-center">
<span className="text-sm text-gray-500 mr-2">
{metrics.tasks_overdue.length}
{sortedOverdueTasks.length}
</span>
{isOverdueCollapsed ? (
<ChevronRightIcon className="h-5 w-5 text-gray-500" />
@ -1540,7 +1558,7 @@ const TasksToday: React.FC = () => {
{!isOverdueCollapsed && (
<>
<TaskList
tasks={metrics.tasks_overdue.slice(
tasks={sortedOverdueTasks.slice(
0,
overdueDisplayLimit
)}
@ -1555,7 +1573,7 @@ const TasksToday: React.FC = () => {
{/* Load More Buttons for Overdue Tasks */}
{overdueDisplayLimit <
metrics.tasks_overdue.length && (
sortedOverdueTasks.length && (
<div className="flex justify-center pt-4 pb-2 gap-3">
<button
onClick={() =>
@ -1574,8 +1592,7 @@ const TasksToday: React.FC = () => {
<button
onClick={() =>
setOverdueDisplayLimit(
metrics.tasks_overdue
.length
sortedOverdueTasks.length
)
}
className="inline-flex items-center px-5 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
@ -1596,10 +1613,9 @@ const TasksToday: React.FC = () => {
{
current: Math.min(
overdueDisplayLimit,
metrics.tasks_overdue.length
sortedOverdueTasks.length
),
total: metrics.tasks_overdue
.length,
total: sortedOverdueTasks.length,
}
)}
</div>
@ -1730,7 +1746,7 @@ const TasksToday: React.FC = () => {
{/* Due Today Tasks */}
{isSettingsLoaded &&
todaySettings.showDueToday &&
metrics.tasks_due_today.length > 0 && (
sortedDueTodayTasks.length > 0 && (
<div className="mb-6" data-testid="due-today-section">
<div
className="flex items-center justify-between cursor-pointer mt-6 mb-2 pb-2 border-b border-gray-200 dark:border-gray-700"
@ -1742,7 +1758,7 @@ const TasksToday: React.FC = () => {
</h3>
<div className="flex items-center">
<span className="text-sm text-gray-500 mr-2">
{metrics.tasks_due_today.length}
{sortedDueTodayTasks.length}
</span>
{isDueTodayCollapsed ? (
<ChevronRightIcon className="h-5 w-5 text-gray-500" />
@ -1754,7 +1770,7 @@ const TasksToday: React.FC = () => {
{!isDueTodayCollapsed && (
<>
<TaskList
tasks={metrics.tasks_due_today.slice(
tasks={sortedDueTodayTasks.slice(
0,
dueTodayDisplayLimit
)}
@ -1769,7 +1785,7 @@ const TasksToday: React.FC = () => {
{/* Load More Buttons for Due Today Tasks */}
{dueTodayDisplayLimit <
metrics.tasks_due_today.length && (
sortedDueTodayTasks.length && (
<div className="flex justify-center pt-4 pb-2 gap-3">
<button
onClick={() =>
@ -1788,8 +1804,7 @@ const TasksToday: React.FC = () => {
<button
onClick={() =>
setDueTodayDisplayLimit(
metrics.tasks_due_today
.length
sortedDueTodayTasks.length
)
}
className="inline-flex items-center px-5 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
@ -1810,11 +1825,9 @@ const TasksToday: React.FC = () => {
{
current: Math.min(
dueTodayDisplayLimit,
metrics.tasks_due_today
.length
sortedDueTodayTasks.length
),
total: metrics.tasks_due_today
.length,
total: sortedDueTodayTasks.length,
}
)}
</div>
@ -1833,7 +1846,7 @@ const TasksToday: React.FC = () => {
<div className="bg-white dark:bg-gray-900 rounded-lg shadow p-4 h-20"></div>
</div>
) : todaySettings.showSuggestions &&
metrics.suggested_tasks.length > 0 ? (
sortedSuggestedTasks.length > 0 ? (
<div className="mt-2 mb-6">
<div
className="flex items-center justify-between cursor-pointer mt-6 mb-2 pb-2 border-b border-gray-200 dark:border-gray-700"
@ -1844,7 +1857,7 @@ const TasksToday: React.FC = () => {
</h3>
<div className="flex items-center">
<span className="text-sm text-gray-500 mr-2">
{metrics.suggested_tasks.length}
{sortedSuggestedTasks.length}
</span>
{isSuggestedCollapsed ? (
<ChevronRightIcon className="h-5 w-5 text-gray-500" />
@ -1854,16 +1867,62 @@ const TasksToday: React.FC = () => {
</div>
</div>
{!isSuggestedCollapsed && (
<TaskList
tasks={metrics.suggested_tasks}
onTaskUpdate={handleTaskUpdate}
onTaskCompletionToggle={
handleTaskCompletionToggle
}
onTaskDelete={handleTaskDelete}
projects={localProjects}
onToggleToday={handleToggleToday}
/>
<>
<TaskList
tasks={sortedSuggestedTasks.slice(
0,
suggestedDisplayLimit
)}
onTaskUpdate={handleTaskUpdate}
onTaskCompletionToggle={
handleTaskCompletionToggle
}
onTaskDelete={handleTaskDelete}
projects={localProjects}
onToggleToday={handleToggleToday}
/>
{suggestedDisplayLimit <
sortedSuggestedTasks.length && (
<div className="flex justify-center pt-4 pb-2 gap-3">
<button
onClick={() =>
setSuggestedDisplayLimit(
(prev) => prev + 20
)
}
className="inline-flex items-center px-5 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
>
<QueueListIcon className="h-4 w-4 mr-2" />
{t('common.loadMore', 'Load More')}
</button>
<button
onClick={() =>
setSuggestedDisplayLimit(
sortedSuggestedTasks.length
)
}
className="inline-flex items-center px-5 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
>
{t('common.showAll', 'Show All')}
</button>
</div>
)}
<div className="text-center text-sm text-gray-500 dark:text-gray-400 pt-2 pb-4">
{t(
'tasks.showingItems',
'Showing {{current}} of {{total}} tasks',
{
current: Math.min(
suggestedDisplayLimit,
sortedSuggestedTasks.length
),
total: sortedSuggestedTasks.length,
}
)}
</div>
</>
)}
</div>
) : 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 && (
<p className="text-gray-500 text-center mt-4">
{t('tasks.noTasksAvailable')}

View file

@ -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<TodayPlanProps> = ({
// 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) {

View file

@ -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());
});
};