parent
269197e3db
commit
a8548b045b
9 changed files with 434 additions and 133 deletions
|
|
@ -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'));
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
98
backend/tests/integration/tasks-metrics.test.js
Normal file
98
backend/tests/integration/tasks-metrics.test.js
Normal 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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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')}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
60
frontend/utils/taskSortUtils.ts
Normal file
60
frontend/utils/taskSortUtils.ts
Normal 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());
|
||||
});
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue