diff --git a/backend/routes/tasks/core/builders.js b/backend/routes/tasks/core/builders.js index ffe7ae7..93a5832 100644 --- a/backend/routes/tasks/core/builders.js +++ b/backend/routes/tasks/core/builders.js @@ -17,6 +17,10 @@ function buildTaskAttributes(body, userId, timezone, isUpdate = false) { body.recurrence_weekday !== undefined ? body.recurrence_weekday : null, + recurrence_weekdays: + body.recurrence_weekdays !== undefined + ? body.recurrence_weekdays + : null, recurrence_month_day: body.recurrence_month_day !== undefined ? body.recurrence_month_day @@ -65,6 +69,10 @@ function buildUpdateAttributes(body, task, timezone) { body.recurrence_weekday !== undefined ? body.recurrence_weekday : task.recurrence_weekday, + recurrence_weekdays: + body.recurrence_weekdays !== undefined + ? body.recurrence_weekdays + : task.recurrence_weekdays, recurrence_month_day: body.recurrence_month_day !== undefined ? body.recurrence_month_day diff --git a/backend/routes/tasks/index.js b/backend/routes/tasks/index.js index 2d7ff89..5292fed 100644 --- a/backend/routes/tasks/index.js +++ b/backend/routes/tasks/index.js @@ -445,6 +445,7 @@ router.patch('/task/:id', requireTaskWriteAccess, async (req, res) => { 'recurrence_interval', 'recurrence_end_date', 'recurrence_weekday', + 'recurrence_weekdays', 'recurrence_month_day', 'recurrence_week_of_month', 'completion_based', diff --git a/backend/routes/tasks/operations/recurring.js b/backend/routes/tasks/operations/recurring.js index 9d6ea21..6d05cb7 100644 --- a/backend/routes/tasks/operations/recurring.js +++ b/backend/routes/tasks/operations/recurring.js @@ -51,7 +51,18 @@ async function handleRecurrenceUpdate(task, recurrenceFields, reqBody) { if (newRecurrenceType !== 'none') { for (const futureInstance of futureInstances) { - await futureInstance.destroy(); + try { + await futureInstance.destroy(); + } catch (error) { + // If dependent records block deletion (e.g., subtasks FK), skip that instance + console.warn( + 'Skipping recurring instance deletion due to constraint:', + { + id: futureInstance.id, + error: error?.message, + } + ); + } } } } diff --git a/backend/routes/tasks/utils/constants.js b/backend/routes/tasks/utils/constants.js index 37ee585..cdfa3b9 100644 --- a/backend/routes/tasks/utils/constants.js +++ b/backend/routes/tasks/utils/constants.js @@ -8,7 +8,7 @@ const TASK_INCLUDES = [ }, { model: Project, - attributes: ['id', 'name', 'uid'], + attributes: ['id', 'name', 'uid', 'image_url'], required: false, }, ]; diff --git a/e2e/tests/task.spec.ts b/e2e/tests/task.spec.ts index bcd78fa..55b4819 100644 --- a/e2e/tests/task.spec.ts +++ b/e2e/tests/task.spec.ts @@ -147,9 +147,13 @@ test('user can mark a task as complete', async ({ page, baseURL }) => { await createTask(page, taskName); // Enable "Show completed" first to ensure completed tasks remain visible - const showCompletedButton = page.locator('button:has-text("Show completed")').first(); - if (await showCompletedButton.isVisible()) { - await showCompletedButton.click(); + const sortFilterButton = page.getByRole('button', { name: /sort tasks/i }); + await expect(sortFilterButton).toBeVisible({ timeout: 5000 }); + await sortFilterButton.click(); + + const showCompletedToggle = page.getByRole('button', { name: /show completed/i }); + if (await showCompletedToggle.isVisible()) { + await showCompletedToggle.click(); await page.waitForLoadState('networkidle'); } @@ -169,8 +173,8 @@ test('user can mark a task as complete', async ({ page, baseURL }) => { // Wait for network idle after completing the task await page.waitForLoadState('networkidle'); - // Click the "Show completed" toggle to make completed tasks visible - const showCompletedToggle = page.getByText('Show completed'); + // Click the "Show completed" toggle to make completed tasks visible (now inside the sort/filter dropdown) + await sortFilterButton.click(); await expect(showCompletedToggle).toBeVisible({ timeout: 5000 }); await showCompletedToggle.click(); await page.waitForLoadState('networkidle'); @@ -198,4 +202,4 @@ test('user can mark a task as complete', async ({ page, baseURL }) => { // task completion API worked (we saw status 200 and status: 2 in the response) const showCompletedState = await page.getByText('Show completed').textContent(); } -}); \ No newline at end of file +}); diff --git a/frontend/Layout.tsx b/frontend/Layout.tsx index eeed495..8ea4969 100644 --- a/frontend/Layout.tsx +++ b/frontend/Layout.tsx @@ -28,7 +28,7 @@ import { } from './utils/projectsService'; import { createTask, updateTask } from './utils/tasksService'; import { isAuthError } from './utils/authUtils'; -import { Link } from 'react-router-dom'; +import { Link, useLocation } from 'react-router-dom'; interface LayoutProps { currentUser: User; @@ -47,6 +47,8 @@ const Layout: React.FC = ({ }) => { const { t } = useTranslation(); const { showSuccessToast } = useToast(); + const location = useLocation(); + const isUpcomingView = location.pathname === '/upcoming'; const [isSidebarOpen, setIsSidebarOpen] = useState( window.innerWidth >= 1024 ); @@ -336,7 +338,11 @@ const Layout: React.FC = ({ } }; - const mainContentMarginLeft = isSidebarOpen ? 'ml-72' : 'ml-0'; + const mainContentMarginLeft = isUpcomingView + ? 'ml-0' + : isSidebarOpen + ? 'ml-72' + : 'ml-0'; const isLoading = isNotesLoading || @@ -460,9 +466,9 @@ const Layout: React.FC = ({ >
{children}
diff --git a/frontend/components/Notes.tsx b/frontend/components/Notes.tsx index b743b05..4377e9c 100644 --- a/frontend/components/Notes.tsx +++ b/frontend/components/Notes.tsx @@ -526,11 +526,11 @@ const Notes: React.FC = () => { onClick={() => handleSelectNote(note)} className={`p-5 cursor-pointer ${ previewNote?.uid === note.uid - ? 'bg-white dark:bg-gray-900 border-b border-transparent' + ? 'bg-white dark:bg-gray-900 border-b border-transparent mr-4 rounded-lg' : index !== sortedNotes.length - 1 - ? 'border-b border-gray-200/50 dark:border-gray-700/50 hover:bg-gray-50 dark:hover:bg-gray-800' - : 'border-b border-transparent hover:bg-gray-50 dark:hover:bg-gray-800' + ? 'border-b border-gray-200/30 dark:border-gray-700/30 hover:bg-gray-50 dark:hover:bg-gray-800 mr-4' + : 'border-b border-transparent hover:bg-gray-50 dark:hover:bg-gray-800 mr-4' }`} >

diff --git a/frontend/components/Project/ProjectDetails.tsx b/frontend/components/Project/ProjectDetails.tsx index 62d41e5..2983ed7 100644 --- a/frontend/components/Project/ProjectDetails.tsx +++ b/frontend/components/Project/ProjectDetails.tsx @@ -854,10 +854,11 @@ const ProjectDetails: React.FC = () => { } return ( -
-
+
+ {/* Project Banner - Full Width */} +
{/* Project Banner - Unified for both with and without images */} -
+
{/* Background - Image or Gradient */} {project.image_url ? ( {
+
- {/* Header with Tab Links and Controls */} -
- {/* Mobile Layout */} -
-
+ {/* Content Container - Centered with max width */} +
+
+ {/* Header with Tab Links and Controls */} +
+ {/* Mobile Layout */} +
+
+ {/* Tab Navigation Links */} +
+ + +
+ + {/* Inline Controls - Always visible for tasks tab */} + {activeTab === 'tasks' && ( +
+ {/* Search Button */} + + +
+ )} +
+
+ + {/* Desktop Layout */} +
{/* Tab Navigation Links */}
{/* Inline Controls - Always visible for tasks tab */} {activeTab === 'tasks' && ( -
+
{/* Search Button */} {
- {/* Desktop Layout */} -
- {/* Tab Navigation Links */} -
- - -
- - {/* Inline Controls - Always visible for tasks tab */} - {activeTab === 'tasks' && ( -
- {/* Search Button */} - - +
+ + + setTaskSearchQuery(e.target.value) + } + className="w-full bg-transparent border-none focus:ring-0 focus:outline-none dark:text-white" />
- )} -
-
+
+ )} - {/* Search input section, collapsible - only for tasks tab */} - {activeTab === 'tasks' && ( -
-
- - - setTaskSearchQuery(e.target.value) - } - className="w-full bg-transparent border-none focus:ring-0 focus:outline-none dark:text-white" + {/* Auto-suggest form for tasks with no items */} + {activeTab === 'tasks' && showAutoSuggestForm && ( +
+ { + if (project?.id) { + handleCreateNextAction( + project.id, + actionDescription + ); + } + }} + onDismiss={handleSkipNextAction} />
-
- )} + )} - {/* Auto-suggest form for tasks with no items */} - {activeTab === 'tasks' && showAutoSuggestForm && ( -
- { - if (project?.id) { - handleCreateNextAction( - project.id, - actionDescription - ); - } - }} - onDismiss={handleSkipNextAction} - /> -
- )} + {/* Tasks Tab Content */} + {activeTab === 'tasks' && ( + <> +
+ +
- {/* Tasks Tab Content */} - {activeTab === 'tasks' && ( - <> -
- -
+
+ {displayTasks.length > 0 ? ( +
+ +
+ ) : ( +
+

+ {taskSearchQuery.trim() + ? t( + 'tasks.noTasksAvailable', + 'No tasks available.' + ) + : showCompleted + ? t( + 'project.noCompletedTasks', + 'No completed tasks.' + ) + : t( + 'project.noTasks', + 'No tasks.' + )} +

+
+ )} +
+ + )} -
- {displayTasks.length > 0 ? ( -
- + {/* Notes Content */} + {activeTab === 'notes' && ( +
+ {/* Create New Note Button - Always visible */} +
+ +
+ + {/* Notes Grid or Empty State */} + {notes.length > 0 ? ( +
+ {notes.map((note) => ( + { + setNoteToDelete(note); + setIsConfirmDialogOpen(true); + }} + showActions={true} + showProject={false} + /> + ))}
) : ( -
-

- {taskSearchQuery.trim() - ? t( - 'tasks.noTasksAvailable', - 'No tasks available.' - ) - : showCompleted - ? t( - 'project.noCompletedTasks', - 'No completed tasks.' - ) - : t( - 'project.noTasks', - 'No tasks.' - )} +

+

+ {t( + 'project.noNotes', + 'No notes for this project.' + )}

)}
- - )} + )} - {/* Notes Content */} - {activeTab === 'notes' && ( -
- {/* Create New Note Button - Always visible */} -
- -
- - {/* Notes Grid or Empty State */} - {notes.length > 0 ? ( -
- {notes.map((note) => ( - { - setNoteToDelete(note); - setIsConfirmDialogOpen(true); - }} - showActions={true} - showProject={false} - /> - ))} -
- ) : ( -
-

- {t( - 'project.noNotes', - 'No notes for this project.' - )} -

-
- )} -
- )} - - - - {/* NoteModal */} - { - setIsNoteModalOpen(false); - setSelectedNote(null); - }} - onSave={handleSaveNote} - note={selectedNote} - projects={allProjects} - /> - - {isConfirmDialogOpen && noteToDelete && ( - { - const identifier = - noteToDelete?.uid ?? - (noteToDelete?.id !== undefined - ? String(noteToDelete.id) - : null); - - if (identifier) { - handleDeleteNote(identifier); - } - }} - onCancel={() => { - setIsConfirmDialogOpen(false); - setNoteToDelete(null); - }} + - )} - {isConfirmDialogOpen && !noteToDelete && ( - setIsConfirmDialogOpen(false)} + + {/* NoteModal */} + { + setIsNoteModalOpen(false); + setSelectedNote(null); + }} + onSave={handleSaveNote} + note={selectedNote} + projects={allProjects} /> - )} + + {isConfirmDialogOpen && noteToDelete && ( + { + const identifier = + noteToDelete?.uid ?? + (noteToDelete?.id !== undefined + ? String(noteToDelete.id) + : null); + + if (identifier) { + handleDeleteNote(identifier); + } + }} + onCancel={() => { + setIsConfirmDialogOpen(false); + setNoteToDelete(null); + }} + /> + )} + {isConfirmDialogOpen && !noteToDelete && ( + setIsConfirmDialogOpen(false)} + /> + )} +
); diff --git a/frontend/components/Shared/DatePicker.tsx b/frontend/components/Shared/DatePicker.tsx index 0457be9..0e54549 100644 --- a/frontend/components/Shared/DatePicker.tsx +++ b/frontend/components/Shared/DatePicker.tsx @@ -271,10 +271,10 @@ const DatePicker: React.FC = ({
-
- - Show completed + + {isSearchExpanded + ? t('common.hideSearch', 'Hide search') + : t('common.search', 'Search tasks')} - -
- +
{/* Tasks Section */} - {displayTasks.length > 0 && ( -
-

+
+
+

{t('tasks.title')} ({displayTasks.length})

+ setShowCompleted((v) => !v)} + className="w-full flex items-center justify-between text-sm text-gray-700 dark:text-gray-300" + aria-pressed={showCompleted} + aria-label={ + showCompleted + ? t( + 'tasks.hideCompleted', + 'Hide completed tasks' + ) + : t( + 'tasks.showCompleted', + 'Show completed tasks' + ) + } + title={ + showCompleted + ? t( + 'tasks.hideCompleted', + 'Hide completed tasks' + ) + : t( + 'tasks.showCompleted', + 'Show completed tasks' + ) + } + > + + {t( + 'tasks.showCompleted', + 'Show completed' + )} + + + + + + } + /> +
+ {displayTasks.length > 0 ? ( { onToggleToday={handleToggleToday} showCompletedTasks={showCompleted} /> -
- )} + ) : ( +

+ {t('tasks.noTasksAvailable', 'No tasks available.')} +

+ )} +

{/* Notes Section */} {notes.length > 0 && ( diff --git a/frontend/components/Tag/TagInput.tsx b/frontend/components/Tag/TagInput.tsx index 8109f91..ac59069 100644 --- a/frontend/components/Tag/TagInput.tsx +++ b/frontend/components/Tag/TagInput.tsx @@ -199,13 +199,15 @@ const TagInput: React.FC = ({
3 ? 'py-3' : 'py-2' + }`} > {tags.length > 0 ? ( tags.map((tag, index) => ( {tag}
- {/* Search Bar with Icon */} -
-
- + {/* Search input section, collapsible */} +
+
+ { const { uid } = useParams<{ uid: string }>(); const navigate = useNavigate(); - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const { showSuccessToast, showErrorToast } = useToast(); const projects = useStore((state: any) => state.projectsStore.projects); + 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) => @@ -61,10 +66,72 @@ const TaskDetails: React.FC = () => { const [timelineRefreshKey, setTimelineRefreshKey] = useState(0); const [isOverdueAlertDismissed, setIsOverdueAlertDismissed] = useState(false); + const [isSummaryAlertDismissed, setIsSummaryAlertDismissed] = + useState(false); const [nextIterations, setNextIterations] = useState([]); const [loadingIterations, setLoadingIterations] = useState(false); const [parentTask, setParentTask] = useState(null); const [loadingParent, setLoadingParent] = useState(false); + const [isEditingSubtasks, setIsEditingSubtasks] = useState(false); + const [editedSubtasks, setEditedSubtasks] = useState([]); + const [actionsMenuOpen, setActionsMenuOpen] = useState(false); + const actionsMenuRef = useRef(null); + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if ( + actionsMenuOpen && + actionsMenuRef.current && + !actionsMenuRef.current.contains(e.target as Node) + ) { + setActionsMenuOpen(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [actionsMenuOpen]); + const [isEditingDueDate, setIsEditingDueDate] = useState(false); + const [editedDueDate, setEditedDueDate] = useState( + task?.due_date || '' + ); + 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, + }); + + 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, + ]); // Load tags early and check for pending modal state on mount useEffect(() => { @@ -115,26 +182,159 @@ const TaskDetails: React.FC = () => { } }, [uid, tagsStore]); - // Date and recurrence formatting functions (from TaskHeader) - const formatDueDate = (dueDate: string) => { - const today = new Date().toISOString().split('T')[0]; - const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000) - .toISOString() - .split('T')[0]; - const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000) - .toISOString() - .split('T')[0]; - if (dueDate === today) return t('dateIndicators.today', 'TODAY'); - if (dueDate === tomorrow) - return t('dateIndicators.tomorrow', 'TOMORROW'); - if (dueDate === yesterday) - return t('dateIndicators.yesterday', 'YESTERDAY'); - - return new Date(dueDate).toLocaleDateString(undefined, { - year: 'numeric', - month: 'short', - day: 'numeric', + 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 handleRecurrenceChange = (field: string, value: any) => { + setRecurrenceForm((prev) => ({ + ...prev, + [field]: value, + })); + }; + + const handleSaveRecurrence = async () => { + if (!task?.id) { + setIsEditingRecurrence(false); + return; + } + + try { + const recurrencePayload: Partial = { + 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.id, { ...task, ...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 handleRecurrenceCardClick = () => { + if (task.recurring_parent_id) return; + handleStartRecurrenceEdit(); + }; + + const handleStartDueDateEdit = () => { + setEditedDueDate(task?.due_date || ''); + setIsEditingDueDate(true); + }; + + const handleSaveDueDate = async () => { + if (!task?.id) { + setIsEditingDueDate(false); + setEditedDueDate(task?.due_date || ''); + return; + } + + if ((editedDueDate || '') === (task.due_date || '')) { + setIsEditingDueDate(false); + return; + } + + try { + await updateTask(task.id, { + ...task, + 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 formatDateWithDayName = (dateString: string) => { @@ -142,8 +342,10 @@ const TaskDetails: React.FC = () => { const today = new Date().toISOString().split('T')[0]; const isToday = dateString === today; - const dayName = date.toLocaleDateString(undefined, { weekday: 'long' }); - const formattedDate = date.toLocaleDateString(undefined, { + const dayName = date.toLocaleDateString(i18n.language, { + weekday: 'long', + }); + const formattedDate = date.toLocaleDateString(i18n.language, { day: 'numeric', month: 'long', }); @@ -156,21 +358,114 @@ const TaskDetails: React.FC = () => { }; }; - const formatRecurrence = (recurrenceType: string) => { - switch (recurrenceType) { - case 'daily': - return t('recurrence.daily', 'Daily'); - case 'weekly': - return t('recurrence.weekly', 'Weekly'); - case 'monthly': - return t('recurrence.monthly', 'Monthly'); - case 'monthly_weekday': - return t('recurrence.monthlyWeekday', 'Monthly'); - case 'monthly_last_day': - return t('recurrence.monthlyLastDay', 'Monthly'); - default: - return t('recurrence.recurring', 'Recurring'); + const getDueDateDisplay = (dueDate: string) => { + const date = new Date(dueDate); + if (Number.isNaN(date.getTime())) return null; + + const formattedDate = date.toLocaleDateString(i18n.language, { + day: '2-digit', + month: '2-digit', + year: 'numeric', + }); + + const today = new Date(); + today.setHours(0, 0, 0, 0); + const target = new Date(date); + target.setHours(0, 0, 0, 0); + + const diffDays = Math.round( + (target.getTime() - today.getTime()) / (1000 * 60 * 60 * 24) + ); + + let relativeText = ''; + if (diffDays === 0) { + relativeText = t('dateIndicators.today', 'today'); + } else if (diffDays === 1) { + relativeText = t('dateIndicators.tomorrow', 'tomorrow'); + } else if (diffDays === -1) { + relativeText = t('dateIndicators.yesterday', 'yesterday'); + } else if (diffDays > 0) { + relativeText = t('task.inDays', 'in {{count}} days', { + count: diffDays, + }); + } else { + relativeText = t('task.daysAgo', '{{count}} days ago', { + count: Math.abs(diffDays), + }); } + + return { formattedDate, relativeText }; + }; + + const getStatusLabel = () => { + switch (task.status) { + case 'not_started': + case 0: + return t('task.status.notStarted', 'not started'); + case 'in_progress': + case 1: + return t('task.status.inProgress', 'in progress'); + case 'done': + case 2: + return t('task.status.done', 'completed'); + case 'archived': + case 3: + return t('task.status.archived', 'archived'); + default: + return t('task.status.unknown', 'ongoing'); + } + }; + + const getPriorityLabel = () => { + if (task.priority === null || task.priority === undefined) { + return null; + } + switch (task.priority) { + case 'low': + case 0: + return t('task.lowPriority', 'low priority'); + case 'medium': + case 1: + return t('task.mediumPriority', 'medium priority'); + case 'high': + case 2: + return t('task.highPriority', 'high priority'); + default: + return null; + } + }; + + const getTaskPlainSummary = () => { + const statusText = getStatusLabel(); + const priorityText = getPriorityLabel(); + const dueInfo = task.due_date ? getDueDateDisplay(task.due_date) : null; + + return ( + + {t('task.thisTask', 'This task')} {t('task.is', 'is')}{' '} + {statusText} + {priorityText && ( + <> + {' '} + {t('task.and', 'and')} {t('task.has', 'has')}{' '} + {priorityText} + + )} + {dueInfo && ( + <> + {`, ${t('task.dueOn', 'due')} ${dueInfo.relativeText}`}{' '} + ({dueInfo.formattedDate}) + + )} + {task.Project && ( + <> + {`, ${t('task.fromProject', 'from project')}`}{' '} + {task.Project.name} + + )} + . + + ); }; useEffect(() => { @@ -290,6 +585,123 @@ const TaskDetails: React.FC = () => { loadParentTask(); }, [task?.recurring_parent_uid]); + const handleStartSubtasksEdit = () => { + setIsEditingSubtasks(true); + setEditedSubtasks([...subtasks]); + }; + + const handleSaveSubtasks = async () => { + if (!task?.id) { + setIsEditingSubtasks(false); + setEditedSubtasks([]); + return; + } + + try { + // Update task with new subtasks + await updateTask(task.id, { ...task, subtasks: editedSubtasks }); + + // Refresh the task from server to get updated subtasks + 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.subtasksUpdated', 'Subtasks updated successfully') + ); + setIsEditingSubtasks(false); + + // Refresh timeline to show subtask changes + setTimelineRefreshKey((prev) => prev + 1); + } catch (error) { + console.error('Error updating subtasks:', error); + showErrorToast( + t('task.subtasksUpdateError', 'Failed to update subtasks') + ); + setEditedSubtasks([...subtasks]); + setIsEditingSubtasks(false); + } + }; + + const handleCancelSubtasksEdit = () => { + setIsEditingSubtasks(false); + setEditedSubtasks([]); + }; + + const handleProjectSelection = async (project: Project) => { + if (!task?.id) return; + + try { + await updateTask(task.id, { ...task, project_id: project.id }); + + // Refresh the task from server + 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') + ); + + // Refresh timeline + 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?.id) return; + + try { + await updateTask(task.id, { ...task, project_id: null }); + + // Refresh the task from server + 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') + ); + + // Refresh timeline + setTimelineRefreshKey((prev) => prev + 1); + } catch (error) { + console.error('Error clearing project:', error); + showErrorToast( + t('task.projectClearError', 'Failed to clear project') + ); + } + }; + const handleEdit = (e?: React.MouseEvent) => { if (e) { e.preventDefault(); @@ -409,6 +821,206 @@ const TaskDetails: React.FC = () => { } }; + 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}`; + }; + + // Wrapper handlers for new components + const handleTitleUpdate = async (newTitle: string) => { + if (!task?.id || !newTitle.trim()) { + return; + } + + if (newTitle.trim() === task.name) { + return; + } + + try { + await updateTask(task.id, { ...task, name: newTitle.trim() }); + + // Update the task in the global store + 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') + ); + + // Refresh timeline to show title change activity + 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?.id) { + return; + } + + const trimmedContent = newContent.trim(); + + if (trimmedContent === (task.note || '').trim()) { + return; + } + + try { + await updateTask(task.id, { ...task, note: trimmedContent }); + + // Update the task in the global store + 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') + ); + + // Refresh timeline to show content change activity + 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?.id || !name.trim()) return; + + try { + const newProject = await createProject({ name }); + + // Add to projects store + projectsStore.setProjects([...projectsStore.projects, newProject]); + + // Update task with new project + await updateTask(task.id, { ...task, project_id: newProject.id }); + + // Refresh the task from server + 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') + ); + + // Refresh timeline + 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?.id) { + return; + } + + const currentTags = task.tags?.map((tag: any) => tag.name) || []; + if ( + tags.length === currentTags.length && + tags.every((tag, idx) => tag === currentTags[idx]) + ) { + return; + } + + try { + await updateTask(task.id, { + ...task, + 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; + tasksStore.setTasks(updatedTasks); + } + } + + showSuccessToast( + t('task.tagsUpdated', 'Tags updated successfully') + ); + + setTimelineRefreshKey((prev) => prev + 1); + } catch (error) { + console.error('Error updating tags:', error); + showErrorToast(t('task.tagsUpdateError', 'Failed to update tags')); + throw error; + } + }; + + const handlePriorityUpdate = async (priority: any) => { + if (!task?.id) return; + + try { + await updateTask(task.id, { + ...task, + 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 ; } @@ -441,377 +1053,193 @@ const TaskDetails: React.FC = () => { } return ( -
-
+
+
{/* Header Section with Title and Action Buttons */} -
-
- -
-

- {task.name} -

- {/* Project, tags, due date, and recurrence under title */} - {(task.Project || - (task.tags && task.tags.length > 0) || - task.due_date || - (task.recurrence_type && - task.recurrence_type !== 'none') || - task.recurring_parent_id) && ( -
- {task.Project && ( -
- - - {task.Project.name} - -
- )} - {task.Project && - task.tags && - task.tags.length > 0 && ( - - )} - {task.tags && task.tags.length > 0 && ( -
- - - {task.tags.map( - ( - tag: any, - index: number - ) => ( - - - {tag.name} - - {index < - task.tags! - .length - - 1 && ', '} - - ) - )} - -
- )} - {(task.Project || - (task.tags && task.tags.length > 0)) && - task.due_date && ( - - )} - {task.due_date && ( -
- - - {formatDueDate(task.due_date)} - -
- )} - {(task.Project || - (task.tags && task.tags.length > 0) || - task.due_date) && - task.recurrence_type && - task.recurrence_type !== 'none' && ( - - )} - {task.recurrence_type && - task.recurrence_type !== 'none' && ( -
- - - {formatRecurrence( - task.recurrence_type - )} - -
- )} - {(task.Project || - (task.tags && task.tags.length > 0) || - task.due_date || - (task.recurrence_type && - task.recurrence_type !== 'none')) && - task.recurring_parent_id && ( - - )} - {task.recurring_parent_id && ( -
- - - {t( - 'recurrence.instance', - 'Recurring task instance' - )} - -
- )} -
- )} -
-
-
- - -
-
+ - {/* Overdue Alert */} - {isTaskOverdue(task) && !isOverdueAlertDismissed && ( -
- -
- -
-

- {t( - 'task.overdueAlert', - "This task was in your plan yesterday and wasn't completed." - )} -

-

- {task.today_move_count && - task.today_move_count > 1 - ? t( - 'task.overdueMultipleDays', - `This task has been postponed {{count}} times.`, - { count: task.today_move_count } - ) - : t( - 'task.overdueYesterday', - 'Consider prioritizing this task or breaking it into smaller steps.' - )} -

-
-
-
- )} + {/* Summary and Overdue Alerts */} + setIsSummaryAlertDismissed(true)} + onDismissOverdue={() => setIsOverdueAlertDismissed(true)} + /> - {/* Content - Two column layout */} + {/* Content - Full width layout */}
-
- {/* Left Column - Notes and Subtasks */} -
+
+ {/* Left Column - Main Content */} +
{/* Notes Section - Always Visible */} -
-

- {t('task.content', 'Content')} -

- {task.note ? ( -
- -
- ) : ( -
-
- - - {t( - 'task.noNotes', - 'No content added yet' - )} - -
-
- )} -
+ {/* Subtasks Section - Always Visible */}
-

+

{t('task.subtasks', 'Subtasks')}

- {subtasks.length > 0 ? ( -
+ {isEditingSubtasks ? ( +
+ +
+
+ + +
+
+
+ ) : subtasks.length > 0 ? ( +
{subtasks.map((subtask: Task) => (
-
-
-
- e.stopPropagation() - } - > - { - e?.stopPropagation(); - if ( - subtask.id - ) { - try { - await toggleTaskCompletion( - subtask.id +
+ { + console.log( + 'Toggling subtask:', + subtask.id + ); + if (subtask.id) { + try { + // Pass the current subtask to avoid fetching it + await toggleTaskCompletion( + subtask.id, + subtask + ); + // Refresh task data which includes updated subtasks + if (uid) { + const updatedTask = + await fetchTaskByUid( + uid ); - // Reload subtasks after toggling completion - if ( - task?.id - ) { - // Refresh task data which includes updated subtasks - if ( + const existingIndex = + tasksStore.tasks.findIndex( + ( + t: Task + ) => + t.uid === 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 - ); - } - } - - // Refresh timeline to show subtask completion activity - setTimelineRefreshKey( - ( - prev - ) => - prev + - 1 - ); - } - } catch (error) { - console.error( - 'Error toggling subtask completion:', - error + ); + if ( + existingIndex >= + 0 + ) { + const updatedTasks = + [ + ...tasksStore.tasks, + ]; + updatedTasks[ + existingIndex + ] = + updatedTask; + tasksStore.setTasks( + updatedTasks ); } } - }} - /> -
- - {subtask.name} - -
+ + // Refresh timeline to show subtask completion activity + setTimelineRefreshKey( + ( + prev + ) => + prev + + 1 + ); + } catch (error) { + console.error( + 'Error toggling subtask completion:', + error + ); + } + } + }} + /> + + {subtask.name} +
))}
) : ( -
+
{t( - 'task.noSubtasks', - 'No subtasks yet' + 'task.noSubtasksClickToAdd', + 'No subtasks yet, click to add' )}
@@ -819,279 +1247,523 @@ const TaskDetails: React.FC = () => { )}
- {/* Recurring Setup Section - Show for recurring tasks or child tasks */} - {((task.recurrence_type && - task.recurrence_type !== 'none') || - task.recurring_parent_id) && ( -
-

- {t( - 'task.recurringSetup', - 'Recurring Setup' - )} -

-
- {/* Show info for child tasks */} - {task.recurring_parent_id && ( -
-
- - - {t( - 'task.instanceOf', - 'This is an instance of a recurring task' - )} - -
- {loadingParent && ( -
-
- - {t( - 'common.loading', - 'Loading parent task...' - )} - -
- )} - {parentTask && ( -
-

- - {t( - 'task.parentTask', - 'Parent Task' - )} - : - {' '} - - { - parentTask.name - } - -

-
- )} -
- )} +
+

+ {t( + 'task.recurringSetup', + 'Recurring Setup' + )} +

+
{ + if ( + !isEditingRecurrence && + !task.recurring_parent_id && + e.key === 'Enter' + ) { + e.preventDefault(); + handleRecurrenceCardClick(); + } + }} + > + - {/* Recurrence Configuration - Use parent task data for child tasks */} - {(task.recurrence_type && - task.recurrence_type !== 'none') || - (parentTask?.recurrence_type && - parentTask.recurrence_type !== - 'none') ? ( -
- + +
+ +
- ) : null} - - {/* Next Iterations - Show for both parent and child tasks */} - {((task.recurrence_type && - task.recurrence_type !== 'none') || - (task.recurring_parent_id && - parentTask?.recurrence_type && +
+ ) : ( + <> + {(task.recurrence_type && + task.recurrence_type !== + 'none') || + (parentTask?.recurrence_type && parentTask.recurrence_type !== - 'none')) && ( -
-
- - - {task.recurring_parent_id - ? t( - 'task.nextOccurrencesAfterThis', - 'Next Occurrences After This' - ) - : t( - 'task.nextOccurrences', - 'Next Occurrences' - )} - {!loadingIterations && - nextIterations.length > - 0 && - nextIterations.some( - (iter) => - formatDateWithDayName( - iter.date - ).isToday - ) && ( - - ( - {t( - 'task.includingToday', - 'including today' - )} - ) - - )} - + 'none') ? ( +
+
+ ) : ( +
+ {t( + 'task.notRecurring', + 'This task is not recurring yet.' + )} +
+ )} - {loadingIterations ? ( -
-
- - {t( - 'common.loading', - 'Loading...' - )} + {((task.recurrence_type && + task.recurrence_type !== + 'none') || + (task.recurring_parent_id && + parentTask?.recurrence_type && + parentTask.recurrence_type !== + 'none')) && ( +
+
+ + + {task.recurring_parent_id + ? t( + 'task.nextOccurrencesAfterThis', + 'Next Occurrences After This' + ) + : t( + 'task.nextOccurrences', + 'Next Occurrences' + )} + {!loadingIterations && + nextIterations.length > + 0 && + nextIterations.some( + (iter) => + formatDateWithDayName( + iter.date + ) + .isToday + ) && ( + + ( + {t( + 'task.includingToday', + 'including today' + )} + ) + + )}
- ) : nextIterations.length > - 0 ? ( -
- {nextIterations.map( - ( - iteration, - index - ) => { - const dateInfo = - formatDateWithDayName( - iteration.date - ); - return ( -
+ + {loadingIterations ? ( +
+
+ + {t( + 'common.loading', + 'Loading...' + )} + +
+ ) : nextIterations.length > + 0 ? ( +
+ {nextIterations.map( + ( + iteration, + index + ) => { + const dateInfo = + formatDateWithDayName( + iteration.date + ); + return (
- - {index + - 1} - -
-
- { - dateInfo.dayName - } - {dateInfo.isToday && ( - - {t( - 'dateIndicators.today', - 'TODAY' - )} - - )} + + {index + + 1} +
-
- { - dateInfo.formattedDate - } +
+
+ { + dateInfo.dayName + } + {dateInfo.isToday && ( + + {t( + 'dateIndicators.today', + 'TODAY' + )} + + )} +
+
+ { + dateInfo.formattedDate + } +
-
- ); - } - )} -
- ) : ( -
- {t( - 'task.noMoreIterations', - 'No more iterations scheduled' - )} -
- )} -
- )} -
+ ); + } + )} +
+ ) : ( +
+ {t( + 'task.noMoreIterations', + 'No more iterations scheduled' + )} +
+ )} +
+ )} + + )}
- )} +
- {/* Right Column - Recent Activity */} -
-

- {t('task.recentActivity', 'Recent Activity')} -

-
- + {/* Right Column - Metadata and Recent Activity */} +
+ {/* Project Section */} + + + {/* Tags Section */} + tagsStore.loadTags()} + /> + + {/* Priority Section */} + + + {/* Due Date Section */} +
+

+ {t('task.dueDate', 'Due Date')} +

+
{ + const dueDate = new Date( + task.due_date + ); + const today = new Date(); + today.setHours(0, 0, 0, 0); + dueDate.setHours(0, 0, 0, 0); + const isCompleted = + task.status === 'done' || + task.status === 2 || + task.status === 'archived' || + task.status === 3 || + task.completed_at; + return ( + dueDate < today && !isCompleted + ); + })() + ? 'border-red-500 dark:border-red-400' + : '' + }`} + > + {isEditingDueDate ? ( +
+ +
+ + +
+
+ ) : ( + + )} +
+
+ + {/* Recent Activity Section */} +
+

+ {t( + 'task.recentActivity', + 'Recent Activity' + )} +

+
+ +
diff --git a/frontend/components/Task/TaskDetails/TaskContentSection.tsx b/frontend/components/Task/TaskDetails/TaskContentSection.tsx new file mode 100644 index 0000000..041af4a --- /dev/null +++ b/frontend/components/Task/TaskDetails/TaskContentSection.tsx @@ -0,0 +1,182 @@ +import React, { useRef, useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + PencilSquareIcon, + EyeIcon, + PencilIcon, +} from '@heroicons/react/24/outline'; +import MarkdownRenderer from '../../Shared/MarkdownRenderer'; + +interface TaskContentSectionProps { + content: string; + onUpdate: (newContent: string) => Promise; +} + +const TaskContentSection: React.FC = ({ + content, + onUpdate, +}) => { + const { t } = useTranslation(); + const [isEditing, setIsEditing] = useState(false); + const [editedContent, setEditedContent] = useState(content); + const [contentTab, setContentTab] = useState<'edit' | 'preview'>('edit'); + const contentTextareaRef = useRef(null); + + useEffect(() => { + setEditedContent(content); + }, [content]); + + useEffect(() => { + if (isEditing && contentTextareaRef.current) { + contentTextareaRef.current.focus(); + } + }, [isEditing]); + + const handleStartEdit = () => { + setIsEditing(true); + }; + + const handleSave = async () => { + if (editedContent !== content) { + await onUpdate(editedContent); + } + setIsEditing(false); + }; + + const handleCancel = () => { + setEditedContent(content); + setIsEditing(false); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { + handleSave(); + } else if (e.key === 'Escape') { + handleCancel(); + } + }; + + return ( +
+

+ {t('task.content', 'Content')} +

+ {isEditing ? ( +
+
+ {/* Floating toggle buttons */} +
+ + +
+ + {contentTab === 'edit' ? ( +