From 10d96397c3bba2d7b112f26090957e375313b7f7 Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 17 Jul 2025 17:43:56 +0300 Subject: [PATCH] Sorting fixes (#174) * Update version * Order Inbox items by creation timestamp, desc * Fix input keyboard tab and enter handling * Fix lint issues * Move in progress items to the top of today list * Make tags and projects clickable * fixup! Make tags and projects clickable * fixup! fixup! Make tags and projects clickable --- backend/eslint.config.js | 1 + backend/routes/tasks.js | 5 + backend/tests/integration/inbox.test.js | 44 ++++++ frontend/components/Inbox/InboxItemDetail.tsx | 64 +++++++- frontend/components/Inbox/InboxModal.tsx | 147 +++++++++++++++++- frontend/components/Shared/ToastContext.tsx | 2 +- frontend/components/Task/TaskHeader.tsx | 47 +++++- frontend/components/Task/TaskItem.tsx | 2 +- frontend/components/Task/TaskList.tsx | 22 +-- frontend/components/Task/TodayPlan.tsx | 30 +++- frontend/store/useStore.ts | 2 +- package.json | 2 +- 12 files changed, 339 insertions(+), 29 deletions(-) diff --git a/backend/eslint.config.js b/backend/eslint.config.js index 8d31e44..c241f59 100644 --- a/backend/eslint.config.js +++ b/backend/eslint.config.js @@ -1,6 +1,7 @@ module.exports = [ { files: ['**/*.js'], + ignores: ['dist/**', 'node_modules/**', 'coverage/**'], languageOptions: { ecmaVersion: 2022, sourceType: 'commonjs', diff --git a/backend/routes/tasks.js b/backend/routes/tasks.js index 1703c95..5bf3128 100644 --- a/backend/routes/tasks.js +++ b/backend/routes/tasks.js @@ -118,6 +118,11 @@ async function filterTasksByParams(params, userId) { let orderClause = [['created_at', 'ASC']]; + // Special ordering for inbox - newest items first + if (params.type === 'inbox') { + orderClause = [['created_at', 'DESC']]; + } + // Apply ordering if (params.order_by) { const [orderColumn, orderDirection = 'asc'] = diff --git a/backend/tests/integration/inbox.test.js b/backend/tests/integration/inbox.test.js index c326390..3f00166 100644 --- a/backend/tests/integration/inbox.test.js +++ b/backend/tests/integration/inbox.test.js @@ -95,6 +95,50 @@ describe('Inbox Routes', () => { expect(response.body[0].status).toBe('added'); }); + it('should return inbox items ordered by created_at DESC (newest first)', async () => { + // Create additional items with slight delay to ensure different timestamps + const item1 = await InboxItem.create({ + content: 'First item (oldest)', + status: 'added', + source: 'test', + user_id: user.id, + }); + + // Small delay to ensure different timestamps + await new Promise((resolve) => setTimeout(resolve, 10)); + + const item2 = await InboxItem.create({ + content: 'Second item', + status: 'added', + source: 'test', + user_id: user.id, + }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + const item3 = await InboxItem.create({ + content: 'Third item (newest)', + status: 'added', + source: 'test', + user_id: user.id, + }); + + const response = await agent.get('/api/inbox'); + + expect(response.status).toBe(200); + expect(response.body.length).toBe(4); // Including the item from beforeEach + + // Check that items are ordered by newest first + expect(response.body[0].id).toBe(item3.id); + expect(response.body[1].id).toBe(item2.id); + expect(response.body[2].id).toBe(item1.id); + + // Verify the content matches expected order + expect(response.body[0].content).toBe('Third item (newest)'); + expect(response.body[1].content).toBe('Second item'); + expect(response.body[2].content).toBe('First item (oldest)'); + }); + it('should require authentication', async () => { const response = await request(app).get('/api/inbox'); diff --git a/frontend/components/Inbox/InboxItemDetail.tsx b/frontend/components/Inbox/InboxItemDetail.tsx index 636d6db..2f7b49a 100644 --- a/frontend/components/Inbox/InboxItemDetail.tsx +++ b/frontend/components/Inbox/InboxItemDetail.tsx @@ -1,4 +1,5 @@ import React, { useState } from 'react'; +import { Link } from 'react-router-dom'; import { InboxItem } from '../../entities/InboxItem'; import { useTranslation } from 'react-i18next'; import { @@ -400,7 +401,50 @@ const InboxItemDetail: React.FC = ({ {projectRefs.length > 0 && (
- {projectRefs.join(', ')} + + {projectRefs.map( + (projectRef, index) => { + // Find matching project + const matchingProject = + projects.find( + (project) => + project.name.toLowerCase() === + projectRef.toLowerCase() + ); + + if (matchingProject) { + return ( + + + {projectRef} + + {index < + projectRefs.length - + 1 && ', '} + + ); + } else { + return ( + + + {projectRef} + + {index < + projectRefs.length - + 1 && ', '} + + ); + } + } + )} +
)} @@ -413,7 +457,23 @@ const InboxItemDetail: React.FC = ({ {hashtags.length > 0 && (
- {hashtags.join(', ')} + + {hashtags.map((hashtag, index) => { + return ( + + + {hashtag} + + {index < + hashtags.length - 1 && + ', '} + + ); + })} +
)} diff --git a/frontend/components/Inbox/InboxModal.tsx b/frontend/components/Inbox/InboxModal.tsx index 0818a47..aa3e2f9 100644 --- a/frontend/components/Inbox/InboxModal.tsx +++ b/frontend/components/Inbox/InboxModal.tsx @@ -62,6 +62,7 @@ const InboxModal: React.FC = ({ left: 0, top: 0, }); + const [selectedSuggestionIndex, setSelectedSuggestionIndex] = useState(-1); // const [urlPreview, setUrlPreview] = useState(null); // Real-time text analysis state @@ -542,6 +543,7 @@ const InboxModal: React.FC = ({ // Hide project suggestions when showing tag suggestions setShowProjectSuggestions(false); setFilteredProjects([]); + setSelectedSuggestionIndex(-1); // Filter tags based on current query const filtered = tags @@ -561,6 +563,7 @@ const InboxModal: React.FC = ({ setFilteredTags(filtered); setShowTagSuggestions(true); + setSelectedSuggestionIndex(-1); } else if ( (newText.charAt(newCursorPosition - 1) === '+' || projectQuery) && projectQuery !== '' @@ -568,6 +571,7 @@ const InboxModal: React.FC = ({ // Hide tag suggestions when showing project suggestions setShowTagSuggestions(false); setFilteredTags([]); + setSelectedSuggestionIndex(-1); // Filter projects based on current query const filtered = projects @@ -587,11 +591,13 @@ const InboxModal: React.FC = ({ setFilteredProjects(filtered); setShowProjectSuggestions(true); + setSelectedSuggestionIndex(-1); } else { setShowTagSuggestions(false); setFilteredTags([]); setShowProjectSuggestions(false); setFilteredProjects([]); + setSelectedSuggestionIndex(-1); } }; @@ -783,6 +789,7 @@ const InboxModal: React.FC = ({ setInputText(newText); setShowTagSuggestions(false); setFilteredTags([]); + setSelectedSuggestionIndex(-1); // Focus back on input and set cursor position setTimeout(() => { @@ -850,6 +857,7 @@ const InboxModal: React.FC = ({ setInputText(newText); setShowProjectSuggestions(false); setFilteredProjects([]); + setSelectedSuggestionIndex(-1); // Focus back on input and set cursor position setTimeout(() => { @@ -1231,9 +1239,11 @@ const InboxModal: React.FC = ({ if (showTagSuggestions) { setShowTagSuggestions(false); setFilteredTags([]); + setSelectedSuggestionIndex(-1); } else if (showProjectSuggestions) { setShowProjectSuggestions(false); setFilteredProjects([]); + setSelectedSuggestionIndex(-1); } else { handleClose(); } @@ -1253,9 +1263,11 @@ const InboxModal: React.FC = ({ if (showTagSuggestions) { setShowTagSuggestions(false); setFilteredTags([]); + setSelectedSuggestionIndex(-1); } else if (showProjectSuggestions) { setShowProjectSuggestions(false); setFilteredProjects([]); + setSelectedSuggestionIndex(-1); } else { handleClose(); } @@ -1357,12 +1369,130 @@ const InboxModal: React.FC = ({ className="w-full text-xl font-semibold dark:bg-gray-800 text-black dark:text-white focus:outline-none shadow-sm py-2" placeholder={t('inbox.captureThought')} onKeyDown={(e) => { + // Handle dropdown navigation + if ( + showTagSuggestions && + filteredTags.length > 0 + ) { + if (e.key === 'ArrowDown') { + e.preventDefault(); + setSelectedSuggestionIndex( + (prev) => + prev < + filteredTags.length - 1 + ? prev + 1 + : 0 + ); + return; + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setSelectedSuggestionIndex( + (prev) => + prev > 0 + ? prev - 1 + : filteredTags.length - + 1 + ); + return; + } else if (e.key === 'Tab') { + e.preventDefault(); + const selectedTag = + selectedSuggestionIndex >= 0 + ? filteredTags[ + selectedSuggestionIndex + ] + : filteredTags[0]; + handleTagSelect( + selectedTag.name + ); + return; + } else if ( + e.key === 'Enter' && + selectedSuggestionIndex >= 0 + ) { + e.preventDefault(); + handleTagSelect( + filteredTags[ + selectedSuggestionIndex + ].name + ); + return; + } else if (e.key === 'Escape') { + e.preventDefault(); + setShowTagSuggestions(false); + setFilteredTags([]); + setSelectedSuggestionIndex(-1); + return; + } + } + + // Handle project dropdown navigation + if ( + showProjectSuggestions && + filteredProjects.length > 0 + ) { + if (e.key === 'ArrowDown') { + e.preventDefault(); + setSelectedSuggestionIndex( + (prev) => + prev < + filteredProjects.length - + 1 + ? prev + 1 + : 0 + ); + return; + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setSelectedSuggestionIndex( + (prev) => + prev > 0 + ? prev - 1 + : filteredProjects.length - + 1 + ); + return; + } else if (e.key === 'Tab') { + e.preventDefault(); + const selectedProject = + selectedSuggestionIndex >= 0 + ? filteredProjects[ + selectedSuggestionIndex + ] + : filteredProjects[0]; + handleProjectSelect( + selectedProject.name + ); + return; + } else if ( + e.key === 'Enter' && + selectedSuggestionIndex >= 0 + ) { + e.preventDefault(); + handleProjectSelect( + filteredProjects[ + selectedSuggestionIndex + ].name + ); + return; + } else if (e.key === 'Escape') { + e.preventDefault(); + setShowProjectSuggestions( + false + ); + setFilteredProjects([]); + setSelectedSuggestionIndex(-1); + return; + } + } + + // Handle form submission if ( e.key === 'Enter' && !e.shiftKey && !isSaving ) { - // If suggestions are showing and there are filtered options, let the user navigate + // If suggestions are showing and there are filtered options, don't submit if ( (showTagSuggestions && filteredTags.length > 0) || @@ -1372,7 +1502,6 @@ const InboxModal: React.FC = ({ // Don't submit, let the user select from suggestions return; } - // Otherwise, submit the form e.preventDefault(); handleSubmit(); @@ -1549,7 +1678,12 @@ const InboxModal: React.FC = ({ tag.name ) } - className="w-full text-left px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 text-sm text-gray-900 dark:text-gray-100 first:rounded-t-md last:rounded-b-md" + className={`w-full text-left px-3 py-2 text-sm text-gray-900 dark:text-gray-100 first:rounded-t-md last:rounded-b-md ${ + selectedSuggestionIndex === + index + ? 'bg-blue-100 dark:bg-blue-800' + : 'hover:bg-gray-100 dark:hover:bg-gray-600' + }`} > #{tag.name} @@ -1580,7 +1714,12 @@ const InboxModal: React.FC = ({ project.name ) } - className="w-full text-left px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 text-sm text-gray-900 dark:text-gray-100 first:rounded-t-md last:rounded-b-md" + className={`w-full text-left px-3 py-2 text-sm text-gray-900 dark:text-gray-100 first:rounded-t-md last:rounded-b-md ${ + selectedSuggestionIndex === + index + ? 'bg-blue-100 dark:bg-blue-800' + : 'hover:bg-gray-100 dark:hover:bg-gray-600' + }`} > +{project.name} diff --git a/frontend/components/Shared/ToastContext.tsx b/frontend/components/Shared/ToastContext.tsx index c3471de..510474a 100644 --- a/frontend/components/Shared/ToastContext.tsx +++ b/frontend/components/Shared/ToastContext.tsx @@ -81,7 +81,7 @@ const ToastComponent: React.FC<{ return (
diff --git a/frontend/components/Task/TaskHeader.tsx b/frontend/components/Task/TaskHeader.tsx index 7913718..84f6859 100644 --- a/frontend/components/Task/TaskHeader.tsx +++ b/frontend/components/Task/TaskHeader.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { Link } from 'react-router-dom'; import { CalendarDaysIcon, CalendarIcon, @@ -140,7 +141,12 @@ const TaskHeader: React.FC = ({ {project && !hideProjectName && (
- {project.name} + + {project.name} +
)} {project && @@ -153,9 +159,19 @@ const TaskHeader: React.FC = ({
- {task.tags - .map((tag) => tag.name) - .join(', ')} + {task.tags.map((tag, index) => ( + + + {tag.name} + + {index < + task.tags!.length - 1 && + ', '} + + ))}
)} @@ -289,16 +305,31 @@ const TaskHeader: React.FC = ({ {project && !hideProjectName && (
- {project.name} + + {project.name} +
)} {task.tags && task.tags.length > 0 && (
- {task.tags - .map((tag) => tag.name) - .join(', ')} + {task.tags.map((tag, index) => ( + + + {tag.name} + + {index < + task.tags!.length - 1 && + ', '} + + ))}
)} diff --git a/frontend/components/Task/TaskItem.tsx b/frontend/components/Task/TaskItem.tsx index e5ea1ea..2e219fe 100644 --- a/frontend/components/Task/TaskItem.tsx +++ b/frontend/components/Task/TaskItem.tsx @@ -89,7 +89,7 @@ const TaskItem: React.FC = ({ return (
= ({ onToggleToday, }) => { return ( -
+
{tasks.length > 0 ? ( tasks.map((task) => ( - + className="task-item-wrapper transition-all duration-200 ease-in-out" + > + +
)) ) : (

diff --git a/frontend/components/Task/TodayPlan.tsx b/frontend/components/Task/TodayPlan.tsx index 3ca162c..66cc6ad 100644 --- a/frontend/components/Task/TodayPlan.tsx +++ b/frontend/components/Task/TodayPlan.tsx @@ -25,7 +25,33 @@ const TodayPlan: React.FC = ({ // Handle undefined or null todayPlanTasks const safeTodayPlanTasks = todayPlanTasks || []; - if (safeTodayPlanTasks.length === 0) { + // Sort tasks to move in-progress tasks to the top + const sortedTasks = React.useMemo(() => { + if (safeTodayPlanTasks.length === 0) return []; + + return [...safeTodayPlanTasks].sort((a, b) => { + const aInProgress = a.status === 'in_progress' || a.status === 1; + const bInProgress = b.status === 'in_progress' || b.status === 1; + + // If both are in progress, sort by updated_at (recently updated to bottom) + if (aInProgress && bInProgress) { + // Recently updated tasks should be at the bottom of in-progress group + const aUpdated = new Date(a.updated_at || a.created_at || 0); + const bUpdated = new Date(b.updated_at || b.created_at || 0); + return aUpdated.getTime() - bUpdated.getTime(); // Older tasks first, newer to bottom + } + + // If both are not in progress, maintain original order + if (!aInProgress && !bInProgress) { + return 0; + } + + // Put in-progress tasks first + return aInProgress ? -1 : 1; + }); + }, [safeTodayPlanTasks]); + + if (sortedTasks.length === 0) { return ( <>

@@ -52,7 +78,7 @@ const TodayPlan: React.FC = ({ return ( <> ((set) => ({ set((state) => ({ inboxStore: { ...state.inboxStore, - inboxItems: [...state.inboxStore.inboxItems, inboxItem], + inboxItems: [inboxItem, ...state.inboxStore.inboxItems], }, })), updateInboxItem: (inboxItem) => diff --git a/package.json b/package.json index 10bcca7..2ea8210 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tududi", - "version": "v0.72.1", + "version": "v0.72.2", "description": "Self-hosted task management with hierarchical organization (Areas > Projects > Tasks), multi-language support, and Telegram integration. Built with React/TypeScript frontend and functional programming Express.js backend.", "main": "backend/app.js", "directories": {