diff --git a/frontend/components/Task/GroupedTaskList.tsx b/frontend/components/Task/GroupedTaskList.tsx index 1045967..fc64986 100644 --- a/frontend/components/Task/GroupedTaskList.tsx +++ b/frontend/components/Task/GroupedTaskList.tsx @@ -30,6 +30,14 @@ interface TaskGroup { instances: Task[]; } +interface ProjectGroup { + key: string; + projectId?: number; + projectUid?: string; + tasks: Task[]; + order: number; +} + const GroupedTaskList: React.FC = ({ tasks, groupedTasks, @@ -62,15 +70,7 @@ const GroupedTaskList: React.FC = ({ // Filter tasks based on completion status const filteredTasks = showCompletedTasks - ? tasks.filter((task) => { - // Show only completed tasks (done=2 or archived=3) - const isCompleted = - task.status === 'done' || - task.status === 'archived' || - task.status === 2 || - task.status === 3; - return isCompleted; - }) + ? tasks : tasks.filter((task) => { // Show only non-completed tasks const isCompleted = @@ -144,15 +144,7 @@ const GroupedTaskList: React.FC = ({ Object.entries(groupedTasks).forEach(([groupName, groupTasks]) => { // Filter by completion status let filteredTasks = showCompletedTasks - ? groupTasks.filter((task) => { - // Show only completed tasks - const isCompleted = - task.status === 'done' || - task.status === 'archived' || - task.status === 2 || - task.status === 3; - return isCompleted; - }) + ? groupTasks : groupTasks.filter((task) => { // Show only non-completed tasks const isCompleted = @@ -184,16 +176,20 @@ const GroupedTaskList: React.FC = ({ const groupedByProject = useMemo(() => { if (groupBy !== 'project') return null; + const normalizeProjectId = ( + value: number | string | null | undefined + ): number | undefined => { + if (typeof value === 'number') return value; + if (typeof value === 'string') { + const parsed = Number(value); + return Number.isNaN(parsed) ? undefined : parsed; + } + return undefined; + }; + // Apply completion filter const filtered = showCompletedTasks - ? tasks.filter((task) => { - const isCompleted = - task.status === 'done' || - task.status === 'archived' || - task.status === 2 || - task.status === 3; - return isCompleted; - }) + ? tasks : tasks.filter((task) => { const isCompleted = task.status === 'done' || @@ -212,19 +208,53 @@ const GroupedTaskList: React.FC = ({ ) : filtered; - const byProject = new Map(); + const getGroupKey = ( + projectId?: number, + projectUid?: string + ): string => { + if (projectId !== undefined && projectId !== null) { + return `id-${projectId}`; + } + if (projectUid) { + return `uid-${projectUid}`; + } + return 'no_project'; + }; + + const byProject = new Map(); filteredBySearch.forEach((task) => { - const key = task.project_id || 'no_project'; - const arr = byProject.get(key) || []; - arr.push(task); - byProject.set(key, arr); + const resolvedProjectId = + normalizeProjectId(task.project_id) ?? + normalizeProjectId(task.Project?.id); + const resolvedProjectUid = + task.project_uid || task.Project?.uid || undefined; + + const key = getGroupKey(resolvedProjectId, resolvedProjectUid); + + if (!byProject.has(key)) { + byProject.set(key, { + key, + projectId: resolvedProjectId, + projectUid: resolvedProjectUid, + tasks: [], + order: byProject.size, + }); + } + byProject.get(key)!.tasks.push(task); }); - return Array.from(byProject.entries()).map( - ([projectId, projectTasks]) => ({ - projectId, - tasks: projectTasks, - }) - ); + + const groups = Array.from(byProject.values()); + groups.sort((a, b) => { + if (a.key === 'no_project' && b.key !== 'no_project') { + return -1; + } + if (b.key === 'no_project' && a.key !== 'no_project') { + return 1; + } + return a.order - b.order; + }); + + return groups; }, [groupBy, tasks, showCompletedTasks, searchQuery]); const toggleRecurringGroup = (templateId: number) => { @@ -363,10 +393,28 @@ const GroupedTaskList: React.FC = ({ {/* Standalone tasks */} {groupBy === 'project' && groupedByProject ? groupedByProject.map( - ({ projectId, tasks: projectTasks }, index) => { + ( + { key, projectId, projectUid, tasks: projectTasks }, + index + ) => { + const matchingProject = projects.find((p) => { + if ( + projectId !== undefined && + projectId !== null && + p.id === projectId + ) { + return true; + } + if (projectUid && p.uid === projectUid) { + return true; + } + return false; + }); + const projectName = - projects.find((p) => p.id === projectId)?.name || - (projectId === 'no_project' + matchingProject?.name || + projectTasks[0]?.Project?.name || + (key === 'no_project' ? t('tasks.noProject', 'No project') : t( 'tasks.unknownProject', @@ -374,7 +422,7 @@ const GroupedTaskList: React.FC = ({ )); return (
0 ? 'pt-4' : ''}`} >
diff --git a/frontend/components/ViewDetail.tsx b/frontend/components/ViewDetail.tsx index 330702c..433e3fe 100644 --- a/frontend/components/ViewDetail.tsx +++ b/frontend/components/ViewDetail.tsx @@ -1,5 +1,11 @@ -import React, { useState, useEffect, useRef, useMemo } from 'react'; -import { useParams, useNavigate, Link } from 'react-router-dom'; +import React, { + useState, + useEffect, + useRef, + useMemo, + useCallback, +} from 'react'; +import { useParams, useNavigate, Link, useLocation } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { TrashIcon, @@ -16,12 +22,14 @@ import { Task } from '../entities/Task'; import { Note } from '../entities/Note'; import { Project } from '../entities/Project'; import TaskList from './Task/TaskList'; +import GroupedTaskList from './Task/GroupedTaskList'; import ProjectItem from './Project/ProjectItem'; import ConfirmDialog from './Shared/ConfirmDialog'; import { searchUniversal } from '../utils/searchService'; import { getApiPath } from '../config/paths'; import { SortOption } from './Shared/SortFilterButton'; import IconSortDropdown from './Shared/IconSortDropdown'; +import { useStore } from '../store/useStore'; interface View { id: number; @@ -41,10 +49,42 @@ const ViewDetail: React.FC = () => { const { t } = useTranslation(); const { uid } = useParams<{ uid: string }>(); const navigate = useNavigate(); + const location = useLocation(); const [view, setView] = useState(null); const [tasks, setTasks] = useState([]); const [notes, setNotes] = useState([]); const [projects, setProjects] = useState([]); + const globalProjects = useStore((state) => state.projectsStore.projects); + const updateQueryParams = useCallback( + (updates: Record) => { + const params = new URLSearchParams(location.search); + let shouldNavigate = false; + + Object.entries(updates).forEach(([key, value]) => { + if (value === null || value === '') { + if (params.has(key)) { + params.delete(key); + shouldNavigate = true; + } + } else if (params.get(key) !== value) { + params.set(key, value); + shouldNavigate = true; + } + }); + + if (shouldNavigate) { + const searchString = params.toString(); + navigate( + { + pathname: location.pathname, + search: searchString ? `?${searchString}` : '', + }, + { replace: true } + ); + } + }, + [location.pathname, location.search, navigate] + ); const [isLoading, setIsLoading] = useState(true); const [isEditingName, setIsEditingName] = useState(false); const [editedName, setEditedName] = useState(''); @@ -58,6 +98,7 @@ const ViewDetail: React.FC = () => { 'all' | 'active' | 'completed' >('active'); const [orderBy, setOrderBy] = useState('created_at:desc'); + const [groupBy, setGroupBy] = useState<'none' | 'project'>('none'); // Pagination state const [offset, setOffset] = useState(0); @@ -84,6 +125,50 @@ const ViewDetail: React.FC = () => { { value: 'created_at:desc', label: t('sort.created_at', 'Created At') }, ]; + const projectLookupList = useMemo(() => { + const map = new Map(); + + 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); + } + }; + + globalProjects.forEach(addProject); + projects.forEach(addProject); + + return Array.from(map.values()); + }, [globalProjects, projects]); + + const projectLookupMap = useMemo(() => { + const byId = new Map(); + const byUid = new Map(); + + projectLookupList.forEach((project) => { + if (project.id !== undefined && project.id !== null) { + const numericId = + typeof project.id === 'string' + ? Number(project.id) + : project.id; + if (!Number.isNaN(numericId)) { + byId.set(numericId, project); + } + } + if (project.uid) { + byUid.set(project.uid, project); + } + }); + + return { byId, byUid }; + }, [projectLookupList]); + // Filter and sort tasks const displayTasks = useMemo(() => { let filteredTasks: Task[]; @@ -171,13 +256,147 @@ const ViewDetail: React.FC = () => { return 0; }); - return sortedTasks; - }, [tasks, taskStatusFilter, taskSearchQuery, orderBy, t]); + return sortedTasks.map((task) => { + if (task.Project) { + return task; + } + + let matchedProject: Project | undefined; + if ( + task.project_id !== undefined && + task.project_id !== null && + typeof task.project_id !== 'string' + ) { + matchedProject = projectLookupMap.byId.get(task.project_id); + } + + if (!matchedProject && task.project_uid) { + matchedProject = projectLookupMap.byUid.get(task.project_uid); + } + + if (!matchedProject && typeof task.project_id === 'string') { + const numericId = Number(task.project_id); + if (!Number.isNaN(numericId)) { + matchedProject = projectLookupMap.byId.get(numericId); + } + } + + return matchedProject + ? { + ...task, + Project: matchedProject, + } + : task; + }); + }, [ + tasks, + taskStatusFilter, + taskSearchQuery, + orderBy, + t, + projectLookupMap, + ]); + + const handleSortChange = useCallback( + (newOrderBy: string, options?: { skipUrlUpdate?: boolean }) => { + setOrderBy(newOrderBy); + if (!options?.skipUrlUpdate) { + updateQueryParams({ + order_by: + newOrderBy === 'created_at:desc' ? null : newOrderBy, + }); + } + }, + [updateQueryParams] + ); + + const handleStatusChange = useCallback( + ( + status: 'all' | 'active' | 'completed', + options?: { skipUrlUpdate?: boolean } + ) => { + setTaskStatusFilter(status); + if (!options?.skipUrlUpdate) { + updateQueryParams({ + status: status === 'active' ? null : status, + }); + } + }, + [updateQueryParams] + ); + + const handleGroupByChange = useCallback( + (value: 'none' | 'project', options?: { skipUrlUpdate?: boolean }) => { + setGroupBy(value); + if (uid) { + localStorage.setItem(`view_${uid}_group_by`, value); + } + if (!options?.skipUrlUpdate) { + updateQueryParams({ + group_by: value === 'none' ? null : value, + }); + } + }, + [uid, updateQueryParams] + ); + + const handleSearchChange = useCallback( + (value: string, options?: { skipUrlUpdate?: boolean }) => { + setTaskSearchQuery(value); + if (!options?.skipUrlUpdate) { + updateQueryParams({ + search: value.trim() ? value : null, + }); + } + }, + [updateQueryParams] + ); + + const showCompletedTasks = taskStatusFilter !== 'active'; useEffect(() => { fetchViewAndResults(); }, [uid]); + useEffect(() => { + const params = new URLSearchParams(location.search); + const urlOrderBy = params.get('order_by') || 'created_at:desc'; + handleSortChange(urlOrderBy, { skipUrlUpdate: true }); + + const urlStatus = params.get('status'); + const normalizedStatus = + urlStatus === 'completed' + ? 'completed' + : urlStatus === 'all' + ? 'all' + : 'active'; + handleStatusChange(normalizedStatus, { skipUrlUpdate: true }); + + const urlGroup = + params.get('group_by') === 'project' ? 'project' : 'none'; + setGroupBy((prev) => { + if (prev !== urlGroup) { + if (uid) { + localStorage.setItem(`view_${uid}_group_by`, urlGroup); + } + return urlGroup; + } + return prev; + }); + + const urlSearch = params.get('search') || ''; + if (urlSearch) { + setIsSearchExpanded(true); + } + handleSearchChange(urlSearch, { skipUrlUpdate: true }); + }, [ + location.search, + uid, + handleSortChange, + handleStatusChange, + handleSearchChange, + ]); + // Close dropdown when clicking outside useEffect(() => { const handleClickOutside = (event: MouseEvent) => { @@ -552,12 +771,51 @@ const ViewDetail: React.FC = () => { +
+
+ {t('tasks.groupBy', 'Group by')} +
+
+ {['none', 'project'].map((val) => ( + + ))} +
+
{t('tasks.show', 'Show')} @@ -594,7 +852,7 @@ const ViewDetail: React.FC = () => { key={opt.key} type="button" onClick={() => - setTaskStatusFilter( + handleStatusChange( opt.key as | 'all' | 'active' @@ -651,7 +909,7 @@ const ViewDetail: React.FC = () => { orderBy.split( ':' ); - setOrderBy( + handleSortChange( `${field}:${dir.key}` ); }} @@ -878,7 +1136,7 @@ const ViewDetail: React.FC = () => { 'Search tasks...' )} value={taskSearchQuery} - onChange={(e) => setTaskSearchQuery(e.target.value)} + onChange={(e) => handleSearchChange(e.target.value)} className="w-full bg-transparent border-none focus:ring-0 focus:outline-none dark:text-white" />
@@ -890,16 +1148,35 @@ const ViewDetail: React.FC = () => {

{t('tasks.title')} ({displayTasks.length})

- + {groupBy === 'project' ? ( + + ) : ( + + )} {/* Load more button */} {hasMore && (