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
This commit is contained in:
Chris 2025-12-19 11:13:27 +02:00 committed by GitHub
parent 1e51cff18c
commit 4d2ea4212c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 1471 additions and 1822 deletions

View file

@ -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;
};

View file

@ -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 },
},
],
},
],
},

View file

@ -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 {

View file

@ -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');
});
});
});

View file

@ -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) {

View file

@ -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<StatusDropdownProps> = ({ value, onChange }) => {
const { t } = useTranslation();
const statusString = getStatusString(value);
const statuses = [
{
value: 'not_started',
label: t('status.notStarted', 'Not Started'),
icon: (
<MinusIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" />
<MinusIcon className="w-5 h-5 text-gray-500 dark:text-gray-400" />
),
},
{
value: 'planned',
label: t('status.planned', 'Planned'),
icon: (
<CalendarIcon className="w-5 h-5 text-purple-500 dark:text-purple-400" />
),
},
{
value: 'in_progress',
label: t('status.inProgress', 'In Progress'),
icon: (
<ClockIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" />
<PlayIcon className="w-5 h-5 text-blue-500 dark:text-blue-400" />
),
},
{
value: 'waiting',
label: t('status.waiting', 'Waiting'),
icon: (
<ClockIcon className="w-5 h-5 text-yellow-500 dark:text-yellow-400" />
),
},
{
value: 'done',
label: t('status.done', 'Done'),
icon: (
<CheckCircleIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" />
<CheckCircleIcon className="w-5 h-5 text-green-500 dark:text-green-400" />
),
},
{
value: 'cancelled',
label: t('status.cancelled', 'Cancelled'),
icon: (
<XCircleIcon className="w-5 h-5 text-red-500 dark:text-red-400" />
),
},
{
value: 'archived',
label: t('status.archived', 'Archived'),
icon: (
<ArchiveBoxIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" />
<ArchiveBoxIcon className="w-5 h-5 text-gray-500 dark:text-gray-400" />
),
},
];
@ -80,7 +106,7 @@ const StatusDropdown: React.FC<StatusDropdownProps> = ({ value, onChange }) => {
};
}, [isOpen]);
const selectedStatus = statuses.find((s) => s.value === value);
const selectedStatus = statuses.find((s) => s.value === statusString);
return (
<div

View file

@ -12,7 +12,6 @@ import {
fetchTaskByUid,
fetchTaskNextIterations,
TaskIteration,
toggleTaskToday,
toggleTaskCompletion,
} from '../../utils/tasksService';
import { createProject } from '../../utils/projectsService';
@ -804,43 +803,6 @@ const TaskDetails: React.FC = () => {
[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}

View file

@ -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<TaskDetailsHeaderProps> = ({
onOverdueIconClick,
isOverdueAlertVisible = false,
onDismissOverdueAlert,
onToggleTodayPlan,
onQuickStatusToggle,
attachmentCount = 0,
subtasksCount = 0,
@ -68,11 +62,9 @@ const TaskDetailsHeader: React.FC<TaskDetailsHeaderProps> = ({
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<HTMLInputElement>(null);
const actionsMenuRef = useRef<HTMLDivElement>(null);
const statusDropdownRef = useRef<HTMLDivElement>(null);
const priorityDropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
@ -95,13 +87,6 @@ const TaskDetailsHeader: React.FC<TaskDetailsHeaderProps> = ({
) {
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<TaskDetailsHeaderProps> = ({
}
};
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<TaskDetailsHeaderProps> = ({
}
};
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<TaskDetailsHeaderProps> = ({
{task.name}
</h2>
{/* Status Dropdown Button - Next to title */}
<div className="flex items-center gap-2 flex-wrap">
<div
className="relative flex-shrink-0"
ref={statusDropdownRef}
>
<button
className={getStatusButtonClass()}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setStatusDropdownOpen(
!statusDropdownOpen
);
}}
aria-haspopup="true"
aria-expanded={
statusDropdownOpen
}
>
{React.createElement(
getStatusIcon(),
{
className: `h-4 w-4 ${getStatusIconClass()}`,
}
)}
<span className="capitalize hidden sm:inline">
{getStatusLabel()}
</span>
<ChevronDownIcon className="h-4 w-4" />
</button>
{statusDropdownOpen && (
<div className="absolute left-0 sm:right-0 sm:left-auto mt-2 w-48 rounded-lg shadow-lg bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 z-20">
<button
className={`w-full text-left px-3 py-2 text-sm rounded-t-lg flex items-center gap-2 ${
task.status === 0 ||
task.status ===
'not_started'
? 'bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100 font-medium'
: 'text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800'
}`}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleStatusChange(
0
);
}}
>
<PauseCircleIcon
className={`h-4 w-4 ${getStatusIconClass(0)}`}
/>
<span className="capitalize flex-1">
{t(
'task.status.notStarted',
'Not started'
)}
</span>
{(task.status === 0 ||
task.status ===
'not_started') && (
<CheckIcon className="h-4 w-4" />
)}
</button>
<button
className={`w-full text-left px-3 py-2 text-sm flex items-center gap-2 ${
task.status === 1 ||
task.status ===
'in_progress'
? 'bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100 font-medium'
: 'text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800'
}`}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleStatusChange(
1
);
}}
>
<PlayCircleIcon
className={`h-4 w-4 ${getStatusIconClass(1)}`}
/>
<span className="capitalize flex-1">
{t(
'task.status.inProgress',
'In progress'
)}
</span>
{(task.status === 1 ||
task.status ===
'in_progress') && (
<CheckIcon className="h-4 w-4" />
)}
</button>
<button
className={`w-full text-left px-3 py-2 text-sm rounded-b-lg flex items-center gap-2 ${
task.status === 2 ||
task.status ===
'done'
? 'bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100 font-medium'
: 'text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800'
}`}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleStatusChange(
2
);
}}
>
<CheckCircleIcon
className={`h-4 w-4 ${getStatusIconClass(2)}`}
/>
<span className="capitalize flex-1">
{t(
'task.status.setAsDone',
'Set as done'
)}
</span>
{(task.status === 2 ||
task.status ===
'done') && (
<CheckIcon className="h-4 w-4" />
)}
</button>
</div>
)}
</div>
<TaskStatusControl
task={task}
onToggleCompletion={
onQuickStatusToggle
}
onTaskUpdate={
handleStatusControlUpdate
}
hoverRevealQuickActions={false}
showMobileVariant={false}
variant="square"
className="flex-shrink-0"
/>
{/* Priority Dropdown Button - Next to status */}
<div
@ -812,9 +616,7 @@ const TaskDetailsHeader: React.FC<TaskDetailsHeaderProps> = ({
{t('task.activity', 'Activity')}
</button>
</div>
{(showOverdueIcon ||
onToggleTodayPlan ||
onQuickStatusToggle) && (
{(showOverdueIcon || onQuickStatusToggle) && (
<div className="flex items-center gap-2 flex-shrink-0">
{showOverdueIcon && (
<div
@ -886,81 +688,6 @@ const TaskDetailsHeader: React.FC<TaskDetailsHeaderProps> = ({
)}
</div>
)}
{onToggleTodayPlan && (
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onToggleTodayPlan();
}}
className={`inline-flex items-center justify-center rounded-full transition-all duration-200 ${
Number(task.today_move_count || 0) > 1
? 'px-3 h-8'
: 'w-8 h-8'
} ${
task.today
? 'bg-green-100 dark:bg-green-900 text-green-600 dark:text-green-400 hover:bg-green-200 dark:hover:bg-green-800'
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600'
}`}
title={
task.today
? t(
'tasks.removeFromToday',
'Remove from today plan'
)
: t(
'tasks.addToToday',
'Add to today plan'
)
}
>
{task.today ? (
<CalendarDaysIcon className="h-4 w-4" />
) : (
<CalendarIcon className="h-4 w-4" />
)}
{Number(task.today_move_count || 0) > 1 && (
<span className="ml-1 text-xs font-medium">
{Number(task.today_move_count || 0)}
</span>
)}
</button>
)}
{onQuickStatusToggle &&
(task.status === 'not_started' ||
task.status === 'in_progress' ||
task.status === 0 ||
task.status === 1) && (
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onQuickStatusToggle();
}}
className={`flex items-center justify-center w-8 h-8 rounded-full transition-all duration-200 ${
task.status === 'in_progress' ||
task.status === 1
? 'bg-green-100 dark:bg-green-900 text-green-600 dark:text-green-400 hover:bg-green-200 dark:hover:bg-green-800 animate-pulse'
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600'
}`}
title={
task.status === 'in_progress' ||
task.status === 1
? t(
'tasks.setNotStarted',
'Set to not started'
)
: t(
'tasks.setInProgress',
'Set in progress'
)
}
>
<PlayIcon className="h-4 w-4" />
</button>
)}
<div
className="relative flex items-center"
ref={actionsMenuRef}

File diff suppressed because it is too large Load diff

View file

@ -1,24 +1,47 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { Task } from '../../entities/Task';
import { Project } from '../../entities/Project';
import TaskHeader from './TaskHeader';
import { useToast } from '../Shared/ToastContext';
import TaskPriorityIcon from './TaskPriorityIcon';
import { isTaskCompleted } from '../../constants/taskStatus';
// Import SubtasksDisplay component from TaskHeader
interface SubtasksDisplayProps {
showSubtasks: boolean;
loadingSubtasks: boolean;
subtasks: Task[];
onTaskClick: (e: React.MouseEvent, task: Task) => void;
onTaskUpdate: (task: Task) => Promise<void>;
loadSubtasks: () => Promise<void>;
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<SubtasksDisplayProps> = ({
showSubtasks,
loadingSubtasks,
subtasks,
onTaskClick,
@ -27,102 +50,93 @@ const SubtasksDisplay: React.FC<SubtasksDisplayProps> = ({
}) => {
const { t } = useTranslation();
if (!showSubtasks) return null;
if (loadingSubtasks) {
return (
<div className="ml-[10%] text-sm text-gray-500 dark:text-gray-400">
{t('loading.subtasks', 'Loading subtasks...')}
</div>
);
}
if (subtasks.length === 0) {
return (
<div className="ml-[10%] text-sm text-gray-500 dark:text-gray-400">
{t('subtasks.noSubtasks', 'No subtasks found')}
</div>
);
}
return (
<div className="mt-1 space-y-1">
{loadingSubtasks ? (
<div className="ml-12 text-sm text-gray-500 dark:text-gray-400">
{t('loading.subtasks', 'Loading subtasks...')}
</div>
) : subtasks.length > 0 ? (
subtasks.map((subtask, index) => (
<div
key={subtask.id || `subtask-${index}`}
className="ml-12 group"
>
{subtasks.map((subtask) => {
const borderClass = isTaskCompleted(subtask.status)
? 'border-l-4 border-l-green-500'
: getPriorityBorderClassName(subtask.priority);
return (
<div key={subtask.id} className="ml-[10%]">
<div
className={`rounded-lg shadow-sm bg-white dark:bg-gray-900 cursor-pointer transition-all duration-200 ${
subtask.status === 'in_progress' ||
subtask.status === 1
? 'border border-blue-500/60 dark:border-blue-600/60'
: ''
}`}
className={`rounded-lg shadow-sm bg-white dark:bg-gray-900 relative overflow-visible transition-colors duration-200 ease-in-out hover:ring-1 hover:ring-gray-200 dark:hover:ring-gray-700 cursor-pointer ${borderClass}`}
onClick={(e) => {
e.stopPropagation();
onTaskClick(e, subtask);
}}
>
<div className="px-4 py-2.5 flex items-center justify-between">
<div className="flex items-center space-x-3 flex-1 min-w-0">
<div className="flex-shrink-0">
<TaskPriorityIcon
priority={subtask.priority || 'low'}
status={
subtask.status || 'not_started'
}
onToggleCompletion={async () => {
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
<div className="px-3 py-2.5 flex items-center justify-between">
<div className="flex items-center space-x-2 flex-1 min-w-0">
<TaskPriorityIcon
priority={subtask.priority || 'low'}
status={subtask.status || 'not_started'}
onToggleCompletion={async () => {
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();
}
}}
/>
</div>
}
}}
/>
<span
className={`text-base font-medium flex-1 truncate ${
subtask.status === 'done' ||
subtask.status === 2 ||
subtask.status === 'archived' ||
subtask.status === 3
? 'text-gray-500 dark:text-gray-400'
className={`text-sm flex-1 truncate ${
isTaskCompleted(subtask.status)
? 'text-gray-500 dark:text-gray-400 line-through'
: 'text-gray-900 dark:text-gray-100'
}`}
>
{subtask.name}
{subtask.original_name || subtask.name}
</span>
</div>
<div className="flex items-center space-x-1">
{/* Right side status indicators removed */}
</div>
{isTaskCompleted(subtask.status) && (
<span className="text-xs text-green-600 dark:text-green-400">
</span>
)}
</div>
</div>
</div>
))
) : (
<div className="ml-12 text-sm text-gray-500 dark:text-gray-400">
{t('subtasks.noSubtasks', 'No subtasks found')}
</div>
)}
);
})}
</div>
);
};
@ -171,16 +185,30 @@ const TaskItem: React.FC<TaskItemProps> = ({
const [isAnimatingOut, setIsAnimatingOut] = useState(false);
// Subtasks state
const [showSubtasks, setShowSubtasks] = useState(false);
const [subtasks, setSubtasks] = useState<Task[]>([]);
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<TaskItemProps> = ({
};
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<TaskItemProps> = ({
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<TaskItemProps> = ({
// 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 (
<>
<div
className={`rounded-lg shadow-sm bg-white dark:bg-gray-900 relative overflow-visible transition-opacity duration-300 ease-in-out ${priorityBorderClass} ${
className={`rounded-lg shadow-sm bg-white dark:bg-gray-900 relative overflow-visible transition-colors duration-200 ease-in-out hover:ring-1 hover:ring-gray-200 dark:hover:ring-gray-700 ${priorityBorderClass} ${
isInProgress
? 'ring-1 ring-blue-500/60 dark:ring-blue-600/60'
: ''
@ -499,8 +473,12 @@ const TaskItem: React.FC<TaskItemProps> = ({
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<TaskItemProps> = ({
{/* Progress bar at bottom of parent task */}
{subtasks.length > 0 && (
<div
className={`absolute bottom-0 left-0 right-0 h-0.5 transition-all duration-300 ease-in-out overflow-hidden rounded-b-lg ${
showSubtasks
? 'opacity-100 transform translate-y-0'
: 'opacity-0 transform translate-y-2'
}`}
>
<div className="w-full h-full bg-gray-200 dark:bg-gray-700">
<div className="absolute bottom-0 left-0 right-0 h-0.5 opacity-100">
<div className="absolute inset-0 bg-gray-200 dark:bg-gray-700 ml-1 rounded-r-lg overflow-hidden">
<div
className="h-full bg-gradient-to-r from-green-400 via-green-500 to-green-600 transition-all duration-500 ease-out"
style={{ width: `${completionPercentage}%` }}
@ -526,28 +498,28 @@ const TaskItem: React.FC<TaskItemProps> = ({
</div>
{/* Hide subtasks display for archived tasks */}
{!(task.status === 'archived' || task.status === 3) && (
<SubtasksDisplay
showSubtasks={showSubtasks}
loadingSubtasks={loadingSubtasks}
subtasks={subtasks}
onTaskClick={(e) => {
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) && (
<SubtasksDisplay
loadingSubtasks={loadingSubtasks}
subtasks={subtasks}
onTaskClick={(e) => {
e.stopPropagation();
handleSubtaskClick();
}}
loadSubtasks={loadSubtasks}
onSubtaskUpdate={(updatedSubtask) => {
setSubtasks((prev) =>
prev.map((st) =>
st.id === updatedSubtask.id
? updatedSubtask
: st
)
);
}}
/>
)}
<TaskModal
isOpen={isModalOpen}

View file

@ -4,8 +4,12 @@ import {
CheckCircleIcon,
ArchiveBoxIcon,
ArrowPathIcon,
ClockIcon,
XCircleIcon,
CalendarIcon,
} from '@heroicons/react/24/solid';
import { StatusType } from '../../entities/Task';
import { getStatusString } from '../../constants/taskStatus';
interface TaskStatusBadgeProps {
status: StatusType | number;
@ -16,20 +20,6 @@ const TaskStatusBadge: React.FC<TaskStatusBadgeProps> = ({
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<TaskStatusBadgeProps> = ({
case 'not_started':
statusIcon = <MinusIcon className="h-4 w-4 text-gray-400" />;
break;
case 'planned':
statusIcon = <CalendarIcon className="h-4 w-4 text-purple-400" />;
break;
case 'in_progress':
statusIcon = <ArrowPathIcon className="h-4 w-4 text-blue-400" />;
break;
case 'waiting':
statusIcon = <ClockIcon className="h-4 w-4 text-yellow-400" />;
break;
case 'done':
statusIcon = <CheckCircleIcon className="h-4 w-4 text-green-400" />;
break;
case 'cancelled':
statusIcon = <XCircleIcon className="h-4 w-4 text-red-400" />;
break;
case 'archived':
statusIcon = <ArchiveBoxIcon className="h-4 w-4 text-gray-400" />;
break;

View file

@ -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<void>;
hoverRevealQuickActions?: boolean;
showMobileVariant?: boolean;
className?: string;
variant?: 'pill' | 'square';
showQuickActions?: boolean;
}
const quickStartStatuses = new Set([
'not_started',
'planned',
'waiting',
'cancelled',
]);
const TaskStatusControl: React.FC<TaskStatusControlProps> = ({
task,
onToggleCompletion,
onTaskUpdate,
hoverRevealQuickActions = true,
showMobileVariant = true,
className = '',
variant = 'square',
showQuickActions = true,
}) => {
const { t } = useTranslation();
const [completionMenuOpen, setCompletionMenuOpen] =
useState<CompletionMenuTarget | null>(null);
const [isCompletingTask, setIsCompletingTask] = useState(false);
const desktopCompletionMenuRef = useRef<HTMLDivElement>(null);
const mobileCompletionMenuRef = useRef<HTMLDivElement>(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 (
<button
key={`${menuType}-${option.value}`}
type="button"
onClick={async (event) => {
if (option.completion) {
await handleCompletionClick(event);
} else {
await handleStatusSelection(event, option.value);
}
}}
className={`w-full px-3 py-2 text-left text-sm flex items-center gap-2 ${roundedClass} ${stateClasses}`}
disabled={option.completion ? isCompletingTask : false}
>
<option.Icon className={`h-4 w-4 ${iconClass}`} />
<span className="flex-1">{option.label}</span>
</button>
);
});
};
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<typeof getStatusString>,
{
label: string;
Icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
}
> = {
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 (
<div className={`relative ${className}`}>
<div
className={`inline-flex items-stretch ${containerRoundedClass} border ${statusBorderColorClass} overflow-hidden ${hoverRevealQuickActions ? 'group' : ''}`}
ref={desktopCompletionMenuRef}
>
<button
type="button"
onClick={
taskInProgress ||
(!taskCompleted &&
(task.status === 'not_started' ||
isTaskNotStarted(task.status)))
? (e) => {
e.preventDefault();
e.stopPropagation();
}
: handleCompletionClick
}
className={`${completionButtonMainClasses} ${completionButtonPaddingClass} ${statusButtonColorClasses}`}
title={
taskCompleted
? t('common.undo', 'Undo')
: taskInProgress
? t('tasks.inProgress', 'In Progress')
: t('tasks.notStarted', 'Not Started')
}
>
<CompletionIcon className={iconSizeClass} />
{completionButtonLabel}
</button>
{showQuickStartButton && (
<button
type="button"
onClick={async (e) => {
await handleStatusSelection(e, 'in_progress');
}}
className={quickButtonClasses}
title={t('tasks.setInProgress', 'Set in progress')}
>
<PlayIcon className={iconSizeClass} />
</button>
)}
{showQuickCompleteButton && (
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleCompletionClick(e);
}}
className={quickCompleteClasses}
title={t('tasks.markAsDone', 'Mark as done')}
disabled={isCompletingTask}
>
<CheckIcon
className={`${iconSizeClass} transition-all duration-300 ${isCompletingTask ? 'scale-125 text-green-600 dark:text-green-400' : ''}`}
/>
</button>
)}
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setCompletionMenuOpen((prev) =>
prev === 'desktop' ? null : 'desktop'
);
}}
className={`${completionButtonChevronClasses} ${quickButtonPaddingClass} border-l ${statusBorderColorClass}`}
aria-haspopup="menu"
aria-expanded={completionMenuOpen === 'desktop'}
>
<ChevronDownIcon className={iconSizeClass} />
</button>
</div>
{completionMenuOpen === 'desktop' && (
<div
className={`absolute right-0 top-full mt-1 w-48 bg-white dark:bg-gray-900 border ${statusBorderColorClass} rounded-lg shadow-lg z-[9999]`}
>
{renderStatusMenuOptions('desktop')}
</div>
)}
{showMobileVariant && (
<div
className="mt-2 relative block md:hidden"
ref={mobileCompletionMenuRef}
>
<div
className={`inline-flex items-stretch ${containerRoundedClass} border ${statusBorderColorClass} overflow-hidden text-xs`}
>
<button
type="button"
onClick={
taskInProgress ||
(!taskCompleted &&
(task.status === 'not_started' ||
isTaskNotStarted(task.status)))
? (e) => {
e.preventDefault();
e.stopPropagation();
}
: handleCompletionClick
}
className={`${completionButtonMainClasses} px-2 py-1 ${statusButtonColorClasses}`}
>
<CompletionIcon className="h-3.5 w-3.5" />
<span className="ml-1">
{completionButtonLabel}
</span>
</button>
{showQuickStartButton && (
<button
type="button"
onClick={async (e) => {
await handleStatusSelection(
e,
'in_progress'
);
}}
className={`${completionButtonChevronClasses} ${statusButtonColorClasses} px-2 border-l ${statusBorderColorClass}`}
title={t(
'tasks.setInProgress',
'Set in progress'
)}
>
<PlayIcon className="h-3.5 w-3.5" />
</button>
)}
{showQuickCompleteButton && (
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleCompletionClick(e);
}}
className={`${isCompletingTask ? 'bg-green-100 dark:bg-green-900/50 text-green-600 dark:text-green-400' : `${completionButtonChevronClasses} ${statusButtonColorClasses}`} px-2 border-l ${statusBorderColorClass} transition-all duration-300`}
title={t('tasks.markAsDone', 'Mark as done')}
disabled={isCompletingTask}
>
<CheckIcon
className={`h-3.5 w-3.5 transition-all duration-300 ${isCompletingTask ? 'scale-125 text-green-600 dark:text-green-400' : ''}`}
/>
</button>
)}
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setCompletionMenuOpen((prev) =>
prev === 'mobile' ? null : 'mobile'
);
}}
className={`${completionButtonChevronClasses} px-2 border-l ${statusBorderColorClass}`}
aria-haspopup="menu"
aria-expanded={completionMenuOpen === 'mobile'}
>
<ChevronDownIcon className="h-3.5 w-3.5" />
</button>
</div>
{completionMenuOpen === 'mobile' && (
<div
className={`absolute right-0 top-full mt-1 w-48 bg-white dark:bg-gray-900 border ${statusBorderColorClass} rounded-lg shadow-lg z-[9999]`}
>
{renderStatusMenuOptions('mobile')}
</div>
)}
</div>
)}
</div>
);
};
interface StatusDropdownOption {
value: StatusType;
label: string;
Icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
activeClasses: string;
inactiveClasses: string;
activeIconClass: string;
inactiveIconClass: string;
completion?: boolean;
}
export default TaskStatusControl;

View file

@ -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<string | null>(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 && (
<>
<TodayPlan
todayPlanTasks={plannedTasks}
todayPlanTasks={plannedTasks.slice(
0,
plannedDisplayLimit
)}
projects={localProjects}
onTaskUpdate={handleTaskUpdate}
onTaskDelete={handleTaskDelete}
@ -1662,59 +1523,31 @@ const TasksToday: React.FC = () => {
}
/>
{/* Load More Buttons for Today Plan Tasks */}
{pagination.hasMore && (
{/* Load More Buttons for Planned Tasks */}
{plannedDisplayLimit <
plannedTasks.length && (
<div className="flex justify-center pt-4 pb-2 gap-3">
<button
onClick={() =>
handleLoadMore(false)
setPlannedDisplayLimit(
(prev) => prev + 20
)
}
disabled={isLoading}
className="inline-flex items-center px-5 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
className="inline-flex items-center px-5 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
>
{isLoading ? (
<>
<svg
className="animate-spin -ml-1 mr-2 h-4 w-4 text-gray-500"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
{t(
'common.loading',
'Loading...'
)}
</>
) : (
<>
<QueueListIcon className="h-4 w-4 mr-2" />
{t(
'common.loadMore',
'Load More'
)}
</>
<QueueListIcon className="h-4 w-4 mr-2" />
{t(
'common.loadMore',
'Load More'
)}
</button>
<button
onClick={() =>
handleLoadMore(true)
setPlannedDisplayLimit(
plannedTasks.length
)
}
disabled={isLoading}
className="inline-flex items-center px-5 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
className="inline-flex items-center px-5 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
>
{t(
'common.showAll',
@ -1724,15 +1557,17 @@ const TasksToday: React.FC = () => {
</div>
)}
{/* Pagination info for Today Plan tasks */}
{/* Pagination info for Planned tasks */}
<div className="text-center text-sm text-gray-500 dark:text-gray-400 pt-2 pb-4">
{t(
'tasks.showingItems',
'Showing {{current}} of {{total}} tasks',
{
current:
plannedTasks.length,
total: pagination.total,
current: Math.min(
plannedDisplayLimit,
plannedTasks.length
),
total: plannedTasks.length,
}
)}
</div>

View file

@ -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<StatusKey, StatusStyle> = {
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;
};

View file

@ -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<string, TaskStatusValue> = {
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<TaskStatusString, string> = {
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);
}

View file

@ -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'

View file

@ -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 });
};

4
package-lock.json generated
View file

@ -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",