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
This commit is contained in:
Chris 2025-07-17 17:43:56 +03:00 committed by GitHub
parent b37457b574
commit 10d96397c3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 339 additions and 29 deletions

View file

@ -1,6 +1,7 @@
module.exports = [ module.exports = [
{ {
files: ['**/*.js'], files: ['**/*.js'],
ignores: ['dist/**', 'node_modules/**', 'coverage/**'],
languageOptions: { languageOptions: {
ecmaVersion: 2022, ecmaVersion: 2022,
sourceType: 'commonjs', sourceType: 'commonjs',

View file

@ -118,6 +118,11 @@ async function filterTasksByParams(params, userId) {
let orderClause = [['created_at', 'ASC']]; let orderClause = [['created_at', 'ASC']];
// Special ordering for inbox - newest items first
if (params.type === 'inbox') {
orderClause = [['created_at', 'DESC']];
}
// Apply ordering // Apply ordering
if (params.order_by) { if (params.order_by) {
const [orderColumn, orderDirection = 'asc'] = const [orderColumn, orderDirection = 'asc'] =

View file

@ -95,6 +95,50 @@ describe('Inbox Routes', () => {
expect(response.body[0].status).toBe('added'); 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 () => { it('should require authentication', async () => {
const response = await request(app).get('/api/inbox'); const response = await request(app).get('/api/inbox');

View file

@ -1,4 +1,5 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Link } from 'react-router-dom';
import { InboxItem } from '../../entities/InboxItem'; import { InboxItem } from '../../entities/InboxItem';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
@ -400,7 +401,50 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
{projectRefs.length > 0 && ( {projectRefs.length > 0 && (
<div className="flex items-center"> <div className="flex items-center">
<FolderIcon className="h-3 w-3 mr-1" /> <FolderIcon className="h-3 w-3 mr-1" />
<span>{projectRefs.join(', ')}</span> <span>
{projectRefs.map(
(projectRef, index) => {
// Find matching project
const matchingProject =
projects.find(
(project) =>
project.name.toLowerCase() ===
projectRef.toLowerCase()
);
if (matchingProject) {
return (
<React.Fragment
key={projectRef}
>
<Link
to={`/project/${matchingProject.id}`}
className="text-gray-500 dark:text-gray-400 hover:underline transition-colors"
>
{projectRef}
</Link>
{index <
projectRefs.length -
1 && ', '}
</React.Fragment>
);
} else {
return (
<React.Fragment
key={projectRef}
>
<span>
{projectRef}
</span>
{index <
projectRefs.length -
1 && ', '}
</React.Fragment>
);
}
}
)}
</span>
</div> </div>
)} )}
@ -413,7 +457,23 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
{hashtags.length > 0 && ( {hashtags.length > 0 && (
<div className="flex items-center"> <div className="flex items-center">
<TagIcon className="h-3 w-3 mr-1" /> <TagIcon className="h-3 w-3 mr-1" />
<span>{hashtags.join(', ')}</span> <span>
{hashtags.map((hashtag, index) => {
return (
<React.Fragment key={hashtag}>
<Link
to={`/tag/${encodeURIComponent(hashtag)}`}
className="text-gray-500 dark:text-gray-400 hover:underline transition-colors"
>
{hashtag}
</Link>
{index <
hashtags.length - 1 &&
', '}
</React.Fragment>
);
})}
</span>
</div> </div>
)} )}
</div> </div>

View file

@ -62,6 +62,7 @@ const InboxModal: React.FC<InboxModalProps> = ({
left: 0, left: 0,
top: 0, top: 0,
}); });
const [selectedSuggestionIndex, setSelectedSuggestionIndex] = useState(-1);
// const [urlPreview, setUrlPreview] = useState<UrlTitleResult | null>(null); // const [urlPreview, setUrlPreview] = useState<UrlTitleResult | null>(null);
// Real-time text analysis state // Real-time text analysis state
@ -542,6 +543,7 @@ const InboxModal: React.FC<InboxModalProps> = ({
// Hide project suggestions when showing tag suggestions // Hide project suggestions when showing tag suggestions
setShowProjectSuggestions(false); setShowProjectSuggestions(false);
setFilteredProjects([]); setFilteredProjects([]);
setSelectedSuggestionIndex(-1);
// Filter tags based on current query // Filter tags based on current query
const filtered = tags const filtered = tags
@ -561,6 +563,7 @@ const InboxModal: React.FC<InboxModalProps> = ({
setFilteredTags(filtered); setFilteredTags(filtered);
setShowTagSuggestions(true); setShowTagSuggestions(true);
setSelectedSuggestionIndex(-1);
} else if ( } else if (
(newText.charAt(newCursorPosition - 1) === '+' || projectQuery) && (newText.charAt(newCursorPosition - 1) === '+' || projectQuery) &&
projectQuery !== '' projectQuery !== ''
@ -568,6 +571,7 @@ const InboxModal: React.FC<InboxModalProps> = ({
// Hide tag suggestions when showing project suggestions // Hide tag suggestions when showing project suggestions
setShowTagSuggestions(false); setShowTagSuggestions(false);
setFilteredTags([]); setFilteredTags([]);
setSelectedSuggestionIndex(-1);
// Filter projects based on current query // Filter projects based on current query
const filtered = projects const filtered = projects
@ -587,11 +591,13 @@ const InboxModal: React.FC<InboxModalProps> = ({
setFilteredProjects(filtered); setFilteredProjects(filtered);
setShowProjectSuggestions(true); setShowProjectSuggestions(true);
setSelectedSuggestionIndex(-1);
} else { } else {
setShowTagSuggestions(false); setShowTagSuggestions(false);
setFilteredTags([]); setFilteredTags([]);
setShowProjectSuggestions(false); setShowProjectSuggestions(false);
setFilteredProjects([]); setFilteredProjects([]);
setSelectedSuggestionIndex(-1);
} }
}; };
@ -783,6 +789,7 @@ const InboxModal: React.FC<InboxModalProps> = ({
setInputText(newText); setInputText(newText);
setShowTagSuggestions(false); setShowTagSuggestions(false);
setFilteredTags([]); setFilteredTags([]);
setSelectedSuggestionIndex(-1);
// Focus back on input and set cursor position // Focus back on input and set cursor position
setTimeout(() => { setTimeout(() => {
@ -850,6 +857,7 @@ const InboxModal: React.FC<InboxModalProps> = ({
setInputText(newText); setInputText(newText);
setShowProjectSuggestions(false); setShowProjectSuggestions(false);
setFilteredProjects([]); setFilteredProjects([]);
setSelectedSuggestionIndex(-1);
// Focus back on input and set cursor position // Focus back on input and set cursor position
setTimeout(() => { setTimeout(() => {
@ -1231,9 +1239,11 @@ const InboxModal: React.FC<InboxModalProps> = ({
if (showTagSuggestions) { if (showTagSuggestions) {
setShowTagSuggestions(false); setShowTagSuggestions(false);
setFilteredTags([]); setFilteredTags([]);
setSelectedSuggestionIndex(-1);
} else if (showProjectSuggestions) { } else if (showProjectSuggestions) {
setShowProjectSuggestions(false); setShowProjectSuggestions(false);
setFilteredProjects([]); setFilteredProjects([]);
setSelectedSuggestionIndex(-1);
} else { } else {
handleClose(); handleClose();
} }
@ -1253,9 +1263,11 @@ const InboxModal: React.FC<InboxModalProps> = ({
if (showTagSuggestions) { if (showTagSuggestions) {
setShowTagSuggestions(false); setShowTagSuggestions(false);
setFilteredTags([]); setFilteredTags([]);
setSelectedSuggestionIndex(-1);
} else if (showProjectSuggestions) { } else if (showProjectSuggestions) {
setShowProjectSuggestions(false); setShowProjectSuggestions(false);
setFilteredProjects([]); setFilteredProjects([]);
setSelectedSuggestionIndex(-1);
} else { } else {
handleClose(); handleClose();
} }
@ -1357,12 +1369,130 @@ const InboxModal: React.FC<InboxModalProps> = ({
className="w-full text-xl font-semibold dark:bg-gray-800 text-black dark:text-white focus:outline-none shadow-sm py-2" 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')} placeholder={t('inbox.captureThought')}
onKeyDown={(e) => { 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 ( if (
e.key === 'Enter' && e.key === 'Enter' &&
!e.shiftKey && !e.shiftKey &&
!isSaving !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 ( if (
(showTagSuggestions && (showTagSuggestions &&
filteredTags.length > 0) || filteredTags.length > 0) ||
@ -1372,7 +1502,6 @@ const InboxModal: React.FC<InboxModalProps> = ({
// Don't submit, let the user select from suggestions // Don't submit, let the user select from suggestions
return; return;
} }
// Otherwise, submit the form // Otherwise, submit the form
e.preventDefault(); e.preventDefault();
handleSubmit(); handleSubmit();
@ -1549,7 +1678,12 @@ const InboxModal: React.FC<InboxModalProps> = ({
tag.name 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} #{tag.name}
</button> </button>
@ -1580,7 +1714,12 @@ const InboxModal: React.FC<InboxModalProps> = ({
project.name 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} +{project.name}
</button> </button>

View file

@ -81,7 +81,7 @@ const ToastComponent: React.FC<{
return ( return (
<div <div
className={`px-4 py-3 rounded-lg shadow-md text-white transition-all duration-300 ${ className={`px-4 py-3 rounded-lg shadow-md text-white transition-all duration-300 ${
type === 'success' ? 'bg-green-500' : 'bg-red-500' type === 'success' ? 'bg-green-600' : 'bg-red-500'
}`} }`}
style={style} style={style}
> >

View file

@ -1,4 +1,5 @@
import React from 'react'; import React from 'react';
import { Link } from 'react-router-dom';
import { import {
CalendarDaysIcon, CalendarDaysIcon,
CalendarIcon, CalendarIcon,
@ -140,7 +141,12 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
{project && !hideProjectName && ( {project && !hideProjectName && (
<div className="flex items-center"> <div className="flex items-center">
<FolderIcon className="h-3 w-3 mr-1" /> <FolderIcon className="h-3 w-3 mr-1" />
<span>{project.name}</span> <Link
to={`/project/${project.id}`}
className="text-gray-500 dark:text-gray-400 hover:underline transition-colors"
>
{project.name}
</Link>
</div> </div>
)} )}
{project && {project &&
@ -153,9 +159,19 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
<div className="flex items-center"> <div className="flex items-center">
<TagIcon className="h-3 w-3 mr-1" /> <TagIcon className="h-3 w-3 mr-1" />
<span> <span>
{task.tags {task.tags.map((tag, index) => (
.map((tag) => tag.name) <React.Fragment key={tag.name}>
.join(', ')} <Link
to={`/tag/${encodeURIComponent(tag.name)}`}
className="text-gray-500 dark:text-gray-400 hover:underline transition-colors"
>
{tag.name}
</Link>
{index <
task.tags!.length - 1 &&
', '}
</React.Fragment>
))}
</span> </span>
</div> </div>
)} )}
@ -289,16 +305,31 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
{project && !hideProjectName && ( {project && !hideProjectName && (
<div className="flex items-center"> <div className="flex items-center">
<FolderIcon className="h-3 w-3 mr-1" /> <FolderIcon className="h-3 w-3 mr-1" />
<span>{project.name}</span> <Link
to={`/project/${project.id}`}
className="text-gray-500 dark:text-gray-400 hover:underline transition-colors"
>
{project.name}
</Link>
</div> </div>
)} )}
{task.tags && task.tags.length > 0 && ( {task.tags && task.tags.length > 0 && (
<div className="flex items-center"> <div className="flex items-center">
<TagIcon className="h-3 w-3 mr-1" /> <TagIcon className="h-3 w-3 mr-1" />
<span> <span>
{task.tags {task.tags.map((tag, index) => (
.map((tag) => tag.name) <React.Fragment key={tag.name}>
.join(', ')} <Link
to={`/tag/${encodeURIComponent(tag.name)}`}
className="text-gray-500 dark:text-gray-400 hover:underline transition-colors"
>
{tag.name}
</Link>
{index <
task.tags!.length - 1 &&
', '}
</React.Fragment>
))}
</span> </span>
</div> </div>
)} )}

View file

@ -89,7 +89,7 @@ const TaskItem: React.FC<TaskItemProps> = ({
return ( return (
<div <div
className={`rounded-lg shadow-sm bg-white dark:bg-gray-900 mt-1 ${ className={`rounded-lg shadow-sm bg-white dark:bg-gray-900 mt-1 transition-all duration-200 ease-in-out ${
isInProgress isInProgress
? 'border-2 border-green-400/60 dark:border-green-500/60' ? 'border-2 border-green-400/60 dark:border-green-500/60'
: 'border-2 border-gray-50 dark:border-gray-800' : 'border-2 border-gray-50 dark:border-gray-800'

View file

@ -22,11 +22,14 @@ const TaskList: React.FC<TaskListProps> = ({
onToggleToday, onToggleToday,
}) => { }) => {
return ( return (
<div> <div className="task-list-container">
{tasks.length > 0 ? ( {tasks.length > 0 ? (
tasks.map((task) => ( tasks.map((task) => (
<TaskItem <div
key={task.id} key={task.id}
className="task-item-wrapper transition-all duration-200 ease-in-out"
>
<TaskItem
task={task} task={task}
onTaskUpdate={onTaskUpdate} onTaskUpdate={onTaskUpdate}
onTaskDelete={onTaskDelete} onTaskDelete={onTaskDelete}
@ -34,6 +37,7 @@ const TaskList: React.FC<TaskListProps> = ({
hideProjectName={hideProjectName} hideProjectName={hideProjectName}
onToggleToday={onToggleToday} onToggleToday={onToggleToday}
/> />
</div>
)) ))
) : ( ) : (
<p className="text-gray-500 dark:text-gray-400 text-center mt-4"> <p className="text-gray-500 dark:text-gray-400 text-center mt-4">

View file

@ -25,7 +25,33 @@ const TodayPlan: React.FC<TodayPlanProps> = ({
// Handle undefined or null todayPlanTasks // Handle undefined or null todayPlanTasks
const safeTodayPlanTasks = 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 ( return (
<> <>
<div className="flex justify-center items-center mt-4"> <div className="flex justify-center items-center mt-4">
@ -52,7 +78,7 @@ const TodayPlan: React.FC<TodayPlanProps> = ({
return ( return (
<> <>
<TaskList <TaskList
tasks={safeTodayPlanTasks} tasks={sortedTasks}
onTaskUpdate={onTaskUpdate} onTaskUpdate={onTaskUpdate}
onTaskDelete={onTaskDelete} onTaskDelete={onTaskDelete}
projects={projects} projects={projects}

View file

@ -178,7 +178,7 @@ export const useStore = create<StoreState>((set) => ({
set((state) => ({ set((state) => ({
inboxStore: { inboxStore: {
...state.inboxStore, ...state.inboxStore,
inboxItems: [...state.inboxStore.inboxItems, inboxItem], inboxItems: [inboxItem, ...state.inboxStore.inboxItems],
}, },
})), })),
updateInboxItem: (inboxItem) => updateInboxItem: (inboxItem) =>

View file

@ -1,6 +1,6 @@
{ {
"name": "tududi", "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.", "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", "main": "backend/app.js",
"directories": { "directories": {