From 4d2ea4212c58d10666e18689f4c14e3d4f312fd4 Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 19 Dec 2025 11:13:27 +0200 Subject: [PATCH] Cleanup statuses (#724) * Cleanup statuses * Add more statuses * Hide buttons * fixup! Hide buttons * Show subtasks on click * Fix status button in taskdetails page * fixup! Fix status button in taskdetails page * fixup! fixup! Fix status button in taskdetails page * Fix today planned query --- backend/models/task.js | 8 +- .../routes/tasks/queries/metrics-queries.js | 49 +- backend/scripts/reset-and-seed.js | 21 +- .../integration/tasks-today-plan.test.js | 262 ++++ e2e/tests/today-view.spec.ts | 41 +- frontend/components/Shared/StatusDropdown.tsx | 38 +- frontend/components/Task/TaskDetails.tsx | 39 - .../Task/TaskDetails/TaskDetailsHeader.tsx | 321 +---- frontend/components/Task/TaskHeader.tsx | 1094 ++--------------- frontend/components/Task/TaskItem.tsx | 356 +++--- frontend/components/Task/TaskStatusBadge.tsx | 27 +- .../components/Task/TaskStatusControl.tsx | 522 ++++++++ frontend/components/Task/TasksToday.tsx | 265 +--- frontend/components/Task/statusStyles.ts | 70 ++ frontend/constants/taskStatus.ts | 162 +++ frontend/entities/Task.ts | 4 +- frontend/utils/tasksService.ts | 10 +- package-lock.json | 4 +- 18 files changed, 1471 insertions(+), 1822 deletions(-) create mode 100644 backend/tests/integration/tasks-today-plan.test.js create mode 100644 frontend/components/Task/TaskStatusControl.tsx create mode 100644 frontend/components/Task/statusStyles.ts create mode 100644 frontend/constants/taskStatus.ts diff --git a/backend/models/task.js b/backend/models/task.js index 40fd397..5f5bb77 100644 --- a/backend/models/task.js +++ b/backend/models/task.js @@ -58,7 +58,7 @@ module.exports = (sequelize) => { defaultValue: 0, validate: { min: 0, - max: 4, + max: 6, }, }, note: { @@ -262,6 +262,8 @@ module.exports = (sequelize) => { DONE: 2, ARCHIVED: 3, WAITING: 4, + CANCELLED: 5, + PLANNED: 6, }; Task.RECURRENCE_TYPE = { @@ -301,6 +303,8 @@ module.exports = (sequelize) => { 'done', 'archived', 'waiting', + 'cancelled', + 'planned', ]; return statuses[statusValue] || 'not_started'; }; @@ -319,6 +323,8 @@ module.exports = (sequelize) => { done: 2, archived: 3, waiting: 4, + cancelled: 5, + planned: 6, }; return statuses[statusName] !== undefined ? statuses[statusName] : 0; }; diff --git a/backend/routes/tasks/queries/metrics-queries.js b/backend/routes/tasks/queries/metrics-queries.js index ad18368..f204342 100644 --- a/backend/routes/tasks/queries/metrics-queries.js +++ b/backend/routes/tasks/queries/metrics-queries.js @@ -49,22 +49,55 @@ async function fetchTasksInProgress(visibleTasksWhere) { } async function fetchTodayPlanTasks(visibleTasksWhere) { + const todayPlanStatuses = [ + Task.STATUS.IN_PROGRESS, + Task.STATUS.WAITING, + Task.STATUS.PLANNED, + 'in_progress', + 'waiting', + 'planned', + ]; + + const excludedStatuses = [ + Task.STATUS.NOT_STARTED, + Task.STATUS.DONE, + Task.STATUS.ARCHIVED, + Task.STATUS.CANCELLED, + 'not_started', + 'done', + 'archived', + 'cancelled', + ]; + return await Task.findAll({ where: { [Op.and]: [ visibleTasksWhere, { - today: true, status: { - [Op.notIn]: [ - Task.STATUS.DONE, - Task.STATUS.ARCHIVED, - 'done', - 'archived', - ], + [Op.in]: todayPlanStatuses, + [Op.notIn]: excludedStatuses, }, parent_task_id: null, - recurring_parent_id: null, + // Exclude recurring parent tasks - only include non-recurring tasks or recurring instances + [Op.or]: [ + { + // Non-recurring tasks + [Op.and]: [ + { + [Op.or]: [ + { recurrence_type: 'none' }, + { recurrence_type: null }, + ], + }, + { recurring_parent_id: null }, + ], + }, + { + // Recurring instances (not parents) + recurring_parent_id: { [Op.ne]: null }, + }, + ], }, ], }, diff --git a/backend/scripts/reset-and-seed.js b/backend/scripts/reset-and-seed.js index 7ca8816..ceceeeb 100755 --- a/backend/scripts/reset-and-seed.js +++ b/backend/scripts/reset-and-seed.js @@ -45,25 +45,8 @@ async function main() { await seedDatabase(); console.log(' ✅ Basic data seeded\n'); - // Step 4: Seed notification test data - console.log('4️⃣ Seeding notification test data...'); - const { - seedNotificationTestData, - } = require('./seed-notification-test-data'); - - // Override process.exit to prevent the seeder from exiting - const originalExit = process.exit; - process.exit = () => {}; // No-op - - await seedNotificationTestData(); - - // Restore original process.exit - process.exit = originalExit; - - console.log(' ✅ Notification test data seeded\n'); - - // Step 5: Generate notifications - console.log('5️⃣ Generating notifications...'); + // Step 4: Generate notifications + console.log('4️⃣ Generating notifications...'); const { checkDueTasks } = require('../services/dueTaskService'); const { diff --git a/backend/tests/integration/tasks-today-plan.test.js b/backend/tests/integration/tasks-today-plan.test.js new file mode 100644 index 0000000..d2020bd --- /dev/null +++ b/backend/tests/integration/tasks-today-plan.test.js @@ -0,0 +1,262 @@ +const request = require('supertest'); +const app = require('../../app'); +const { Task } = require('../../models'); +const { createTestUser } = require('../helpers/testUtils'); + +describe('Tasks Today Plan - Status-Based Filtering', () => { + let user, agent; + + beforeEach(async () => { + user = await createTestUser({ + email: 'todayplan@example.com', + }); + + // Create authenticated agent + agent = request.agent(app); + await agent.post('/api/login').send({ + email: 'todayplan@example.com', + password: 'password123', + }); + }); + + describe('GET /api/tasks?type=today&include_lists=true - tasks_today_plan', () => { + it('should return tasks with status in_progress, planned, and waiting', async () => { + // Create tasks with different statuses + const inProgressTask = await Task.create({ + name: 'In Progress Task', + user_id: user.id, + status: Task.STATUS.IN_PROGRESS, + today: false, // Deliberately false to verify it doesn't depend on 'today' field + }); + + const plannedTask = await Task.create({ + name: 'Planned Task', + user_id: user.id, + status: Task.STATUS.PLANNED, + today: false, + }); + + const waitingTask = await Task.create({ + name: 'Waiting Task', + user_id: user.id, + status: Task.STATUS.WAITING, + today: false, + }); + + const response = await agent + .get('/api/tasks?type=today&include_lists=true') + .expect(200); + + expect(response.body.tasks_today_plan).toBeDefined(); + expect(response.body.tasks_today_plan).toHaveLength(3); + + const taskIds = response.body.tasks_today_plan.map((t) => t.id); + expect(taskIds).toContain(inProgressTask.id); + expect(taskIds).toContain(plannedTask.id); + expect(taskIds).toContain(waitingTask.id); + }); + + it('should exclude tasks with status not_started', async () => { + await Task.create({ + name: 'Not Started Task', + user_id: user.id, + status: Task.STATUS.NOT_STARTED, + today: true, // Even with today=true, should not appear in tasks_today_plan + }); + + await Task.create({ + name: 'In Progress Task', + user_id: user.id, + status: Task.STATUS.IN_PROGRESS, + today: false, + }); + + const response = await agent + .get('/api/tasks?type=today&include_lists=true') + .expect(200); + + expect(response.body.tasks_today_plan).toBeDefined(); + expect(response.body.tasks_today_plan).toHaveLength(1); + expect(response.body.tasks_today_plan[0].name).toBe( + 'In Progress Task' + ); + }); + + it('should exclude tasks with status done, archived, and cancelled', async () => { + await Task.create({ + name: 'Done Task', + user_id: user.id, + status: Task.STATUS.DONE, + today: true, + }); + + await Task.create({ + name: 'Archived Task', + user_id: user.id, + status: Task.STATUS.ARCHIVED, + today: true, + }); + + await Task.create({ + name: 'Cancelled Task', + user_id: user.id, + status: Task.STATUS.CANCELLED, + today: true, + }); + + await Task.create({ + name: 'Planned Task', + user_id: user.id, + status: Task.STATUS.PLANNED, + today: false, + }); + + const response = await agent + .get('/api/tasks?type=today&include_lists=true') + .expect(200); + + expect(response.body.tasks_today_plan).toBeDefined(); + expect(response.body.tasks_today_plan).toHaveLength(1); + expect(response.body.tasks_today_plan[0].name).toBe('Planned Task'); + }); + + it('should exclude subtasks from tasks_today_plan', async () => { + const parentTask = await Task.create({ + name: 'Parent Task', + user_id: user.id, + status: Task.STATUS.IN_PROGRESS, + }); + + await Task.create({ + name: 'Subtask', + user_id: user.id, + parent_task_id: parentTask.id, + status: Task.STATUS.IN_PROGRESS, + }); + + const response = await agent + .get('/api/tasks?type=today&include_lists=true') + .expect(200); + + expect(response.body.tasks_today_plan).toBeDefined(); + expect(response.body.tasks_today_plan).toHaveLength(1); + expect(response.body.tasks_today_plan[0].id).toBe(parentTask.id); + }); + + it('should exclude recurring parent tasks from tasks_today_plan', async () => { + const recurringParent = await Task.create({ + name: 'Recurring Parent', + user_id: user.id, + status: Task.STATUS.PLANNED, + recurrence_type: 'daily', + }); + + await Task.create({ + name: 'Recurring Instance', + user_id: user.id, + status: Task.STATUS.PLANNED, + recurring_parent_id: recurringParent.id, + }); + + const response = await agent + .get('/api/tasks?type=today&include_lists=true') + .expect(200); + + expect(response.body.tasks_today_plan).toBeDefined(); + // Should only include the instance, not the parent + const taskNames = response.body.tasks_today_plan.map((t) => t.name); + expect(taskNames).not.toContain('Recurring Parent'); + expect(taskNames).toContain('Recurring Instance'); + }); + + it('should work independently of the today field', async () => { + // Task with status PLANNED but today=false + const plannedTask = await Task.create({ + name: 'Planned Not Today', + user_id: user.id, + status: Task.STATUS.PLANNED, + today: false, + }); + + // Task with status NOT_STARTED but today=true + await Task.create({ + name: 'Not Started Today', + user_id: user.id, + status: Task.STATUS.NOT_STARTED, + today: true, + }); + + const response = await agent + .get('/api/tasks?type=today&include_lists=true') + .expect(200); + + expect(response.body.tasks_today_plan).toBeDefined(); + expect(response.body.tasks_today_plan).toHaveLength(1); + expect(response.body.tasks_today_plan[0].id).toBe(plannedTask.id); + }); + + it('should return empty array when no planned tasks exist', async () => { + await Task.create({ + name: 'Not Started Task', + user_id: user.id, + status: Task.STATUS.NOT_STARTED, + }); + + await Task.create({ + name: 'Done Task', + user_id: user.id, + status: Task.STATUS.DONE, + }); + + const response = await agent + .get('/api/tasks?type=today&include_lists=true') + .expect(200); + + expect(response.body.tasks_today_plan).toBeDefined(); + expect(response.body.tasks_today_plan).toHaveLength(0); + }); + + it('should order tasks by priority DESC, due_date ASC, project_id ASC', async () => { + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + const nextWeek = new Date(); + nextWeek.setDate(nextWeek.getDate() + 7); + + await Task.create({ + name: 'Low Priority', + user_id: user.id, + status: Task.STATUS.PLANNED, + priority: Task.PRIORITY.LOW, + }); + + await Task.create({ + name: 'High Priority Due Later', + user_id: user.id, + status: Task.STATUS.PLANNED, + priority: Task.PRIORITY.HIGH, + due_date: nextWeek, + }); + + await Task.create({ + name: 'High Priority Due Soon', + user_id: user.id, + status: Task.STATUS.PLANNED, + priority: Task.PRIORITY.HIGH, + due_date: tomorrow, + }); + + const response = await agent + .get('/api/tasks?type=today&include_lists=true') + .expect(200); + + expect(response.body.tasks_today_plan).toBeDefined(); + expect(response.body.tasks_today_plan).toHaveLength(3); + + const taskNames = response.body.tasks_today_plan.map((t) => t.name); + // High priority tasks should come first, ordered by due date + expect(taskNames[0]).toBe('High Priority Due Soon'); + expect(taskNames[1]).toBe('High Priority Due Later'); + expect(taskNames[2]).toBe('Low Priority'); + }); + }); +}); diff --git a/e2e/tests/today-view.spec.ts b/e2e/tests/today-view.spec.ts index a5c4d76..e1d63ff 100644 --- a/e2e/tests/today-view.spec.ts +++ b/e2e/tests/today-view.spec.ts @@ -19,7 +19,7 @@ test.describe('Today', () => { await page.waitForURL(/\/(dashboard|today)/, { timeout: 10000 }); } - test('Planned: only today=true tasks', async ({ + test('Planned: shows tasks with status in_progress, planned, or waiting', async ({ page, context, baseURL, @@ -32,17 +32,26 @@ test.describe('Today', () => { const timestamp = Date.now(); // Create tasks via API using the logged-in context + // Status values: 0=NOT_STARTED, 1=IN_PROGRESS, 4=WAITING, 6=PLANNED const tasksToCreate = [ { - name: `High Priority Planned ${timestamp}`, - today: true, + name: `In Progress Task ${timestamp}`, + status: 1, // IN_PROGRESS priority: 2, - }, // 2 = HIGH + today: false, // Verify it works independently of today field + }, { - name: `Task Without Today Flag ${timestamp}`, - today: false, + name: `Planned Task ${timestamp}`, + status: 6, // PLANNED priority: 2, - }, // 2 = HIGH + today: false, + }, + { + name: `Not Started Task ${timestamp}`, + status: 0, // NOT_STARTED + priority: 2, + today: true, // Even with today=true, should NOT appear in planned + }, ]; const taskIds: string[] = []; @@ -67,17 +76,23 @@ test.describe('Today', () => { const plannedSection = page.getByTestId('planned-section'); await expect(plannedSection).toBeVisible({ timeout: 10000 }); - // Verify task with today flag is visible in the Planned section - const withTodayFlagTask = plannedSection.getByTestId( + // Verify in_progress task is visible in the Planned section + const inProgressTask = plannedSection.getByTestId( `task-item-${taskIds[0]}` ); - await expect(withTodayFlagTask).toBeVisible({ timeout: 10000 }); + await expect(inProgressTask).toBeVisible({ timeout: 10000 }); - // Verify task without today flag is NOT visible in Planned section - const withoutFlagTask = plannedSection.getByTestId( + // Verify planned task is visible in the Planned section + const plannedTask = plannedSection.getByTestId( `task-item-${taskIds[1]}` ); - await expect(withoutFlagTask).not.toBeVisible(); + await expect(plannedTask).toBeVisible({ timeout: 10000 }); + + // Verify not_started task is NOT visible in Planned section + const notStartedTask = plannedSection.getByTestId( + `task-item-${taskIds[2]}` + ); + await expect(notStartedTask).not.toBeVisible(); // Clean up created tasks for (const taskId of taskIds) { diff --git a/frontend/components/Shared/StatusDropdown.tsx b/frontend/components/Shared/StatusDropdown.tsx index 9a4f327..2c9db51 100644 --- a/frontend/components/Shared/StatusDropdown.tsx +++ b/frontend/components/Shared/StatusDropdown.tsx @@ -5,45 +5,71 @@ import { ClockIcon, CheckCircleIcon, ArchiveBoxIcon, + CalendarIcon, + PlayIcon, + XCircleIcon, } from '@heroicons/react/24/outline'; import { StatusType } from '../../entities/Task'; import { useTranslation } from 'react-i18next'; +import { getStatusString } from '../../constants/taskStatus'; interface StatusDropdownProps { - value: StatusType; + value: StatusType | number; onChange: (value: StatusType) => void; } const StatusDropdown: React.FC = ({ value, onChange }) => { const { t } = useTranslation(); + const statusString = getStatusString(value); const statuses = [ { value: 'not_started', label: t('status.notStarted', 'Not Started'), icon: ( - + + ), + }, + { + value: 'planned', + label: t('status.planned', 'Planned'), + icon: ( + ), }, { value: 'in_progress', label: t('status.inProgress', 'In Progress'), icon: ( - + + ), + }, + { + value: 'waiting', + label: t('status.waiting', 'Waiting'), + icon: ( + ), }, { value: 'done', label: t('status.done', 'Done'), icon: ( - + + ), + }, + { + value: 'cancelled', + label: t('status.cancelled', 'Cancelled'), + icon: ( + ), }, { value: 'archived', label: t('status.archived', 'Archived'), icon: ( - + ), }, ]; @@ -80,7 +106,7 @@ const StatusDropdown: React.FC = ({ value, onChange }) => { }; }, [isOpen]); - const selectedStatus = statuses.find((s) => s.value === value); + const selectedStatus = statuses.find((s) => s.value === statusString); return (
{ [parentTask?.id, parentTask?.recurrence_type] ); - const handleToggleTodayPlan = async () => { - if (!task?.id || !task?.uid) { - return; - } - - try { - const updatedTask = await toggleTaskToday(task.id, task); - let latestTaskData: Task | null = updatedTask; - - if (uid) { - const refreshedTask = await fetchTaskByUid(uid); - latestTaskData = refreshedTask; - const existingIndex = tasksStore.tasks.findIndex( - (t: Task) => t.uid === uid - ); - if (existingIndex >= 0) { - const updatedTasks = [...tasksStore.tasks]; - updatedTasks[existingIndex] = refreshedTask; - tasksStore.setTasks(updatedTasks); - } - } - - await refreshRecurringSetup(latestTaskData); - setTimelineRefreshKey((prev) => prev + 1); - showSuccessToast( - updatedTask.today - ? t('tasks.addToToday', 'Add to today plan') - : t('tasks.removeFromToday', 'Remove from today plan') - ); - } catch (error) { - console.error('Error toggling today plan:', error); - showErrorToast( - t('task.statusUpdateError', 'Failed to update status') - ); - } - }; - const handleQuickStatusToggle = async () => { if (!task?.uid) { return; @@ -1245,7 +1207,6 @@ const TaskDetails: React.FC = () => { onOverdueIconClick={handleOverdueIconClick} isOverdueAlertVisible={isOverdue && isOverdueBubbleVisible} onDismissOverdueAlert={handleDismissOverdueAlert} - onToggleTodayPlan={handleToggleTodayPlan} onQuickStatusToggle={handleQuickStatusToggle} attachmentCount={attachmentCount} subtasksCount={subtasks.length} diff --git a/frontend/components/Task/TaskDetails/TaskDetailsHeader.tsx b/frontend/components/Task/TaskDetails/TaskDetailsHeader.tsx index 61dd92a..06f154f 100644 --- a/frontend/components/Task/TaskDetails/TaskDetailsHeader.tsx +++ b/frontend/components/Task/TaskDetails/TaskDetailsHeader.tsx @@ -6,13 +6,7 @@ import { FolderIcon, TagIcon, ChevronDownIcon, - PauseCircleIcon, - PlayCircleIcon, - CheckCircleIcon, ExclamationTriangleIcon, - CalendarDaysIcon, - CalendarIcon, - PlayIcon, FireIcon, ArrowUpIcon, ArrowDownIcon, @@ -20,6 +14,8 @@ import { import { Link } from 'react-router-dom'; import { Task, PriorityType } from '../../../entities/Task'; import { formatDateTime } from '../../../utils/dateUtils'; +import TaskStatusControl from '../TaskStatusControl'; +import { getStatusValue } from '../../../constants/taskStatus'; interface TaskDetailsHeaderProps { task: Task; @@ -37,7 +33,6 @@ interface TaskDetailsHeaderProps { onOverdueIconClick?: () => void; isOverdueAlertVisible?: boolean; onDismissOverdueAlert?: () => void; - onToggleTodayPlan?: () => void; onQuickStatusToggle?: () => void; attachmentCount?: number; subtasksCount?: number; @@ -59,7 +54,6 @@ const TaskDetailsHeader: React.FC = ({ onOverdueIconClick, isOverdueAlertVisible = false, onDismissOverdueAlert, - onToggleTodayPlan, onQuickStatusToggle, attachmentCount = 0, subtasksCount = 0, @@ -68,11 +62,9 @@ const TaskDetailsHeader: React.FC = ({ const [isEditingTitle, setIsEditingTitle] = useState(false); const [editedTitle, setEditedTitle] = useState(task.name); const [actionsMenuOpen, setActionsMenuOpen] = useState(false); - const [statusDropdownOpen, setStatusDropdownOpen] = useState(false); const [priorityDropdownOpen, setPriorityDropdownOpen] = useState(false); const titleInputRef = useRef(null); const actionsMenuRef = useRef(null); - const statusDropdownRef = useRef(null); const priorityDropdownRef = useRef(null); useEffect(() => { @@ -95,13 +87,6 @@ const TaskDetailsHeader: React.FC = ({ ) { setActionsMenuOpen(false); } - if ( - statusDropdownOpen && - statusDropdownRef.current && - !statusDropdownRef.current.contains(e.target as Node) - ) { - setStatusDropdownOpen(false); - } if ( priorityDropdownOpen && priorityDropdownRef.current && @@ -111,12 +96,12 @@ const TaskDetailsHeader: React.FC = ({ } }; - if (actionsMenuOpen || statusDropdownOpen || priorityDropdownOpen) { + if (actionsMenuOpen || priorityDropdownOpen) { document.addEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside); } - }, [actionsMenuOpen, statusDropdownOpen, priorityDropdownOpen]); + }, [actionsMenuOpen, priorityDropdownOpen]); const handleStartTitleEdit = () => { setIsEditingTitle(true); @@ -142,79 +127,13 @@ const TaskDetailsHeader: React.FC = ({ } }; - const getStatusLabel = () => { - const status = task.status; - if (status === 'not_started' || status === 0) { - return t('task.status.notStarted', 'Not started'); - } else if (status === 'in_progress' || status === 1) { - return t('task.status.inProgress', 'In progress'); - } else if (status === 'done' || status === 2) { - return t('task.status.done', 'Done'); - } else if (status === 'archived' || status === 3) { - return t('task.status.archived', 'Archived'); - } else if (status === 'waiting' || status === 4) { - return t('task.status.waiting', 'Waiting'); + const handleStatusControlUpdate = async (updatedTask: Task) => { + const currentStatusValue = getStatusValue(task.status); + const nextStatusValue = getStatusValue(updatedTask.status); + + if (currentStatusValue !== nextStatusValue) { + await onStatusUpdate(nextStatusValue); } - return t('task.status.notStarted', 'Not started'); - }; - - const getStatusButtonClass = () => { - const status = task.status; - - if (status === 'not_started' || status === 0) { - return 'px-2 sm:px-2.5 py-1 rounded-md text-xs font-medium transition-colors flex items-center gap-1 sm:gap-2 sm:ml-2 border border-gray-300 text-gray-600 dark:border-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800/60'; - } - - const baseClass = - 'px-2 sm:px-2.5 py-1 rounded-md text-xs font-medium transition-colors flex items-center gap-1 sm:gap-2 sm:ml-2 border'; - - if (status === 'in_progress' || status === 1) { - return `${baseClass} border-blue-500 text-blue-600 dark:border-blue-400 dark:text-blue-300 hover:bg-blue-50 dark:hover:bg-blue-900/30`; - } else if (status === 'done' || status === 2) { - return `${baseClass} border-green-500 text-green-600 dark:border-green-400 dark:text-green-300 hover:bg-green-50 dark:hover:bg-green-900/30`; - } else if (status === 'archived' || status === 3) { - return `${baseClass} border-purple-500 text-purple-600 dark:border-purple-400 dark:text-purple-300 hover:bg-purple-50 dark:hover:bg-purple-900/30`; - } else if (status === 'waiting' || status === 4) { - return `${baseClass} border-yellow-500 text-yellow-600 dark:border-yellow-400 dark:text-yellow-300 hover:bg-yellow-50 dark:hover:bg-yellow-900/30`; - } - return `${baseClass} border-gray-300 text-gray-700 dark:border-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800/60`; - }; - - const handleStatusChange = async (newStatus: number | string) => { - setStatusDropdownOpen(false); - const statusNum = - typeof newStatus === 'string' ? parseInt(newStatus) : newStatus; - await onStatusUpdate(statusNum); - }; - - const getStatusIcon = ( - statusOverride?: number | string - ): React.ElementType => { - const status = - typeof statusOverride !== 'undefined' - ? statusOverride - : task.status; - - if (status === 'in_progress' || status === 1) { - return PlayCircleIcon; - } else if (status === 'done' || status === 2) { - return CheckCircleIcon; - } - return PauseCircleIcon; - }; - - const getStatusIconClass = (statusOverride?: number | string) => { - const status = - typeof statusOverride !== 'undefined' - ? statusOverride - : task.status; - - if (status === 'in_progress' || status === 1) { - return 'text-blue-500 dark:text-blue-400'; - } else if (status === 'done' || status === 2) { - return 'text-green-500 dark:text-green-400'; - } - return 'text-gray-500 dark:text-gray-400'; }; const getPriorityLabel = (priorityOverride?: PriorityType) => { @@ -347,135 +266,20 @@ const TaskDetailsHeader: React.FC = ({ {task.name} - {/* Status Dropdown Button - Next to title */}
-
- - {statusDropdownOpen && ( -
- - - -
- )} -
+ {/* Priority Dropdown Button - Next to status */}
= ({ {t('task.activity', 'Activity')}
- {(showOverdueIcon || - onToggleTodayPlan || - onQuickStatusToggle) && ( + {(showOverdueIcon || onQuickStatusToggle) && (
{showOverdueIcon && (
= ({ )}
)} - {onToggleTodayPlan && ( - - )} - {onQuickStatusToggle && - (task.status === 'not_started' || - task.status === 'in_progress' || - task.status === 0 || - task.status === 1) && ( - - )}
Promise; onTaskUpdate?: (task: Task) => Promise; isOverdue?: boolean; - // Props for subtasks functionality showSubtasks?: boolean; hasSubtasks?: boolean; onSubtasksToggle?: (e: React.MouseEvent) => void; @@ -45,93 +40,52 @@ const TaskHeader: React.FC = ({ onTaskClick, onToggleCompletion, hideProjectName = false, - onToggleToday, + onToggleToday: _onToggleToday, onTaskUpdate, - // Props for subtasks functionality showSubtasks, hasSubtasks, onSubtasksToggle, // Props for edit and delete functionality - onEdit, - onDelete, + onEdit: _onEdit, + onDelete: _onDelete, isUpcomingView = false, }) => { const { t } = useTranslation(); - const [isDropdownOpen, setIsDropdownOpen] = useState(false); - const buttonRef = useRef(null); - const dropdownId = useRef( - `dropdown-${Math.random().toString(36).substr(2, 9)}` - ).current; - const desktopCompletionMenuRef = useRef(null); - const mobileCompletionMenuRef = useRef(null); - const [completionMenuOpen, setCompletionMenuOpen] = useState< - 'desktop' | 'mobile' | null - >(null); - const [isCompletingTask, setIsCompletingTask] = useState(false); + void _onToggleToday; + void _onEdit; + void _onDelete; + const SubtasksToggleButton = () => { + if (!hasSubtasks || !onSubtasksToggle) return null; - // Close dropdown when clicking outside - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if (isDropdownOpen && buttonRef.current) { - const target = event.target as Node; - const isOutsideButton = !buttonRef.current.contains(target); - const currentDropdown = document.querySelector( - `[data-dropdown-id="${dropdownId}"]` - ); - const isOutsideDropdown = !currentDropdown?.contains(target); - - if (isOutsideButton && isOutsideDropdown) { - setIsDropdownOpen(false); + return ( + + ); + }; const formatDueDate = (dueDate: string) => { const today = new Date().toISOString().split('T')[0]; @@ -192,17 +146,6 @@ const TaskHeader: React.FC = ({ } }; - const handleTodayToggle = async (e: React.MouseEvent) => { - e.stopPropagation(); // Prevent opening task modal - if (onToggleToday && task.id) { - try { - await onToggleToday(task.id, task); - } catch (error) { - console.error('Failed to toggle today status:', error); - } - } - }; - const formattedDeferUntil = task.defer_until ? formatDeferUntil(task.defer_until) : null; @@ -216,77 +159,6 @@ const TaskHeader: React.FC = ({ task.recurring_parent_id || !!formattedDeferUntil; - const isTaskCompleted = - task.status === 'done' || - task.status === 2 || - task.status === 'archived' || - task.status === 3; - - const isTaskInProgress = task.status === 'in_progress' || task.status === 1; - - const completionButtonBorderClass = isTaskCompleted - ? 'border-green-200 dark:border-green-900' - : isTaskInProgress - ? 'border-blue-200 dark:border-blue-900' - : 'border-gray-200 dark:border-gray-700'; - - const completionButtonTextClass = isTaskCompleted - ? 'text-green-600 dark:text-green-400' - : isTaskInProgress - ? 'text-blue-600 dark:text-blue-400' - : 'text-gray-600 dark:text-gray-400'; - - const completionButtonHoverClass = isTaskCompleted - ? 'hover:bg-green-50 dark:hover:bg-green-900/40' - : isTaskInProgress - ? 'hover:bg-blue-50 dark:hover:bg-blue-900/40' - : 'hover:bg-gray-50 dark:hover:bg-gray-800'; - - // Highlighted background for the active status button part - const completionButtonMainBgClass = isTaskCompleted - ? 'bg-green-100 dark:bg-green-900/50' - : isTaskInProgress - ? 'bg-blue-100 dark:bg-blue-900/50' - : 'bg-gray-200 dark:bg-gray-700'; - - const completionButtonMainTextClass = isTaskCompleted - ? 'text-green-900 dark:text-green-100 font-semibold' - : isTaskInProgress - ? 'text-blue-900 dark:text-blue-100 font-semibold' - : 'text-gray-900 dark:text-gray-100 font-semibold'; - - const completionButtonMainClasses = `inline-flex items-center gap-2 text-sm transition ${completionButtonMainTextClass} ${completionButtonMainBgClass} ${completionButtonHoverClass} focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500`; - - const completionButtonChevronClasses = `inline-flex items-center justify-center transition ${completionButtonTextClass} ${completionButtonHoverClass} focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500`; - - const CompletionIcon = isTaskCompleted - ? CheckIcon - : isTaskInProgress - ? PlayIcon - : CheckIcon; - - const handleCompletionClick = async (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - setCompletionMenuOpen(null); - - if (onToggleCompletion) { - // Add animation delay when marking as done (not when undoing) - if (!isTaskCompleted) { - setIsCompletingTask(true); - // Wait for green animation to complete (1200ms) - await new Promise((resolve) => setTimeout(resolve, 1200)); - } - - onToggleCompletion(); - - // Reset animation state after completion - setTimeout(() => { - setIsCompletingTask(false); - }, 100); - } - }; - return (
= ({ {isUpcomingView ? (
{/* Full width title that wraps */} -
+
{task.habit_mode && ( = ({ )} {task.original_name || task.name} +
{/* Show project and tags info in upcoming view */} {project && !hideProjectName && ( @@ -423,6 +296,7 @@ const TaskHeader: React.FC = ({ {task.original_name || task.name} +
)} {/* Project, tags, due date, and recurrence in same row, with spacing when they exist */} @@ -547,433 +421,15 @@ const TaskHeader: React.FC = ({ )}
- {!isUpcomingView && !task.habit_mode && ( -
-
-
- {/* Today Plan Controls */} - {onToggleToday && !isTaskCompleted && ( - - )} - - {/* Show Subtasks Controls */} - {hasSubtasks && - !( - task.status === 'archived' || - task.status === 3 - ) && ( - - )} - - {/* Three Dots Menu for Edit and Delete */} - {(onEdit || onDelete) && ( -
- - - {/* Dropdown Menu */} - {isDropdownOpen && ( -
- e.stopPropagation() - } - > -
- {/* Edit Button */} - {onEdit && ( - - )} - - {/* Delete Button */} - {onDelete && ( - - )} -
-
- )} -
- )} -
- - {onToggleCompletion && ( -
-
- - {!isTaskCompleted && - (task.status === 'not_started' || - task.status === 0) && ( - - )} - {isTaskInProgress && ( - - )} - -
- {completionMenuOpen === 'desktop' && ( -
- - - -
- )} -
- )} -
+ {!isUpcomingView && !task.habit_mode && onToggleCompletion && ( +
+
)}
@@ -1003,6 +459,7 @@ const TaskHeader: React.FC = ({ /> )} {task.original_name || task.name} +
@@ -1123,384 +580,17 @@ const TaskHeader: React.FC = ({
{onToggleCompletion && ( -
-
- - {!isTaskCompleted && - (task.status === 'not_started' || - task.status === 0) && ( - - )} - {isTaskInProgress && ( - - )} - -
- {completionMenuOpen === 'mobile' && ( -
- - - -
- )} +
+
)}
- - {/* Mobile 3-dot dropdown menu */} - {!task.habit_mode && ( -
- - - {/* Dropdown Menu - Positioned Relatively */} - {isDropdownOpen && ( -
- window.innerHeight - ? 'translateY(-100%) translateY(-8px)' - : 'none', - }} - onClick={(e) => e.stopPropagation()} - > -
- {/* Today Plan Controls */} - {onToggleToday && !isTaskCompleted && ( - - )} - - {/* Show Subtasks Controls */} - {hasSubtasks && - !( - task.status === 'archived' || - task.status === 3 - ) && ( - - )} - - {/* Edit Button */} - {onEdit && ( - - )} - - {/* Delete Button */} - {onDelete && ( - - )} -
-
- )} -
- )}
@@ -1536,8 +626,7 @@ const SubtasksDisplay: React.FC = ({
= ({ /> = ({ {/* Right side - Status indicator */}
- {subtask.status === 'done' || - subtask.status === 2 || - subtask.status === 'archived' || - subtask.status === 3 ? ( + {isTaskCompleted(subtask.status) ? ( @@ -1600,60 +683,45 @@ const TaskWithSubtasks: React.FC = (props) => { const [showSubtasks, setShowSubtasks] = useState(false); const [subtasks, setSubtasks] = useState([]); const [loadingSubtasks, setLoadingSubtasks] = useState(false); - const [hasSubtasks, setHasSubtasks] = useState(false); - // Check if task has subtasks using included data - useEffect(() => { - const hasSubtasksFromData = - props.task.subtasks && props.task.subtasks.length > 0; - setHasSubtasks(!!hasSubtasksFromData); - - // Set initial subtasks state if they are already loaded - if (hasSubtasksFromData && props.task.subtasks) { - setSubtasks(props.task.subtasks); - } - }, [props.task.id, props.task.subtasks]); - - const loadSubtasks = async () => { + const loadSubtasks = useCallback(async () => { if (!props.task.id) return; - // If subtasks are already included in the task data, use them - if (props.task.subtasks && props.task.subtasks.length > 0) { - setSubtasks(props.task.subtasks); - return; - } - - // Only fetch if not already included (fallback for older API responses) setLoadingSubtasks(true); try { const subtasksData = await fetchSubtasks(props.task.id); setSubtasks(subtasksData); + setShowSubtasks(subtasksData.length > 0); } catch (error) { console.error('Failed to load subtasks:', error); setSubtasks([]); + setShowSubtasks(false); } finally { setLoadingSubtasks(false); } - }; + }, [props.task.id]); - const handleSubtasksToggle = async (e: React.MouseEvent) => { - e.stopPropagation(); // Prevent opening task modal + useEffect(() => { + const subtasksData = props.task.subtasks || props.task.Subtasks || []; + const hasSubtasksFromData = subtasksData.length > 0; + setSubtasks(subtasksData); + setShowSubtasks(hasSubtasksFromData); - if (!showSubtasks && subtasks.length === 0) { - await loadSubtasks(); + if (!hasSubtasksFromData) { + void loadSubtasks(); } - - setShowSubtasks(!showSubtasks); - }; + }, [props.task.id, props.task.subtasks, props.task.Subtasks, loadSubtasks]); return ( <> 0 || loadingSubtasks} + onSubtasksToggle={(e) => { + e.stopPropagation(); + setShowSubtasks((prev) => !prev); + }} /> void; - onTaskUpdate: (task: Task) => Promise; loadSubtasks: () => Promise; onSubtaskUpdate: (updatedSubtask: Task) => void; } +const getPriorityBorderClassName = ( + priority?: Task['priority'] | number +): string => { + let normalizedPriority = priority; + if (typeof normalizedPriority === 'number') { + const priorityNames: Array<'low' | 'medium' | 'high'> = [ + 'low', + 'medium', + 'high', + ]; + normalizedPriority = priorityNames[normalizedPriority] || undefined; + } + + switch (normalizedPriority) { + case 'high': + return 'border-l-4 border-l-red-500'; + case 'medium': + return 'border-l-4 border-l-yellow-400'; + case 'low': + return 'border-l-4 border-l-blue-400'; + default: + return 'border-l-4 border-l-transparent'; + } +}; + const SubtasksDisplay: React.FC = ({ - showSubtasks, loadingSubtasks, subtasks, onTaskClick, @@ -27,102 +50,93 @@ const SubtasksDisplay: React.FC = ({ }) => { const { t } = useTranslation(); - if (!showSubtasks) return null; + if (loadingSubtasks) { + return ( +
+ {t('loading.subtasks', 'Loading subtasks...')} +
+ ); + } + + if (subtasks.length === 0) { + return ( +
+ {t('subtasks.noSubtasks', 'No subtasks found')} +
+ ); + } return (
- {loadingSubtasks ? ( -
- {t('loading.subtasks', 'Loading subtasks...')} -
- ) : subtasks.length > 0 ? ( - subtasks.map((subtask, index) => ( -
+ {subtasks.map((subtask) => { + const borderClass = isTaskCompleted(subtask.status) + ? 'border-l-4 border-l-green-500' + : getPriorityBorderClassName(subtask.priority); + return ( +
{ e.stopPropagation(); onTaskClick(e, subtask); }} > -
-
-
- { - if (subtask.uid) { - try { - const updatedSubtask = - await toggleTaskCompletion( - subtask.uid, - subtask - ); - - // 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 +
+
+ { + if (subtask.uid) { + try { + const updatedSubtask = + await toggleTaskCompletion( + subtask.uid, + subtask ); - } catch (error) { - console.error( - 'Error toggling subtask completion:', - error - ); - // Refresh subtasks on error - await loadSubtasks(); + + if ( + updatedSubtask.parent_child_logic_executed + ) { + setTimeout(() => { + window.location.reload(); + }, 200); + return; } + + onSubtaskUpdate( + updatedSubtask + ); + } catch (error) { + console.error( + 'Error toggling subtask completion:', + error + ); + await loadSubtasks(); } - }} - /> -
+ } + }} + /> - {subtask.name} + {subtask.original_name || subtask.name}
-
- {/* Right side status indicators removed */} -
+ {isTaskCompleted(subtask.status) && ( + + ✓ + + )}
- )) - ) : ( -
- {t('subtasks.noSubtasks', 'No subtasks found')} -
- )} + ); + })}
); }; @@ -171,16 +185,30 @@ const TaskItem: React.FC = ({ const [isAnimatingOut, setIsAnimatingOut] = useState(false); // Subtasks state - const [showSubtasks, setShowSubtasks] = useState(false); const [subtasks, setSubtasks] = useState([]); const [loadingSubtasks, setLoadingSubtasks] = useState(false); - const [hasSubtasks, setHasSubtasks] = useState(false); + const [showSubtasks, setShowSubtasks] = useState(false); // Update projectList when projects prop changes useEffect(() => { setProjectList(projects); }, [projects]); + const loadSubtasks = useCallback(async () => { + if (!task.id) return; + + setLoadingSubtasks(true); + try { + const subtasksData = await fetchSubtasks(task.id); + setSubtasks(subtasksData); + } catch (error) { + console.error('Failed to load subtasks:', error); + setSubtasks([]); + } finally { + setLoadingSubtasks(false); + } + }, [task.id]); + // Calculate completion percentage const calculateCompletionPercentage = () => { if (subtasks.length === 0) return 0; @@ -195,58 +223,22 @@ const TaskItem: React.FC = ({ }; const completionPercentage = calculateCompletionPercentage(); + const hasInitialSubtasks = + (task.subtasks && task.subtasks.length > 0) || + (task.Subtasks && task.Subtasks.length > 0); + const shouldShowSubtasksIcon = + hasInitialSubtasks || subtasks.length > 0 || loadingSubtasks; // Check if task has subtasks using the included subtasks data useEffect(() => { // Handle both 'subtasks' and 'Subtasks' property names (case sensitivity) const subtasksData = task.subtasks || task.Subtasks || []; - const hasSubtasksFromData = subtasksData.length > 0; - - // Update subtasks and hasSubtasks state based on task data - setHasSubtasks(hasSubtasksFromData); setSubtasks(subtasksData); - }, [task.id, task.subtasks, task.Subtasks]); // Removed task.updated_at which was causing frequent re-renders + }, [task.id, task.subtasks, task.Subtasks]); - const loadSubtasks = async () => { - if (!task.id) return; - - // If subtasks are already included in the task data, use them (handle case sensitivity) - const subtasksData = task.subtasks || task.Subtasks || []; - if (subtasksData.length > 0) { - setSubtasks(subtasksData); - return; - } - - // Only fetch if not already included (fallback for older API responses) - setLoadingSubtasks(true); - try { - const subtasksData = await fetchSubtasks(task.id); - setSubtasks(subtasksData); - } catch (error) { - console.error('Failed to load subtasks:', error); - setSubtasks([]); - } finally { - setLoadingSubtasks(false); - } - }; - - // Reload subtasks when showSubtasks changes to true useEffect(() => { - if (showSubtasks && subtasks.length === 0) { - loadSubtasks(); - } - }, [showSubtasks, subtasks.length]); - - const handleSubtasksToggle = async (e: React.MouseEvent) => { - e.stopPropagation(); - - if (!showSubtasks && subtasks.length === 0) { - await loadSubtasks(); - } - - setShowSubtasks(!showSubtasks); - }; - + setShowSubtasks(false); + }, [task.id]); const handleTaskClick = () => { if (task.uid) { if (task.habit_mode) { @@ -270,6 +262,19 @@ const TaskItem: React.FC = ({ setSelectedSubtask(null); }; + const handleSubtasksToggle = async (e: React.MouseEvent) => { + e.stopPropagation(); + + if (!showSubtasks) { + if (subtasks.length === 0) { + await loadSubtasks(); + } + setShowSubtasks(true); + } else { + setShowSubtasks(false); + } + }; + const handleSubtaskDelete = async () => { if (selectedSubtask && selectedSubtask.uid) { await onTaskDelete(selectedSubtask.uid); @@ -445,45 +450,14 @@ const TaskItem: React.FC = ({ // Check if task is overdue (created yesterday or earlier and not completed) const isOverdue = isTaskOverdue(task); - const getPriorityBorderClass = () => { - // Show green border for completed tasks - if ( - task.status === 'done' || - task.status === 2 || - task.status === 'archived' || - task.status === 3 - ) { - return 'border-l-4 border-l-green-500'; - } - - let priority = task.priority; - if (typeof priority === 'number') { - const priorityNames: Array<'low' | 'medium' | 'high'> = [ - 'low', - 'medium', - 'high', - ]; - priority = priorityNames[priority] || undefined; - } - - switch (priority) { - case 'high': - return 'border-l-4 border-l-red-500'; - case 'medium': - return 'border-l-4 border-l-yellow-400'; - case 'low': - return 'border-l-4 border-l-blue-400'; - default: - return 'border-l-4 border-l-transparent'; - } - }; - - const priorityBorderClass = getPriorityBorderClass(); + const priorityBorderClass = isTaskCompleted(task.status) + ? 'border-l-4 border-l-green-500' + : getPriorityBorderClassName(task.priority); return ( <>
= ({ onTaskUpdate={onTaskUpdate} isOverdue={isOverdue} showSubtasks={showSubtasks} - hasSubtasks={hasSubtasks} - onSubtasksToggle={handleSubtasksToggle} + hasSubtasks={shouldShowSubtasksIcon} + onSubtasksToggle={ + shouldShowSubtasksIcon + ? handleSubtasksToggle + : undefined + } onEdit={handleEdit} onDelete={handleDeleteClick} isUpcomingView={isUpcomingView} @@ -508,14 +486,8 @@ const TaskItem: React.FC = ({ {/* Progress bar at bottom of parent task */} {subtasks.length > 0 && ( -
-
+
+
= ({
{/* Hide subtasks display for archived tasks */} - {!(task.status === 'archived' || task.status === 3) && ( - { - e.stopPropagation(); - handleSubtaskClick(); - }} - onTaskUpdate={onTaskUpdate} - loadSubtasks={loadSubtasks} - onSubtaskUpdate={(updatedSubtask) => { - setSubtasks((prev) => - prev.map((st) => - st.id === updatedSubtask.id - ? updatedSubtask - : st - ) - ); - }} - /> - )} + {showSubtasks && + (subtasks.length > 0 || loadingSubtasks) && + !(task.status === 'archived' || task.status === 3) && ( + { + e.stopPropagation(); + handleSubtaskClick(); + }} + loadSubtasks={loadSubtasks} + onSubtaskUpdate={(updatedSubtask) => { + setSubtasks((prev) => + prev.map((st) => + st.id === updatedSubtask.id + ? updatedSubtask + : st + ) + ); + }} + /> + )} = ({ status, className, }) => { - // Convert numeric status to string - const getStatusString = (status: StatusType | number): StatusType => { - if (typeof status === 'number') { - const statusNames: StatusType[] = [ - 'not_started', - 'in_progress', - 'done', - 'archived', - ]; - return statusNames[status] || 'not_started'; - } - return status; - }; - const statusString = getStatusString(status); let statusIcon; @@ -37,12 +27,21 @@ const TaskStatusBadge: React.FC = ({ case 'not_started': statusIcon = ; break; + case 'planned': + statusIcon = ; + break; case 'in_progress': statusIcon = ; break; + case 'waiting': + statusIcon = ; + break; case 'done': statusIcon = ; break; + case 'cancelled': + statusIcon = ; + break; case 'archived': statusIcon = ; break; diff --git a/frontend/components/Task/TaskStatusControl.tsx b/frontend/components/Task/TaskStatusControl.tsx new file mode 100644 index 0000000..6ff4eed --- /dev/null +++ b/frontend/components/Task/TaskStatusControl.tsx @@ -0,0 +1,522 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { + ChevronDownIcon, + PlayIcon, + PauseCircleIcon, + CheckIcon, + ClockIcon, + XCircleIcon, + CalendarIcon, +} from '@heroicons/react/24/outline'; +import { useTranslation } from 'react-i18next'; +import { Task, StatusType } from '../../entities/Task'; +import { + isTaskCompleted, + isTaskInProgress, + isTaskNotStarted, + getStatusString, +} from '../../constants/taskStatus'; +import { + getStatusBorderColorClasses, + getStatusButtonColorClasses, +} from './statusStyles'; + +type CompletionMenuTarget = 'desktop' | 'mobile'; + +interface TaskStatusControlProps { + task: Task; + onToggleCompletion?: () => void; + onTaskUpdate?: (task: Task) => Promise; + hoverRevealQuickActions?: boolean; + showMobileVariant?: boolean; + className?: string; + variant?: 'pill' | 'square'; + showQuickActions?: boolean; +} + +const quickStartStatuses = new Set([ + 'not_started', + 'planned', + 'waiting', + 'cancelled', +]); + +const TaskStatusControl: React.FC = ({ + task, + onToggleCompletion, + onTaskUpdate, + hoverRevealQuickActions = true, + showMobileVariant = true, + className = '', + variant = 'square', + showQuickActions = true, +}) => { + const { t } = useTranslation(); + const [completionMenuOpen, setCompletionMenuOpen] = + useState(null); + const [isCompletingTask, setIsCompletingTask] = useState(false); + const desktopCompletionMenuRef = useRef(null); + const mobileCompletionMenuRef = useRef(null); + + useEffect(() => { + if (!completionMenuOpen) return; + + const handleClickOutside = (event: MouseEvent) => { + const target = event.target as Node; + const activeRef = + completionMenuOpen === 'desktop' + ? desktopCompletionMenuRef.current + : mobileCompletionMenuRef.current; + + if (activeRef && activeRef.contains(target)) { + return; + } + + setCompletionMenuOpen(null); + }; + + document.addEventListener('click', handleClickOutside); + return () => { + document.removeEventListener('click', handleClickOutside); + }; + }, [completionMenuOpen]); + + const taskCompleted = isTaskCompleted(task.status); + const taskInProgress = isTaskInProgress(task.status); + const currentStatusString = getStatusString(task.status); + + const completionButtonTextClass = taskCompleted + ? 'text-green-600 dark:text-green-400' + : taskInProgress + ? 'text-blue-600 dark:text-blue-400' + : 'text-gray-600 dark:text-gray-400'; + + const completionButtonHoverClass = taskCompleted + ? 'hover:bg-green-50 dark:hover:bg-green-900/40' + : taskInProgress + ? 'hover:bg-blue-50 dark:hover:bg-blue-900/40' + : 'hover:bg-gray-50 dark:hover:bg-gray-800'; + + const completionButtonMainBgClass = taskCompleted + ? 'bg-green-100 dark:bg-green-900/50' + : taskInProgress + ? 'bg-blue-100 dark:bg-blue-900/50' + : 'bg-gray-200 dark:bg-gray-700'; + + const completionButtonMainTextClass = taskCompleted + ? 'text-green-900 dark:text-green-100 font-semibold' + : taskInProgress + ? 'text-blue-900 dark:text-blue-100 font-semibold' + : 'text-gray-900 dark:text-gray-100 font-semibold'; + + const isSquareVariant = variant === 'square'; + const textSizeClass = isSquareVariant ? 'text-xs' : 'text-sm'; + const gapClass = isSquareVariant ? 'gap-1.5' : 'gap-2'; + const iconSizeClass = isSquareVariant ? 'h-3.5 w-3.5' : 'h-4 w-4'; + const containerRoundedClass = isSquareVariant + ? 'rounded-lg' + : 'rounded-full'; + const completionButtonPaddingClass = isSquareVariant + ? 'px-2.5 py-1' + : 'px-3 py-1'; + const quickButtonPaddingClass = isSquareVariant ? 'px-1.5' : 'px-2'; + const hoverPaddingClass = isSquareVariant + ? 'md:group-hover:px-1.5' + : 'md:group-hover:px-2'; + + const completionButtonMainClasses = `inline-flex items-center ${gapClass} ${textSizeClass} transition ${completionButtonMainTextClass} ${completionButtonMainBgClass} ${completionButtonHoverClass} focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500`; + + const completionButtonChevronClasses = `inline-flex items-center justify-center transition ${completionButtonTextClass} ${completionButtonHoverClass} focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500`; + + const statusButtonColorClasses = getStatusButtonColorClasses(task.status); + const statusBorderColorClass = getStatusBorderColorClasses(task.status); + + const showQuickStartButton = + showQuickActions && quickStartStatuses.has(currentStatusString); + const showQuickCompleteButton = + showQuickActions && currentStatusString !== 'done'; + + const handleCompletionClick = async (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + setCompletionMenuOpen(null); + + if (onToggleCompletion) { + if (!taskCompleted) { + setIsCompletingTask(true); + await new Promise((resolve) => setTimeout(resolve, 1200)); + } + + onToggleCompletion(); + + setTimeout(() => { + setIsCompletingTask(false); + }, 100); + } + }; + + const handleStatusSelection = async ( + e: React.MouseEvent, + statusValue: StatusType + ) => { + e.preventDefault(); + e.stopPropagation(); + setCompletionMenuOpen(null); + if (onTaskUpdate && task.id) { + const updatedTask = { + ...task, + status: statusValue, + }; + await onTaskUpdate(updatedTask); + } + }; + + const renderStatusMenuOptions = (menuType: CompletionMenuTarget) => { + const options: StatusDropdownOption[] = [ + { + value: 'not_started', + label: t('task.status.notStarted', 'Not started'), + Icon: PauseCircleIcon, + activeClasses: + 'bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-gray-100 font-semibold border-l-2 border-gray-500 dark:border-gray-400', + inactiveClasses: + 'text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800', + activeIconClass: 'text-gray-600 dark:text-gray-300', + inactiveIconClass: 'text-gray-500 dark:text-gray-400', + }, + { + value: 'planned', + label: t('task.status.planned', 'Planned'), + Icon: ClockIcon, + activeClasses: + 'bg-purple-100 dark:bg-purple-900/50 text-purple-900 dark:text-purple-100 font-semibold border-l-2 border-purple-500 dark:border-purple-400', + inactiveClasses: + 'text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800', + activeIconClass: 'text-purple-600 dark:text-purple-300', + inactiveIconClass: 'text-purple-500 dark:text-purple-400', + }, + { + value: 'in_progress', + label: t('task.status.inProgress', 'In progress'), + Icon: PlayIcon, + activeClasses: + 'bg-blue-100 dark:bg-blue-900/50 text-blue-900 dark:text-blue-100 font-semibold border-l-2 border-blue-500 dark:border-blue-400', + inactiveClasses: + 'text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800', + activeIconClass: 'text-blue-600 dark:text-blue-300', + inactiveIconClass: 'text-blue-500 dark:text-blue-400', + }, + { + value: 'waiting', + label: t('task.status.waiting', 'Waiting'), + Icon: ClockIcon, + activeClasses: + 'bg-yellow-100 dark:bg-yellow-900/50 text-yellow-900 dark:text-yellow-100 font-semibold border-l-2 border-yellow-500 dark:border-yellow-400', + inactiveClasses: + 'text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800', + activeIconClass: 'text-yellow-600 dark:text-yellow-300', + inactiveIconClass: 'text-yellow-500 dark:text-yellow-400', + }, + { + value: 'cancelled', + label: t('task.status.cancelled', 'Cancelled'), + Icon: XCircleIcon, + activeClasses: + 'bg-red-100 dark:bg-red-900/50 text-red-900 dark:text-red-100 font-semibold border-l-2 border-red-500 dark:border-red-400', + inactiveClasses: + 'text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800', + activeIconClass: 'text-red-600 dark:text-red-300', + inactiveIconClass: 'text-red-500 dark:text-red-400', + }, + { + value: 'done', + label: t('task.status.setAsDone', 'Set as done'), + Icon: CheckIcon, + activeClasses: + 'bg-green-100 dark:bg-green-900/50 text-green-900 dark:text-green-100 font-semibold border-l-2 border-green-500 dark:border-green-400', + inactiveClasses: + 'text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800', + activeIconClass: 'text-green-600 dark:text-green-300', + inactiveIconClass: 'text-green-500 dark:text-green-400', + completion: true, + }, + ]; + + const currentStatus = getStatusString(task.status); + + return options.map((option, index) => { + const isActive = currentStatus === option.value; + const roundedClass = + index === 0 + ? 'rounded-t-lg' + : index === options.length - 1 + ? 'rounded-b-lg' + : ''; + const iconClass = isActive + ? option.activeIconClass + : option.inactiveIconClass; + const stateClasses = isActive + ? option.activeClasses + : option.inactiveClasses; + + return ( + + ); + }); + }; + + const quickButtonBaseClasses = `${completionButtonChevronClasses} ${statusButtonColorClasses} border-l ${statusBorderColorClass} flex transition-all duration-200`; + const quickButtonClasses = hoverRevealQuickActions + ? `${quickButtonBaseClasses} ${quickButtonPaddingClass} md:px-0 md:w-0 md:opacity-0 md:pointer-events-none md:border-l-0 ${hoverPaddingClass} md:group-hover:w-auto md:group-hover:opacity-100 md:group-hover:pointer-events-auto md:group-hover:border-l` + : `${quickButtonBaseClasses} ${quickButtonPaddingClass}`; + + const quickCompleteClasses = hoverRevealQuickActions + ? `${completionButtonChevronClasses} ${statusButtonColorClasses} border-l ${statusBorderColorClass} flex transition-all duration-200 ${quickButtonPaddingClass} md:px-0 md:w-0 md:opacity-0 md:pointer-events-none md:border-l-0 ${hoverPaddingClass} md:group-hover:w-auto md:group-hover:opacity-100 md:group-hover:pointer-events-auto md:group-hover:border-l` + : `${completionButtonChevronClasses} ${statusButtonColorClasses} border-l ${statusBorderColorClass} flex transition-all duration-200 ${quickButtonPaddingClass}`; + + const statusDisplayConfig: Record< + ReturnType, + { + label: string; + Icon: React.ComponentType>; + } + > = { + not_started: { + label: t('task.status.notStarted', 'Not started'), + Icon: PauseCircleIcon, + }, + planned: { + label: t('task.status.planned', 'Planned'), + Icon: CalendarIcon, + }, + in_progress: { + label: t('task.status.inProgress', 'In progress'), + Icon: PlayIcon, + }, + waiting: { + label: t('task.status.waiting', 'Waiting'), + Icon: ClockIcon, + }, + cancelled: { + label: t('task.status.cancelled', 'Cancelled'), + Icon: XCircleIcon, + }, + done: { + label: t('tasks.done', 'Done'), + Icon: CheckIcon, + }, + archived: { + label: t('task.status.archived', 'Archived'), + Icon: CheckIcon, + }, + }; + + const statusDisplay = + statusDisplayConfig[currentStatusString] || + statusDisplayConfig.not_started; + const CompletionIcon = statusDisplay.Icon; + const completionButtonLabel = statusDisplay.label; + + return ( +
+
+ + {showQuickStartButton && ( + + )} + {showQuickCompleteButton && ( + + )} + +
+ {completionMenuOpen === 'desktop' && ( +
+ {renderStatusMenuOptions('desktop')} +
+ )} + + {showMobileVariant && ( +
+
+ + {showQuickStartButton && ( + + )} + {showQuickCompleteButton && ( + + )} + +
+ {completionMenuOpen === 'mobile' && ( +
+ {renderStatusMenuOptions('mobile')} +
+ )} +
+ )} +
+ ); +}; + +interface StatusDropdownOption { + value: StatusType; + label: string; + Icon: React.ComponentType>; + activeClasses: string; + inactiveClasses: string; + activeIconClass: string; + inactiveIconClass: string; + completion?: boolean; +} + +export default TaskStatusControl; diff --git a/frontend/components/Task/TasksToday.tsx b/frontend/components/Task/TasksToday.tsx index 91f55e9..86fb313 100644 --- a/frontend/components/Task/TasksToday.tsx +++ b/frontend/components/Task/TasksToday.tsx @@ -26,6 +26,11 @@ import { deleteTask, toggleTaskToday, } from '../../utils/tasksService'; +import { + isTaskDone, + isTaskActive, + isHabitArchived, +} from '../../constants/taskStatus'; import { fetchProjects } from '../../utils/projectsService'; import { Task } from '../../entities/Task'; import { useStore } from '../../store/useStore'; @@ -150,17 +155,12 @@ const TasksToday: React.FC = () => { tasks_completed_today: [], }); - // Pagination state for Today Plan tasks - const [pagination, setPagination] = useState({ - total: 0, - limit: 20, - offset: 0, - hasMore: false, - }); - // Client-side pagination for Due Today tasks (since backend returns all) const [dueTodayDisplayLimit, setDueTodayDisplayLimit] = useState(20); + // Client-side pagination for Planned tasks (since backend returns all) + const [plannedDisplayLimit, setPlannedDisplayLimit] = useState(20); + // Client-side pagination for Overdue tasks (since backend returns all) const [overdueDisplayLimit, setOverdueDisplayLimit] = useState(20); @@ -172,10 +172,11 @@ const TasksToday: React.FC = () => { useState(20); const [habitActionUid, setHabitActionUid] = useState(null); - const plannedTasks = useMemo( - () => filterNonHabitTasks(metrics.today_plan_tasks || []), - [metrics.today_plan_tasks] - ); + const plannedTasks = useMemo(() => { + // Only use today_plan_tasks from backend - it already filters by status + // (in_progress, planned, waiting) regardless of the 'today' field + return filterNonHabitTasks(metrics.today_plan_tasks || []); + }, [metrics.today_plan_tasks]); const completedTasksList = useMemo( () => filterNonHabitTasks(metrics.tasks_completed_today || []), [metrics.tasks_completed_today] @@ -297,9 +298,6 @@ const TasksToday: React.FC = () => { localStorage.setItem('dueTodayTasksCollapsed', newState.toString()); }; - const isHabitArchived = (habit: Task) => - habit.status === 3 || habit.status === 'archived'; - const isHabitCompletedToday = useCallback((habit: Task) => { if (!habit.habit_last_completion_at) { return false; @@ -317,7 +315,8 @@ const TasksToday: React.FC = () => { () => todayHabits.filter( (habit) => - !isHabitArchived(habit) && !isHabitCompletedToday(habit) + !isHabitArchived(habit.status) && + !isHabitCompletedToday(habit) ), [todayHabits, isHabitCompletedToday] ); @@ -326,7 +325,8 @@ const TasksToday: React.FC = () => { () => todayHabits.filter( (habit) => - !isHabitArchived(habit) && isHabitCompletedToday(habit) + !isHabitArchived(habit.status) && + isHabitCompletedToday(habit) ), [todayHabits, isHabitCompletedToday] ); @@ -493,17 +493,12 @@ const TasksToday: React.FC = () => { tasks_in_progress: result.tasks_in_progress || [], tasks_due_today: result.tasks_due_today || [], tasks_overdue: result.tasks_overdue || [], - today_plan_tasks: result.tasks || [], // Main tasks array is today plan + today_plan_tasks: result.tasks_today_plan || [], suggested_tasks: result.suggested_tasks || [], tasks_completed_today: result.tasks_completed_today || [], } as any); - // Update pagination state if pagination metadata is present - if (result.pagination) { - setPagination(result.pagination); - } - useStore.getState().tasksStore.setTasks(result.tasks); setIsError(false); } @@ -774,7 +769,7 @@ const TasksToday: React.FC = () => { ); // Always remove from completed first // Now, add the task to the appropriate list(s) based on its new status - if (updatedTask.status === 'done' || updatedTask.status === 2) { + if (isTaskDone(updatedTask.status)) { // If completed, add to tasks_completed_today if it was completed today if (updatedTask.completed_at) { const completedDate = new Date( @@ -795,7 +790,7 @@ const TasksToday: React.FC = () => { // If not completed, add to relevant active lists if ( updatedTask.today && - updatedTask.status !== 'archived' + updatedTask.status !== 'cancelled' ) { newMetrics.today_plan_tasks = updateOrAddTask( newMetrics.today_plan_tasks, @@ -811,7 +806,7 @@ const TasksToday: React.FC = () => { // Check if task has a due date (and not already in today_plan_tasks or in_progress) if ( updatedTask.due_date && - updatedTask.status !== 'archived' && + updatedTask.status !== 'cancelled' && !newMetrics.today_plan_tasks.some( (t) => t.id === updatedTask.id ) && @@ -840,22 +835,15 @@ const TasksToday: React.FC = () => { ); } } - // Check for suggested tasks (and not already in other active lists) const isSuggested = !updatedTask.today && !updatedTask.project_id && !updatedTask.due_date; - // Check if task is not completed (can be string or number) - const taskStatus = updatedTask.status as string | number; - const isNotCompleted = - taskStatus !== 'archived' && - taskStatus !== 'done' && - taskStatus !== 2 && - taskStatus !== 3; + const isActive = isTaskActive(updatedTask.status); if ( isSuggested && - isNotCompleted && + isActive && !newMetrics.today_plan_tasks.some( (t) => t.id === updatedTask.id ) && @@ -884,15 +872,6 @@ const TasksToday: React.FC = () => { return newMetrics; }); - // Update pagination total to match the actual count of today_plan_tasks - setMetrics((prevMetrics) => { - setPagination((prevPagination) => ({ - ...prevPagination, - total: prevMetrics.today_plan_tasks?.length || 0, - })); - return prevMetrics; - }); - // Update the store with the updated task useStore.getState().tasksStore.updateTaskInStore(updatedTask); @@ -966,15 +945,6 @@ const TasksToday: React.FC = () => { return newMetrics; }); - // Update pagination total after server response - setMetrics((prevMetrics) => { - setPagination((prevPagination) => ({ - ...prevPagination, - total: prevMetrics.today_plan_tasks?.length || 0, - })); - return prevMetrics; - }); - // Also update the store with server response useStore .getState() @@ -1004,17 +974,11 @@ const TasksToday: React.FC = () => { tasks_in_progress: result.tasks_in_progress || [], tasks_due_today: result.tasks_due_today || [], tasks_overdue: result.tasks_overdue || [], - today_plan_tasks: result.tasks || [], + today_plan_tasks: result.tasks_today_plan || [], suggested_tasks: result.suggested_tasks || [], tasks_completed_today: result.tasks_completed_today || [], } as any); - // Update pagination to match the reloaded tasks - setPagination((prev) => ({ - ...prev, - ...(result.pagination || {}), - total: result.tasks?.length || 0, // Use actual task count - })); } } catch (error) { console.error('Error deleting task:', error); @@ -1039,17 +1003,11 @@ const TasksToday: React.FC = () => { tasks_in_progress: result.tasks_in_progress || [], tasks_due_today: result.tasks_due_today || [], tasks_overdue: result.tasks_overdue || [], - today_plan_tasks: result.tasks || [], + today_plan_tasks: result.tasks_today_plan || [], suggested_tasks: result.suggested_tasks || [], tasks_completed_today: result.tasks_completed_today || [], } as any); - // Update pagination to match the reloaded tasks - setPagination((prev) => ({ - ...prev, - ...(result.pagination || {}), - total: result.tasks?.length || 0, // Use actual task count - })); } } catch (error) { console.error('Error toggling task today status:', error); @@ -1073,106 +1031,6 @@ const TasksToday: React.FC = () => { [handleTaskUpdate] ); - // Load more tasks (pagination) - const handleLoadMore = useCallback( - async (all: boolean = false) => { - if (!isMounted.current || isLoading) return; - if (!all && !pagination.hasMore) return; - - setIsLoading(true); - try { - let limit: number, offset: number; - if (all) { - // Load all remaining tasks - limit = pagination.total > 0 ? pagination.total : 10000; - offset = 0; - } else { - // Load next page - limit = pagination.limit; - offset = pagination.offset + pagination.limit; - } - - const result = await fetchTasks( - `?type=today&limit=${limit}&offset=${offset}` - ); - - if (isMounted.current) { - if (all) { - // Replace all tasks when loading all - setMetrics({ - ...result.metrics, - tasks_in_progress: result.tasks_in_progress || [], - tasks_due_today: result.tasks_due_today || [], - tasks_overdue: result.tasks_overdue || [], - today_plan_tasks: result.tasks || [], - suggested_tasks: result.suggested_tasks || [], - tasks_completed_today: - result.tasks_completed_today || [], - } as any); - - useStore.getState().tasksStore.setTasks(result.tasks); - } else { - // Append new tasks to existing ones - setMetrics((prevMetrics) => ({ - ...result.metrics, - tasks_in_progress: [ - ...(prevMetrics.tasks_in_progress || []), - ...(result.tasks_in_progress || []), - ], - tasks_due_today: [ - ...(prevMetrics.tasks_due_today || []), - ...(result.tasks_due_today || []), - ], - tasks_overdue: [ - ...(prevMetrics.tasks_overdue || []), - ...(result.tasks_overdue || []), - ], - today_plan_tasks: [ - ...(prevMetrics.today_plan_tasks || []), - ...(result.tasks || []), - ], - suggested_tasks: [ - ...(prevMetrics.suggested_tasks || []), - ...(result.suggested_tasks || []), - ], - tasks_completed_today: [ - ...(prevMetrics.tasks_completed_today || []), - ...(result.tasks_completed_today || []), - ], - })); - - // Append tasks to store - const currentTasks = - useStore.getState().tasksStore.tasks; - useStore - .getState() - .tasksStore.setTasks([ - ...currentTasks, - ...result.tasks, - ]); - } - - // Update pagination state - if (result.pagination) { - setPagination(result.pagination); - } - - // If loading all, mark hasMore as false - if (all) { - setPagination((prev) => ({ ...prev, hasMore: false })); - } - } - } catch (error) { - console.error('Error loading more tasks:', error); - } finally { - if (isMounted.current) { - setIsLoading(false); - } - } - }, - [pagination, isLoading] - ); - // Calculate today's progress for the progress bar const getTodayProgress = () => { const todayTasks = plannedTasks; @@ -1652,7 +1510,10 @@ const TasksToday: React.FC = () => { {plannedTasks.length > 0 && ( <> { } /> - {/* Load More Buttons for Today Plan Tasks */} - {pagination.hasMore && ( + {/* Load More Buttons for Planned Tasks */} + {plannedDisplayLimit < + plannedTasks.length && (
)} - {/* Pagination info for Today Plan tasks */} + {/* Pagination info for Planned tasks */}
{t( 'tasks.showingItems', 'Showing {{current}} of {{total}} tasks', { - current: - plannedTasks.length, - total: pagination.total, + current: Math.min( + plannedDisplayLimit, + plannedTasks.length + ), + total: plannedTasks.length, } )}
diff --git a/frontend/components/Task/statusStyles.ts b/frontend/components/Task/statusStyles.ts new file mode 100644 index 0000000..1573198 --- /dev/null +++ b/frontend/components/Task/statusStyles.ts @@ -0,0 +1,70 @@ +import { StatusType } from '../../entities/Task'; + +type StatusKey = + | 'not_started' + | 'in_progress' + | 'done' + | 'archived' + | 'waiting' + | 'cancelled' + | 'planned'; + +interface StatusStyle { + button: string; + border: string; +} + +const STATUS_STYLES: Record = { + planned: { + button: 'bg-purple-50 dark:bg-purple-900/20 text-purple-700 dark:text-purple-300', + border: 'border-purple-200 dark:border-purple-800', + }, + in_progress: { + button: 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300', + border: 'border-blue-200 dark:border-blue-800', + }, + waiting: { + button: 'bg-yellow-50 dark:bg-yellow-900/20 text-yellow-700 dark:text-yellow-300', + border: 'border-yellow-200 dark:border-yellow-800', + }, + done: { + button: 'bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-300', + border: 'border-green-200 dark:border-green-800', + }, + cancelled: { + button: 'bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300', + border: 'border-red-200 dark:border-red-800', + }, + archived: { + button: 'bg-gray-50 dark:bg-gray-900/20 text-gray-700 dark:text-gray-300', + border: 'border-gray-200 dark:border-gray-800', + }, + not_started: { + button: 'bg-gray-50 dark:bg-gray-900/20 text-gray-700 dark:text-gray-300', + border: 'border-gray-200 dark:border-gray-700', + }, +}; + +const resolveStatusKey = (status?: StatusType | number | null): StatusKey => { + if (status === 'planned' || status === 6) return 'planned'; + if (status === 'in_progress' || status === 1) return 'in_progress'; + if (status === 'done' || status === 2) return 'done'; + if (status === 'archived' || status === 3) return 'archived'; + if (status === 'waiting' || status === 4) return 'waiting'; + if (status === 'cancelled' || status === 5) return 'cancelled'; + return 'not_started'; +}; + +export const getStatusButtonColorClasses = ( + status?: StatusType | number | null +) => { + const style = STATUS_STYLES[resolveStatusKey(status)]; + return style.button; +}; + +export const getStatusBorderColorClasses = ( + status?: StatusType | number | null +) => { + const { border } = STATUS_STYLES[resolveStatusKey(status)]; + return border; +}; diff --git a/frontend/constants/taskStatus.ts b/frontend/constants/taskStatus.ts new file mode 100644 index 0000000..f90d2ce --- /dev/null +++ b/frontend/constants/taskStatus.ts @@ -0,0 +1,162 @@ +import { StatusType } from '../entities/Task'; + +export const TASK_STATUS = { + NOT_STARTED: 0, + IN_PROGRESS: 1, + DONE: 2, + ARCHIVED: 3, + WAITING: 4, + CANCELLED: 5, + PLANNED: 6, +} as const; + +export const TASK_STATUS_STRINGS = { + NOT_STARTED: 'not_started', + IN_PROGRESS: 'in_progress', + DONE: 'done', + ARCHIVED: 'archived', + WAITING: 'waiting', + CANCELLED: 'cancelled', + PLANNED: 'planned', +} as const; + +export const HABIT_STATUS_CANCELLED = 5; +export const HABIT_STATUS_CANCELLED_STRING = 'cancelled'; + +export type TaskStatusValue = (typeof TASK_STATUS)[keyof typeof TASK_STATUS]; +export type TaskStatusString = + (typeof TASK_STATUS_STRINGS)[keyof typeof TASK_STATUS_STRINGS]; + +export function getStatusString(status: StatusType | number): TaskStatusString { + if (typeof status === 'string') { + return status as TaskStatusString; + } + + const statusNames: TaskStatusString[] = [ + 'not_started', + 'in_progress', + 'done', + 'archived', + 'waiting', + 'cancelled', + 'planned', + ]; + + return statusNames[status] || 'not_started'; +} + +export function getStatusValue(status: StatusType | number): TaskStatusValue { + if (typeof status === 'number') { + return status as TaskStatusValue; + } + + const statusMap: Record = { + not_started: TASK_STATUS.NOT_STARTED, + in_progress: TASK_STATUS.IN_PROGRESS, + done: TASK_STATUS.DONE, + archived: TASK_STATUS.ARCHIVED, + waiting: TASK_STATUS.WAITING, + cancelled: TASK_STATUS.CANCELLED, + planned: TASK_STATUS.PLANNED, + }; + + return statusMap[status] ?? TASK_STATUS.NOT_STARTED; +} + +export function getStatusLabel(status: StatusType | number): string { + const statusString = getStatusString(status); + + const labels: Record = { + not_started: 'Not Started', + in_progress: 'In Progress', + done: 'Completed', + archived: 'Archived', + waiting: 'Waiting', + cancelled: 'Cancelled', + planned: 'Planned', + }; + + return labels[statusString] || 'Unknown'; +} + +export function isTaskDone( + status: StatusType | number | undefined | null +): boolean { + if (status === undefined || status === null) return false; + return status === TASK_STATUS.DONE || status === 'done'; +} + +export function isTaskInProgress( + status: StatusType | number | undefined | null +): boolean { + if (status === undefined || status === null) return false; + return status === TASK_STATUS.IN_PROGRESS || status === 'in_progress'; +} + +export function isTaskNotStarted( + status: StatusType | number | undefined | null +): boolean { + if (status === undefined || status === null) return false; + return status === TASK_STATUS.NOT_STARTED || status === 'not_started'; +} + +export function isTaskArchived( + status: StatusType | number | undefined | null +): boolean { + if (status === undefined || status === null) return false; + return status === TASK_STATUS.ARCHIVED || status === 'archived'; +} + +export function isTaskWaiting( + status: StatusType | number | undefined | null +): boolean { + if (status === undefined || status === null) return false; + return status === TASK_STATUS.WAITING || status === 'waiting'; +} + +export function isTaskCancelled( + status: StatusType | string | number | undefined | null +): boolean { + if (status === undefined || status === null) return false; + return status === TASK_STATUS.CANCELLED || status === 'cancelled'; +} + +export function isTaskPlanned( + status: StatusType | number | undefined | null +): boolean { + if (status === undefined || status === null) return false; + return status === TASK_STATUS.PLANNED || status === 'planned'; +} + +export function isTaskActive( + status: StatusType | number | undefined | null +): boolean { + return ( + !isTaskDone(status) && + !isTaskArchived(status) && + !isTaskCancelled(status) + ); +} + +export function isTaskCompleted( + status: StatusType | number | undefined | null +): boolean { + return isTaskDone(status) || isTaskArchived(status); +} + +export function isTaskActionable( + status: StatusType | number | undefined | null +): boolean { + return ( + !isTaskDone(status) && + !isTaskArchived(status) && + !isTaskCancelled(status) && + !isTaskWaiting(status) + ); +} + +export function isHabitArchived( + status: StatusType | number | undefined | null +): boolean { + return isTaskArchived(status) || isTaskCancelled(status); +} diff --git a/frontend/entities/Task.ts b/frontend/entities/Task.ts index 0445c36..46b069a 100644 --- a/frontend/entities/Task.ts +++ b/frontend/entities/Task.ts @@ -54,7 +54,9 @@ export type StatusType = | 'in_progress' | 'done' | 'archived' - | 'waiting'; + | 'waiting' + | 'cancelled' + | 'planned'; export type PriorityType = 'low' | 'medium' | 'high' | null | undefined; export type RecurrenceType = | 'none' diff --git a/frontend/utils/tasksService.ts b/frontend/utils/tasksService.ts index a092127..92ff7f6 100644 --- a/frontend/utils/tasksService.ts +++ b/frontend/utils/tasksService.ts @@ -6,6 +6,7 @@ import { getPostHeaders, } from './authUtils'; import { getApiPath } from '../config/paths'; +import { isTaskDone, TASK_STATUS } from '../constants/taskStatus'; export interface GroupedTasks { [groupName: string]: Task[]; @@ -18,6 +19,7 @@ export const fetchTasks = async ( metrics: Metrics; groupedTasks?: GroupedTasks; tasks_in_progress?: Task[]; + tasks_today_plan?: Task[]; tasks_due_today?: Task[]; tasks_overdue?: Task[]; suggested_tasks?: Task[]; @@ -64,6 +66,7 @@ export const fetchTasks = async ( groupedTasks: tasksResult.groupedTasks, // Dashboard task lists (only present when include_lists=true) tasks_in_progress: tasksResult.tasks_in_progress, + tasks_today_plan: tasksResult.tasks_today_plan, tasks_due_today: tasksResult.tasks_due_today, tasks_overdue: tasksResult.tasks_overdue, suggested_tasks: tasksResult.suggested_tasks, @@ -116,8 +119,11 @@ export const toggleTaskCompletion = async ( return result.task; } - const newStatus = - task.status === 2 || task.status === 'done' ? (task.note ? 1 : 0) : 2; + const newStatus = isTaskDone(task.status) + ? task.note + ? TASK_STATUS.IN_PROGRESS + : TASK_STATUS.NOT_STARTED + : TASK_STATUS.DONE; return await updateTask(taskUid, { status: newStatus }); }; diff --git a/package-lock.json b/package-lock.json index 892f170..9a1d6e4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "tududi", - "version": "v0.88.0-dev.1", + "version": "v0.88.2-dev.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "tududi", - "version": "v0.88.0-dev.1", + "version": "v0.88.2-dev.1", "license": "ISC", "dependencies": { "@playwright/test": "^1.57.0",