From 1fdf4401fb8150aa5b1aef69c246615eb69e3efb Mon Sep 17 00:00:00 2001 From: ANIR1604 Date: Fri, 9 Jan 2026 16:53:55 +0530 Subject: [PATCH 1/3] feat: add completion stats to project and tag filters - Add calculation functions for project and tag completion stats - Update MultiSelectFilter to display completion percentage - Show completed/total tasks count in filter dropdowns - Calculate stats dynamically when tasks are loaded or synced --- .../components/HomeComponents/Tasks/Tasks.tsx | 24 ++++++ .../HomeComponents/Tasks/tasks-utils.ts | 74 +++++++++++++++++++ frontend/src/components/ui/multi-select.tsx | 19 ++++- 3 files changed, 116 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/HomeComponents/Tasks/Tasks.tsx b/frontend/src/components/HomeComponents/Tasks/Tasks.tsx index 4a3fec6e..cb048406 100644 --- a/frontend/src/components/HomeComponents/Tasks/Tasks.tsx +++ b/frontend/src/components/HomeComponents/Tasks/Tasks.tsx @@ -37,6 +37,8 @@ import { getTimeSinceLastSync, hashKey, isOverdue, + calculateProjectStats, + calculateTagStats, } from './tasks-utils'; import Pagination from './Pagination'; import { url } from '@/components/utils/URLs'; @@ -74,6 +76,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'); @@ -230,6 +238,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); } @@ -274,6 +286,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(); @@ -1045,6 +1067,7 @@ export const Tasks = ( onSelectionChange={setSelectedProjects} className="hidden lg:flex min-w-[140px]" icon={} + completionStats={projectStats} /> } + completionStats={tagStats} />
{ } return Math.abs(hash).toString(36); }; + +/** + * Calculate completion statistics for projects + * Returns an object with completion stats for each project + */ +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; +}; + +/** + * Calculate completion statistics for tags + * Returns an object with completion stats for each tag + */ +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; + } + } + }); + }); + + // Calculate percentages + 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}% + + )} +
); })} From 3badef44d77b0758dab0fc77a932bd71b75aab28 Mon Sep 17 00:00:00 2001 From: ANIR1604 Date: Fri, 9 Jan 2026 23:05:29 +0530 Subject: [PATCH 2/3] feat: add completion stats to project and tag filters - Add calculateProjectStats and calculateTagStats functions - Display completed/total tasks and percentage in filter dropdowns - Update stats dynamically on task changes and sync - Add comprehensive test coverage for new functionality --- .../Tasks/__tests__/Tasks.test.tsx | 160 ++++++++++- .../Tasks/__tests__/tasks-utils.test.ts | 261 ++++++++++++++++++ .../HomeComponents/Tasks/tasks-utils.ts | 10 - 3 files changed, 419 insertions(+), 12 deletions(-) diff --git a/frontend/src/components/HomeComponents/Tasks/__tests__/Tasks.test.tsx b/frontend/src/components/HomeComponents/Tasks/__tests__/Tasks.test.tsx index 5232b324..ef228c51 100644 --- a/frontend/src/components/HomeComponents/Tasks/__tests__/Tasks.test.tsx +++ b/frontend/src/components/HomeComponents/Tasks/__tests__/Tasks.test.tsx @@ -42,8 +42,15 @@ jest.mock('../tasks-utils', () => { }); 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)} + + )} +
)), })); @@ -1361,4 +1368,153 @@ describe('Tasks Component', () => { expect(row).not.toHaveClass('border-l-red-500'); }); }); + + 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); + }); + }); }); 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 a68469aa..3dc05bee 100644 --- a/frontend/src/components/HomeComponents/Tasks/__tests__/tasks-utils.test.ts +++ b/frontend/src/components/HomeComponents/Tasks/__tests__/tasks-utils.test.ts @@ -13,6 +13,8 @@ import { hashKey, parseTaskwarriorDate, isOverdue, + calculateProjectStats, + calculateTagStats, } from '../tasks-utils'; import { Task } from '@/components/utils/types'; @@ -636,3 +638,262 @@ describe('isOverdue', () => { expect(isOverdue('invalid-date')).toBe(false); }); }); + +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); + }); +}); diff --git a/frontend/src/components/HomeComponents/Tasks/tasks-utils.ts b/frontend/src/components/HomeComponents/Tasks/tasks-utils.ts index 2f3b00a3..4a217de2 100644 --- a/frontend/src/components/HomeComponents/Tasks/tasks-utils.ts +++ b/frontend/src/components/HomeComponents/Tasks/tasks-utils.ts @@ -290,10 +290,6 @@ export const hashKey = (key: string, email: string): string => { return Math.abs(hash).toString(36); }; -/** - * Calculate completion statistics for projects - * Returns an object with completion stats for each project - */ export const calculateProjectStats = ( tasks: Task[] ): Record => { @@ -316,7 +312,6 @@ export const calculateProjectStats = ( } }); - // Calculate percentages Object.keys(stats).forEach((project) => { const { completed, total } = stats[project]; stats[project].percentage = @@ -326,10 +321,6 @@ export const calculateProjectStats = ( return stats; }; -/** - * Calculate completion statistics for tags - * Returns an object with completion stats for each tag - */ export const calculateTagStats = ( tasks: Task[] ): Record => { @@ -354,7 +345,6 @@ export const calculateTagStats = ( }); }); - // Calculate percentages Object.keys(stats).forEach((tag) => { const { completed, total } = stats[tag]; stats[tag].percentage = From 1f6a2844c9f93d3d01798a968c27441fd2a67b35 Mon Sep 17 00:00:00 2001 From: ANIR1604 Date: Sat, 10 Jan 2026 23:18:05 +0530 Subject: [PATCH 3/3] feat: add completion stats to project and tag filters - Add calculateProjectStats and calculateTagStats functions - Display completed/total tasks and percentage in filter dropdowns - Update stats dynamically on task changes and sync - Add comprehensive test coverage for new functionality --- frontend/src/components/HomeComponents/Tasks/Tasks.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/HomeComponents/Tasks/Tasks.tsx b/frontend/src/components/HomeComponents/Tasks/Tasks.tsx index 1adcff3e..37488410 100644 --- a/frontend/src/components/HomeComponents/Tasks/Tasks.tsx +++ b/frontend/src/components/HomeComponents/Tasks/Tasks.tsx @@ -1066,7 +1066,7 @@ export const Tasks = ( selectedValues={selectedProjects} onSelectionChange={setSelectedProjects} className="hidden lg:flex min-w-[140px]" - icon={} + icon={} completionStats={projectStats} /> } + icon={} completionStats={tagStats} />