diff --git a/frontend/src/components/HomeComponents/BottomBar/BottomBar.tsx b/frontend/src/components/HomeComponents/BottomBar/BottomBar.tsx index 4931a51a..e93db751 100644 --- a/frontend/src/components/HomeComponents/BottomBar/BottomBar.tsx +++ b/frontend/src/components/HomeComponents/BottomBar/BottomBar.tsx @@ -4,6 +4,7 @@ import { buttonVariants } from '@/components/ui/button'; import { BottomBarProps } from './bottom-bar-utils'; import { Icons } from '@/components/ui/icons'; import { MultiSelectFilter } from '@/components/ui/multi-select'; +import { StatsMultiSelectFilter } from '../Tasks/StatsMultiSelectFilter'; import { Popover, PopoverTrigger, @@ -59,7 +60,7 @@ const BottomBar: React.FC = ({ - = ({ onSelectionChange={setSelectedStatus} className="min-w-[200px]" /> - ({ )), })); +// Mock the StatsMultiSelectFilter component +jest.mock('../../Tasks/StatsMultiSelectFilter', () => ({ + StatsMultiSelectFilter: jest.fn(({ title, selectedValues }) => ( +
+ + {title} + + + {selectedValues.length} + +
+ )), +})); + const mockProps: BottomBarProps = { projects: ['Project A', 'Project B'], selectedProjects: ['Project A'], diff --git a/frontend/src/components/HomeComponents/Tasks/StatsMultiSelectFilter.tsx b/frontend/src/components/HomeComponents/Tasks/StatsMultiSelectFilter.tsx new file mode 100644 index 00000000..007d9077 --- /dev/null +++ b/frontend/src/components/HomeComponents/Tasks/StatsMultiSelectFilter.tsx @@ -0,0 +1,128 @@ +import * as React from 'react'; +import { Check, ChevronsUpDown } from 'lucide-react'; + +import { cn } from '@/components/utils/utils'; +import { Button } from '@/components/ui/button'; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from '@/components/ui/command'; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover'; + +const ALL_ITEMS_VALUE = '__ALL__'; + +interface StatsMultiSelectFilterProps { + id?: string; + title: string; + options: string[]; + selectedValues: string[]; + onSelectionChange: (values: string[]) => void; + className?: string; + icon?: React.ReactNode; +} + +export function StatsMultiSelectFilter({ + id, + title, + options, + selectedValues, + onSelectionChange, + className, + icon, +}: StatsMultiSelectFilterProps) { + const [open, setOpen] = React.useState(false); + + const handleSelect = (value: string) => { + if (value === ALL_ITEMS_VALUE) { + onSelectionChange([]); + setOpen(false); + return; + } + const newSelectedValues = selectedValues.includes(value) + ? selectedValues.filter((item) => item !== value) + : [...selectedValues, value]; + onSelectionChange(newSelectedValues); + }; + + return ( + + + + + + + + + + No results found. + + handleSelect(ALL_ITEMS_VALUE)} + className="text-muted-foreground cursor-pointer" + > + All {title} + + {options.map((option) => { + const isSelected = selectedValues.includes(option); + + // Parse option for stats formatting + const parts = option.split(' '); + const hasStats = parts.length === 3; + + return ( + handleSelect(option)} + > + + {hasStats ? ( + <> + {parts[0]} + + {parts[1]} {parts[2]} + + + ) : ( + option + )} + + ); + })} + + + + + + ); +} diff --git a/frontend/src/components/HomeComponents/Tasks/Tasks.tsx b/frontend/src/components/HomeComponents/Tasks/Tasks.tsx index d5b0df33..005a0ac9 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,10 +65,14 @@ import { sortTasksById, getTimeSinceLastSync, hashKey, + aggregateProjectStats, + aggregateTagStats, + buildLabelMaps, } from './tasks-utils'; import Pagination from './Pagination'; import { url } from '@/components/utils/URLs'; import { MultiSelectFilter } from '@/components/ui/multi-select'; +import { StatsMultiSelectFilter } from './StatsMultiSelectFilter'; import BottomBar from '../BottomBar/BottomBar'; import { addTaskToBackend, @@ -143,6 +155,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 +981,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={} /> @@ -989,12 +1072,14 @@ export const Tasks = ( className="flex-1 min-w-[140px]" icon={} /> - + 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..56a0945d 100644 --- a/frontend/src/components/HomeComponents/Tasks/__tests__/Tasks.test.tsx +++ b/frontend/src/components/HomeComponents/Tasks/__tests__/Tasks.test.tsx @@ -45,6 +45,17 @@ jest.mock('@/components/ui/multi-select', () => ({ )), })); +jest.mock('../StatsMultiSelectFilter', () => ({ + StatsMultiSelectFilter: jest.fn(({ title, options }) => ( +
+ Mocked Stats MultiSelect: {title} +
+ {JSON.stringify(options)} +
+
+ )), +})); + jest.mock('../../BottomBar/BottomBar', () => { return jest.fn(() =>
Mocked BottomBar
); }); @@ -142,6 +153,37 @@ describe('Tasks Component', () => { expect(screen.getByText('Mocked BottomBar')).toBeInTheDocument(); }); + test('displays completion stats inside filter dropdown options', async () => { + const { StatsMultiSelectFilter } = require('../StatsMultiSelectFilter'); + render(); + + expect(await screen.findByText('Task 1')).toBeInTheDocument(); + + const projectCall = StatsMultiSelectFilter.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 = StatsMultiSelectFilter.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; +};