diff --git a/frontend/src/components/HomeComponents/Tasks/Tasks.tsx b/frontend/src/components/HomeComponents/Tasks/Tasks.tsx index d5b0df33..489da6e0 100644 --- a/frontend/src/components/HomeComponents/Tasks/Tasks.tsx +++ b/frontend/src/components/HomeComponents/Tasks/Tasks.tsx @@ -1,4 +1,12 @@ -import { useEffect, useState, useCallback, useRef } from 'react'; +import { + useEffect, + useState, + useCallback, + useMemo, + useRef, + Dispatch, + SetStateAction, +} from 'react'; import { Task } from '../../utils/types'; import { ReportsView } from './ReportsView'; import Fuse from 'fuse.js'; @@ -57,6 +65,9 @@ import { sortTasksById, getTimeSinceLastSync, hashKey, + aggregateProjectStats, + aggregateTagStats, + buildLabelMaps, } from './tasks-utils'; import Pagination from './Pagination'; import { url } from '@/components/utils/URLs'; @@ -143,6 +154,75 @@ export const Tasks = ( const [hotkeysEnabled, setHotkeysEnabled] = useState(false); const [selectedIndex, setSelectedIndex] = useState(0); + const projectStats = useMemo(() => aggregateProjectStats(tasks), [tasks]); + const tagStats = useMemo(() => aggregateTagStats(tasks), [tasks]); + + const { + options: projectDisplayOptions, + valueToDisplay: projectValueToDisplay, + displayToValue: projectDisplayToValue, + } = useMemo( + () => buildLabelMaps(uniqueProjects, projectStats), + [uniqueProjects, projectStats] + ); + + const { + options: tagDisplayOptions, + valueToDisplay: tagValueToDisplay, + displayToValue: tagDisplayToValue, + } = useMemo( + () => buildLabelMaps(uniqueTags, tagStats), + [uniqueTags, tagStats] + ); + + const selectedProjectDisplayValues = useMemo( + () => + selectedProjects + .map((project) => projectValueToDisplay[project]) + .filter((label): label is string => Boolean(label)), + [selectedProjects, projectValueToDisplay] + ); + + const selectedTagDisplayValues = useMemo( + () => + selectedTags + .map((tag) => tagValueToDisplay[tag]) + .filter((label): label is string => Boolean(label)), + [selectedTags, tagValueToDisplay] + ); + + const handleProjectSelectionChange = useCallback< + Dispatch> + >( + (valueOrUpdater) => { + if (typeof valueOrUpdater === 'function') { + setSelectedProjects(valueOrUpdater); + return; + } + const rawValues = valueOrUpdater + .map((label) => projectDisplayToValue[label]) + .filter((value): value is string => Boolean(value)); + setSelectedProjects(rawValues); + }, + [projectDisplayToValue, setSelectedProjects] + ); + + const handleTagSelectionChange = useCallback< + Dispatch> + >( + (valueOrUpdater) => { + if (typeof valueOrUpdater === 'function') { + setSelectedTags(valueOrUpdater); + return; + } + const rawValues = valueOrUpdater + .map((label) => tagDisplayToValue[label]) + .filter((value): value is string => Boolean(value)); + setSelectedTags(rawValues); + }, + [tagDisplayToValue, setSelectedTags] + ); + const isOverdue = (due?: string) => { if (!due) return false; @@ -900,15 +980,15 @@ export const Tasks = ( className="container py-24 pl-1 pr-1 md:pr-4 md:pl-4 sm:py-32" >

+ handleProjectSelectionChange(values) + } className="flex-1 min-w-[140px]" icon={} /> @@ -992,9 +1074,11 @@ export const Tasks = ( + handleTagSelectionChange(values) + } className="flex-1 min-w-[140px]" icon={} /> diff --git a/frontend/src/components/HomeComponents/Tasks/__tests__/Tasks.test.tsx b/frontend/src/components/HomeComponents/Tasks/__tests__/Tasks.test.tsx index b9ded47f..3acee9e8 100644 --- a/frontend/src/components/HomeComponents/Tasks/__tests__/Tasks.test.tsx +++ b/frontend/src/components/HomeComponents/Tasks/__tests__/Tasks.test.tsx @@ -40,8 +40,13 @@ jest.mock('../tasks-utils', () => { }); jest.mock('@/components/ui/multi-select', () => ({ - MultiSelectFilter: jest.fn(({ title }) => ( -
Mocked MultiSelect: {title}
+ MultiSelectFilter: jest.fn(({ title, options }) => ( +
+ Mocked MultiSelect: {title} +
+ {options ? JSON.stringify(options) : ''} +
+
)), })); @@ -142,6 +147,37 @@ describe('Tasks Component', () => { expect(screen.getByText('Mocked BottomBar')).toBeInTheDocument(); }); + test('displays completion stats inside filter dropdown options', async () => { + const { MultiSelectFilter } = require('@/components/ui/multi-select'); + render(); + + expect(await screen.findByText('Task 1')).toBeInTheDocument(); + + const projectCall = MultiSelectFilter.mock.calls.find( + ([props]: [{ title: string }]) => props.title === 'Projects' + ); + expect(projectCall).toBeTruthy(); + const projectOptions = projectCall![0].options; + expect( + projectOptions.some( + (opt: string) => + opt.includes('ProjectA') && opt.includes('0/6') && opt.includes('0%') + ) + ).toBe(true); + + const tagCall = MultiSelectFilter.mock.calls.find( + ([props]: [{ title: string }]) => props.title === 'Tags' + ); + expect(tagCall).toBeTruthy(); + const tagOptions = tagCall![0].options; + expect( + tagOptions.some( + (opt: string) => + opt.includes('tag1') && opt.includes('0/4') && opt.includes('0%') + ) + ).toBe(true); + }); + test('renders the "Tasks per Page" dropdown with default value', async () => { render(); diff --git a/frontend/src/components/HomeComponents/Tasks/constants.ts b/frontend/src/components/HomeComponents/Tasks/constants.ts new file mode 100644 index 00000000..1a5f034a --- /dev/null +++ b/frontend/src/components/HomeComponents/Tasks/constants.ts @@ -0,0 +1 @@ +export const COMPLETED_STATUS = 'completed'; diff --git a/frontend/src/components/HomeComponents/Tasks/tasks-utils.ts b/frontend/src/components/HomeComponents/Tasks/tasks-utils.ts index dcfde5fe..85e140ec 100644 --- a/frontend/src/components/HomeComponents/Tasks/tasks-utils.ts +++ b/frontend/src/components/HomeComponents/Tasks/tasks-utils.ts @@ -2,6 +2,8 @@ import { Task } from '@/components/utils/types'; import { url } from '@/components/utils/URLs'; import { format, parseISO } from 'date-fns'; import { toast } from 'react-toastify'; +import { CompletionSummary, LabelMaps } from './types'; +import { COMPLETED_STATUS } from './constants'; export type Props = { email: string; @@ -183,3 +185,58 @@ export const hashKey = (key: string, email: string): string => { } return Math.abs(hash).toString(36); }; + +export const roundPercentage = (completed: number, total: number) => + total === 0 ? 0 : Math.round((completed / total) * 100); + +export const aggregateProjectStats = (tasks: Task[]): CompletionSummary => + tasks.reduce((acc, task) => { + if (!task.project) { + return acc; + } + if (!acc[task.project]) { + acc[task.project] = { total: 0, completed: 0 }; + } + acc[task.project].total += 1; + if (task.status === COMPLETED_STATUS) { + acc[task.project].completed += 1; + } + return acc; + }, {} as CompletionSummary); + +export const aggregateTagStats = (tasks: Task[]): CompletionSummary => + tasks.reduce((acc, task) => { + (task.tags || []).forEach((tag) => { + if (!tag) { + return; + } + if (!acc[tag]) { + acc[tag] = { total: 0, completed: 0 }; + } + acc[tag].total += 1; + if (task.status === COMPLETED_STATUS) { + acc[tag].completed += 1; + } + }); + return acc; + }, {} as CompletionSummary); + +export const buildLabelMaps = ( + keys: string[], + stats: CompletionSummary +): LabelMaps => + keys.reduce( + (acc, key) => { + const { total = 0, completed = 0 } = stats[key] ?? { + total: 0, + completed: 0, + }; + const percentage = roundPercentage(completed, total); + const label = `${key} ${completed}/${total} ${percentage}%`; + acc.options.push(label); + acc.valueToDisplay[key] = label; + acc.displayToValue[label] = key; + return acc; + }, + { options: [], valueToDisplay: {}, displayToValue: {} } as LabelMaps + ); diff --git a/frontend/src/components/HomeComponents/Tasks/types.ts b/frontend/src/components/HomeComponents/Tasks/types.ts new file mode 100644 index 00000000..89ca7ed3 --- /dev/null +++ b/frontend/src/components/HomeComponents/Tasks/types.ts @@ -0,0 +1,10 @@ +export type CompletionSummary = Record< + string, + { total: number; completed: number } +>; + +export type LabelMaps = { + options: string[]; + valueToDisplay: Record; + displayToValue: Record; +}; diff --git a/frontend/src/components/ui/multi-select.tsx b/frontend/src/components/ui/multi-select.tsx index 5089635a..83b10bde 100644 --- a/frontend/src/components/ui/multi-select.tsx +++ b/frontend/src/components/ui/multi-select.tsx @@ -90,6 +90,11 @@ export function MultiSelectFilter({ {options.map((option) => { const isSelected = selectedValues.includes(option); + + // Parse option for stats formatting + const parts = option.split(' '); + const hasStats = parts.length === 3; + return ( - {option} + {hasStats ? ( + <> + {parts[0]} + + {parts[1]} {parts[2]} + + + ) : ( + option + )} ); })}