Fix today recurring missing (#548)

* Expose today task from a recurring series

* fixup! Expose today task from a recurring series

* fixup! fixup! Expose today task from a recurring series
This commit is contained in:
Chris 2025-11-16 18:00:39 +02:00 committed by GitHub
parent b6748aa0d7
commit b0041bafe1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 341 additions and 44 deletions

View file

@ -6,6 +6,7 @@ const {
getTaskTodayMoveCount,
getTaskTodayMoveCounts,
} = require('../../../services/taskEventService');
const taskRepository = require('../../../repositories/TaskRepository');
async function serializeTask(
task,
@ -54,11 +55,23 @@ async function serializeTask(
}
}
let recurringParentUid = null;
if (taskJson.recurring_parent_id) {
const parentTask = await taskRepository.findById(
taskJson.recurring_parent_id,
{
attributes: ['uid'],
}
);
recurringParentUid = parentTask?.uid || null;
}
return {
...taskWithoutSubtasks,
name: displayName,
original_name: taskJson.name,
uid: task.uid,
recurring_parent_uid: recurringParentUid,
due_date: processDueDateForResponse(taskJson.due_date, safeTimezone),
tags: taskJson.Tags || [],
Project: taskJson.Project

View file

@ -19,7 +19,10 @@ const { logEvent } = require('../../services/taskEventService');
const { serializeTask, serializeTasks } = require('./core/serializers');
const { updateTaskTags } = require('./operations/tags');
const { filterTasksByParams } = require('./queries/query-builders');
const { getSafeTimezone } = require('../../utils/timezone-utils');
const {
getSafeTimezone,
getTodayBoundsInUTC,
} = require('../../utils/timezone-utils');
const {
validateProjectAccess,
@ -76,7 +79,36 @@ router.get('/tasks', async (req, res) => {
await handleRecurringTasks(userId, type);
const tasks = await filterTasksByParams(req.query, userId, timezone);
let tasks = await filterTasksByParams(req.query, userId, timezone);
// For type=today, exclude templates that have instances with due_date in today's range
if (type === 'today') {
const safeTimezone = getSafeTimezone(timezone);
const todayBounds = getTodayBoundsInUTC(safeTimezone);
// Find all instances with due_date in today's range
const instancesForToday = tasks.filter(
(t) =>
t.recurring_parent_id &&
t.due_date &&
new Date(t.due_date) >= todayBounds.start &&
new Date(t.due_date) <= todayBounds.end
);
// Get parent IDs of those instances
const parentIdsWithTodayInstances = new Set(
instancesForToday.map((t) => t.recurring_parent_id)
);
// Filter out templates that have instances for today
tasks = tasks.filter(
(t) =>
!t.recurrence_type ||
t.recurrence_type === 'none' ||
t.recurring_parent_id !== null ||
!parentIdsWithTodayInstances.has(t.id)
);
}
const groupedTasks = await buildGroupedTasks(
tasks,

View file

@ -8,6 +8,8 @@ const { computeTaskMetrics } = require('../queries/metrics-computation');
async function handleRecurringTasks(userId, queryType) {
if (queryType === 'upcoming') {
await generateRecurringTasksWithLock(userId, 7);
} else if (queryType === 'today') {
await generateRecurringTasksWithLock(userId, 1);
}
}

View file

@ -4,6 +4,7 @@ const permissionsService = require('../../../services/permissionsService');
const {
getSafeTimezone,
getUpcomingRangeInUTC,
getTodayBoundsInUTC,
} = require('../../../utils/timezone-utils');
async function filterTasksByParams(
@ -101,8 +102,10 @@ async function filterTasksByParams(
];
switch (params.type) {
case 'today':
whereClause.recurring_parent_id = null;
case 'today': {
const safeTimezone = getSafeTimezone(userTimezone);
const todayBounds = getTodayBoundsInUTC(safeTimezone);
whereClause.status = {
[Op.notIn]: [
Task.STATUS.DONE,
@ -111,7 +114,42 @@ async function filterTasksByParams(
'archived',
],
};
whereClause[Op.or] = [
{
[Op.and]: [
{
[Op.or]: [
{ recurrence_type: 'none' },
{ recurrence_type: null },
],
},
{ recurring_parent_id: null },
],
},
{
[Op.and]: [
{ recurrence_type: { [Op.ne]: 'none' } },
{ recurrence_type: { [Op.ne]: null } },
{ recurring_parent_id: null },
{ today: true },
],
},
{
[Op.and]: [
{ recurring_parent_id: { [Op.ne]: null } },
{
due_date: {
[Op.between]: [
todayBounds.start,
todayBounds.end,
],
},
},
],
},
];
break;
}
case 'upcoming': {
const safeTimezone = getSafeTimezone(userTimezone);
const upcomingRange = getUpcomingRangeInUTC(safeTimezone, 7);

View file

@ -0,0 +1,38 @@
const {
generateRecurringTasksWithLock,
} = require('../services/recurringTaskService');
const { User } = require('../models');
async function generateTasks() {
// Get first user
const user = await User.findOne();
if (!user) {
console.log('No users found');
process.exit(1);
}
console.log(
`Generating recurring tasks for user ${user.id} (${user.email})`
);
const tasks = await generateRecurringTasksWithLock(user.id, 1);
console.log(`Generated ${tasks.length} task instances`);
if (tasks.length > 0) {
console.log('\nGenerated tasks:');
tasks.forEach((t) => {
console.log(
`- ${t.name} (due: ${t.due_date ? t.due_date.toISOString().split('T')[0] : 'none'})`
);
});
}
process.exit(0);
}
generateTasks().catch((err) => {
console.error('Error:', err);
process.exit(1);
});

View file

@ -0,0 +1,56 @@
const { Task, sequelize } = require('../models');
const { Op } = require('sequelize');
async function testQuery() {
const whereClause = {
parent_task_id: null,
status: {
[Op.notIn]: [
Task.STATUS.DONE,
Task.STATUS.ARCHIVED,
'done',
'archived',
],
},
};
whereClause[Op.or] = [
{
[Op.and]: [
{
[Op.or]: [
{ recurrence_type: 'none' },
{ recurrence_type: null },
],
},
{ recurring_parent_id: null },
],
},
{
[Op.and]: [{ recurring_parent_id: { [Op.ne]: null } }],
},
];
// Log the SQL that will be generated
const query = Task.findAll({
where: whereClause,
attributes: ['id', 'name', 'recurrence_type', 'recurring_parent_id'],
logging: console.log,
});
console.log('\nThis query should:');
console.log(
'✓ Include: Regular tasks (recurrence_type = null/none, recurring_parent_id = null)'
);
console.log('✓ Include: Recurring instances (recurring_parent_id != null)');
console.log(
'✗ Exclude: Recurring parent templates (recurrence_type = daily/weekly/etc, recurring_parent_id = null)'
);
await sequelize.close();
}
testQuery().catch((err) => {
console.error(err);
process.exit(1);
});

View file

@ -73,8 +73,10 @@ const processRecurringTask = async (task, now, lookAheadDate = null) => {
return newTasks;
}
if (!task.last_generated_date && task.due_date) {
const originalDueDate = new Date(task.due_date.getTime());
if (!task.last_generated_date) {
const originalDueDate = task.due_date
? new Date(task.due_date.getTime())
: new Date(now.getTime());
if (originalDueDate <= generateUpTo) {
const startOfDay = new Date(originalDueDate);

View file

@ -643,37 +643,38 @@ describe('Recurring Tasks API', () => {
});
});
it('should exclude recurring task instances from type=today API response', async () => {
it('should include recurring task instances in type=today API response', async () => {
const response = await agent.get('/api/tasks?type=today');
expect(response.status).toBe(200);
expect(response.body.tasks).toBeDefined();
// Should only return regular task + recurring template, not the instance
expect(response.body.tasks.length).toBe(2);
// Should return at least the regular task + recurring instance
// Parent tasks should NOT appear in today view
expect(response.body.tasks.length).toBeGreaterThanOrEqual(2);
const taskIds = response.body.tasks.map((t) => t.id);
expect(taskIds).toContain(regularTask.id);
expect(taskIds).toContain(parentTask.id);
expect(taskIds).not.toContain(childTask.id); // Instance should be filtered out
expect(taskIds).not.toContain(parentTask.id); // Parent should NOT be included
expect(taskIds).toContain(childTask.id); // Instance should be included
});
it('should preserve original names for recurring tasks in type=today API response', async () => {
it('should preserve original names for recurring task instances in type=today API response', async () => {
const response = await agent.get('/api/tasks?type=today');
expect(response.status).toBe(200);
expect(response.body.tasks).toBeDefined();
// Find the recurring task in the response
const recurringTask = response.body.tasks.find(
(t) => t.id === parentTask.id
// Find the recurring task instance in the response
const recurringInstance = response.body.tasks.find(
(t) => t.id === childTask.id
);
expect(recurringTask).toBeDefined();
expect(recurringInstance).toBeDefined();
// Should show original name, not "Daily"
expect(recurringTask.name).toBe('Take vitamins');
expect(recurringTask.original_name).toBe('Take vitamins');
expect(recurringTask.name).not.toBe('Daily');
// Instances should show original name, not "Daily"
expect(recurringInstance.name).toBe('Take vitamins');
expect(recurringInstance.original_name).toBe('Take vitamins');
expect(recurringInstance.name).not.toBe('Daily');
});
it('should show generic names for non-today API calls (backward compatibility)', async () => {
@ -763,4 +764,99 @@ describe('Recurring Tasks API', () => {
});
});
});
describe('Recurring tasks in Today view', () => {
it('should show recurring task instances in type=today API response', async () => {
const recurringTaskService = require('../../services/recurringTaskService');
// Create a recurring daily task with due date today
const today = new Date();
today.setHours(12, 0, 0, 0);
const taskData = {
name: 'Daily standup meeting',
recurrence_type: 'daily',
recurrence_interval: 1,
due_date: today.toISOString(),
priority: 1,
completion_based: false,
};
const createResponse = await agent.post('/api/task').send(taskData);
expect(createResponse.status).toBe(201);
const recurringTaskId = createResponse.body.id;
// Generate recurring task instances
await recurringTaskService.generateRecurringTasks(user.id, 2);
// Verify instances were created
const instances = await Task.findAll({
where: {
user_id: user.id,
recurring_parent_id: recurringTaskId,
},
});
expect(instances.length).toBeGreaterThan(0);
// Fetch tasks with type=today
const todayResponse = await agent.get('/api/tasks?type=today');
expect(todayResponse.status).toBe(200);
// Find the recurring task instance in the today response
const todayTasks = todayResponse.body.tasks;
// Check if we have the recurring instance (but not the parent)
const recurringTasksInToday = todayTasks.filter(
(task) => task.recurring_parent_id === recurringTaskId
);
// Should find at least one instance (the one due today)
expect(recurringTasksInToday.length).toBeGreaterThan(0);
// Verify at least one task with this name appears
const taskWithName = todayTasks.find(
(task) => task.name === 'Daily standup meeting'
);
expect(taskWithName).toBeDefined();
expect(taskWithName.recurring_parent_id).toBe(recurringTaskId);
});
it('should include recurring_parent_uid in serialized task instances', async () => {
const today = new Date();
const taskResponse = await agent.post('/api/task').send({
name: 'Recurring parent test',
recurrence_type: 'daily',
recurrence_interval: 1,
due_date: today.toISOString().split('T')[0],
});
expect(taskResponse.status).toBe(201);
const recurringTask = taskResponse.body;
await agent.post('/api/tasks/generate-recurring');
// Find the generated instance
const generatedInstance = await Task.findOne({
where: {
user_id: user.id,
recurring_parent_id: recurringTask.id,
},
});
expect(generatedInstance).toBeDefined();
const response = await agent.get('/api/tasks?type=today');
expect(response.status).toBe(200);
const instance = response.body.tasks.find(
(task) => task.recurring_parent_id === recurringTask.id
);
expect(instance).toBeDefined();
expect(instance.recurring_parent_uid).toBeDefined();
expect(instance.recurring_parent_uid).toBe(recurringTask.uid);
});
});
});

View file

@ -70,10 +70,15 @@ test('user can set task priority to high', async ({ page, baseURL }) => {
// Wait for dropdown to close
await expect(page.locator('[data-testid="priority-dropdown"][data-state="closed"]')).toBeVisible();
// Verify modal is still open after priority change
await expect(page.locator('[data-testid="task-modal"][data-state="idle"]')).toBeVisible();
// Wait for the save button to be stable after priority change
await page.waitForTimeout(200);
await expect(page.locator('[data-testid="task-save-button"]')).toBeVisible();
await page.locator('[data-testid="task-save-button"]').click();
await page.waitForTimeout(500);
const saveButton0 = page.locator('[data-testid="task-save-button"]');
await expect(saveButton0).toBeAttached({ timeout: 5000 });
await expect(saveButton0).toBeVisible({ timeout: 5000 });
await saveButton0.click();
// Wait for saving state then idle state
await expect(page.locator('[data-testid="task-modal"][data-state="saving"]')).toBeVisible();
@ -115,10 +120,15 @@ test('user can set task priority to medium and low', async ({ page, baseURL }) =
await mediumPriorityOption.click();
await expect(page.locator('[data-testid="priority-dropdown"][data-state="closed"]')).toBeVisible();
// Verify modal is still open after priority change
await expect(page.locator('[data-testid="task-modal"][data-state="idle"]')).toBeVisible();
// Wait for the save button to be stable after priority change
await page.waitForTimeout(200);
await expect(page.locator('[data-testid="task-save-button"]')).toBeVisible();
await page.locator('[data-testid="task-save-button"]').click();
await page.waitForTimeout(500);
const saveButton1 = page.locator('[data-testid="task-save-button"]');
await expect(saveButton1).toBeAttached({ timeout: 5000 });
await expect(saveButton1).toBeVisible({ timeout: 5000 });
await saveButton1.click();
await expect(page.locator('[data-testid="task-modal"][data-state="saving"]')).toBeVisible();
await expect(page.locator('[data-testid="task-modal"]')).not.toBeVisible({ timeout: 10000 });
@ -149,10 +159,15 @@ test('user can set task priority to medium and low', async ({ page, baseURL }) =
await lowPriorityOption.click();
await expect(page.locator('[data-testid="priority-dropdown"][data-state="closed"]')).toBeVisible();
// Verify modal is still open after priority change
await expect(page.locator('[data-testid="task-modal"][data-state="idle"]')).toBeVisible();
// Wait for the save button to be stable after priority change
await page.waitForTimeout(200);
await expect(page.locator('[data-testid="task-save-button"]')).toBeVisible();
await page.locator('[data-testid="task-save-button"]').click();
await page.waitForTimeout(500);
const saveButton2 = page.locator('[data-testid="task-save-button"]');
await expect(saveButton2).toBeAttached({ timeout: 5000 });
await expect(saveButton2).toBeVisible({ timeout: 5000 });
await saveButton2.click();
await expect(page.locator('[data-testid="task-modal"][data-state="saving"]')).toBeVisible();
await expect(page.locator('[data-testid="task-modal"]')).not.toBeVisible({ timeout: 10000 });
@ -195,10 +210,15 @@ test('user can set a due date for a task', async ({ page, baseURL }) => {
await dayButton.click();
await expect(page.locator('[data-testid="datepicker"][data-state="closed"]')).toBeVisible();
// Verify modal is still open after date change
await expect(page.locator('[data-testid="task-modal"][data-state="idle"]')).toBeVisible();
// Wait for the save button to be stable after date change
await page.waitForTimeout(200);
await expect(page.locator('[data-testid="task-save-button"]')).toBeVisible();
await page.locator('[data-testid="task-save-button"]').click();
await page.waitForTimeout(500);
const saveButton3 = page.locator('[data-testid="task-save-button"]');
await expect(saveButton3).toBeAttached({ timeout: 5000 });
await expect(saveButton3).toBeVisible({ timeout: 5000 });
await saveButton3.click();
await expect(page.locator('[data-testid="task-modal"][data-state="saving"]')).toBeVisible();
await expect(page.locator('[data-testid="task-modal"]')).not.toBeVisible({ timeout: 10000 });

View file

@ -22,7 +22,6 @@ import {
deleteTask,
toggleTaskCompletion,
fetchTaskByUid,
fetchTaskById,
fetchTaskNextIterations,
TaskIteration,
} from '../../utils/tasksService';
@ -252,11 +251,11 @@ const TaskDetails: React.FC = () => {
// Load parent task for child tasks (recurring instances)
useEffect(() => {
const loadParentTask = async () => {
if (task?.recurring_parent_id) {
if (task?.recurring_parent_uid) {
try {
setLoadingParent(true);
const parent = await fetchTaskById(
task.recurring_parent_id
const parent = await fetchTaskByUid(
task.recurring_parent_uid
);
setParentTask(parent);
} catch (error) {
@ -269,7 +268,7 @@ const TaskDetails: React.FC = () => {
};
loadParentTask();
}, [task?.recurring_parent_id]);
}, [task?.recurring_parent_uid]);
const handleEdit = (e?: React.MouseEvent) => {
if (e) {

View file

@ -6,7 +6,7 @@ import DiscardChangesDialog from '../Shared/DiscardChangesDialog';
import { useToast } from '../Shared/ToastContext';
import { Project } from '../../entities/Project';
import { useStore } from '../../store/useStore';
import { fetchTaskById } from '../../utils/tasksService';
import { fetchTaskByUid } from '../../utils/tasksService';
import {
analyzeTaskName,
TaskAnalysis,
@ -100,7 +100,7 @@ const TaskModal: React.FC<TaskModalProps> = ({
const expandedSections = {
...baseSections,
subtasks: baseSections.subtasks || autoFocusSubtasks,
recurrence: baseSections.recurrence || !!task.recurring_parent_id, // Auto-expand for child tasks
recurrence: baseSections.recurrence || !!task.recurring_parent_uid, // Auto-expand for child tasks
};
const { showSuccessToast, showErrorToast } = useToast();
@ -179,11 +179,11 @@ const TaskModal: React.FC<TaskModalProps> = ({
// Handle parent task fetching separately
useEffect(() => {
const fetchParentTask = async () => {
if (task.recurring_parent_id && isOpen) {
if (task.recurring_parent_uid && isOpen) {
setParentTaskLoading(true);
try {
const parent = await fetchTaskById(
task.recurring_parent_id
const parent = await fetchTaskByUid(
task.recurring_parent_uid
);
setParentTask(parent);
} catch (error) {
@ -198,7 +198,7 @@ const TaskModal: React.FC<TaskModalProps> = ({
};
fetchParentTask();
}, [task.recurring_parent_id, isOpen]);
}, [task.recurring_parent_uid, isOpen]);
// Fetch task intelligence setting from user profile
useEffect(() => {
@ -934,7 +934,7 @@ const TaskModal: React.FC<TaskModalProps> = ({
>
<ArrowPathIcon className="h-5 w-5" />
{(formData.recurrence_type ||
formData.recurring_parent_id) && (
formData.recurring_parent_uid) && (
<span className="absolute -top-1 -right-1 w-3 h-3 bg-green-500 rounded-full"></span>
)}
</button>

View file

@ -25,6 +25,7 @@ export interface Task {
recurrence_week_of_month?: number;
completion_based?: boolean;
recurring_parent_id?: number;
recurring_parent_uid?: string;
last_generated_date?: string;
completed_at: string | null;
parent_task_id?: number;