diff --git a/frontend/src/components/HomeComponents/Tasks/Tasks.tsx b/frontend/src/components/HomeComponents/Tasks/Tasks.tsx index 49434109..78a3f940 100644 --- a/frontend/src/components/HomeComponents/Tasks/Tasks.tsx +++ b/frontend/src/components/HomeComponents/Tasks/Tasks.tsx @@ -39,6 +39,8 @@ import { isOverdue, getPinnedTasks, togglePinnedTask, + calculateProjectStats, + calculateTagStats, } from './tasks-utils'; import Pagination from './Pagination'; import { url } from '@/components/utils/URLs'; @@ -76,6 +78,12 @@ export const Tasks = ( const [tempTasks, setTempTasks] = useState([]); const [selectedStatuses, setSelectedStatuses] = useState([]); const status = ['pending', 'completed', 'deleted', 'overdue']; + const [projectStats, setProjectStats] = useState< + Record + >({}); + const [tagStats, setTagStats] = useState< + Record + >({}); const [currentPage, setCurrentPage] = useState(1); const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc'); const [idSortOrder, setIdSortOrder] = useState<'asc' | 'desc'>('asc'); @@ -238,6 +246,10 @@ export const Tasks = ( .filter((tag) => tag !== '') .sort((a, b) => (a > b ? 1 : -1)); setUniqueTags(filteredTags); + + // Calculate completion stats + setProjectStats(calculateProjectStats(tasksFromDB)); + setTagStats(calculateTagStats(tasksFromDB)); } catch (error) { console.error('Error fetching tasks:', error); } @@ -282,6 +294,16 @@ export const Tasks = ( .filter((project) => project !== '') .sort((a, b) => (a > b ? 1 : -1)); setUniqueProjects(filteredProjects); + + const tagsSet = new Set(sortedTasks.flatMap((task) => task.tags || [])); + const filteredTags = Array.from(tagsSet) + .filter((tag) => tag !== '') + .sort((a, b) => (a > b ? 1 : -1)); + setUniqueTags(filteredTags); + + // Calculate completion stats + setProjectStats(calculateProjectStats(sortedTasks)); + setTagStats(calculateTagStats(sortedTasks)); }); const currentTime = Date.now(); @@ -1074,6 +1096,7 @@ export const Tasks = ( onSelectionChange={setSelectedProjects} className="hidden lg:flex min-w-[140px]" icon={} + completionStats={projectStats} /> } + completionStats={tagStats} />
{ }); jest.mock('@/components/ui/multi-select', () => ({ - MultiSelectFilter: jest.fn(({ title }) => ( -
Mocked MultiSelect: {title}
+ MultiSelectFilter: jest.fn(({ title, completionStats }) => ( +
+ Mocked MultiSelect: {title} + {completionStats && ( + + {JSON.stringify(completionStats)} + + )} +
)), })); @@ -1364,6 +1371,155 @@ describe('Tasks Component', () => { }); }); + test('calculates and passes project completion stats to MultiSelectFilter', async () => { + render(); + + await waitFor(async () => { + expect(await screen.findByText('Task 1')).toBeInTheDocument(); + }); + + const { MultiSelectFilter } = require('@/components/ui/multi-select'); + + // Find the Projects filter call + const projectsFilterCall = MultiSelectFilter.mock.calls.find( + (call: any) => call[0].title === 'Projects' + ); + + expect(projectsFilterCall).toBeDefined(); + expect(projectsFilterCall[0].completionStats).toBeDefined(); + + const stats = projectsFilterCall[0].completionStats; + + // ProjectA has tasks: 1,3,5,7,9,11 (pending) + task 16 (completed) = 1 completed out of 7 total + expect(stats['ProjectA']).toBeDefined(); + expect(stats['ProjectA'].completed).toBeGreaterThanOrEqual(1); + expect(stats['ProjectA'].total).toBeGreaterThanOrEqual(1); + expect(stats['ProjectA'].percentage).toBeGreaterThanOrEqual(0); + expect(stats['ProjectA'].percentage).toBeLessThanOrEqual(100); + + // ProjectB has tasks: 2,4,6,8,10,12 (pending) + task 17 (deleted) = 0 completed + expect(stats['ProjectB']).toBeDefined(); + expect(stats['ProjectB'].total).toBeGreaterThanOrEqual(1); + }); + + test('calculates and passes tag completion stats to MultiSelectFilter', async () => { + render(); + + await waitFor(async () => { + expect(await screen.findByText('Task 1')).toBeInTheDocument(); + }); + + const { MultiSelectFilter } = require('@/components/ui/multi-select'); + + // Find the Tags filter call + const tagsFilterCall = MultiSelectFilter.mock.calls.find( + (call: any) => call[0].title === 'Tags' + ); + + expect(tagsFilterCall).toBeDefined(); + expect(tagsFilterCall[0].completionStats).toBeDefined(); + + const stats = tagsFilterCall[0].completionStats; + + // Verify stats structure + Object.keys(stats).forEach((tag) => { + expect(stats[tag]).toHaveProperty('completed'); + expect(stats[tag]).toHaveProperty('total'); + expect(stats[tag]).toHaveProperty('percentage'); + expect(typeof stats[tag].completed).toBe('number'); + expect(typeof stats[tag].total).toBe('number'); + expect(typeof stats[tag].percentage).toBe('number'); + expect(stats[tag].percentage).toBeGreaterThanOrEqual(0); + expect(stats[tag].percentage).toBeLessThanOrEqual(100); + }); + }); + + test('recalculates completion stats after sync', async () => { + const hooks = require('../hooks'); + + render(); + + await waitFor(async () => { + expect(await screen.findByText('Task 1')).toBeInTheDocument(); + }); + + const { MultiSelectFilter } = require('@/components/ui/multi-select'); + + hooks.fetchTaskwarriorTasks.mockResolvedValueOnce([ + { + id: 1, + description: 'Task 1', + status: 'completed', + project: 'ProjectA', + tags: ['tag1'], + uuid: 'uuid-1', + }, + { + id: 2, + description: 'Task 2', + status: 'completed', + project: 'ProjectB', + tags: ['tag2'], + uuid: 'uuid-2', + }, + ]); + + MultiSelectFilter.mockClear(); + + const syncButtons = screen.getAllByText('Sync'); + fireEvent.click(syncButtons[0]); + + await waitFor(() => { + const projectsCall = MultiSelectFilter.mock.calls.find( + (call: any) => call[0].title === 'Projects' + ); + expect(projectsCall).toBeDefined(); + }); + + const updatedProjectsCall = MultiSelectFilter.mock.calls.find( + (call: any) => call[0].title === 'Projects' + ); + + expect(updatedProjectsCall).toBeDefined(); + expect(updatedProjectsCall[0].completionStats).toBeDefined(); + + const updatedStats = updatedProjectsCall[0].completionStats; + expect(updatedStats['ProjectA']).toBeDefined(); + expect(updatedStats['ProjectB']).toBeDefined(); + }); + + test('completion stats structure is correct', async () => { + render(); + + await waitFor(async () => { + expect(await screen.findByText('Task 1')).toBeInTheDocument(); + }); + + const { MultiSelectFilter } = require('@/components/ui/multi-select'); + + const projectsCall = MultiSelectFilter.mock.calls.find( + (call: any) => call[0].title === 'Projects' + ); + + expect(projectsCall).toBeDefined(); + const stats = projectsCall[0].completionStats; + + // Verify stats structure for any project that exists + Object.keys(stats).forEach((project) => { + expect(stats[project]).toHaveProperty('completed'); + expect(stats[project]).toHaveProperty('total'); + expect(stats[project]).toHaveProperty('percentage'); + expect(typeof stats[project].completed).toBe('number'); + expect(typeof stats[project].total).toBe('number'); + expect(typeof stats[project].percentage).toBe('number'); + expect(stats[project].completed).toBeLessThanOrEqual( + stats[project].total + ); + expect(stats[project].percentage).toBeGreaterThanOrEqual(0); + expect(stats[project].percentage).toBeLessThanOrEqual(100); + }); + }); + describe('Pin Functionality', () => { test('should load pinned tasks from localStorage on mount', async () => { const { getPinnedTasks } = require('../tasks-utils'); diff --git a/frontend/src/components/HomeComponents/Tasks/__tests__/tasks-utils.test.ts b/frontend/src/components/HomeComponents/Tasks/__tests__/tasks-utils.test.ts index 569242b6..4f2b6366 100644 --- a/frontend/src/components/HomeComponents/Tasks/__tests__/tasks-utils.test.ts +++ b/frontend/src/components/HomeComponents/Tasks/__tests__/tasks-utils.test.ts @@ -17,6 +17,8 @@ import { savePinnedTasks, togglePinnedTask, isTaskPinned, + calculateProjectStats, + calculateTagStats, } from '../tasks-utils'; import { Task } from '@/components/utils/types'; @@ -641,6 +643,265 @@ describe('isOverdue', () => { }); }); +describe('calculateProjectStats', () => { + it('calculates stats for single project with all completed tasks', () => { + const tasks: Task[] = [ + createTask(1, 'completed', 'Task 1', 'ProjectA', []), + createTask(2, 'completed', 'Task 2', 'ProjectA', []), + createTask(3, 'completed', 'Task 3', 'ProjectA', []), + ]; + + const stats = calculateProjectStats(tasks); + + expect(stats['ProjectA']).toEqual({ + completed: 3, + total: 3, + percentage: 100, + }); + }); + + it('calculates stats for single project with mixed completion', () => { + const tasks: Task[] = [ + createTask(1, 'completed', 'Task 1', 'ProjectA', []), + createTask(2, 'pending', 'Task 2', 'ProjectA', []), + createTask(3, 'completed', 'Task 3', 'ProjectA', []), + createTask(4, 'pending', 'Task 4', 'ProjectA', []), + ]; + + const stats = calculateProjectStats(tasks); + + expect(stats['ProjectA']).toEqual({ + completed: 2, + total: 4, + percentage: 50, + }); + }); + + it('calculates stats for multiple projects independently', () => { + const tasks: Task[] = [ + createTask(1, 'completed', 'Task 1', 'ProjectA', []), + createTask(2, 'pending', 'Task 2', 'ProjectA', []), + createTask(3, 'completed', 'Task 3', 'ProjectB', []), + createTask(4, 'completed', 'Task 4', 'ProjectB', []), + createTask(5, 'completed', 'Task 5', 'ProjectB', []), + ]; + + const stats = calculateProjectStats(tasks); + + expect(stats['ProjectA']).toEqual({ + completed: 1, + total: 2, + percentage: 50, + }); + + expect(stats['ProjectB']).toEqual({ + completed: 3, + total: 3, + percentage: 100, + }); + }); + + it('ignores tasks with empty project names', () => { + const tasks: Task[] = [ + createTask(1, 'completed', 'Task 1', '', []), + createTask(2, 'completed', 'Task 2', 'ProjectA', []), + ]; + + const stats = calculateProjectStats(tasks); + + expect(stats['']).toBeUndefined(); + expect(stats['ProjectA']).toEqual({ + completed: 1, + total: 1, + percentage: 100, + }); + }); + + it('returns empty object for empty task list', () => { + const stats = calculateProjectStats([]); + expect(stats).toEqual({}); + }); + + it('handles project with zero completed tasks', () => { + const tasks: Task[] = [ + createTask(1, 'pending', 'Task 1', 'ProjectA', []), + createTask(2, 'pending', 'Task 2', 'ProjectA', []), + ]; + + const stats = calculateProjectStats(tasks); + + expect(stats['ProjectA']).toEqual({ + completed: 0, + total: 2, + percentage: 0, + }); + }); + + it('rounds percentage correctly', () => { + const tasks: Task[] = [ + createTask(1, 'completed', 'Task 1', 'ProjectA', []), + createTask(2, 'pending', 'Task 2', 'ProjectA', []), + createTask(3, 'pending', 'Task 3', 'ProjectA', []), + ]; + + const stats = calculateProjectStats(tasks); + + expect(stats['ProjectA'].percentage).toBe(33); + }); +}); + +describe('calculateTagStats', () => { + it('calculates stats for single tag with all completed tasks', () => { + const tasks: Task[] = [ + createTask(1, 'completed', 'Task 1', 'Project', ['urgent']), + createTask(2, 'completed', 'Task 2', 'Project', ['urgent']), + createTask(3, 'completed', 'Task 3', 'Project', ['urgent']), + ]; + + const stats = calculateTagStats(tasks); + + expect(stats['urgent']).toEqual({ + completed: 3, + total: 3, + percentage: 100, + }); + }); + + it('calculates stats for single tag with mixed completion', () => { + const tasks: Task[] = [ + createTask(1, 'completed', 'Task 1', 'Project', ['urgent']), + createTask(2, 'pending', 'Task 2', 'Project', ['urgent']), + createTask(3, 'completed', 'Task 3', 'Project', ['urgent']), + createTask(4, 'pending', 'Task 4', 'Project', ['urgent']), + createTask(5, 'pending', 'Task 5', 'Project', ['urgent']), + ]; + + const stats = calculateTagStats(tasks); + + expect(stats['urgent']).toEqual({ + completed: 2, + total: 5, + percentage: 40, + }); + }); + + it('calculates stats for multiple tags independently', () => { + const tasks: Task[] = [ + createTask(1, 'completed', 'Task 1', 'Project', ['urgent']), + createTask(2, 'pending', 'Task 2', 'Project', ['urgent']), + createTask(3, 'completed', 'Task 3', 'Project', ['backend']), + createTask(4, 'completed', 'Task 4', 'Project', ['backend']), + createTask(5, 'completed', 'Task 5', 'Project', ['backend']), + ]; + + const stats = calculateTagStats(tasks); + + expect(stats['urgent']).toEqual({ + completed: 1, + total: 2, + percentage: 50, + }); + + expect(stats['backend']).toEqual({ + completed: 3, + total: 3, + percentage: 100, + }); + }); + + it('handles tasks with multiple tags correctly', () => { + const tasks: Task[] = [ + createTask(1, 'completed', 'Task 1', 'Project', ['urgent', 'backend']), + createTask(2, 'pending', 'Task 2', 'Project', ['urgent', 'frontend']), + createTask(3, 'completed', 'Task 3', 'Project', ['backend']), + ]; + + const stats = calculateTagStats(tasks); + + expect(stats['urgent']).toEqual({ + completed: 1, + total: 2, + percentage: 50, + }); + + expect(stats['backend']).toEqual({ + completed: 2, + total: 2, + percentage: 100, + }); + + expect(stats['frontend']).toEqual({ + completed: 0, + total: 1, + percentage: 0, + }); + }); + + it('ignores tasks with empty tags array', () => { + const tasks: Task[] = [ + createTask(1, 'completed', 'Task 1', 'Project', []), + createTask(2, 'completed', 'Task 2', 'Project', ['urgent']), + ]; + + const stats = calculateTagStats(tasks); + + expect(Object.keys(stats)).toHaveLength(1); + expect(stats['urgent']).toEqual({ + completed: 1, + total: 1, + percentage: 100, + }); + }); + + it('ignores empty string tags', () => { + const tasks: Task[] = [ + createTask(1, 'completed', 'Task 1', 'Project', ['', 'urgent']), + createTask(2, 'completed', 'Task 2', 'Project', ['urgent']), + ]; + + const stats = calculateTagStats(tasks); + + expect(stats['']).toBeUndefined(); + expect(stats['urgent']).toEqual({ + completed: 2, + total: 2, + percentage: 100, + }); + }); + + it('returns empty object for empty task list', () => { + const stats = calculateTagStats([]); + expect(stats).toEqual({}); + }); + + it('handles tag with zero completed tasks', () => { + const tasks: Task[] = [ + createTask(1, 'pending', 'Task 1', 'Project', ['urgent']), + createTask(2, 'pending', 'Task 2', 'Project', ['urgent']), + ]; + + const stats = calculateTagStats(tasks); + + expect(stats['urgent']).toEqual({ + completed: 0, + total: 2, + percentage: 0, + }); + }); + + it('rounds percentage correctly', () => { + const tasks: Task[] = [ + createTask(1, 'completed', 'Task 1', 'Project', ['urgent']), + createTask(2, 'pending', 'Task 2', 'Project', ['urgent']), + createTask(3, 'pending', 'Task 3', 'Project', ['urgent']), + ]; + + const stats = calculateTagStats(tasks); + + expect(stats['urgent'].percentage).toBe(33); + }); +}); + describe('Pin Functionality', () => { const testEmail = 'test@example.com'; const taskUuid1 = 'task-uuid-123'; diff --git a/frontend/src/components/HomeComponents/Tasks/tasks-utils.ts b/frontend/src/components/HomeComponents/Tasks/tasks-utils.ts index 232bac3b..3d7591e8 100644 --- a/frontend/src/components/HomeComponents/Tasks/tasks-utils.ts +++ b/frontend/src/components/HomeComponents/Tasks/tasks-utils.ts @@ -326,3 +326,68 @@ export const togglePinnedTask = (email: string, taskUuid: string): boolean => { export const isTaskPinned = (email: string, taskUuid: string): boolean => { return getPinnedTasks(email).has(taskUuid); }; + +export const calculateProjectStats = ( + tasks: Task[] +): Record => { + const stats: Record< + string, + { completed: number; total: number; percentage: number } + > = {}; + + tasks.forEach((task) => { + const project = task.project; + if (project && project !== '') { + if (!stats[project]) { + stats[project] = { completed: 0, total: 0, percentage: 0 }; + } + + stats[project].total += 1; + if (task.status === 'completed') { + stats[project].completed += 1; + } + } + }); + + // Calculate percentages + Object.keys(stats).forEach((project) => { + const { completed, total } = stats[project]; + stats[project].percentage = + total > 0 ? Math.round((completed / total) * 100) : 0; + }); + + return stats; +}; + +export const calculateTagStats = ( + tasks: Task[] +): Record => { + const stats: Record< + string, + { completed: number; total: number; percentage: number } + > = {}; + + tasks.forEach((task) => { + const tags = task.tags || []; + tags.forEach((tag) => { + if (tag && tag !== '') { + if (!stats[tag]) { + stats[tag] = { completed: 0, total: 0, percentage: 0 }; + } + + stats[tag].total += 1; + if (task.status === 'completed') { + stats[tag].completed += 1; + } + } + }); + }); + + Object.keys(stats).forEach((tag) => { + const { completed, total } = stats[tag]; + stats[tag].percentage = + total > 0 ? Math.round((completed / total) * 100) : 0; + }); + + return stats; +}; diff --git a/frontend/src/components/ui/multi-select.tsx b/frontend/src/components/ui/multi-select.tsx index 5089635a..d75cb2f5 100644 --- a/frontend/src/components/ui/multi-select.tsx +++ b/frontend/src/components/ui/multi-select.tsx @@ -19,6 +19,12 @@ import { const ALL_ITEMS_VALUE = '__ALL__'; +interface CompletionStat { + completed: number; + total: number; + percentage: number; +} + interface MultiSelectFilterProps { id?: string; title: string; @@ -27,6 +33,7 @@ interface MultiSelectFilterProps { onSelectionChange: (values: string[]) => void; className?: string; icon?: React.ReactNode; + completionStats?: Record; } export function MultiSelectFilter({ @@ -37,6 +44,7 @@ export function MultiSelectFilter({ onSelectionChange, className, icon, + completionStats, }: MultiSelectFilterProps) { const [open, setOpen] = React.useState(false); @@ -90,6 +98,7 @@ export function MultiSelectFilter({ {options.map((option) => { const isSelected = selectedValues.includes(option); + const stats = completionStats?.[option]; return ( - {option} +
+ {option} + {stats && ( + + {stats.completed}/{stats.total} tasks,{' '} + {stats.percentage}% + + )} +
); })}