Add universal filter to tag details page (#690)

* Add universal filter to tag details page

* fixup! Add universal filter to tag details page
This commit is contained in:
Chris 2025-12-09 12:45:01 +02:00 committed by GitHub
parent 442ace69bb
commit 67d8f9e0dd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 249 additions and 71 deletions

View file

@ -135,9 +135,6 @@ test.describe('Today', () => {
} else { } else {
// If section not visible, the settings might be hiding it // If section not visible, the settings might be hiding it
// Skip this assertion but don't fail the test // Skip this assertion but don't fail the test
console.log(
'Overdue section not visible - may be hidden by settings'
);
} }
// Clean up // Clean up
@ -195,9 +192,6 @@ test.describe('Today', () => {
await expect(dueTodayTask).toBeVisible(); await expect(dueTodayTask).toBeVisible();
} else { } else {
// If section not visible, the settings might be hiding it // If section not visible, the settings might be hiding it
console.log(
'Due Today section not visible - may be hidden by settings'
);
} }
// Clean up // Clean up

View file

@ -15,6 +15,7 @@ import { Task } from '../../entities/Task';
import { Note } from '../../entities/Note'; import { Note } from '../../entities/Note';
import { Project } from '../../entities/Project'; import { Project } from '../../entities/Project';
import TaskList from '../Task/TaskList'; import TaskList from '../Task/TaskList';
import GroupedTaskList from '../Task/GroupedTaskList';
import ProjectItem from '../Project/ProjectItem'; import ProjectItem from '../Project/ProjectItem';
import ProjectShareModal from '../Project/ProjectShareModal'; import ProjectShareModal from '../Project/ProjectShareModal';
import TagModal from './TagModal'; import TagModal from './TagModal';
@ -40,7 +41,10 @@ const TagDetails: React.FC = () => {
// Search, filter, and sort state // Search, filter, and sort state
const [taskSearchQuery, setTaskSearchQuery] = useState<string>(''); const [taskSearchQuery, setTaskSearchQuery] = useState<string>('');
const [isSearchExpanded, setIsSearchExpanded] = useState(false); const [isSearchExpanded, setIsSearchExpanded] = useState(false);
const [showCompleted, setShowCompleted] = useState(false); const [taskStatusFilter, setTaskStatusFilter] = useState<
'all' | 'active' | 'completed'
>('active');
const [groupBy, setGroupBy] = useState<'none' | 'project'>('none');
const [orderBy, setOrderBy] = useState<string>('created_at:desc'); const [orderBy, setOrderBy] = useState<string>('created_at:desc');
// Filter projects by current tag // Filter projects by current tag
@ -52,6 +56,27 @@ const TagDetails: React.FC = () => {
) )
); );
const projectLookupList = useMemo(() => {
const map = new Map<string, Project>();
const addProject = (project?: Project | null) => {
if (!project) return;
const key =
(project.uid && `uid-${project.uid}`) ??
(project.id !== undefined && project.id !== null
? `id-${project.id}`
: undefined);
if (!key) return;
if (!map.has(key)) {
map.set(key, project);
}
};
allProjects.forEach(addProject);
projects.forEach(addProject);
return Array.from(map.values());
}, [allProjects, projects]);
// State for ProjectItem components // State for ProjectItem components
const [activeDropdown, setActiveDropdown] = useState<number | null>(null); const [activeDropdown, setActiveDropdown] = useState<number | null>(null);
const [hoveredNoteId, setHoveredNoteId] = useState<string | null>(null); const [hoveredNoteId, setHoveredNoteId] = useState<string | null>(null);
@ -89,7 +114,7 @@ const TagDetails: React.FC = () => {
let filteredTasks: Task[]; let filteredTasks: Task[];
// Filter by completion status // Filter by completion status
if (showCompleted) { if (taskStatusFilter === 'completed') {
filteredTasks = tasks.filter( filteredTasks = tasks.filter(
(task: Task) => (task: Task) =>
task.status === 'done' || task.status === 'done' ||
@ -97,7 +122,7 @@ const TagDetails: React.FC = () => {
task.status === 2 || task.status === 2 ||
task.status === 3 task.status === 3
); );
} else { } else if (taskStatusFilter === 'active') {
filteredTasks = tasks.filter( filteredTasks = tasks.filter(
(task: Task) => (task: Task) =>
task.status !== 'done' && task.status !== 'done' &&
@ -105,6 +130,8 @@ const TagDetails: React.FC = () => {
task.status !== 2 && task.status !== 2 &&
task.status !== 3 task.status !== 3
); );
} else {
filteredTasks = tasks;
} }
// Filter by search query // Filter by search query
@ -169,7 +196,7 @@ const TagDetails: React.FC = () => {
}); });
return sortedTasks; return sortedTasks;
}, [tasks, showCompleted, taskSearchQuery, orderBy, t]); }, [tasks, taskStatusFilter, taskSearchQuery, orderBy, t]);
useEffect(() => { useEffect(() => {
const fetchTagData = async () => { const fetchTagData = async () => {
@ -215,6 +242,16 @@ const TagDetails: React.FC = () => {
fetchTagData(); fetchTagData();
}, [uidSlug, t]); }, [uidSlug, t]);
useEffect(() => {
const savedOrderBy =
localStorage.getItem('order_by') || 'created_at:desc';
setOrderBy(savedOrderBy);
const savedGroupBy =
(localStorage.getItem('tasks_group_by') as 'none' | 'project') ||
'none';
setGroupBy(savedGroupBy);
}, []);
// Setup native event listener for edit button to avoid React event system conflicts // Setup native event listener for edit button to avoid React event system conflicts
useEffect(() => { useEffect(() => {
const button = editButtonRef.current; const button = editButtonRef.current;
@ -299,6 +336,14 @@ const TagDetails: React.FC = () => {
} }
}; };
const handleTaskCompletionToggle = (updatedTask: Task) => {
setTasks((prevTasks) =>
prevTasks.map((task) =>
task.id === updatedTask.id ? updatedTask : task
)
);
};
const getCompletionPercentage = (project: Project) => { const getCompletionPercentage = (project: Project) => {
return (project as any).completion_percentage || 0; return (project as any).completion_percentage || 0;
}; };
@ -340,6 +385,22 @@ const TagDetails: React.FC = () => {
} }
}; };
const handleSortChange = (order: string) => {
setOrderBy(order);
localStorage.setItem('order_by', order);
};
const handleGroupByChange = (value: 'none' | 'project') => {
setGroupBy(value);
localStorage.setItem('tasks_group_by', value);
};
const handleStatusChange = (value: 'all' | 'active' | 'completed') => {
setTaskStatusFilter(value);
};
const showCompletedTasks = taskStatusFilter !== 'active';
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center h-screen bg-gray-100 dark:bg-gray-900"> <div className="flex items-center justify-center h-screen bg-gray-100 dark:bg-gray-900">
@ -499,74 +560,197 @@ const TagDetails: React.FC = () => {
<IconSortDropdown <IconSortDropdown
options={sortOptions} options={sortOptions}
value={orderBy} value={orderBy}
onChange={setOrderBy} onChange={handleSortChange}
ariaLabel={t('tasks.sortTasks', 'Sort tasks')} ariaLabel={t('tasks.sortTasks', 'Sort tasks')}
title={t('tasks.sortTasks', 'Sort tasks')} title={t('tasks.sortTasks', 'Sort tasks')}
dropdownLabel={t('tasks.sortBy', 'Sort by')} dropdownLabel={t('tasks.sortBy', 'Sort by')}
extraContent={ footerContent={
<button <div className="space-y-3">
type="button" <div>
onClick={() => setShowCompleted((v) => !v)} <div className="px-3 py-2 text-xs font-bold text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-900/50 border-b border-gray-200 dark:border-gray-700">
className="w-full flex items-center justify-between text-sm text-gray-700 dark:text-gray-300" {t('tasks.groupBy', 'Group by')}
aria-pressed={showCompleted} </div>
aria-label={ <div className="py-1">
showCompleted {['none', 'project'].map((val) => (
? t( <button
'tasks.hideCompleted', key={val}
'Hide completed tasks' onClick={() =>
) handleGroupByChange(
: t( val as
'tasks.showCompleted', | 'none'
'Show completed tasks' | 'project'
) )
} }
title={ className={`w-full text-left px-3 py-2 text-sm transition-colors flex items-center justify-between ${
showCompleted groupBy === val
? t( ? 'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400'
'tasks.hideCompleted', : 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
'Hide completed tasks' }`}
) >
: t( <span>
'tasks.showCompleted', {val === 'project'
'Show completed tasks' ? t(
) 'tasks.groupByProject',
} 'Project'
> )
<span> : t(
{t( 'tasks.grouping.none',
'tasks.showCompleted', 'None'
'Show completed' )}
)} </span>
</span> {groupBy === val && (
<span <CheckIcon className="h-4 w-4" />
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${ )}
showCompleted </button>
? 'bg-blue-600' ))}
: 'bg-gray-200 dark:bg-gray-600' </div>
}`} </div>
> <div>
<span <div className="px-3 py-2 text-xs font-bold text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-900/50 border-t border-b border-gray-200 dark:border-gray-700">
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${ {t('tasks.show', 'Show')}
showCompleted </div>
? 'translate-x-4' <div className="py-1 space-y-1">
: 'translate-x-0.5' {[
}`} {
/> key: 'active',
</span> label: t(
</button> 'tasks.open',
'Open'
),
},
{
key: 'all',
label: t(
'tasks.all',
'All'
),
},
{
key: 'completed',
label: t(
'tasks.completed',
'Completed'
),
},
].map((opt) => {
const isActive =
taskStatusFilter ===
opt.key;
return (
<button
key={opt.key}
type="button"
onClick={() =>
handleStatusChange(
opt.key as
| 'all'
| 'active'
| 'completed'
)
}
className={`w-full text-left px-3 py-2 text-sm transition-colors flex items-center justify-between ${
isActive
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
>
<span>{opt.label}</span>
{isActive && (
<CheckIcon className="h-4 w-4" />
)}
</button>
);
})}
</div>
</div>
<div>
<div className="px-3 py-2 text-xs font-bold text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-900/50 border-t border-b border-gray-200 dark:border-gray-700">
{t('tasks.direction', 'Direction')}
</div>
<div className="py-1">
{[
{
key: 'asc',
label: t(
'tasks.ascending',
'Ascending'
),
},
{
key: 'desc',
label: t(
'tasks.descending',
'Descending'
),
},
].map((dir) => {
const currentDirection =
orderBy.split(':')[1] ||
'asc';
const isActive =
currentDirection ===
dir.key;
return (
<button
key={dir.key}
onClick={() => {
const [field] =
orderBy.split(
':'
);
handleSortChange(
`${field}:${dir.key}`
);
}}
className={`w-full text-left px-3 py-2 text-sm transition-colors flex items-center justify-between ${
isActive
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
>
<span>{dir.label}</span>
{isActive && (
<CheckIcon className="h-4 w-4" />
)}
</button>
);
})}
</div>
</div>
</div>
} }
/> />
</div> </div>
{displayTasks.length > 0 ? ( {displayTasks.length > 0 ? (
<TaskList groupBy === 'project' ? (
tasks={displayTasks} <GroupedTaskList
onTaskUpdate={handleTaskUpdate} tasks={displayTasks}
onTaskDelete={handleTaskDelete} groupBy="project"
projects={[]} // Empty since we're viewing by tag onTaskUpdate={handleTaskUpdate}
hideProjectName={false} onTaskCompletionToggle={
onToggleToday={handleToggleToday} handleTaskCompletionToggle
showCompletedTasks={showCompleted} }
/> onTaskDelete={handleTaskDelete}
projects={projectLookupList}
hideProjectName={false}
onToggleToday={handleToggleToday}
showCompletedTasks={showCompletedTasks}
searchQuery={taskSearchQuery}
/>
) : (
<TaskList
tasks={displayTasks}
onTaskUpdate={handleTaskUpdate}
onTaskCompletionToggle={
handleTaskCompletionToggle
}
onTaskDelete={handleTaskDelete}
projects={projectLookupList}
hideProjectName={false}
onToggleToday={handleToggleToday}
showCompletedTasks={showCompletedTasks}
/>
)
) : ( ) : (
<p className="text-sm text-gray-500 dark:text-gray-400"> <p className="text-sm text-gray-500 dark:text-gray-400">
{t('tasks.noTasksAvailable', 'No tasks available.')} {t('tasks.noTasksAvailable', 'No tasks available.')}