tududi/frontend/components/Task/TaskDetails.tsx
Chris e8c7eed226
fix: prevent subtasks from disappearing when updating parent task (#984)
This commit addresses a critical bug where subtasks would disappear when
updating the parent task (e.g., assigning tags). The issue had multiple
potential causes:

1. Backend vulnerability: The updateSubtasks() function would delete all
   subtasks if an empty array was sent, treating it as "delete everything
   not in this list"

2. Frontend state management: After reloading a task, subtasks weren't
   being preserved if the backend response didn't include them

3. Unclear error messages: "Invalid parent task" errors didn't provide
   enough context for debugging

Changes:
- Added defensive logging in updateSubtasks() to warn when all subtasks
  are being deleted with an empty array
- Enhanced validateParentTaskAccess() error messages to provide detailed
  diagnostics (task not found vs. permission issues vs. wrong user)
- Updated handleTagsUpdate() in TaskDetails to explicitly preserve
  subtasks when reloading task after tag updates

This fix is defensive in nature and adds better observability for
diagnosing similar issues in the future.

Fixes issue reported by user where subtasks disappeared after assigning
tags to parent task, and "Invalid parent task" errors occurred when
trying to update the orphaned subtasks.
2026-04-01 17:37:20 +03:00

1294 lines
47 KiB
TypeScript

import React, { useEffect, useState, useRef, useCallback } from 'react';
import { useParams, useNavigate, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { ExclamationTriangleIcon } from '@heroicons/react/24/outline';
import ConfirmDialog from '../Shared/ConfirmDialog';
import { Task } from '../../entities/Task';
import { Project } from '../../entities/Project';
import {
updateTask,
deleteTask,
fetchTaskByUid,
fetchTaskNextIterations,
fetchSubtasks,
TaskIteration,
toggleTaskCompletion,
} from '../../utils/tasksService';
import { createProject } from '../../utils/projectsService';
import { fetchAttachments } from '../../utils/attachmentsService';
import { useStore } from '../../store/useStore';
import { useToast } from '../Shared/ToastContext';
import LoadingScreen from '../Shared/LoadingScreen';
import TaskTimeline from './TaskTimeline';
import {
TaskDetailsHeader,
TaskContentCard,
TaskProjectCard,
TaskTagsCard,
TaskSubtasksCard,
TaskRecurrenceCard,
TaskDueDateCard,
TaskDeferUntilCard,
TaskAttachmentsCard,
} from './TaskDetails/';
import {
isTaskOverdueInTodayPlan,
isTaskPastDue,
getTodayDateString,
} from '../../utils/dateUtils';
const TaskDetails: React.FC = () => {
const { uid } = useParams<{ uid: string }>();
const navigate = useNavigate();
const location = useLocation();
const { t } = useTranslation();
const isNewTask = location.state?.isNew === true;
const isNewTaskRef = useRef(isNewTask);
const taskModifiedRef = useRef(false);
const { showSuccessToast, showErrorToast } = useToast();
// Clear navigation state so refresh/back doesn't re-trigger edit mode
useEffect(() => {
if (isNewTask) {
navigate(location.pathname, { replace: true, state: {} });
}
}, [isNewTask, navigate, location.pathname]);
// Clean up abandoned new tasks: if user navigates away without modifying anything, delete the task
useEffect(() => {
const taskUid = uid;
return () => {
if (isNewTaskRef.current && !taskModifiedRef.current && taskUid) {
deleteTask(taskUid).catch((err) =>
console.error('Error cleaning up abandoned new task:', err)
);
const store = useStore.getState();
store.tasksStore.setTasks(
store.tasksStore.tasks.filter((t: Task) => t.uid !== taskUid)
);
}
};
}, [uid]);
const projectsStore = useStore((state: any) => state.projectsStore);
const tagsStore = useStore((state: any) => state.tagsStore);
const tasksStore = useStore((state: any) => state.tasksStore);
const task = useStore((state: any) =>
state.tasksStore.tasks.find((t: Task) => t.uid === uid)
);
const subtasks = task?.subtasks || [];
const [loading, setLoading] = useState(!task);
const [error, setError] = useState<string | null>(null);
const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState(false);
const [taskToDelete, setTaskToDelete] = useState<Task | null>(null);
const [timelineRefreshKey, setTimelineRefreshKey] = useState(0);
const [isOverdueBubbleVisible, setIsOverdueBubbleVisible] = useState(false);
const [nextIterations, setNextIterations] = useState<TaskIteration[]>([]);
const [loadingIterations, setLoadingIterations] = useState(false);
const [parentTask, setParentTask] = useState<Task | null>(null);
const [loadingParent, setLoadingParent] = useState(false);
const [pendingSubtasks, setPendingSubtasks] = useState<Task[]>([]);
const [actionsMenuOpen, setActionsMenuOpen] = useState(false);
const actionsMenuRef = useRef<HTMLDivElement>(null);
const lastKnownSubtaskCount = useRef<number>(0);
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as Node;
if (
actionsMenuOpen &&
actionsMenuRef.current &&
!actionsMenuRef.current.contains(target)
) {
setActionsMenuOpen(false);
}
if (isOverdueBubbleVisible) {
const clickedOverdueToggle =
typeof e.composedPath === 'function'
? e
.composedPath()
.some(
(node) =>
node instanceof HTMLElement &&
node.hasAttribute('data-overdue-toggle')
)
: target instanceof HTMLElement &&
!!target.closest('[data-overdue-toggle]');
if (!clickedOverdueToggle) {
setIsOverdueBubbleVisible(false);
}
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [actionsMenuOpen, isOverdueBubbleVisible]);
const [isEditingDueDate, setIsEditingDueDate] = useState(false);
const [editedDueDate, setEditedDueDate] = useState<string>(
task?.due_date || ''
);
const [isEditingDeferUntil, setIsEditingDeferUntil] = useState(false);
const [editedDeferUntil, setEditedDeferUntil] = useState<string>(
task?.defer_until || ''
);
const [isEditingRecurrence, setIsEditingRecurrence] = useState(false);
const [recurrenceForm, setRecurrenceForm] = useState({
recurrence_type: task?.recurrence_type || 'none',
recurrence_interval: task?.recurrence_interval || 1,
recurrence_end_date: task?.recurrence_end_date || '',
recurrence_weekday: task?.recurrence_weekday || null,
recurrence_weekdays: task?.recurrence_weekdays || [],
recurrence_month_day: task?.recurrence_month_day || null,
recurrence_week_of_month: task?.recurrence_week_of_month || null,
completion_based: task?.completion_based || false,
});
const [activePill, setActivePill] = useState('overview');
const [attachmentCount, setAttachmentCount] = useState(0);
const [hasLoadedSubtasks, setHasLoadedSubtasks] = useState(false);
useEffect(() => {
setEditedDueDate(task?.due_date || '');
}, [task?.due_date]);
useEffect(() => {
setRecurrenceForm({
recurrence_type: task?.recurrence_type || 'none',
recurrence_interval: task?.recurrence_interval || 1,
recurrence_end_date: task?.recurrence_end_date || '',
recurrence_weekday: task?.recurrence_weekday || null,
recurrence_weekdays: task?.recurrence_weekdays || [],
recurrence_month_day: task?.recurrence_month_day || null,
recurrence_week_of_month: task?.recurrence_week_of_month || null,
completion_based: task?.completion_based || false,
});
}, [
task?.recurrence_type,
task?.recurrence_interval,
task?.recurrence_end_date,
task?.recurrence_weekday,
task?.recurrence_weekdays,
task?.recurrence_month_day,
task?.recurrence_week_of_month,
task?.completion_based,
]);
useEffect(() => {
if (!tagsStore.hasLoaded && !tagsStore.isLoading) {
tagsStore.loadTags();
}
}, [tagsStore]);
const handleStartRecurrenceEdit = () => {
setRecurrenceForm({
recurrence_type: task?.recurrence_type || 'none',
recurrence_interval: task?.recurrence_interval || 1,
recurrence_end_date: task?.recurrence_end_date || '',
recurrence_weekday: task?.recurrence_weekday || null,
recurrence_weekdays: task?.recurrence_weekdays || [],
recurrence_month_day: task?.recurrence_month_day || null,
recurrence_week_of_month: task?.recurrence_week_of_month || null,
completion_based: task?.completion_based || false,
});
setIsEditingRecurrence(true);
};
const isOverdue = task ? isTaskOverdueInTodayPlan(task) : false;
const isPastDue = task ? isTaskPastDue(task) : false;
useEffect(() => {
if (!isOverdue) {
setIsOverdueBubbleVisible(false);
}
}, [isOverdue]);
const handleOverdueIconClick = () => {
if (!isOverdue) {
return;
}
setIsOverdueBubbleVisible((prev) => !prev);
};
const handleDismissOverdueAlert = () => {
setIsOverdueBubbleVisible(false);
};
const handleRecurrenceChange = (field: string, value: any) => {
setRecurrenceForm((prev) => {
const updated = { ...prev, [field]: value };
if (
field === 'recurrence_type' &&
value === 'monthly' &&
!prev.recurrence_month_day
) {
updated.recurrence_month_day = new Date().getDate();
}
return updated;
});
};
const handleSaveRecurrence = async () => {
if (!task?.uid) {
setIsEditingRecurrence(false);
return;
}
try {
taskModifiedRef.current = true;
const recurrencePayload: Partial<Task> = {
recurrence_type: recurrenceForm.recurrence_type,
recurrence_interval: recurrenceForm.recurrence_interval || 1,
recurrence_end_date: recurrenceForm.recurrence_end_date || null,
recurrence_weekday:
recurrenceForm.recurrence_type === 'weekly' ||
recurrenceForm.recurrence_type === 'monthly_weekday'
? recurrenceForm.recurrence_weekday || null
: null,
recurrence_weekdays:
recurrenceForm.recurrence_type === 'weekly'
? recurrenceForm.recurrence_weekdays || []
: null,
recurrence_month_day:
recurrenceForm.recurrence_type === 'monthly'
? recurrenceForm.recurrence_month_day || null
: null,
recurrence_week_of_month:
recurrenceForm.recurrence_type === 'monthly_weekday'
? recurrenceForm.recurrence_week_of_month || null
: null,
completion_based: recurrenceForm.completion_based,
};
await updateTask(task.uid, recurrencePayload);
if (uid) {
const updatedTask = await fetchTaskByUid(uid);
const existingIndex = tasksStore.tasks.findIndex(
(t: Task) => t.uid === uid
);
if (existingIndex >= 0) {
const updatedTasks = [...tasksStore.tasks];
updatedTasks[existingIndex] = updatedTask;
tasksStore.setTasks(updatedTasks);
}
}
showSuccessToast(
t('task.recurrenceUpdated', 'Recurrence updated successfully')
);
setIsEditingRecurrence(false);
setTimelineRefreshKey((prev) => prev + 1);
} catch (error) {
console.error('Error updating recurrence:', error);
showErrorToast(
t('task.recurrenceUpdateError', 'Failed to update recurrence')
);
setIsEditingRecurrence(false);
}
};
const handleCancelRecurrenceEdit = () => {
setIsEditingRecurrence(false);
setRecurrenceForm({
recurrence_type: task?.recurrence_type || 'none',
recurrence_interval: task?.recurrence_interval || 1,
recurrence_end_date: task?.recurrence_end_date || '',
recurrence_weekday: task?.recurrence_weekday || null,
recurrence_weekdays: task?.recurrence_weekdays || [],
recurrence_month_day: task?.recurrence_month_day || null,
recurrence_week_of_month: task?.recurrence_week_of_month || null,
completion_based: task?.completion_based || false,
});
};
const handleStartDueDateEdit = () => {
setEditedDueDate(task?.due_date || '');
setIsEditingDueDate(true);
};
const handleSaveDueDate = async () => {
if (!task?.uid) {
setIsEditingDueDate(false);
setEditedDueDate(task?.due_date || '');
return;
}
if ((editedDueDate || '') === (task.due_date || '')) {
setIsEditingDueDate(false);
return;
}
if (task.defer_until && editedDueDate) {
const deferDate = new Date(task.defer_until);
const dueDate = new Date(editedDueDate);
if (!isNaN(deferDate.getTime()) && !isNaN(dueDate.getTime())) {
if (deferDate > dueDate) {
showErrorToast(
t(
'task.dueDateBeforeDeferError',
'Due date cannot be before the defer until date'
)
);
return;
}
}
}
if (editedDueDate) {
const todayStr = getTodayDateString();
const dueDateStr = editedDueDate.split('T')[0];
if (dueDateStr < todayStr) {
showErrorToast(
t(
'task.dueDateInPastWarning',
'Warning: You are setting a due date in the past'
)
);
}
}
try {
taskModifiedRef.current = true;
await updateTask(task.uid, {
due_date: editedDueDate || null,
});
if (uid) {
const updatedTask = await fetchTaskByUid(uid);
const existingIndex = tasksStore.tasks.findIndex(
(t: Task) => t.uid === uid
);
if (existingIndex >= 0) {
const updatedTasks = [...tasksStore.tasks];
updatedTasks[existingIndex] = updatedTask;
tasksStore.setTasks(updatedTasks);
}
}
showSuccessToast(
t('task.dueDateUpdated', 'Due date updated successfully')
);
setIsEditingDueDate(false);
setTimelineRefreshKey((prev) => prev + 1);
} catch (error) {
console.error('Error updating due date:', error);
showErrorToast(
t('task.dueDateUpdateError', 'Failed to update due date')
);
setEditedDueDate(task.due_date || '');
setIsEditingDueDate(false);
}
};
const handleCancelDueDateEdit = () => {
setIsEditingDueDate(false);
setEditedDueDate(task?.due_date || '');
};
const handleStartDeferUntilEdit = () => {
setEditedDeferUntil(task?.defer_until || '');
setIsEditingDeferUntil(true);
};
const handleSaveDeferUntil = async () => {
if (!task?.uid) {
setIsEditingDeferUntil(false);
setEditedDeferUntil(task?.defer_until || '');
return;
}
if ((editedDeferUntil || '') === (task.defer_until || '')) {
setIsEditingDeferUntil(false);
return;
}
if (editedDeferUntil && task.due_date) {
const deferDate = new Date(editedDeferUntil);
const dueDate = new Date(task.due_date);
if (!isNaN(deferDate.getTime()) && !isNaN(dueDate.getTime())) {
// For recurring instances, skip strict frontend validation
// Backend will validate against parent's recurrence_end_date
if (!task.recurring_parent_id) {
// Only validate for non-recurring tasks
if (deferDate > dueDate) {
showErrorToast(
t(
'task.deferAfterDueError',
'Defer until date cannot be after the due date'
)
);
return;
}
}
}
}
try {
taskModifiedRef.current = true;
await updateTask(task.uid, {
defer_until: editedDeferUntil || null,
});
if (uid) {
const updatedTask = await fetchTaskByUid(uid);
const existingIndex = tasksStore.tasks.findIndex(
(t: Task) => t.uid === uid
);
if (existingIndex >= 0) {
const updatedTasks = [...tasksStore.tasks];
updatedTasks[existingIndex] = updatedTask;
tasksStore.setTasks(updatedTasks);
}
}
showSuccessToast(
t('task.deferUntilUpdated', 'Defer until successfully updated')
);
setIsEditingDeferUntil(false);
setTimelineRefreshKey((prev) => prev + 1);
} catch (error: any) {
console.error('Error updating defer until:', error);
showErrorToast(
error?.message ||
t(
'task.deferUntilUpdateError',
'Failed to update defer until'
)
);
setEditedDeferUntil(task?.defer_until || '');
setIsEditingDeferUntil(false);
}
};
const handleCancelDeferUntilEdit = () => {
setIsEditingDeferUntil(false);
setEditedDeferUntil(task?.defer_until || '');
};
useEffect(() => {
const fetchTaskData = async () => {
if (!uid) {
setError('No task uid provided');
setLoading(false);
return;
}
if (!task) {
try {
setLoading(true);
const fetchedTask = await fetchTaskByUid(uid);
tasksStore.setTasks([...tasksStore.tasks, fetchedTask]);
} catch (fetchError) {
setError('Task not found');
console.error('Error fetching task:', fetchError);
} finally {
setLoading(false);
}
}
};
fetchTaskData();
}, [uid, task, tasksStore]);
useEffect(() => {
const loadAttachmentCount = async () => {
if (task?.uid) {
try {
const attachments = await fetchAttachments(task.uid);
setAttachmentCount(attachments.length);
} catch (error) {
console.error('Error loading attachment count:', error);
}
}
};
loadAttachmentCount();
}, [task?.uid]);
useEffect(() => {
setHasLoadedSubtasks(false);
lastKnownSubtaskCount.current = 0;
}, [uid]);
useEffect(() => {
const loadSubtasks = async () => {
if (activePill !== 'subtasks' || !task?.uid) {
return;
}
const currentCount = task?.subtasks?.length || 0;
const subtasksDisappeared =
lastKnownSubtaskCount.current > 0 && currentCount === 0;
const needsInitialLoad = !hasLoadedSubtasks && currentCount === 0;
if (needsInitialLoad || subtasksDisappeared) {
try {
const fetchedSubtasks = await fetchSubtasks(task.uid);
setHasLoadedSubtasks(true);
lastKnownSubtaskCount.current = fetchedSubtasks.length;
const existingIndex = tasksStore.tasks.findIndex(
(t: Task) => t.uid === task.uid
);
if (existingIndex >= 0) {
const updatedTasks = [...tasksStore.tasks];
updatedTasks[existingIndex] = {
...task,
subtasks: fetchedSubtasks,
};
tasksStore.setTasks(updatedTasks);
}
} catch (error) {
console.error('Error loading subtasks:', error);
setHasLoadedSubtasks(true);
}
} else if (currentCount > 0) {
lastKnownSubtaskCount.current = currentCount;
}
};
loadSubtasks();
}, [activePill, task?.uid, task?.subtasks, hasLoadedSubtasks, tasksStore]);
useEffect(() => {
setPendingSubtasks(subtasks);
}, [subtasks]);
useEffect(() => {
const loadNextIterations = async () => {
if (
task?.id &&
task.recurrence_type &&
task.recurrence_type !== 'none'
) {
try {
setLoadingIterations(true);
const iterations = await fetchTaskNextIterations(task.uid!);
setNextIterations(iterations);
} catch (error) {
console.error('Error loading next iterations:', error);
setNextIterations([]);
} finally {
setLoadingIterations(false);
}
} else if (
task?.recurring_parent_id &&
parentTask?.uid &&
parentTask.recurrence_type &&
parentTask.recurrence_type !== 'none'
) {
try {
setLoadingIterations(true);
const iterations = await fetchTaskNextIterations(
parentTask.uid
);
setNextIterations(iterations);
} catch (error) {
console.error(
'Error loading next iterations for child task:',
error
);
setNextIterations([]);
} finally {
setLoadingIterations(false);
}
} else {
setNextIterations([]);
}
};
loadNextIterations();
}, [
task?.id,
task?.recurrence_type,
task?.due_date,
task?.recurring_parent_id,
parentTask?.id,
parentTask?.recurrence_type,
]);
useEffect(() => {
const loadParentTask = async () => {
if (task?.recurring_parent_uid) {
try {
setLoadingParent(true);
const parent = await fetchTaskByUid(
task.recurring_parent_uid
);
setParentTask(parent);
} catch (error) {
console.error('Error fetching parent task:', error);
setParentTask(null);
} finally {
setLoadingParent(false);
}
}
};
loadParentTask();
}, [task?.recurring_parent_uid]);
const handleSaveSubtasks = async (subtasksToSave: Task[]) => {
if (!task?.uid) {
return;
}
const hasChanges =
subtasksToSave.length !== subtasks.length ||
subtasksToSave.some(
(ps, i) =>
!subtasks[i] ||
ps.name !== subtasks[i].name ||
ps.status !== subtasks[i].status ||
(ps as any)._isNew ||
(ps as any)._isEdited ||
(ps as any)._statusChanged
);
if (!hasChanges) {
return;
}
try {
taskModifiedRef.current = true;
await updateTask(task.uid, { subtasks: subtasksToSave });
if (uid) {
const updatedTask = await fetchTaskByUid(uid);
lastKnownSubtaskCount.current =
updatedTask.subtasks?.length || 0;
const existingIndex = tasksStore.tasks.findIndex(
(t: Task) => t.uid === uid
);
if (existingIndex >= 0) {
const updatedTasks = [...tasksStore.tasks];
updatedTasks[existingIndex] = updatedTask;
tasksStore.setTasks(updatedTasks);
}
}
setTimelineRefreshKey((prev) => prev + 1);
} catch (error) {
console.error('Error updating subtasks:', error);
showErrorToast(
t('task.subtasksUpdateError', 'Failed to update subtasks')
);
setPendingSubtasks([...subtasks]);
}
};
const handleProjectSelection = async (project: Project) => {
if (!task?.uid) return;
try {
taskModifiedRef.current = true;
await updateTask(task.uid, { project_id: project.id });
if (uid) {
const updatedTask = await fetchTaskByUid(uid);
const existingIndex = tasksStore.tasks.findIndex(
(t: Task) => t.uid === uid
);
if (existingIndex >= 0) {
const updatedTasks = [...tasksStore.tasks];
updatedTasks[existingIndex] = updatedTask;
tasksStore.setTasks(updatedTasks);
}
}
showSuccessToast(
t('task.projectUpdated', 'Project updated successfully')
);
setTimelineRefreshKey((prev) => prev + 1);
} catch (error) {
console.error('Error updating project:', error);
showErrorToast(
t('task.projectUpdateError', 'Failed to update project')
);
}
};
const handleClearProject = async () => {
if (!task?.uid) return;
try {
taskModifiedRef.current = true;
await updateTask(task.uid, { project_id: null });
if (uid) {
const updatedTask = await fetchTaskByUid(uid);
const existingIndex = tasksStore.tasks.findIndex(
(t: Task) => t.uid === uid
);
if (existingIndex >= 0) {
const updatedTasks = [...tasksStore.tasks];
updatedTasks[existingIndex] = updatedTask;
tasksStore.setTasks(updatedTasks);
}
}
showSuccessToast(
t('task.projectCleared', 'Project cleared successfully')
);
setTimelineRefreshKey((prev) => prev + 1);
} catch (error) {
console.error('Error clearing project:', error);
showErrorToast(
t('task.projectClearError', 'Failed to clear project')
);
}
};
const refreshRecurringSetup = useCallback(
async (latestTask?: Task | null) => {
if (!latestTask) {
setNextIterations([]);
return;
}
const isTemplateTask =
latestTask.recurrence_type &&
latestTask.recurrence_type !== 'none' &&
!latestTask.recurring_parent_id;
const canUseParentIterations =
!!latestTask.recurring_parent_id &&
!!parentTask?.id &&
parentTask?.recurrence_type &&
parentTask.recurrence_type !== 'none';
if (!isTemplateTask && !canUseParentIterations) {
setNextIterations([]);
return;
}
try {
setLoadingIterations(true);
if (isTemplateTask) {
const iterations = await fetchTaskNextIterations(
latestTask.uid!
);
setNextIterations(iterations);
} else if (canUseParentIterations && parentTask?.uid) {
const iterations = await fetchTaskNextIterations(
parentTask.uid
);
setNextIterations(iterations);
}
} catch (error) {
console.error('Error refreshing recurring setup:', error);
setNextIterations([]);
} finally {
setLoadingIterations(false);
}
},
[parentTask?.id, parentTask?.recurrence_type]
);
const handleCompletionToggle = async () => {
if (!task?.uid) {
return;
}
try {
taskModifiedRef.current = true;
const updatedTaskResponse = await toggleTaskCompletion(
task.uid,
task
);
const mergedTask = {
...task,
...updatedTaskResponse,
subtasks: updatedTaskResponse.subtasks || task.subtasks || [],
};
if (uid) {
const existingIndex = tasksStore.tasks.findIndex(
(t: Task) => t.uid === uid
);
if (existingIndex >= 0) {
const updatedTasks = [...tasksStore.tasks];
updatedTasks[existingIndex] = mergedTask;
tasksStore.setTasks(updatedTasks);
}
}
await refreshRecurringSetup(mergedTask);
setTimelineRefreshKey((prev) => prev + 1);
showSuccessToast(
t('task.statusUpdated', 'Status updated successfully')
);
} catch (error) {
console.error('Error toggling task completion:', error);
showErrorToast(
t('task.statusUpdateError', 'Failed to update status')
);
}
};
const handleStatusUpdate = async (newStatus: number) => {
if (!task?.uid) return;
try {
taskModifiedRef.current = true;
await updateTask(task.uid, {
status: newStatus,
});
if (uid) {
const updatedTask = await fetchTaskByUid(uid);
const existingIndex = tasksStore.tasks.findIndex(
(t: Task) => t.uid === uid
);
if (existingIndex >= 0) {
const updatedTasks = [...tasksStore.tasks];
updatedTasks[existingIndex] = updatedTask;
tasksStore.setTasks(updatedTasks);
}
}
showSuccessToast(
t('task.statusUpdated', 'Status updated successfully')
);
setTimelineRefreshKey((prev) => prev + 1);
} catch (error) {
console.error('Error updating status:', error);
showErrorToast(
t('task.statusUpdateError', 'Failed to update status')
);
}
};
const handleDeleteClick = () => {
if (task) {
setTaskToDelete(task);
setIsConfirmDialogOpen(true);
}
};
const handleDeleteConfirm = async () => {
if (taskToDelete?.uid) {
try {
taskModifiedRef.current = true;
await deleteTask(taskToDelete.uid);
showSuccessToast(
t('task.deleteSuccess', 'Task deleted successfully')
);
navigate(location.state?.from || '/today');
} catch (error) {
console.error('Error deleting task:', error);
showErrorToast(t('task.deleteError', 'Failed to delete task'));
}
}
setIsConfirmDialogOpen(false);
setTaskToDelete(null);
};
const getProjectLink = (project: Project) => {
if (project.uid) {
const slug = project.name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
return `/project/${project.uid}-${slug}`;
}
return `/project/${project.id}`;
};
const getTagLink = (tag: any) => {
if (tag.uid) {
const slug = tag.name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
return `/tag/${tag.uid}-${slug}`;
}
return `/tag/${encodeURIComponent(tag.name)}`;
};
const handleTitleUpdate = async (newTitle: string) => {
if (!task?.uid || !newTitle.trim()) {
return;
}
if (newTitle.trim() === task.name) {
return;
}
try {
taskModifiedRef.current = true;
await updateTask(task.uid, { name: newTitle.trim() });
if (uid) {
const updatedTask = await fetchTaskByUid(uid);
const existingIndex = tasksStore.tasks.findIndex(
(t: Task) => t.uid === uid
);
if (existingIndex >= 0) {
const updatedTasks = [...tasksStore.tasks];
updatedTasks[existingIndex] = updatedTask;
tasksStore.setTasks(updatedTasks);
}
}
showSuccessToast(
t('task.titleUpdated', 'Task title updated successfully')
);
setTimelineRefreshKey((prev) => prev + 1);
} catch (error) {
console.error('Error updating task title:', error);
showErrorToast(
t('task.titleUpdateError', 'Failed to update task title')
);
throw error;
}
};
const handleContentUpdate = async (newContent: string) => {
if (!task?.uid) {
return;
}
const trimmedContent = newContent.trim();
if (trimmedContent === (task.note || '').trim()) {
return;
}
try {
taskModifiedRef.current = true;
await updateTask(task.uid, { note: trimmedContent });
if (uid) {
const updatedTask = await fetchTaskByUid(uid);
const existingIndex = tasksStore.tasks.findIndex(
(t: Task) => t.uid === uid
);
if (existingIndex >= 0) {
const updatedTasks = [...tasksStore.tasks];
updatedTasks[existingIndex] = updatedTask;
tasksStore.setTasks(updatedTasks);
}
}
showSuccessToast(
t('task.contentUpdated', 'Task content updated successfully')
);
setTimelineRefreshKey((prev) => prev + 1);
} catch (error) {
console.error('Error updating task content:', error);
showErrorToast(
t('task.contentUpdateError', 'Failed to update task content')
);
throw error;
}
};
const handleProjectCreateInlineWrapper = async (name: string) => {
if (!task?.uid || !name.trim()) return;
try {
taskModifiedRef.current = true;
const newProject = await createProject({ name });
projectsStore.setProjects([...projectsStore.projects, newProject]);
await updateTask(task.uid, { project_id: newProject.id });
if (uid) {
const updatedTask = await fetchTaskByUid(uid);
const existingIndex = tasksStore.tasks.findIndex(
(t: Task) => t.uid === uid
);
if (existingIndex >= 0) {
const updatedTasks = [...tasksStore.tasks];
updatedTasks[existingIndex] = updatedTask;
tasksStore.setTasks(updatedTasks);
}
}
showSuccessToast(
t('project.createdAndAssigned', 'Project created and assigned')
);
setTimelineRefreshKey((prev) => prev + 1);
} catch (error) {
console.error('Error creating project:', error);
showErrorToast(
t('project.createError', 'Failed to create project')
);
throw error;
}
};
const handleTagsUpdate = async (tags: string[]) => {
if (!task?.uid) {
return;
}
const currentTags = task.tags?.map((tag: any) => tag.name) || [];
if (
tags.length === currentTags.length &&
tags.every((tag, idx) => tag === currentTags[idx])
) {
return;
}
try {
taskModifiedRef.current = true;
await updateTask(task.uid, {
tags: tags.map((name) => ({ name })),
});
if (uid) {
const updatedTask = await fetchTaskByUid(uid);
const existingIndex = tasksStore.tasks.findIndex(
(t: Task) => t.uid === uid
);
if (existingIndex >= 0) {
const updatedTasks = [...tasksStore.tasks];
updatedTasks[existingIndex] = {
...updatedTask,
subtasks: updatedTask.subtasks || task.subtasks || [],
};
tasksStore.setTasks(updatedTasks);
}
}
showSuccessToast(
t('task.tagsUpdated', 'Tags updated successfully')
);
setTimelineRefreshKey((prev) => prev + 1);
} catch (error: any) {
console.error('Error updating tags:', error);
const details = error?.details;
if (details && Array.isArray(details) && details.length > 0) {
showErrorToast(details.join('. '));
} else {
showErrorToast(
error?.message ||
t('task.tagsUpdateError', 'Failed to update tags')
);
}
throw error;
}
};
const handlePriorityUpdate = async (priority: any) => {
if (!task?.uid) return;
try {
taskModifiedRef.current = true;
await updateTask(task.uid, {
priority: priority,
});
const updatedTask = await fetchTaskByUid(uid!);
tasksStore.updateTaskInStore(updatedTask);
setTimelineRefreshKey((prev) => prev + 1);
showSuccessToast(
t('task.priorityUpdated', 'Priority updated successfully')
);
} catch (error) {
console.error('Error updating priority:', error);
showErrorToast(
t('task.priorityUpdateError', 'Failed to update priority')
);
throw error;
}
};
if (loading) {
return <LoadingScreen />;
}
if (error || !task) {
return (
<div className="flex justify-center px-4 lg:px-2">
<div className="w-full max-w-5xl">
<div className="min-h-[60vh] flex flex-col items-center justify-center text-center">
<ExclamationTriangleIcon className="h-24 w-24 text-gray-400 dark:text-gray-500 mx-auto mb-8" />
<h1 className="text-2xl font-medium text-gray-700 dark:text-gray-300 mb-4">
{error || t('task.notFound', 'Task Not Found')}
</h1>
<p className="text-lg text-gray-600 dark:text-gray-400 mb-8">
{t(
'task.notFoundDescription',
'The task you are looking for does not exist or has been deleted.'
)}
</p>
<button
onClick={() => navigate('/today')}
className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 transition-colors duration-200"
>
{t('common.goToToday', 'Go to Today')}
</button>
</div>
</div>
</div>
);
}
return (
<div className="px-4 lg:px-6 pt-4">
<div className="w-full">
<TaskDetailsHeader
task={task}
onTitleUpdate={handleTitleUpdate}
onStatusUpdate={handleStatusUpdate}
onPriorityUpdate={handlePriorityUpdate}
onDelete={handleDeleteClick}
getProjectLink={getProjectLink}
getTagLink={getTagLink}
activePill={activePill}
onPillChange={setActivePill}
showOverdueIcon={isOverdue}
showPastDueBadge={isPastDue}
onOverdueIconClick={handleOverdueIconClick}
isOverdueAlertVisible={isOverdue && isOverdueBubbleVisible}
onDismissOverdueAlert={handleDismissOverdueAlert}
onQuickStatusToggle={handleCompletionToggle}
attachmentCount={attachmentCount}
subtasksCount={subtasks.length}
autoEditTitle={isNewTask}
/>
<div className="mb-6 mt-6">
{activePill === 'overview' && (
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8">
<div className="lg:col-span-3 space-y-8">
<TaskContentCard
content={task.note || ''}
onUpdate={handleContentUpdate}
/>
</div>
<div className="space-y-6">
<TaskProjectCard
task={task}
projects={projectsStore.projects}
onProjectSelect={handleProjectSelection}
onProjectClear={handleClearProject}
onProjectCreate={
handleProjectCreateInlineWrapper
}
getProjectLink={getProjectLink}
/>
<TaskTagsCard
task={task}
availableTags={tagsStore.tags}
hasLoadedTags={tagsStore.hasLoaded}
isLoadingTags={tagsStore.isLoading}
onUpdate={handleTagsUpdate}
onLoadTags={() => tagsStore.loadTags()}
getTagLink={getTagLink}
/>
<TaskDueDateCard
task={task}
isEditing={isEditingDueDate}
editedDueDate={editedDueDate}
onChangeDate={setEditedDueDate}
onStartEdit={handleStartDueDateEdit}
onSave={handleSaveDueDate}
onCancel={handleCancelDueDateEdit}
/>
<TaskDeferUntilCard
task={task}
isEditing={isEditingDeferUntil}
editedDeferUntil={editedDeferUntil}
onChangeDateTime={setEditedDeferUntil}
onStartEdit={handleStartDeferUntilEdit}
onSave={handleSaveDeferUntil}
onCancel={handleCancelDeferUntilEdit}
/>
</div>
</div>
)}
{activePill === 'recurrence' && (
<div className="grid grid-cols-1">
<TaskRecurrenceCard
task={task}
parentTask={parentTask}
loadingParent={loadingParent}
isEditing={isEditingRecurrence}
recurrenceForm={recurrenceForm}
onStartEdit={handleStartRecurrenceEdit}
onChange={handleRecurrenceChange}
onSave={handleSaveRecurrence}
onCancel={handleCancelRecurrenceEdit}
loadingIterations={loadingIterations}
nextIterations={nextIterations}
canEdit={!task.recurring_parent_id}
/>
</div>
)}
{activePill === 'subtasks' && (
<div className="grid grid-cols-1">
<TaskSubtasksCard
task={task}
subtasks={pendingSubtasks}
onSubtasksChange={setPendingSubtasks}
onSave={handleSaveSubtasks}
/>
</div>
)}
{activePill === 'attachments' && (
<div className="grid grid-cols-1">
<TaskAttachmentsCard
taskUid={task.uid}
onAttachmentsCountChange={setAttachmentCount}
/>
</div>
)}
{activePill === 'activity' && (
<div className="rounded-lg shadow-sm bg-white dark:bg-gray-900 border-2 border-gray-50 dark:border-gray-800 p-6">
<TaskTimeline
taskUid={task.uid}
refreshKey={timelineRefreshKey}
/>
</div>
)}
</div>
{isConfirmDialogOpen && taskToDelete && (
<ConfirmDialog
title={t('task.deleteConfirmTitle', 'Delete Task')}
message={t(
'task.deleteConfirmMessage',
'Are you sure you want to delete this task? This action cannot be undone.'
)}
onConfirm={handleDeleteConfirm}
onCancel={() => {
setIsConfirmDialogOpen(false);
setTaskToDelete(null);
}}
/>
)}
</div>
</div>
);
};
export default TaskDetails;