tududi/backend/routes/tasks/queries/metrics-queries.js
Chris e73c354e7e
Fix bug 733 (#735)
* Refactor today

* fixup! Refactor today

* fixup! fixup! Refactor today
2025-12-27 21:00:52 +02:00

431 lines
13 KiB
JavaScript

const { Task, sequelize } = require('../../../models');
const { Op } = require('sequelize');
const moment = require('moment-timezone');
const {
getSafeTimezone,
getTodayBoundsInUTC,
} = require('../../../utils/timezone-utils');
const { getTaskIncludeConfig } = require('./query-builders');
// Statuses that indicate a task is in the "today plan" (actively being worked on)
// Used to exclude from overdue/due-today sections to avoid duplicates
const TODAY_PLAN_STATUSES = [
Task.STATUS.IN_PROGRESS,
Task.STATUS.WAITING,
Task.STATUS.PLANNED,
'in_progress',
'waiting',
'planned',
];
// Helper to check if a task is in the today plan based on status
function isTaskInTodayPlan(task) {
return TODAY_PLAN_STATUSES.includes(task.status);
}
async function countTotalOpenTasks(visibleTasksWhere) {
return await Task.count({
where: {
...visibleTasksWhere,
status: { [Op.ne]: Task.STATUS.DONE },
parent_task_id: null,
recurring_parent_id: null,
},
});
}
async function countTasksPendingOverMonth(visibleTasksWhere) {
const oneMonthAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
return await Task.count({
where: {
...visibleTasksWhere,
status: { [Op.ne]: Task.STATUS.DONE },
created_at: { [Op.lt]: oneMonthAgo },
parent_task_id: null,
recurring_parent_id: null,
},
});
}
async function fetchTasksInProgress(visibleTasksWhere) {
return await Task.findAll({
where: {
...visibleTasksWhere,
status: { [Op.in]: [Task.STATUS.IN_PROGRESS, 'in_progress'] },
parent_task_id: null,
recurring_parent_id: null,
},
include: getTaskIncludeConfig(),
order: [
['priority', 'DESC'],
['due_date', 'ASC'],
['project_id', 'ASC'],
],
});
}
async function fetchTodayPlanTasks(visibleTasksWhere) {
const todayPlanStatuses = [
Task.STATUS.IN_PROGRESS,
Task.STATUS.WAITING,
Task.STATUS.PLANNED,
'in_progress',
'waiting',
'planned',
];
const excludedStatuses = [
Task.STATUS.NOT_STARTED,
Task.STATUS.DONE,
Task.STATUS.ARCHIVED,
Task.STATUS.CANCELLED,
'not_started',
'done',
'archived',
'cancelled',
];
return await Task.findAll({
where: {
[Op.and]: [
visibleTasksWhere,
{
status: {
[Op.in]: todayPlanStatuses,
[Op.notIn]: excludedStatuses,
},
parent_task_id: null,
// Exclude recurring parent tasks - only include non-recurring tasks or recurring instances
[Op.or]: [
{
// Non-recurring tasks
[Op.and]: [
{
[Op.or]: [
{ recurrence_type: 'none' },
{ recurrence_type: null },
],
},
{ recurring_parent_id: null },
],
},
{
// Recurring instances (not parents)
recurring_parent_id: { [Op.ne]: null },
},
],
},
],
},
include: getTaskIncludeConfig(),
order: [
['priority', 'DESC'],
['due_date', 'ASC'],
['project_id', 'ASC'],
],
});
}
async function fetchTasksDueToday(visibleTasksWhere, userTimezone) {
const safeTimezone = getSafeTimezone(userTimezone);
const todayBounds = getTodayBoundsInUTC(safeTimezone);
return await Task.findAll({
where: {
[Op.and]: [
visibleTasksWhere,
{
status: {
[Op.notIn]: [
Task.STATUS.DONE,
Task.STATUS.ARCHIVED,
'done',
'archived',
...TODAY_PLAN_STATUSES,
],
},
parent_task_id: null,
recurring_parent_id: null,
[Op.or]: [
{
due_date: {
[Op.and]: [
{ [Op.gte]: todayBounds.start },
{ [Op.lte]: todayBounds.end },
],
},
},
sequelize.literal(`EXISTS (
SELECT 1 FROM projects
WHERE projects.id = Task.project_id
AND projects.due_date_at >= '${todayBounds.start.toISOString()}'
AND projects.due_date_at <= '${todayBounds.end.toISOString()}'
)`),
],
},
],
},
include: getTaskIncludeConfig(),
order: [
['priority', 'DESC'],
['due_date', 'ASC'],
['project_id', 'ASC'],
],
});
}
async function fetchOverdueTasks(visibleTasksWhere, userTimezone) {
const safeTimezone = getSafeTimezone(userTimezone);
const todayBounds = getTodayBoundsInUTC(safeTimezone);
return await Task.findAll({
where: {
[Op.and]: [
visibleTasksWhere,
{
status: {
[Op.notIn]: [
Task.STATUS.DONE,
Task.STATUS.ARCHIVED,
'done',
'archived',
// Exclude tasks in today plan (they show in Planned section)
...TODAY_PLAN_STATUSES,
],
},
parent_task_id: null,
recurring_parent_id: null,
[Op.or]: [
{ due_date: { [Op.lt]: todayBounds.start } },
sequelize.literal(`EXISTS (
SELECT 1 FROM projects
WHERE projects.id = Task.project_id
AND projects.due_date_at < '${todayBounds.start.toISOString()}'
)`),
],
},
],
},
include: getTaskIncludeConfig(),
order: [
['priority', 'DESC'],
['due_date', 'ASC'],
['project_id', 'ASC'],
],
});
}
async function fetchSomedayTaskIds(userId) {
return await sequelize
.query(
`SELECT DISTINCT task_id FROM tasks_tags
JOIN tags ON tasks_tags.tag_id = tags.id
WHERE tags.name = 'someday' AND tags.user_id = ?`,
{
replacements: [userId],
type: sequelize.QueryTypes.SELECT,
}
)
.then((results) => results.map((r) => r.task_id));
}
async function fetchNonProjectTasks(
visibleTasksWhere,
excludedTaskIds,
somedayTaskIds,
limit = null
) {
const exclusionIds = [...excludedTaskIds, ...somedayTaskIds];
const queryOptions = {
where: {
...visibleTasksWhere,
status: {
[Op.in]: [Task.STATUS.NOT_STARTED, Task.STATUS.WAITING],
},
[Op.or]: [{ project_id: null }, { project_id: '' }],
parent_task_id: null,
recurring_parent_id: null,
},
include: getTaskIncludeConfig(),
order: [
['priority', 'DESC'],
['due_date', 'ASC'],
['project_id', 'ASC'],
],
};
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,
limit = null
) {
const exclusionIds = [...excludedTaskIds, ...somedayTaskIds];
const queryOptions = {
where: {
...visibleTasksWhere,
status: {
[Op.in]: [Task.STATUS.NOT_STARTED, Task.STATUS.WAITING],
},
project_id: { [Op.not]: null, [Op.ne]: '' },
parent_task_id: null,
recurring_parent_id: null,
},
include: getTaskIncludeConfig(),
order: [
['priority', 'DESC'],
['due_date', 'ASC'],
['project_id', 'ASC'],
],
};
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 = null
) {
if (somedayTaskIds.length === 0) {
return [];
}
const queryOptions = {
where: {
user_id: userId,
status: {
[Op.in]: [Task.STATUS.NOT_STARTED, Task.STATUS.WAITING],
},
parent_task_id: null,
recurring_parent_id: null,
},
include: getTaskIncludeConfig(),
order: [
['priority', 'DESC'],
['due_date', 'ASC'],
['project_id', 'ASC'],
],
};
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) {
const safeTimezone = getSafeTimezone(userTimezone);
const todayBounds = getTodayBoundsInUTC(safeTimezone);
// Fetch regular completed tasks
const regularCompletedTasks = await Task.findAll({
where: {
user_id: userId,
status: Task.STATUS.DONE,
parent_task_id: null,
recurring_parent_id: null,
completed_at: {
[Op.gte]: todayBounds.start,
[Op.lte]: todayBounds.end,
},
},
include: getTaskIncludeConfig(),
});
// Fetch recurring tasks completed today via recurring_completions table
const { RecurringCompletion } = require('../../../models');
const recurringCompletions = await RecurringCompletion.findAll({
where: {
completed_at: {
[Op.gte]: todayBounds.start,
[Op.lte]: todayBounds.end,
},
skipped: false,
},
include: [
{
model: Task,
as: 'Task',
where: {
user_id: userId,
parent_task_id: null,
},
include: getTaskIncludeConfig(),
},
],
});
// Extract the tasks from recurring completions and add completed_at and status
const recurringCompletedTasks = recurringCompletions.map((rc) => {
const task = rc.Task;
// Add virtual completed_at and status for display purposes
task.dataValues.completed_at = rc.completed_at;
task.dataValues.status = Task.STATUS.DONE;
// Also set the direct property to ensure it's accessible
task.status = Task.STATUS.DONE;
task.completed_at = rc.completed_at;
return task;
});
// Combine both lists
const allCompletedTasks = [
...regularCompletedTasks,
...recurringCompletedTasks,
];
// Sort by completed_at DESC
allCompletedTasks.sort((a, b) => {
const aTime = a.completed_at || a.dataValues.completed_at;
const bTime = b.completed_at || b.dataValues.completed_at;
return new Date(bTime) - new Date(aTime);
});
return allCompletedTasks;
}
module.exports = {
countTotalOpenTasks,
countTasksPendingOverMonth,
fetchTasksInProgress,
fetchTodayPlanTasks,
fetchTasksDueToday,
fetchOverdueTasks,
fetchSomedayTaskIds,
fetchNonProjectTasks,
fetchProjectTasks,
fetchSomedayFallbackTasks,
fetchTasksCompletedToday,
};