feat(task-management): add progress statistics and visual representation for task groups

- Implemented progress calculations for tasks grouped by priority and phase, including todo, doing, and done counts.
- Introduced a new GroupProgressBar component to visually represent task progress in the TaskGroupHeader.
- Updated TaskGroupHeader and TaskListV2Table to integrate progress data and display the progress bar conditionally based on grouping.
- Enhanced local storage handling for grouping preferences in the task management feature.
This commit is contained in:
chamikaJ
2025-07-11 15:54:43 +05:30
parent 5c938586b8
commit 26de439fab
6 changed files with 350 additions and 78 deletions

View File

@@ -1174,9 +1174,39 @@ export default class TasksControllerV2 extends TasksControllerBase {
}
});
// Calculate progress stats for priority and phase grouping
if (groupBy === GroupBy.PRIORITY || groupBy === GroupBy.PHASE) {
Object.values(groupedResponse).forEach((group: any) => {
if (group.tasks && group.tasks.length > 0) {
const todoCount = group.tasks.filter((task: any) => {
// For tasks, we need to check their original status category
const originalTask = tasks.find(t => t.id === task.id);
return originalTask?.status_category?.is_todo;
}).length;
const doingCount = group.tasks.filter((task: any) => {
const originalTask = tasks.find(t => t.id === task.id);
return originalTask?.status_category?.is_doing;
}).length;
const doneCount = group.tasks.filter((task: any) => {
const originalTask = tasks.find(t => t.id === task.id);
return originalTask?.status_category?.is_done;
}).length;
const total = group.tasks.length;
// Calculate progress percentages
group.todo_progress = total > 0 ? +((todoCount / total) * 100).toFixed(0) : 0;
group.doing_progress = total > 0 ? +((doingCount / total) * 100).toFixed(0) : 0;
group.done_progress = total > 0 ? +((doneCount / total) * 100).toFixed(0) : 0;
}
});
}
// Create unmapped group if there are tasks without proper phase assignment
if (unmappedTasks.length > 0 && groupBy === GroupBy.PHASE) {
groupedResponse[UNMAPPED.toLowerCase()] = {
const unmappedGroup = {
id: UNMAPPED,
title: UNMAPPED,
groupType: groupBy,
@@ -1189,7 +1219,36 @@ export default class TasksControllerV2 extends TasksControllerBase {
start_date: null,
end_date: null,
sort_index: 999, // Put unmapped group at the end
todo_progress: 0,
doing_progress: 0,
done_progress: 0,
};
// Calculate progress stats for unmapped group
if (unmappedTasks.length > 0) {
const todoCount = unmappedTasks.filter((task: any) => {
const originalTask = tasks.find(t => t.id === task.id);
return originalTask?.status_category?.is_todo;
}).length;
const doingCount = unmappedTasks.filter((task: any) => {
const originalTask = tasks.find(t => t.id === task.id);
return originalTask?.status_category?.is_doing;
}).length;
const doneCount = unmappedTasks.filter((task: any) => {
const originalTask = tasks.find(t => t.id === task.id);
return originalTask?.status_category?.is_done;
}).length;
const total = unmappedTasks.length;
unmappedGroup.todo_progress = total > 0 ? +((todoCount / total) * 100).toFixed(0) : 0;
unmappedGroup.doing_progress = total > 0 ? +((doingCount / total) * 100).toFixed(0) : 0;
unmappedGroup.done_progress = total > 0 ? +((doneCount / total) * 100).toFixed(0) : 0;
}
groupedResponse[UNMAPPED.toLowerCase()] = unmappedGroup;
}
// Sort tasks within each group by order

View File

@@ -0,0 +1,93 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
interface GroupProgressBarProps {
todoProgress: number;
doingProgress: number;
doneProgress: number;
groupType: string;
}
const GroupProgressBar: React.FC<GroupProgressBarProps> = ({
todoProgress,
doingProgress,
doneProgress,
groupType
}) => {
const { t } = useTranslation('task-management');
// Only show for priority and phase grouping
if (groupType !== 'priority' && groupType !== 'phase') {
return null;
}
const total = todoProgress + doingProgress + doneProgress;
// Don't show if no progress values exist
if (total === 0) {
return null;
}
return (
<div className="flex items-center gap-2">
{/* Compact progress text */}
<span className="text-xs text-gray-600 dark:text-gray-400 whitespace-nowrap font-medium">
{doneProgress}% {t('done')}
</span>
{/* Compact progress bar */}
<div className="w-20 bg-gray-200 dark:bg-gray-700 rounded-full h-1.5 overflow-hidden">
<div className="h-full flex">
{/* Todo section - light gray */}
{todoProgress > 0 && (
<div
className="bg-gray-300 dark:bg-gray-600 transition-all duration-300"
style={{ width: `${(todoProgress / total) * 100}%` }}
title={`${t('todo')}: ${todoProgress}%`}
/>
)}
{/* Doing section - blue */}
{doingProgress > 0 && (
<div
className="bg-blue-500 transition-all duration-300"
style={{ width: `${(doingProgress / total) * 100}%` }}
title={`${t('inProgress')}: ${doingProgress}%`}
/>
)}
{/* Done section - green */}
{doneProgress > 0 && (
<div
className="bg-green-500 transition-all duration-300"
style={{ width: `${(doneProgress / total) * 100}%` }}
title={`${t('done')}: ${doneProgress}%`}
/>
)}
</div>
</div>
{/* Small legend dots with better spacing */}
<div className="flex items-center gap-1">
{todoProgress > 0 && (
<div
className="w-1.5 h-1.5 bg-gray-300 dark:bg-gray-600 rounded-full"
title={`${t('todo')}: ${todoProgress}%`}
/>
)}
{doingProgress > 0 && (
<div
className="w-1.5 h-1.5 bg-blue-500 rounded-full"
title={`${t('inProgress')}: ${doingProgress}%`}
/>
)}
{doneProgress > 0 && (
<div
className="w-1.5 h-1.5 bg-green-500 rounded-full"
title={`${t('done')}: ${doneProgress}%`}
/>
)}
</div>
</div>
);
};
export default GroupProgressBar;

View File

@@ -3,6 +3,7 @@ import { useDroppable } from '@dnd-kit/core';
// @ts-ignore: Heroicons module types
import { ChevronDownIcon, ChevronRightIcon, EllipsisHorizontalIcon, PencilIcon, ArrowPathIcon } from '@heroicons/react/24/outline';
import { Checkbox, Dropdown, Menu, Input, Modal, Badge, Flex } from 'antd';
import GroupProgressBar from './GroupProgressBar';
import { useTranslation } from 'react-i18next';
import { getContrastColor } from '@/utils/colorUtils';
import { useAppSelector } from '@/hooks/useAppSelector';
@@ -27,6 +28,10 @@ interface TaskGroupHeaderProps {
name: string;
count: number;
color?: string; // Color for the group indicator
todo_progress?: number;
doing_progress?: number;
done_progress?: number;
groupType?: string;
};
isCollapsed: boolean;
onToggle: () => void;
@@ -44,7 +49,7 @@ const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({ group, isCollapsed, o
const { isOwnerOrAdmin } = useAuthService();
const [dropdownVisible, setDropdownVisible] = useState(false);
const [categoryModalVisible, setCategoryModalVisible] = useState(false);
const [isRenaming, setIsRenaming] = useState(false);
const [isChangingCategory, setIsChangingCategory] = useState(false);
const [isEditingName, setIsEditingName] = useState(false);
@@ -94,7 +99,12 @@ const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({ group, isCollapsed, o
// Handle inline name editing
const handleNameSave = useCallback(async () => {
if (!editingName.trim() || editingName.trim() === group.name || isRenaming) return;
// If no changes or already renaming, just exit editing mode
if (!editingName.trim() || editingName.trim() === group.name || isRenaming) {
setIsEditingName(false);
setEditingName(group.name);
return;
}
setIsRenaming(true);
try {
@@ -122,12 +132,12 @@ const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({ group, isCollapsed, o
// Refresh task list to get updated group names
dispatch(fetchTasksV3(projectId));
setIsEditingName(false);
} catch (error) {
logger.error('Error renaming group:', error);
setEditingName(group.name);
} finally {
setIsEditingName(false);
setIsRenaming(false);
}
}, [editingName, group.name, group.id, currentGrouping, projectId, dispatch, trackMixpanelEvent, isRenaming]);
@@ -150,9 +160,8 @@ const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({ group, isCollapsed, o
}, [group.name, handleNameSave]);
const handleNameBlur = useCallback(() => {
setIsEditingName(false);
setEditingName(group.name);
}, [group.name]);
handleNameSave();
}, [handleNameSave]);
// Handle dropdown menu actions
const handleRenameGroup = useCallback(() => {
@@ -161,10 +170,7 @@ const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({ group, isCollapsed, o
setEditingName(group.name);
}, [group.name]);
const handleChangeCategory = useCallback(() => {
setDropdownVisible(false);
setCategoryModalVisible(true);
}, []);
// Handle category change
const handleCategoryChange = useCallback(async (categoryId: string, e?: React.MouseEvent) => {
@@ -182,7 +188,6 @@ const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({ group, isCollapsed, o
// Refresh status list and tasks
dispatch(fetchStatuses(projectId));
dispatch(fetchTasksV3(projectId));
setCategoryModalVisible(false);
} catch (error) {
logger.error('Error changing category:', error);
@@ -209,19 +214,30 @@ const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({ group, isCollapsed, o
// Only show "Change Category" when grouped by status
if (currentGrouping === 'status') {
items.push({
key: 'changeCategory',
icon: <ArrowPathIcon className="h-4 w-4" />,
label: t('changeCategory'),
const categorySubMenuItems = statusCategories.map((category) => ({
key: `category-${category.id}`,
label: (
<div className="flex items-center gap-2">
<Badge color={category.color_code} />
<span>{category.name}</span>
</div>
),
onClick: (e: any) => {
e?.domEvent?.stopPropagation();
handleChangeCategory();
handleCategoryChange(category.id || '', e?.domEvent);
},
});
}));
items.push({
key: 'changeCategory',
icon: <ArrowPathIcon className="h-4 w-4" />,
label: t('changeCategory'),
children: categorySubMenuItems,
} as any);
}
return items;
}, [currentGrouping, handleRenameGroup, handleChangeCategory, isOwnerOrAdmin]);
}, [currentGrouping, handleRenameGroup, handleCategoryChange, isOwnerOrAdmin, statusCategories, t]);
// Make the group header droppable
const { isOver, setNodeRef } = useDroppable({
@@ -232,75 +248,146 @@ const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({ group, isCollapsed, o
},
});
return (
<div
ref={setNodeRef}
className={`inline-flex w-max items-center px-1 cursor-pointer hover:opacity-80 transition-opacity duration-200 ease-in-out border-t border-b border-gray-200 dark:border-gray-700 rounded-t-md pr-2 ${
isOver ? 'ring-2 ring-blue-400 ring-opacity-50' : ''
}`}
style={{
backgroundColor: isOver ? `${headerBackgroundColor}dd` : headerBackgroundColor,
color: headerTextColor,
position: 'sticky',
top: 0,
zIndex: 25, // Higher than task rows but lower than column headers (z-30)
height: '36px',
minHeight: '36px',
maxHeight: '36px'
}}
onClick={onToggle}
>
{/* Drag Handle Space - ultra minimal width */}
<div style={{ width: '20px' }} className="flex items-center justify-center">
{/* Chevron button */}
<button
className="p-0 rounded-sm hover:shadow-lg hover:scale-105 transition-all duration-300 ease-out"
style={{ backgroundColor: 'transparent', color: headerTextColor }}
onClick={(e) => {
e.stopPropagation();
onToggle();
}}
>
<div
className="transition-transform duration-300 ease-out"
style={{
transform: isCollapsed ? 'rotate(0deg)' : 'rotate(90deg)',
transformOrigin: 'center'
return (
<div className="relative flex items-center">
<div
ref={setNodeRef}
className={`inline-flex w-max items-center px-1 cursor-pointer hover:opacity-80 transition-opacity duration-200 ease-in-out border-t border-b border-gray-200 dark:border-gray-700 rounded-t-md pr-2 ${
isOver ? 'ring-2 ring-blue-400 ring-opacity-50' : ''
}`}
style={{
backgroundColor: isOver ? `${headerBackgroundColor}dd` : headerBackgroundColor,
color: headerTextColor,
position: 'sticky',
top: 0,
zIndex: 25, // Higher than task rows but lower than column headers (z-30)
height: '36px',
minHeight: '36px',
maxHeight: '36px'
}}
onClick={onToggle}
>
{/* Drag Handle Space - ultra minimal width */}
<div style={{ width: '20px' }} className="flex items-center justify-center">
{/* Chevron button */}
<button
className="p-0 rounded-sm hover:shadow-lg hover:scale-105 transition-all duration-300 ease-out"
style={{ backgroundColor: 'transparent', color: headerTextColor }}
onClick={(e) => {
e.stopPropagation();
onToggle();
}}
>
<ChevronRightIcon className="h-3 w-3" style={{ color: headerTextColor }} />
</div>
</button>
</div>
<div
className="transition-transform duration-300 ease-out"
style={{
transform: isCollapsed ? 'rotate(0deg)' : 'rotate(90deg)',
transformOrigin: 'center'
}}
>
<ChevronRightIcon className="h-3 w-3" style={{ color: headerTextColor }} />
</div>
</button>
</div>
{/* Select All Checkbox Space - ultra minimal width */}
<div style={{ width: '28px' }} className="flex items-center justify-center">
<Checkbox
checked={isAllSelected}
indeterminate={isPartiallySelected}
onChange={handleSelectAllChange}
onClick={(e) => e.stopPropagation()}
style={{
color: headerTextColor,
}}
/>
</div>
{/* Select All Checkbox Space - ultra minimal width */}
<div style={{ width: '28px' }} className="flex items-center justify-center">
<Checkbox
checked={isAllSelected}
indeterminate={isPartiallySelected}
onChange={handleSelectAllChange}
onClick={(e) => e.stopPropagation()}
style={{
color: headerTextColor,
}}
/>
</div>
{/* Group indicator and name - no gap at all */}
{/* Group indicator and name - no gap at all */}
<div className="flex items-center flex-1 ml-1">
{/* Group name and count */}
<div className="flex items-center">
<span
className="text-sm font-semibold pr-2"
style={{ color: headerTextColor }}
>
{group.name}
</span>
{isEditingName ? (
<Input
value={editingName}
onChange={(e) => setEditingName(e.target.value)}
onKeyDown={handleNameKeyDown}
onBlur={handleNameBlur}
autoFocus
size="small"
className="text-sm font-semibold"
style={{
width: 'auto',
minWidth: '100px',
backgroundColor: 'rgba(255, 255, 255, 0.1)',
color: headerTextColor,
border: '1px solid rgba(255, 255, 255, 0.3)'
}}
onClick={(e) => e.stopPropagation()}
/>
) : (
<span
className="text-sm font-semibold pr-2 cursor-pointer hover:underline"
style={{ color: headerTextColor }}
onClick={handleNameClick}
>
{group.name}
</span>
)}
<span className="text-sm font-semibold ml-1" style={{ color: headerTextColor }}>
({group.count})
</span>
</div>
</div>
{/* Three-dot menu - only show for status and phase grouping */}
{menuItems.length > 0 && (currentGrouping === 'status' || currentGrouping === 'phase') && (
<div className="flex items-center ml-2">
<Dropdown
menu={{ items: menuItems }}
trigger={['click']}
open={dropdownVisible}
onOpenChange={setDropdownVisible}
placement="bottomRight"
overlayStyle={{ zIndex: 1000 }}
>
<button
className="p-1 rounded-sm hover:bg-black hover:bg-opacity-10 transition-colors duration-200"
style={{ color: headerTextColor }}
onClick={(e) => {
e.stopPropagation();
setDropdownVisible(!dropdownVisible);
}}
>
<EllipsisHorizontalIcon className="h-4 w-4" />
</button>
</Dropdown>
</div>
)}
</div>
{/* Progress Bar - sticky to the right edge during horizontal scroll */}
{(currentGrouping === 'priority' || currentGrouping === 'phase') &&
(group.todo_progress || group.doing_progress || group.done_progress) && (
<div
className="flex items-center bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-md shadow-sm px-3 py-1.5 ml-auto"
style={{
position: 'sticky',
right: '16px',
zIndex: 35, // Higher than header
minWidth: '160px',
height: '30px'
}}
>
<GroupProgressBar
todoProgress={group.todo_progress || 0}
doingProgress={group.doing_progress || 0}
doneProgress={group.done_progress || 0}
groupType={group.groupType || currentGrouping || ''}
/>
</div>
)}
</div>
);
};

View File

@@ -452,6 +452,10 @@ const TaskListV2Section: React.FC = () => {
name: group.title,
count: group.actualCount,
color: group.color,
todo_progress: group.todo_progress,
doing_progress: group.doing_progress,
done_progress: group.done_progress,
groupType: group.groupType,
}}
isCollapsed={isGroupCollapsed}
onToggle={() => handleGroupCollapse(group.id)}

View File

@@ -33,7 +33,7 @@ export const GROUP_BY_OPTIONS: IGroupByOption[] = [
{ label: 'Phase', value: IGroupBy.PHASE },
];
const LOCALSTORAGE_GROUP_KEY = 'worklenz.enhanced-kanban.group_by';
const LOCALSTORAGE_GROUP_KEY = 'worklenz.kanban.group_by';
export const getCurrentGroup = (): IGroupBy => {
const key = localStorage.getItem(LOCALSTORAGE_GROUP_KEY);

View File

@@ -17,8 +17,36 @@ interface LocalGroupingState {
collapsedGroups: string[];
}
// Local storage constants
const LOCALSTORAGE_GROUP_KEY = 'worklenz.tasklist.group_by';
// Utility functions for local storage
const loadGroupingFromLocalStorage = (): GroupingType | null => {
try {
const stored = localStorage.getItem(LOCALSTORAGE_GROUP_KEY);
if (stored && ['status', 'priority', 'phase'].includes(stored)) {
return stored as GroupingType;
}
} catch (error) {
console.warn('Failed to load grouping from localStorage:', error);
}
return 'status'; // Default to 'status' instead of null
};
const saveGroupingToLocalStorage = (grouping: GroupingType | null): void => {
try {
if (grouping) {
localStorage.setItem(LOCALSTORAGE_GROUP_KEY, grouping);
} else {
localStorage.removeItem(LOCALSTORAGE_GROUP_KEY);
}
} catch (error) {
console.warn('Failed to save grouping to localStorage:', error);
}
};
const initialState: LocalGroupingState = {
currentGrouping: null,
currentGrouping: loadGroupingFromLocalStorage(),
customPhases: ['Planning', 'Development', 'Testing', 'Deployment'],
groupOrder: {
status: ['todo', 'doing', 'done'],
@@ -35,6 +63,7 @@ const groupingSlice = createSlice({
reducers: {
setCurrentGrouping: (state, action: PayloadAction<GroupingType | null>) => {
state.currentGrouping = action.payload;
saveGroupingToLocalStorage(action.payload);
},
addCustomPhase: (state, action: PayloadAction<string>) => {