Skip to content
Open
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
110 changes: 97 additions & 13 deletions frontend/src/components/HomeComponents/Tasks/Tasks.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -57,6 +65,9 @@ import {
sortTasksById,
getTimeSinceLastSync,
hashKey,
aggregateProjectStats,
aggregateTagStats,
buildLabelMaps,
} from './tasks-utils';
import Pagination from './Pagination';
import { url } from '@/components/utils/URLs';
Expand Down Expand Up @@ -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<SetStateAction<string[]>>
>(
(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<SetStateAction<string[]>>
>(
(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;

Expand Down Expand Up @@ -900,15 +980,15 @@ export const Tasks = (
className="container py-24 pl-1 pr-1 md:pr-4 md:pl-4 sm:py-32"
>
<BottomBar
projects={uniqueProjects}
selectedProjects={selectedProjects}
setSelectedProject={setSelectedProjects}
projects={projectDisplayOptions}
selectedProjects={selectedProjectDisplayValues}
setSelectedProject={handleProjectSelectionChange}
status={['pending', 'completed', 'deleted']}
selectedStatuses={selectedStatuses}
setSelectedStatus={setSelectedStatuses}
selectedTags={selectedTags}
tags={uniqueTags}
setSelectedTag={setSelectedTags}
selectedTags={selectedTagDisplayValues}
tags={tagDisplayOptions}
setSelectedTag={handleTagSelectionChange}
/>

<h2
Expand Down Expand Up @@ -974,9 +1054,11 @@ export const Tasks = (
<MultiSelectFilter
id="projects"
title="Projects"
options={uniqueProjects}
selectedValues={selectedProjects}
onSelectionChange={setSelectedProjects}
options={projectDisplayOptions}
selectedValues={selectedProjectDisplayValues}
onSelectionChange={(values) =>
handleProjectSelectionChange(values)
}
className="flex-1 min-w-[140px]"
icon={<Key lable="p" />}
/>
Expand All @@ -992,9 +1074,11 @@ export const Tasks = (
<MultiSelectFilter
id="tags"
title="Tags"
options={uniqueTags}
selectedValues={selectedTags}
onSelectionChange={setSelectedTags}
options={tagDisplayOptions}
selectedValues={selectedTagDisplayValues}
onSelectionChange={(values) =>
handleTagSelectionChange(values)
}
className="flex-1 min-w-[140px]"
icon={<Key lable="t" />}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,13 @@ jest.mock('../tasks-utils', () => {
});

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

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

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

Expand Down
1 change: 1 addition & 0 deletions frontend/src/components/HomeComponents/Tasks/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const COMPLETED_STATUS = 'completed';
57 changes: 57 additions & 0 deletions frontend/src/components/HomeComponents/Tasks/tasks-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
);
10 changes: 10 additions & 0 deletions frontend/src/components/HomeComponents/Tasks/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export type CompletionSummary = Record<
string,
{ total: number; completed: number }
>;

export type LabelMaps = {
options: string[];
valueToDisplay: Record<string, string>;
displayToValue: Record<string, string>;
};
16 changes: 15 additions & 1 deletion frontend/src/components/ui/multi-select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,11 @@ export function MultiSelectFilter({
</CommandItem>
{options.map((option) => {
const isSelected = selectedValues.includes(option);

// Parse option for stats formatting
const parts = option.split(' ');
const hasStats = parts.length === 3;

return (
<CommandItem
key={option}
Expand All @@ -101,7 +106,16 @@ export function MultiSelectFilter({
isSelected ? 'opacity-100' : 'opacity-0'
)}
/>
{option}
{hasStats ? (
<>
<span>{parts[0]}</span>
<span className="ml-3 text-muted-foreground text-xs">
{parts[1]} {parts[2]}
</span>
</>
) : (
option
)}
</CommandItem>
);
})}
Expand Down