Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions frontend/src/components/HomeComponents/Tasks/Tasks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ import {
isOverdue,
getPinnedTasks,
togglePinnedTask,
calculateProjectStats,
calculateTagStats,
} from './tasks-utils';
import Pagination from './Pagination';
import { url } from '@/components/utils/URLs';
Expand Down Expand Up @@ -76,6 +78,12 @@ export const Tasks = (
const [tempTasks, setTempTasks] = useState<Task[]>([]);
const [selectedStatuses, setSelectedStatuses] = useState<string[]>([]);
const status = ['pending', 'completed', 'deleted', 'overdue'];
const [projectStats, setProjectStats] = useState<
Record<string, { completed: number; total: number; percentage: number }>
>({});
const [tagStats, setTagStats] = useState<
Record<string, { completed: number; total: number; percentage: number }>
>({});
const [currentPage, setCurrentPage] = useState<number>(1);
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc');
const [idSortOrder, setIdSortOrder] = useState<'asc' | 'desc'>('asc');
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -1074,6 +1096,7 @@ export const Tasks = (
onSelectionChange={setSelectedProjects}
className="hidden lg:flex min-w-[140px]"
icon={<Key label="p" />}
completionStats={projectStats}
/>
<MultiSelectFilter
id="status"
Expand All @@ -1092,6 +1115,7 @@ export const Tasks = (
onSelectionChange={setSelectedTags}
className="hidden lg:flex min-w-[140px]"
icon={<Key label="t" />}
completionStats={tagStats}
/>
<div className="flex justify-center">
<AddTaskdialog
Expand Down
160 changes: 158 additions & 2 deletions frontend/src/components/HomeComponents/Tasks/__tests__/Tasks.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,15 @@ jest.mock('../tasks-utils', () => {
});

jest.mock('@/components/ui/multi-select', () => ({
MultiSelectFilter: jest.fn(({ title }) => (
<div>Mocked MultiSelect: {title}</div>
MultiSelectFilter: jest.fn(({ title, completionStats }) => (
<div data-testid={`multi-select-${title.toLowerCase()}`}>
Mocked MultiSelect: {title}
{completionStats && (
<span data-testid={`stats-${title.toLowerCase()}`}>
{JSON.stringify(completionStats)}
</span>
)}
</div>
)),
}));

Expand Down Expand Up @@ -1364,6 +1371,155 @@ describe('Tasks Component', () => {
});
});

test('calculates and passes project completion stats to MultiSelectFilter', async () => {
render(<Tasks {...mockProps} />);

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(<Tasks {...mockProps} />);

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(<Tasks {...mockProps} />);

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(<Tasks {...mockProps} />);

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');
Expand Down
Loading
Loading