Skip to content

Commit 224ad3a

Browse files
authored
feat: add completion stats to project and tag filters (#385)
* 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 * 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 * 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
1 parent fdf72ac commit 224ad3a

5 files changed

Lines changed: 526 additions & 3 deletions

File tree

frontend/src/components/HomeComponents/Tasks/Tasks.tsx

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ import {
3939
isOverdue,
4040
getPinnedTasks,
4141
togglePinnedTask,
42+
calculateProjectStats,
43+
calculateTagStats,
4244
} from './tasks-utils';
4345
import Pagination from './Pagination';
4446
import { url } from '@/components/utils/URLs';
@@ -76,6 +78,12 @@ export const Tasks = (
7678
const [tempTasks, setTempTasks] = useState<Task[]>([]);
7779
const [selectedStatuses, setSelectedStatuses] = useState<string[]>([]);
7880
const status = ['pending', 'completed', 'deleted', 'overdue'];
81+
const [projectStats, setProjectStats] = useState<
82+
Record<string, { completed: number; total: number; percentage: number }>
83+
>({});
84+
const [tagStats, setTagStats] = useState<
85+
Record<string, { completed: number; total: number; percentage: number }>
86+
>({});
7987
const [currentPage, setCurrentPage] = useState<number>(1);
8088
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc');
8189
const [idSortOrder, setIdSortOrder] = useState<'asc' | 'desc'>('asc');
@@ -238,6 +246,10 @@ export const Tasks = (
238246
.filter((tag) => tag !== '')
239247
.sort((a, b) => (a > b ? 1 : -1));
240248
setUniqueTags(filteredTags);
249+
250+
// Calculate completion stats
251+
setProjectStats(calculateProjectStats(tasksFromDB));
252+
setTagStats(calculateTagStats(tasksFromDB));
241253
} catch (error) {
242254
console.error('Error fetching tasks:', error);
243255
}
@@ -282,6 +294,16 @@ export const Tasks = (
282294
.filter((project) => project !== '')
283295
.sort((a, b) => (a > b ? 1 : -1));
284296
setUniqueProjects(filteredProjects);
297+
298+
const tagsSet = new Set(sortedTasks.flatMap((task) => task.tags || []));
299+
const filteredTags = Array.from(tagsSet)
300+
.filter((tag) => tag !== '')
301+
.sort((a, b) => (a > b ? 1 : -1));
302+
setUniqueTags(filteredTags);
303+
304+
// Calculate completion stats
305+
setProjectStats(calculateProjectStats(sortedTasks));
306+
setTagStats(calculateTagStats(sortedTasks));
285307
});
286308

287309
const currentTime = Date.now();
@@ -1074,6 +1096,7 @@ export const Tasks = (
10741096
onSelectionChange={setSelectedProjects}
10751097
className="hidden lg:flex min-w-[140px]"
10761098
icon={<Key label="p" />}
1099+
completionStats={projectStats}
10771100
/>
10781101
<MultiSelectFilter
10791102
id="status"
@@ -1092,6 +1115,7 @@ export const Tasks = (
10921115
onSelectionChange={setSelectedTags}
10931116
className="hidden lg:flex min-w-[140px]"
10941117
icon={<Key label="t" />}
1118+
completionStats={tagStats}
10951119
/>
10961120
<div className="flex justify-center">
10971121
<AddTaskdialog

frontend/src/components/HomeComponents/Tasks/__tests__/Tasks.test.tsx

Lines changed: 158 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,15 @@ jest.mock('../tasks-utils', () => {
4444
});
4545

4646
jest.mock('@/components/ui/multi-select', () => ({
47-
MultiSelectFilter: jest.fn(({ title }) => (
48-
<div>Mocked MultiSelect: {title}</div>
47+
MultiSelectFilter: jest.fn(({ title, completionStats }) => (
48+
<div data-testid={`multi-select-${title.toLowerCase()}`}>
49+
Mocked MultiSelect: {title}
50+
{completionStats && (
51+
<span data-testid={`stats-${title.toLowerCase()}`}>
52+
{JSON.stringify(completionStats)}
53+
</span>
54+
)}
55+
</div>
4956
)),
5057
}));
5158

@@ -1364,6 +1371,155 @@ describe('Tasks Component', () => {
13641371
});
13651372
});
13661373

1374+
test('calculates and passes project completion stats to MultiSelectFilter', async () => {
1375+
render(<Tasks {...mockProps} />);
1376+
1377+
await waitFor(async () => {
1378+
expect(await screen.findByText('Task 1')).toBeInTheDocument();
1379+
});
1380+
1381+
const { MultiSelectFilter } = require('@/components/ui/multi-select');
1382+
1383+
// Find the Projects filter call
1384+
const projectsFilterCall = MultiSelectFilter.mock.calls.find(
1385+
(call: any) => call[0].title === 'Projects'
1386+
);
1387+
1388+
expect(projectsFilterCall).toBeDefined();
1389+
expect(projectsFilterCall[0].completionStats).toBeDefined();
1390+
1391+
const stats = projectsFilterCall[0].completionStats;
1392+
1393+
// ProjectA has tasks: 1,3,5,7,9,11 (pending) + task 16 (completed) = 1 completed out of 7 total
1394+
expect(stats['ProjectA']).toBeDefined();
1395+
expect(stats['ProjectA'].completed).toBeGreaterThanOrEqual(1);
1396+
expect(stats['ProjectA'].total).toBeGreaterThanOrEqual(1);
1397+
expect(stats['ProjectA'].percentage).toBeGreaterThanOrEqual(0);
1398+
expect(stats['ProjectA'].percentage).toBeLessThanOrEqual(100);
1399+
1400+
// ProjectB has tasks: 2,4,6,8,10,12 (pending) + task 17 (deleted) = 0 completed
1401+
expect(stats['ProjectB']).toBeDefined();
1402+
expect(stats['ProjectB'].total).toBeGreaterThanOrEqual(1);
1403+
});
1404+
1405+
test('calculates and passes tag completion stats to MultiSelectFilter', async () => {
1406+
render(<Tasks {...mockProps} />);
1407+
1408+
await waitFor(async () => {
1409+
expect(await screen.findByText('Task 1')).toBeInTheDocument();
1410+
});
1411+
1412+
const { MultiSelectFilter } = require('@/components/ui/multi-select');
1413+
1414+
// Find the Tags filter call
1415+
const tagsFilterCall = MultiSelectFilter.mock.calls.find(
1416+
(call: any) => call[0].title === 'Tags'
1417+
);
1418+
1419+
expect(tagsFilterCall).toBeDefined();
1420+
expect(tagsFilterCall[0].completionStats).toBeDefined();
1421+
1422+
const stats = tagsFilterCall[0].completionStats;
1423+
1424+
// Verify stats structure
1425+
Object.keys(stats).forEach((tag) => {
1426+
expect(stats[tag]).toHaveProperty('completed');
1427+
expect(stats[tag]).toHaveProperty('total');
1428+
expect(stats[tag]).toHaveProperty('percentage');
1429+
expect(typeof stats[tag].completed).toBe('number');
1430+
expect(typeof stats[tag].total).toBe('number');
1431+
expect(typeof stats[tag].percentage).toBe('number');
1432+
expect(stats[tag].percentage).toBeGreaterThanOrEqual(0);
1433+
expect(stats[tag].percentage).toBeLessThanOrEqual(100);
1434+
});
1435+
});
1436+
1437+
test('recalculates completion stats after sync', async () => {
1438+
const hooks = require('../hooks');
1439+
1440+
render(<Tasks {...mockProps} />);
1441+
1442+
await waitFor(async () => {
1443+
expect(await screen.findByText('Task 1')).toBeInTheDocument();
1444+
});
1445+
1446+
const { MultiSelectFilter } = require('@/components/ui/multi-select');
1447+
1448+
hooks.fetchTaskwarriorTasks.mockResolvedValueOnce([
1449+
{
1450+
id: 1,
1451+
description: 'Task 1',
1452+
status: 'completed',
1453+
project: 'ProjectA',
1454+
tags: ['tag1'],
1455+
uuid: 'uuid-1',
1456+
},
1457+
{
1458+
id: 2,
1459+
description: 'Task 2',
1460+
status: 'completed',
1461+
project: 'ProjectB',
1462+
tags: ['tag2'],
1463+
uuid: 'uuid-2',
1464+
},
1465+
]);
1466+
1467+
MultiSelectFilter.mockClear();
1468+
1469+
const syncButtons = screen.getAllByText('Sync');
1470+
fireEvent.click(syncButtons[0]);
1471+
1472+
await waitFor(() => {
1473+
const projectsCall = MultiSelectFilter.mock.calls.find(
1474+
(call: any) => call[0].title === 'Projects'
1475+
);
1476+
expect(projectsCall).toBeDefined();
1477+
});
1478+
1479+
const updatedProjectsCall = MultiSelectFilter.mock.calls.find(
1480+
(call: any) => call[0].title === 'Projects'
1481+
);
1482+
1483+
expect(updatedProjectsCall).toBeDefined();
1484+
expect(updatedProjectsCall[0].completionStats).toBeDefined();
1485+
1486+
const updatedStats = updatedProjectsCall[0].completionStats;
1487+
expect(updatedStats['ProjectA']).toBeDefined();
1488+
expect(updatedStats['ProjectB']).toBeDefined();
1489+
});
1490+
1491+
test('completion stats structure is correct', async () => {
1492+
render(<Tasks {...mockProps} />);
1493+
1494+
await waitFor(async () => {
1495+
expect(await screen.findByText('Task 1')).toBeInTheDocument();
1496+
});
1497+
1498+
const { MultiSelectFilter } = require('@/components/ui/multi-select');
1499+
1500+
const projectsCall = MultiSelectFilter.mock.calls.find(
1501+
(call: any) => call[0].title === 'Projects'
1502+
);
1503+
1504+
expect(projectsCall).toBeDefined();
1505+
const stats = projectsCall[0].completionStats;
1506+
1507+
// Verify stats structure for any project that exists
1508+
Object.keys(stats).forEach((project) => {
1509+
expect(stats[project]).toHaveProperty('completed');
1510+
expect(stats[project]).toHaveProperty('total');
1511+
expect(stats[project]).toHaveProperty('percentage');
1512+
expect(typeof stats[project].completed).toBe('number');
1513+
expect(typeof stats[project].total).toBe('number');
1514+
expect(typeof stats[project].percentage).toBe('number');
1515+
expect(stats[project].completed).toBeLessThanOrEqual(
1516+
stats[project].total
1517+
);
1518+
expect(stats[project].percentage).toBeGreaterThanOrEqual(0);
1519+
expect(stats[project].percentage).toBeLessThanOrEqual(100);
1520+
});
1521+
});
1522+
13671523
describe('Pin Functionality', () => {
13681524
test('should load pinned tasks from localStorage on mount', async () => {
13691525
const { getPinnedTasks } = require('../tasks-utils');

0 commit comments

Comments
 (0)