292 lines
8.2 KiB
JavaScript
292 lines
8.2 KiB
JavaScript
const { Task } = require('../../../models');
|
|
const { Op } = require('sequelize');
|
|
const moment = require('moment-timezone');
|
|
const permissionsService = require('../../../services/permissionsService');
|
|
const {
|
|
countTotalOpenTasks,
|
|
countTasksPendingOverMonth,
|
|
fetchTasksInProgress,
|
|
fetchTodayPlanTasks,
|
|
fetchTasksDueToday,
|
|
fetchOverdueTasks,
|
|
fetchSomedayTaskIds,
|
|
fetchNonProjectTasks,
|
|
fetchProjectTasks,
|
|
fetchSomedayFallbackTasks,
|
|
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,
|
|
totalOpenTasks,
|
|
tasksInProgress,
|
|
tasksDueToday,
|
|
tasksOverdue,
|
|
todayPlanTasks
|
|
) {
|
|
if (
|
|
totalOpenTasks < 3 &&
|
|
tasksInProgress.length === 0 &&
|
|
tasksDueToday.length === 0
|
|
) {
|
|
return [];
|
|
}
|
|
|
|
const excludedTaskIds = [
|
|
...tasksInProgress.map((t) => t.id),
|
|
...tasksDueToday.map((t) => t.id),
|
|
...tasksOverdue.map((t) => t.id),
|
|
...todayPlanTasks.map((t) => t.id),
|
|
];
|
|
|
|
const somedayTaskIds = await fetchSomedayTaskIds(userId);
|
|
|
|
const [nonProjectTasks, projectTasks] = await Promise.all([
|
|
fetchNonProjectTasks(
|
|
visibleTasksWhere,
|
|
excludedTaskIds,
|
|
somedayTaskIds
|
|
),
|
|
fetchProjectTasks(visibleTasksWhere, excludedTaskIds, somedayTaskIds),
|
|
]);
|
|
|
|
let combinedTasks = [...nonProjectTasks, ...projectTasks];
|
|
|
|
if (combinedTasks.length < 6) {
|
|
const usedTaskIds = [
|
|
...excludedTaskIds,
|
|
...combinedTasks.map((t) => t.id),
|
|
];
|
|
|
|
const somedayFallbackTasks = await fetchSomedayFallbackTasks(
|
|
userId,
|
|
usedTaskIds,
|
|
somedayTaskIds
|
|
);
|
|
|
|
combinedTasks = [...combinedTasks, ...somedayFallbackTasks];
|
|
}
|
|
|
|
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) {
|
|
const todayInUserTz = moment.tz(userTimezone);
|
|
const weekStartInUserTz = moment.tz(userTimezone).subtract(6, 'days');
|
|
const weekStart = weekStartInUserTz.clone().startOf('day').utc().toDate();
|
|
const weekEnd = todayInUserTz.clone().endOf('day').utc().toDate();
|
|
|
|
const weeklyCompletionsRaw = await Task.findAll({
|
|
where: {
|
|
user_id: userId,
|
|
status: Task.STATUS.DONE,
|
|
completed_at: {
|
|
[Op.between]: [weekStart, weekEnd],
|
|
},
|
|
},
|
|
attributes: ['completed_at'],
|
|
raw: true,
|
|
});
|
|
|
|
const dateCountMap = {};
|
|
weeklyCompletionsRaw.forEach((task) => {
|
|
const completedDate = new Date(task.completed_at);
|
|
const dateInUserTz = moment(completedDate)
|
|
.tz(userTimezone)
|
|
.format('YYYY-MM-DD');
|
|
dateCountMap[dateInUserTz] = (dateCountMap[dateInUserTz] || 0) + 1;
|
|
});
|
|
|
|
const weeklyCompletions = Object.entries(dateCountMap).map(
|
|
([date, count]) => ({
|
|
date,
|
|
count: count.toString(),
|
|
})
|
|
);
|
|
|
|
const weeklyData = [];
|
|
for (let i = 6; i >= 0; i--) {
|
|
const dateInUserTz = moment.tz(userTimezone).subtract(i, 'days');
|
|
const dateString = dateInUserTz.format('YYYY-MM-DD');
|
|
|
|
const found = weeklyCompletions.find(
|
|
(item) => item.date === dateString
|
|
);
|
|
const dayData = {
|
|
date: dateString,
|
|
count: found ? parseInt(found.count) : 0,
|
|
dayName: dateInUserTz.format('ddd'),
|
|
};
|
|
weeklyData.push(dayData);
|
|
}
|
|
|
|
return weeklyData;
|
|
}
|
|
|
|
async function computeTaskMetrics(
|
|
userId,
|
|
userTimezone = 'UTC',
|
|
permissionCache = null
|
|
) {
|
|
const visibleTasksWhere =
|
|
await permissionsService.ownershipOrPermissionWhere(
|
|
'task',
|
|
userId,
|
|
permissionCache
|
|
);
|
|
|
|
const [
|
|
totalOpenTasks,
|
|
tasksPendingOverMonth,
|
|
tasksInProgress,
|
|
todayPlanTasks,
|
|
tasksDueToday,
|
|
tasksOverdue,
|
|
tasksCompletedToday,
|
|
weeklyCompletions,
|
|
] = await Promise.all([
|
|
countTotalOpenTasks(visibleTasksWhere),
|
|
countTasksPendingOverMonth(visibleTasksWhere),
|
|
fetchTasksInProgress(visibleTasksWhere),
|
|
fetchTodayPlanTasks(visibleTasksWhere),
|
|
fetchTasksDueToday(visibleTasksWhere, userTimezone),
|
|
fetchOverdueTasks(visibleTasksWhere, userTimezone),
|
|
fetchTasksCompletedToday(userId, userTimezone),
|
|
computeWeeklyCompletions(userId, userTimezone),
|
|
]);
|
|
|
|
const suggestedTasks = await computeSuggestedTasks(
|
|
visibleTasksWhere,
|
|
userId,
|
|
totalOpenTasks,
|
|
tasksInProgress,
|
|
tasksDueToday,
|
|
tasksOverdue,
|
|
todayPlanTasks
|
|
);
|
|
|
|
return {
|
|
total_open_tasks: totalOpenTasks,
|
|
tasks_pending_over_month: tasksPendingOverMonth,
|
|
tasks_in_progress_count: tasksInProgress.length,
|
|
tasks_in_progress: tasksInProgress,
|
|
tasks_due_today: tasksDueToday,
|
|
tasks_overdue: tasksOverdue,
|
|
today_plan_tasks: todayPlanTasks,
|
|
suggested_tasks: suggestedTasks,
|
|
tasks_completed_today: tasksCompletedToday,
|
|
weekly_completions: weeklyCompletions,
|
|
};
|
|
}
|
|
|
|
async function getTaskMetrics(userId, timezone) {
|
|
const metrics = await computeTaskMetrics(userId, timezone);
|
|
const {
|
|
buildMetricsResponse,
|
|
serializeTasks,
|
|
} = require('../core/serializers');
|
|
|
|
const response = await buildMetricsResponse(metrics);
|
|
|
|
const serializedLists = {
|
|
tasks_in_progress: await serializeTasks(
|
|
metrics.tasks_in_progress,
|
|
timezone
|
|
),
|
|
tasks_today_plan: await serializeTasks(
|
|
metrics.today_plan_tasks,
|
|
timezone
|
|
),
|
|
tasks_due_today: await serializeTasks(
|
|
metrics.tasks_due_today,
|
|
timezone
|
|
),
|
|
tasks_overdue: await serializeTasks(metrics.tasks_overdue, timezone),
|
|
suggested_tasks: await serializeTasks(
|
|
metrics.suggested_tasks,
|
|
timezone
|
|
),
|
|
tasks_completed_today: await serializeTasks(
|
|
metrics.tasks_completed_today,
|
|
timezone
|
|
),
|
|
};
|
|
|
|
Object.assign(response, serializedLists);
|
|
response.dashboard_lists = serializedLists;
|
|
|
|
return response;
|
|
}
|
|
|
|
module.exports = {
|
|
computeSuggestedTasks,
|
|
computeWeeklyCompletions,
|
|
computeTaskMetrics,
|
|
getTaskMetrics,
|
|
};
|