Fix an issue with check to done task not moving to completed

This commit is contained in:
Chris Veleris 2025-07-22 21:41:14 +03:00 committed by Chris
parent ec428673a1
commit 3fdc31fb65
15 changed files with 700 additions and 187 deletions

View file

@ -26,11 +26,17 @@ async function serializeTask(task) {
due_date: subtask.due_date
? subtask.due_date.toISOString().split('T')[0]
: null,
completed_at: subtask.completed_at
? subtask.completed_at.toISOString()
: null,
}))
: [],
due_date: task.due_date
? task.due_date.toISOString().split('T')[0]
: null,
completed_at: task.completed_at
? task.completed_at.toISOString()
: null,
today_move_count: todayMoveCount,
};
}
@ -90,22 +96,39 @@ async function checkAndUpdateParentTaskCompletion(parentTaskId, userId) {
);
if (allSubtasksDone) {
// Update parent task to done
await Task.update(
{
status: Task.STATUS.DONE,
completed_at: new Date(),
// Check if parent is already done to avoid unnecessary updates
const parentTask = await Task.findOne({
where: {
id: parentTaskId,
user_id: userId,
},
{
where: {
id: parentTaskId,
user_id: userId,
});
if (
parentTask &&
parentTask.status !== Task.STATUS.DONE &&
parentTask.status !== 'done'
) {
// Update parent task to done
await Task.update(
{
status: Task.STATUS.DONE,
completed_at: new Date(),
},
}
);
{
where: {
id: parentTaskId,
user_id: userId,
},
}
);
return true;
}
}
return false;
} catch (error) {
console.error('Error checking parent task completion:', error);
return false;
}
}
@ -138,16 +161,20 @@ async function undoneParentTaskIfNeeded(parentTaskId, userId) {
},
}
);
return true;
}
return false;
} catch (error) {
console.error('Error undoing parent task:', error);
return false;
}
}
// Helper function to complete all subtasks when parent is done
async function completeAllSubtasks(parentTaskId, userId) {
try {
await Task.update(
// Update all subtasks to be completed - this ensures completed_at is set for all
const result = await Task.update(
{
status: Task.STATUS.DONE,
completed_at: new Date(),
@ -159,15 +186,17 @@ async function completeAllSubtasks(parentTaskId, userId) {
},
}
);
return result[0] > 0; // Return true if any subtasks were actually updated
} catch (error) {
console.error('Error completing all subtasks:', error);
return false;
}
}
// Helper function to undone all subtasks when parent is undone
async function undoneAllSubtasks(parentTaskId, userId) {
try {
await Task.update(
const result = await Task.update(
{
status: Task.STATUS.NOT_STARTED,
completed_at: null,
@ -176,11 +205,16 @@ async function undoneAllSubtasks(parentTaskId, userId) {
where: {
parent_task_id: parentTaskId,
user_id: userId,
status: {
[Op.in]: [Task.STATUS.DONE, 'done'],
},
},
}
);
return result[0] > 0; // Return true if any subtasks were actually updated
} catch (error) {
console.error('Error undoing all subtasks:', error);
return false;
}
}
@ -248,9 +282,11 @@ async function filterTasksByParams(params, userId) {
default:
if (params.status === 'done') {
whereClause.status = { [Op.in]: [Task.STATUS.DONE, 'done'] };
} else {
} else if (!params.client_side_filtering) {
// Only exclude completed tasks if not doing client-side filtering
whereClause.status = { [Op.notIn]: [Task.STATUS.DONE, 'done'] };
}
// If client_side_filtering is true, don't add any status filter (include all)
}
// Filter by tag
@ -827,6 +863,17 @@ router.get('/task/:id', async (req, res) => {
through: { attributes: [] },
},
{ model: Project, attributes: ['name'], required: false },
{
model: Task,
as: 'Subtasks',
include: [
{
model: Tag,
attributes: ['id', 'name'],
through: { attributes: [] },
},
],
},
],
});
@ -1266,22 +1313,60 @@ router.patch('/task/:id', async (req, res) => {
});
}
// Update edited subtasks
const editedSubtasks = subtasks.filter(
(s) => s.isEdited && s.id && s.name && s.name.trim()
// Update edited subtasks and status changes
const subtasksToUpdate = subtasks.filter(
(s) =>
s.id &&
((s.isEdited && s.name && s.name.trim()) ||
s._statusChanged)
);
if (editedSubtasks.length > 0) {
const updatePromises = editedSubtasks.map((subtask) =>
Task.update(
{ name: subtask.name.trim() },
{
where: {
id: subtask.id,
user_id: req.currentUser.id,
},
if (subtasksToUpdate.length > 0) {
const updatePromises = subtasksToUpdate.map((subtask) => {
const updateData = {};
if (
subtask.isEdited &&
subtask.name &&
subtask.name.trim()
) {
updateData.name = subtask.name.trim();
}
if (
subtask._statusChanged ||
subtask.status !== undefined
) {
updateData.status = subtask.status
? typeof subtask.status === 'string'
? Task.getStatusValue(subtask.status)
: subtask.status
: Task.STATUS.NOT_STARTED;
if (
updateData.status === Task.STATUS.DONE &&
!subtask.completed_at
) {
updateData.completed_at = new Date();
} else if (updateData.status !== Task.STATUS.DONE) {
updateData.completed_at = null;
}
)
);
}
if (subtask.priority !== undefined) {
updateData.priority = subtask.priority
? typeof subtask.priority === 'string'
? Task.getPriorityValue(subtask.priority)
: subtask.priority
: Task.PRIORITY.MEDIUM;
}
return Task.update(updateData, {
where: {
id: subtask.id,
user_id: req.currentUser.id,
},
});
});
await Promise.all(updatePromises);
}
@ -1296,9 +1381,24 @@ router.patch('/task/:id', async (req, res) => {
name: subtask.name.trim(),
parent_task_id: task.id,
user_id: req.currentUser.id,
priority: Task.PRIORITY.MEDIUM,
status: Task.STATUS.NOT_STARTED,
today: false,
priority: subtask.priority
? typeof subtask.priority === 'string'
? Task.getPriorityValue(subtask.priority)
: subtask.priority
: Task.PRIORITY.MEDIUM,
status: subtask.status
? typeof subtask.status === 'string'
? Task.getStatusValue(subtask.status)
: subtask.status
: Task.STATUS.NOT_STARTED,
completed_at:
subtask.status === 'done' ||
subtask.status === Task.STATUS.DONE
? subtask.completed_at
? new Date(subtask.completed_at)
: new Date()
: null,
today: subtask.today || false,
recurrence_type: 'none',
completion_based: false,
})
@ -1472,15 +1572,10 @@ router.patch('/task/:id', async (req, res) => {
],
});
const taskJson = taskWithAssociations.toJSON();
// Use serializeTask to include subtasks data
const serializedTask = await serializeTask(taskWithAssociations);
res.json({
...taskJson,
tags: taskJson.Tags || [], // Normalize Tags to tags
due_date: taskWithAssociations.due_date
? taskWithAssociations.due_date.toISOString().split('T')[0]
: null,
});
res.json(serializedTask);
} catch (error) {
console.error('Error updating task:', error);
res.status(400).json({
@ -1497,12 +1592,34 @@ router.patch('/task/:id/toggle_completion', async (req, res) => {
try {
const task = await Task.findOne({
where: { id: req.params.id, user_id: req.currentUser.id },
include: [
{
model: Tag,
attributes: ['id', 'name'],
through: { attributes: [] },
},
{ model: Project, attributes: ['name'], required: false },
{
model: Task,
as: 'Subtasks',
include: [
{
model: Tag,
attributes: ['id', 'name'],
through: { attributes: [] },
},
],
},
],
});
if (!task) {
return res.status(404).json({ error: 'Task not found.' });
}
// Track if parent-child logic was executed
let parentChildLogicExecuted = false;
const newStatus =
task.status === Task.STATUS.DONE || task.status === 'done'
? task.note
@ -1520,29 +1637,63 @@ router.patch('/task/:id/toggle_completion', async (req, res) => {
await task.update(updateData);
// Handle parent-child task completion logic
// Check if subtasks exist in database directly to debug association issue
const directSubtasksQuery = await Task.findAll({
where: {
parent_task_id: task.id,
user_id: req.currentUser.id,
},
attributes: ['id', 'name', 'status', 'parent_task_id'],
});
// If direct query finds subtasks but task.Subtasks is empty, there's an association issue
if (
directSubtasksQuery.length > 0 &&
(!task.Subtasks || task.Subtasks.length === 0)
) {
task.Subtasks = directSubtasksQuery;
}
if (task.parent_task_id) {
if (newStatus === Task.STATUS.DONE) {
if (newStatus === Task.STATUS.DONE || newStatus === 'done') {
// When subtask is done, check if parent should be done
await checkAndUpdateParentTaskCompletion(
const parentUpdated = await checkAndUpdateParentTaskCompletion(
task.parent_task_id,
req.currentUser.id
);
if (parentUpdated) {
parentChildLogicExecuted = true;
}
} else {
// When subtask is undone, undone parent if it was done
await undoneParentTaskIfNeeded(
const parentUpdated = await undoneParentTaskIfNeeded(
task.parent_task_id,
req.currentUser.id
);
if (parentUpdated) {
parentChildLogicExecuted = true;
}
}
} else {
// This is a parent task
} else if (task.Subtasks && task.Subtasks.length > 0) {
// This is a parent task with subtasks
if (newStatus === Task.STATUS.DONE) {
// When parent is done, complete all subtasks
await completeAllSubtasks(task.id, req.currentUser.id);
const subtasksUpdated = await completeAllSubtasks(
task.id,
req.currentUser.id
);
if (subtasksUpdated) {
parentChildLogicExecuted = true;
}
} else {
// When parent is undone, undone all subtasks
await undoneAllSubtasks(task.id, req.currentUser.id);
const subtasksUpdated = await undoneAllSubtasks(
task.id,
req.currentUser.id
);
if (subtasksUpdated) {
parentChildLogicExecuted = true;
}
}
}
@ -1552,12 +1703,11 @@ router.patch('/task/:id/toggle_completion', async (req, res) => {
nextTask = await RecurringTaskService.handleTaskCompletion(task);
}
const response = {
...task.toJSON(),
due_date: task.due_date
? task.due_date.toISOString().split('T')[0]
: null,
};
// Use serializeTask to include subtasks data
const response = await serializeTask(task);
// If parent-child logic was executed, we might need to reload data
// For now, let the frontend handle the refresh to avoid complex reloading logic
if (nextTask) {
response.next_task = {
@ -1568,9 +1718,17 @@ router.patch('/task/:id/toggle_completion', async (req, res) => {
};
}
// Add flag to response to indicate if parent-child logic was executed
response.parent_child_logic_executed = parentChildLogicExecuted;
res.json(response);
} catch (error) {
res.status(422).json({ error: 'Unable to update task' });
console.error('Error in toggle completion endpoint:', error);
console.error('Error stack:', error.stack);
res.status(422).json({
error: 'Unable to update task',
details: error.message,
});
}
});

View file

@ -461,6 +461,7 @@ const Layout: React.FC<LayoutProps> = ({
task={{
name: '',
status: 'not_started',
completed_at: null,
}}
onSave={handleSaveTask}
onDelete={async () => {}}

View file

@ -247,6 +247,7 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
priority: 'medium',
tags: taskTags,
project_id: projectId,
completed_at: null,
};
if (item.id !== undefined) {

View file

@ -514,6 +514,7 @@ const InboxItems: React.FC = () => {
name: '',
status: 'not_started',
priority: 'medium',
completed_at: null,
}
}
onSave={handleSaveTask}

View file

@ -989,6 +989,7 @@ const InboxModal: React.FC<InboxModalProps> = ({
priority: 'medium',
tags: taskTags,
project_id: projectId,
completed_at: null,
};
try {
@ -1127,8 +1128,8 @@ const InboxModal: React.FC<InboxModalProps> = ({
const newTask: Task = {
name: cleanedText,
status: 'not_started',
completed_at: null,
};
try {
await onSave(newTask);
showSuccessToast(t('task.createSuccess'));

View file

@ -122,9 +122,6 @@ const ProjectDetails: React.FC = () => {
localStorage.getItem('project_order_by') ||
'created_at:desc';
console.log(
`Fetching ONLY project ${id} with fetchProjectById`
);
const projectData = await fetchProjectById(id, {
sort: sortParam,
// Remove completed parameter since backend filtering isn't working
@ -171,6 +168,7 @@ const ProjectDetails: React.FC = () => {
name: taskName,
status: 'not_started',
project_id: project.id,
completed_at: null,
});
setTasks([...tasks, newTask]);
@ -318,6 +316,7 @@ const ProjectDetails: React.FC = () => {
status: 'not_started',
project_id: projectId,
priority: 'medium',
completed_at: null,
});
// Update the tasks list to include the new task

View file

@ -26,7 +26,6 @@ const TaskSubtasksSection: React.FC<TaskSubtasksSectionProps> = ({
const subtasksSectionRef = useRef<HTMLDivElement>(null);
const addInputRef = useRef<HTMLInputElement>(null);
const scrollToBottom = () => {
setTimeout(() => {
if (subtasksSectionRef.current) {
@ -51,6 +50,7 @@ const TaskSubtasksSection: React.FC<TaskSubtasksSectionProps> = ({
isNew: true,
// Also keep for UI purposes
_isNew: true,
completed_at: null,
} as Task;
onSubtasksChange([...subtasks, newSubtask]);
@ -113,11 +113,21 @@ const TaskSubtasksSection: React.FC<TaskSubtasksSectionProps> = ({
const handleToggleNewSubtaskCompletion = (index: number) => {
const updatedSubtasks = subtasks.map((subtask, i) => {
if (i === index) {
const isDone = subtask.status === 'done' || subtask.status === 2;
const isDone =
subtask.status === 'done' || subtask.status === 2;
const newStatus = isDone
? ('not_started' as const)
: ('done' as const);
const hasId =
subtask.id &&
!((subtask as any)._isNew || (subtask as any).isNew);
return {
...subtask,
status: isDone ? ('not_started' as const) : ('done' as const),
completed_at: isDone ? undefined : new Date().toISOString(),
status: newStatus,
completed_at: isDone ? null : new Date().toISOString(),
// Mark for backend update if it has an ID (existing subtask)
_statusChanged: hasId,
};
}
return subtask;
@ -150,8 +160,16 @@ const TaskSubtasksSection: React.FC<TaskSubtasksSectionProps> = ({
subtask.status || 'not_started'
}
onToggleCompletion={async () => {
if (subtask.id && onSubtaskUpdate) {
// Existing subtask - use API
if (
subtask.id &&
onSubtaskUpdate &&
!(
(subtask as any)
._isNew ||
(subtask as any).isNew
)
) {
// Existing subtask - use API for immediate toggle, then update callback
try {
const updatedSubtask =
await toggleTaskCompletion(
@ -167,8 +185,10 @@ const TaskSubtasksSection: React.FC<TaskSubtasksSectionProps> = ({
);
}
} else {
// New subtask - handle locally
handleToggleNewSubtaskCompletion(index);
// New subtask or no callback - handle locally
handleToggleNewSubtaskCompletion(
index
);
}
}}
/>
@ -213,8 +233,17 @@ const TaskSubtasksSection: React.FC<TaskSubtasksSectionProps> = ({
'not_started'
}
onToggleCompletion={async () => {
if (subtask.id && onSubtaskUpdate) {
// Existing subtask - use API
if (
subtask.id &&
onSubtaskUpdate &&
!(
(subtask as any)
._isNew ||
(subtask as any)
.isNew
)
) {
// Existing subtask - use API for immediate toggle, then update callback
try {
const updatedSubtask =
await toggleTaskCompletion(
@ -230,8 +259,10 @@ const TaskSubtasksSection: React.FC<TaskSubtasksSectionProps> = ({
);
}
} else {
// New subtask - handle locally
handleToggleNewSubtaskCompletion(index);
// New subtask or no callback - handle locally
handleToggleNewSubtaskCompletion(
index
);
}
}}
/>

View file

@ -69,6 +69,18 @@ const SubtasksDisplay: React.FC<SubtasksDisplayProps> = ({
subtask.id
);
// Check if parent-child logic was executed
if (
updatedSubtask.parent_child_logic_executed
) {
// For subtasks, we need a full page refresh because the parent task
// might be displayed in multiple places (task list, today view, etc.)
setTimeout(() => {
window.location.reload();
}, 200);
return;
}
// Update the subtask in local state immediately
onSubtaskUpdate(
updatedSubtask
@ -125,6 +137,7 @@ import { useTranslation } from 'react-i18next';
interface TaskItemProps {
task: Task;
onTaskUpdate: (task: Task) => Promise<void>;
onTaskCompletionToggle?: (task: Task) => void;
onTaskDelete: (taskId: number) => void;
projects: Project[];
hideProjectName?: boolean;
@ -134,6 +147,7 @@ interface TaskItemProps {
const TaskItem: React.FC<TaskItemProps> = ({
task,
onTaskUpdate,
onTaskCompletionToggle,
onTaskDelete,
projects,
hideProjectName = false,
@ -286,8 +300,39 @@ const TaskItem: React.FC<TaskItemProps> = ({
const handleToggleCompletion = async () => {
if (task.id) {
try {
const updatedTask = await toggleTaskCompletion(task.id);
await onTaskUpdate(updatedTask);
const response = await toggleTaskCompletion(task.id);
// Handle the updated task
if (onTaskCompletionToggle) {
onTaskCompletionToggle(response);
} else {
await onTaskUpdate(response);
}
// Only refresh if parent-child logic was executed (affecting other tasks)
if (response.parent_child_logic_executed) {
// Instead of refreshing, let's refetch and update the task data
setTimeout(async () => {
try {
// Refetch the current task with updated subtasks
const updatedTaskResponse = await fetch(
`/api/task/${task.id}`
);
if (updatedTaskResponse.ok) {
const updatedTaskData =
await updatedTaskResponse.json();
await onTaskUpdate(updatedTaskData);
}
} catch (error) {
console.error(
'Error refetching task after parent-child logic:',
error
);
// Fallback to refresh if API call fails
window.location.reload();
}
}, 200);
}
} catch (error) {
console.error('Error toggling task completion:', error);
}

View file

@ -6,25 +6,41 @@ import { Task } from '../../entities/Task';
interface TaskListProps {
tasks: Task[];
onTaskUpdate: (task: Task) => Promise<void>;
onTaskCompletionToggle?: (task: Task) => void;
onTaskCreate?: (task: Task) => void;
onTaskDelete: (taskId: number) => void;
projects: Project[];
hideProjectName?: boolean;
onToggleToday?: (taskId: number) => Promise<void>;
showCompletedTasks?: boolean; // New prop
}
const TaskList: React.FC<TaskListProps> = ({
tasks,
onTaskUpdate,
onTaskCompletionToggle,
onTaskDelete,
projects,
hideProjectName = false,
onToggleToday,
showCompletedTasks = false, // Default to false
}) => {
// Conditionally filter tasks based on showCompletedTasks prop
const filteredTasks = showCompletedTasks
? tasks
: tasks.filter((task) => {
const isCompleted =
task.status === 'done' ||
task.status === 'archived' ||
task.status === 2 ||
task.status === 3;
return !isCompleted;
});
return (
<div className="task-list-container">
{tasks.length > 0 ? (
tasks.map((task) => (
{filteredTasks.length > 0 ? (
filteredTasks.map((task) => (
<div
key={task.id}
className="task-item-wrapper transition-all duration-200 ease-in-out"
@ -32,6 +48,7 @@ const TaskList: React.FC<TaskListProps> = ({
<TaskItem
task={task}
onTaskUpdate={onTaskUpdate}
onTaskCompletionToggle={onTaskCompletionToggle}
onTaskDelete={onTaskDelete}
projects={projects}
hideProjectName={hideProjectName}

View file

@ -51,8 +51,11 @@ const getLocale = (language: string) => {
const TasksToday: React.FC = () => {
const { t } = useTranslation();
// Get tasks from store at the top level to avoid conditional hook usage
const storeTasks = useStore((state) => state.tasksStore.tasks);
// Temporarily use local state to debug infinite loop
const [tasks, setTasks] = useState<Task[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
const [hasInitialized, setHasInitialized] = useState(false);
@ -202,9 +205,13 @@ const TasksToday: React.FC = () => {
const { tasks: fetchedTasks, metrics: fetchedMetrics } =
await fetchTasks('?type=today');
if (isMounted.current) {
setTasks(fetchedTasks);
setMetrics(fetchedMetrics);
setIsError(false);
// setTasks(fetchedTasks); // Removed local state
if (isMounted.current) {
// setTasks(fetchedTasks); // Removed local state
setMetrics(fetchedMetrics);
useStore.getState().tasksStore.setTasks(fetchedTasks);
setIsError(false);
}
}
} catch (error) {
console.error('Failed to fetch tasks:', error);
@ -400,78 +407,171 @@ const TasksToday: React.FC = () => {
async (updatedTask: Task): Promise<void> => {
if (!updatedTask.id || !isMounted.current) return;
// Helper function to update a task in an array
const updateTaskInArray = (tasks: Task[]) =>
tasks.map((task) =>
task.id === updatedTask.id
? {
...task,
...updatedTask,
updated_at: new Date().toISOString(),
}
: task
// Optimistically update UI
setMetrics((prevMetrics) => {
const newMetrics = { ...prevMetrics };
// Helper to remove task from a list
const removeTask = (list: Task[]) =>
list.filter((task) => task.id !== updatedTask.id);
// Helper to add or update task in a list
const updateOrAddTask = (list: Task[], taskToProcess: Task) => {
const existingIndex = list.findIndex(
(task) => task.id === taskToProcess.id
);
if (existingIndex > -1) {
// Task exists, update it by creating a new object and a new array
// Preserve subtasks data to prevent loss
return list.map((task, index) =>
index === existingIndex
? {
...task,
...taskToProcess,
// Explicitly preserve subtasks data
subtasks:
taskToProcess.subtasks ||
taskToProcess.Subtasks ||
task.subtasks ||
task.Subtasks ||
[],
Subtasks:
taskToProcess.subtasks ||
taskToProcess.Subtasks ||
task.subtasks ||
task.Subtasks ||
[],
}
: task
);
} else {
// Task does not exist, add it by creating a new array
return [...list, taskToProcess];
}
};
// Remove task from all potential "active" lists first
newMetrics.today_plan_tasks = removeTask(
newMetrics.today_plan_tasks || []
);
// Check if this task exists in any of our task lists
const taskExistsInLocal = tasks.some(
(task) => task.id === updatedTask.id
);
const taskExistsInMetrics =
metrics.today_plan_tasks?.some(
(task) => task.id === updatedTask.id
) ||
metrics.suggested_tasks.some(
(task) => task.id === updatedTask.id
) ||
metrics.tasks_due_today.some(
(task) => task.id === updatedTask.id
) ||
metrics.tasks_in_progress.some(
(task) => task.id === updatedTask.id
) ||
metrics.tasks_completed_today.some(
(task) => task.id === updatedTask.id
newMetrics.suggested_tasks = removeTask(
newMetrics.suggested_tasks || []
);
newMetrics.tasks_due_today = removeTask(
newMetrics.tasks_due_today || []
);
newMetrics.tasks_in_progress = removeTask(
newMetrics.tasks_in_progress || []
);
newMetrics.tasks_completed_today = removeTask(
newMetrics.tasks_completed_today || []
); // Always remove from completed first
// Update local task state
if (taskExistsInLocal) {
setTasks((prevTasks) => updateTaskInArray(prevTasks));
}
// Now, add the task to the appropriate list(s) based on its new status
if (updatedTask.status === 'done' || updatedTask.status === 2) {
// If completed, add to tasks_completed_today if it was completed today
if (updatedTask.completed_at) {
const completedDate = new Date(
updatedTask.completed_at
);
const today = new Date();
if (
format(completedDate, 'yyyy-MM-dd') ===
format(today, 'yyyy-MM-dd')
) {
newMetrics.tasks_completed_today = updateOrAddTask(
newMetrics.tasks_completed_today,
updatedTask
);
}
}
} else {
// If not completed, add to relevant active lists
if (
updatedTask.today &&
updatedTask.status !== 'archived'
) {
newMetrics.today_plan_tasks = updateOrAddTask(
newMetrics.today_plan_tasks,
updatedTask
);
}
if (updatedTask.status === 'in_progress') {
newMetrics.tasks_in_progress = updateOrAddTask(
newMetrics.tasks_in_progress,
updatedTask
);
}
// Check if due today (and not already in today_plan_tasks or in_progress)
const isDueToday =
updatedTask.due_date &&
format(new Date(updatedTask.due_date), 'yyyy-MM-dd') ===
format(new Date(), 'yyyy-MM-dd');
if (
isDueToday &&
updatedTask.status !== 'archived' &&
!newMetrics.today_plan_tasks.some(
(t) => t.id === updatedTask.id
) &&
!newMetrics.tasks_in_progress.some(
(t) => t.id === updatedTask.id
)
) {
newMetrics.tasks_due_today = updateOrAddTask(
newMetrics.tasks_due_today,
updatedTask
);
}
// Check for suggested tasks (and not already in other active lists)
const isSuggested =
!updatedTask.today &&
!updatedTask.project_id &&
!updatedTask.due_date;
if (
isSuggested &&
updatedTask.status !== 'archived' &&
updatedTask.status !== 'done' &&
updatedTask.status !== 2 &&
!newMetrics.today_plan_tasks.some(
(t) => t.id === updatedTask.id
) &&
!newMetrics.tasks_due_today.some(
(t) => t.id === updatedTask.id
) &&
!newMetrics.tasks_in_progress.some(
(t) => t.id === updatedTask.id
)
) {
newMetrics.suggested_tasks = updateOrAddTask(
newMetrics.suggested_tasks,
updatedTask
);
}
}
if (taskExistsInMetrics) {
setMetrics((prevMetrics) => ({
...prevMetrics,
today_plan_tasks: updateTaskInArray(
prevMetrics.today_plan_tasks || []
),
suggested_tasks: updateTaskInArray(
prevMetrics.suggested_tasks || []
),
tasks_due_today: updateTaskInArray(
prevMetrics.tasks_due_today || []
),
tasks_in_progress: updateTaskInArray(
prevMetrics.tasks_in_progress || []
),
tasks_completed_today: updateTaskInArray(
prevMetrics.tasks_completed_today || []
),
}));
}
// Recalculate total_open_tasks based on the updated active lists
newMetrics.total_open_tasks =
newMetrics.today_plan_tasks.length +
newMetrics.suggested_tasks.length +
newMetrics.tasks_due_today.length +
newMetrics.tasks_in_progress.length;
return newMetrics;
});
// Update the store with the updated task
useStore.getState().tasksStore.updateTaskInStore(updatedTask);
try {
// Make API call if the task exists anywhere
if (taskExistsInLocal || taskExistsInMetrics) {
await updateTask(updatedTask.id, updatedTask);
}
// Make API call to persist the change
await updateTask(updatedTask.id, updatedTask);
} catch (error) {
console.error('Error updating task:', error);
if (isMounted.current) {
// Error handling is now managed by the store
}
// Revert UI on error if necessary, or re-fetch to sync
// For now, just log the error
}
},
[tasks, metrics]
[] // Dependencies are now handled by direct state manipulation
);
const handleTaskDelete = useCallback(
@ -485,7 +585,7 @@ const TasksToday: React.FC = () => {
const { tasks: updatedTasks, metrics: updatedMetrics } =
await fetchTasks('?type=today');
if (isMounted.current) {
setTasks(updatedTasks);
useStore.getState().tasksStore.setTasks(updatedTasks);
setMetrics(updatedMetrics);
}
} catch (error) {
@ -506,7 +606,7 @@ const TasksToday: React.FC = () => {
const { tasks: updatedTasks, metrics: updatedMetrics } =
await fetchTasks('?type=today');
if (isMounted.current) {
setTasks(updatedTasks);
useStore.getState().tasksStore.setTasks(updatedTasks);
setMetrics(updatedMetrics);
}
} catch (error) {
@ -516,6 +616,21 @@ const TasksToday: React.FC = () => {
[]
);
const handleTaskCompletionToggle = useCallback(
async (updatedTask: Task): Promise<void> => {
if (!isMounted.current) return;
try {
// The updatedTask is already the result of the API call from TaskItem
// Use the centralized task update handler to update UI optimistically
await handleTaskUpdate(updatedTask);
} catch (error) {
console.error('Error toggling task completion:', error);
}
},
[handleTaskUpdate]
);
// Calculate today's progress for the progress bar
const getTodayProgress = () => {
const todayTasks = metrics.today_plan_tasks || [];
@ -551,7 +666,7 @@ const TasksToday: React.FC = () => {
}
// Show error state
if (isError && tasks.length === 0) {
if (isError && storeTasks.length === 0) {
return (
<div className="flex justify-center items-center h-64">
<p className="text-red-500">
@ -814,7 +929,7 @@ const TasksToday: React.FC = () => {
productivityAssistantEnabled &&
profileSettings.productivity_assistant_enabled === true ? (
<ProductivityAssistant
tasks={tasks}
tasks={storeTasks}
projects={localProjects}
/>
) : null}
@ -854,6 +969,7 @@ const TasksToday: React.FC = () => {
onTaskUpdate={handleTaskUpdate}
onTaskDelete={handleTaskDelete}
onToggleToday={handleToggleToday}
onTaskCompletionToggle={handleTaskCompletionToggle}
/>
{/* Suggested Tasks - Separate setting */}
@ -890,6 +1006,9 @@ const TasksToday: React.FC = () => {
<TaskList
tasks={metrics.suggested_tasks}
onTaskUpdate={handleTaskUpdate}
onTaskCompletionToggle={
handleTaskCompletionToggle
}
onTaskDelete={handleTaskDelete}
projects={localProjects}
onToggleToday={handleToggleToday}
@ -912,6 +1031,9 @@ const TasksToday: React.FC = () => {
onTaskDelete={handleTaskDelete}
projects={localProjects}
onToggleToday={handleToggleToday}
onTaskCompletionToggle={
handleTaskCompletionToggle
}
/>
</div>
)}
@ -919,37 +1041,49 @@ const TasksToday: React.FC = () => {
{/* Completed Tasks - Conditionally Rendered */}
{isSettingsLoaded &&
todaySettings.showCompleted &&
metrics.tasks_completed_today.length > 0 && (
<div className="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"
onClick={toggleCompletedCollapsed}
>
<h3 className="text-xl font-medium">
{t('tasks.completedToday')}
</h3>
<div className="flex items-center">
<span className="text-sm text-gray-500 mr-2">
{metrics.tasks_completed_today.length}
</span>
{isCompletedCollapsed ? (
<ChevronRightIcon className="h-5 w-5 text-gray-500" />
) : (
<ChevronDownIcon className="h-5 w-5 text-gray-500" />
)}
(() => {
const completedToday = metrics.tasks_completed_today; // Use the already filtered list from backend
return (
<div className="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"
onClick={toggleCompletedCollapsed}
>
<h3 className="text-xl font-medium">
{t('tasks.completedToday')}
</h3>
<div className="flex items-center">
<span className="text-sm text-gray-500 mr-2">
{completedToday.length}
</span>
{isCompletedCollapsed ? (
<ChevronRightIcon className="h-5 w-5 text-gray-500" />
) : (
<ChevronDownIcon className="h-5 w-5 text-gray-500" />
)}
</div>
</div>
{!isCompletedCollapsed &&
(completedToday.length > 0 ? (
<TaskList
tasks={completedToday}
onTaskUpdate={handleTaskUpdate}
onTaskDelete={handleTaskDelete}
projects={localProjects}
onToggleToday={handleToggleToday}
showCompletedTasks={true}
/>
) : (
<p className="text-gray-500 dark:text-gray-400 text-center mt-4">
{t(
'tasks.noCompletedTasksToday',
'No completed tasks today.'
)}
</p>
))}
</div>
{!isCompletedCollapsed && (
<TaskList
tasks={metrics.tasks_completed_today}
onTaskUpdate={handleTaskUpdate}
onTaskDelete={handleTaskDelete}
projects={localProjects}
onToggleToday={handleToggleToday}
/>
)}
</div>
)}
);
})()}
{metrics.tasks_due_today.length === 0 &&
metrics.tasks_in_progress.length === 0 &&

View file

@ -11,6 +11,7 @@ interface TodayPlanProps {
onTaskUpdate: (task: Task) => Promise<void>;
onTaskDelete: (taskId: number) => Promise<void>;
onToggleToday?: (taskId: number) => Promise<void>;
onTaskCompletionToggle?: (task: Task) => void; // New prop
}
const TodayPlan: React.FC<TodayPlanProps> = ({
@ -19,6 +20,7 @@ const TodayPlan: React.FC<TodayPlanProps> = ({
onTaskUpdate,
onTaskDelete,
onToggleToday,
onTaskCompletionToggle, // Destructure new prop
}) => {
const { t } = useTranslation();
@ -83,6 +85,7 @@ const TodayPlan: React.FC<TodayPlanProps> = ({
onTaskDelete={onTaskDelete}
projects={projects}
onToggleToday={onToggleToday}
onTaskCompletionToggle={onTaskCompletionToggle}
/>
</>
);

View file

@ -1,4 +1,4 @@
import React, { useEffect, useState, useRef } from 'react';
import React, { useEffect, useState, useRef, useMemo } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import TaskList from './Task/TaskList';
@ -15,6 +15,7 @@ import {
TagIcon,
XMarkIcon,
MagnifyingGlassIcon,
CheckCircleIcon,
} from '@heroicons/react/24/solid';
const capitalize = (str: string) => str.charAt(0).toUpperCase() + str.slice(1);
@ -45,10 +46,49 @@ const Tasks: React.FC = () => {
const [taskSearchQuery, setTaskSearchQuery] = useState<string>('');
const [isInfoExpanded, setIsInfoExpanded] = useState(false); // Collapsed by default
const [isSearchExpanded, setIsSearchExpanded] = useState(false); // Collapsed by default
const [showCompleted, setShowCompleted] = useState(false); // Show completed tasks toggle
const dropdownRef = useRef<HTMLDivElement>(null);
const location = useLocation();
const navigate = useNavigate();
// Filter tasks based on completion status and search query
const displayTasks = useMemo(() => {
let filteredTasks;
// First filter by completion status
if (showCompleted) {
// Show only completed tasks (done=2 or archived=3)
filteredTasks = tasks.filter(
(task) =>
task.status === 'done' ||
task.status === 'archived' ||
task.status === 2 ||
task.status === 3
);
} else {
// Show only non-completed tasks - exclude done(2) and archived(3)
filteredTasks = tasks.filter(
(task) =>
task.status !== 'done' &&
task.status !== 'archived' &&
task.status !== 2 &&
task.status !== 3
);
}
// Then filter by search query if provided
if (taskSearchQuery.trim()) {
const query = taskSearchQuery.toLowerCase();
filteredTasks = filteredTasks.filter(
(task) =>
task.name.toLowerCase().includes(query) ||
task.note?.toLowerCase().includes(query)
);
}
return filteredTasks;
}, [tasks, showCompleted, taskSearchQuery]);
const query = new URLSearchParams(location.search);
const { title: stateTitle } = location.state || {};
@ -98,9 +138,15 @@ const Tasks: React.FC = () => {
setError(null);
try {
const tagId = query.get('tag');
// Fetch all tasks (both completed and non-completed) for client-side filtering
const allTasksUrl = new URLSearchParams(location.search);
// Add special parameter to get ALL tasks (completed and non-completed)
allTasksUrl.set('client_side_filtering', 'true');
const searchParams = allTasksUrl.toString();
const [tasksResponse, projectsResponse] = await Promise.all([
fetch(
`/api/tasks${location.search}${tagId ? `&tag=${tagId}` : ''}`
`/api/tasks?${searchParams}${tagId ? `&tag=${tagId}` : ''}`
),
fetch('/api/projects'),
]);
@ -196,7 +242,25 @@ const Tasks: React.FC = () => {
if (response.ok) {
setTasks((prevTasks) =>
prevTasks.map((task) =>
task.id === updatedTask.id ? updatedTask : task
task.id === updatedTask.id
? {
...task,
...updatedTask,
// Explicitly preserve subtasks data
subtasks:
updatedTask.subtasks ||
updatedTask.Subtasks ||
task.subtasks ||
task.Subtasks ||
[],
Subtasks:
updatedTask.subtasks ||
updatedTask.Subtasks ||
task.subtasks ||
task.Subtasks ||
[],
}
: task
)
);
} else {
@ -210,6 +274,15 @@ const Tasks: React.FC = () => {
}
};
// Handler specifically for task completion toggles (no API call needed, just state update)
const handleTaskCompletionToggle = (updatedTask: Task) => {
setTasks((prevTasks) =>
prevTasks.map((task) =>
task.id === updatedTask.id ? updatedTask : task
)
);
};
const handleTaskDelete = async (taskId: number) => {
try {
const response = await fetch(`/api/task/${taskId}`, {
@ -290,10 +363,6 @@ const Tasks: React.FC = () => {
return status !== 'done';
};
const filteredTasks = tasks.filter((task) =>
task.name.toLowerCase().includes(taskSearchQuery.toLowerCase())
);
return (
<div className="flex justify-center px-4 lg:px-2">
<div className="w-full max-w-5xl">
@ -316,7 +385,7 @@ const Tasks: React.FC = () => {
</div>
)}
</div>
{/* Info expand/collapse button, search button, and sort dropdown */}
{/* Info expand/collapse button, search button, show completed toggle, and sort dropdown */}
<div className="flex items-center gap-2 w-full sm:w-auto justify-end mt-2 sm:mt-0">
<button
onClick={() => setIsInfoExpanded((v) => !v)}
@ -372,6 +441,32 @@ const Tasks: React.FC = () => {
: 'Search Tasks'}
</span>
</button>
<button
onClick={() => setShowCompleted((v) => !v)}
className={`flex items-center transition-all duration-300 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-inset rounded-lg p-2 ${
showCompleted
? 'bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400'
: 'bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 text-gray-600 dark:text-gray-400'
}`}
aria-pressed={showCompleted}
aria-label={
showCompleted
? 'Hide completed tasks'
: 'Show completed tasks'
}
title={
showCompleted
? 'Hide completed tasks'
: 'Show completed tasks'
}
>
<CheckCircleIcon className="h-5 w-5" />
<span className="sr-only">
{showCompleted
? 'Hide completed tasks'
: 'Show completed tasks'}
</span>
</button>
<SortFilter
sortOptions={sortOptions}
sortValue={orderBy}
@ -450,14 +545,18 @@ const Tasks: React.FC = () => {
/>
)}
{filteredTasks.length > 0 ? (
{displayTasks.length > 0 ? (
<TaskList
tasks={filteredTasks}
tasks={displayTasks}
onTaskCreate={handleTaskCreate}
onTaskUpdate={handleTaskUpdate}
onTaskCompletionToggle={
handleTaskCompletionToggle
}
onTaskDelete={handleTaskDelete}
projects={projects}
onToggleToday={handleToggleToday}
showCompletedTasks={showCompleted}
/>
) : (
<div className="flex justify-center items-center mt-4">

View file

@ -24,13 +24,18 @@ export interface Task {
recurrence_week_of_month?: number;
completion_based?: boolean;
recurring_parent_id?: number;
completed_at?: string;
completed_at: string | null;
parent_task_id?: number;
subtasks?: Task[];
Subtasks?: Task[]; // Handle API response case sensitivity (temporary)
}
export type StatusType = 'not_started' | 'in_progress' | 'done' | 'archived';
export type StatusType =
| 'not_started'
| 'in_progress'
| 'done'
| 'archived'
| 'waiting';
export type PriorityType = 'low' | 'medium' | 'high';
export type RecurrenceType =
| 'none'

View file

@ -509,7 +509,25 @@ export const useStore = create<StoreState>((set) => ({
tasksStore: {
...state.tasksStore,
tasks: state.tasksStore.tasks.map((task) =>
task.id === updatedTask.id ? updatedTask : task
task.id === updatedTask.id
? {
...task,
...updatedTask,
// Explicitly preserve subtasks data
subtasks:
updatedTask.subtasks ||
updatedTask.Subtasks ||
task.subtasks ||
task.Subtasks ||
[],
Subtasks:
updatedTask.subtasks ||
updatedTask.Subtasks ||
task.subtasks ||
task.Subtasks ||
[],
}
: task
),
},
})),

View file

@ -146,7 +146,7 @@ export const isTaskOverdue = (task: {
created_at?: string;
today_move_count?: number;
status: string | number;
completed_at?: string;
completed_at: string | null;
}): boolean => {
// If task is not in today plan, it's not overdue
if (!task.today) {