Skip to content

Commit 2cbb3a7

Browse files
authored
feat: Add enhanced pin/unpin task feature with improved UX (#384)
* feat: Add enhanced pin/unpin task feature with improved UX - Add clickable pin icon directly in task row for one-click pin/unpin - Pinned tasks displayed with amber-colored filled pin icon - Unpinned tasks show subtle pin icon on hover - Add Pin/Unpin toggle button in task dialog footer as secondary option - Implement sorting to show pinned tasks at top of list (above overdue tasks) - Pinned tasks work within all filtered views (projects, tags, status, search) - Store pinned task UUIDs in localStorage per user (using hashed keys) - Add responsive mobile layout with 2-row design: * Row 1: ID, Description, Project * Row 2: Tags, Status, Pin button - Increase task row height on mobile for better touch interaction - No backend changes required (frontend-only feature) Addresses PR feedback: - Quick pin/unpin access without opening dialog - Mobile-optimized layout prevents horizontal scrolling - Pin icon clickable with stopPropagation to avoid opening dialog * TC Passed * feat: add task pinning functionality with localStorage persistence - One-click pin/unpin toggle in task rows - Pinned tasks stay at top of list across filters - Comprehensive test coverage (39 new tests) - Privacy-preserving storage with hashed keys * Tests Restored * Delete .github/.cursorrules * test: add pin functionality tests for Tasks component * Typo Fixed
1 parent 544534e commit 2cbb3a7

7 files changed

Lines changed: 733 additions & 62 deletions

File tree

frontend/src/components/HomeComponents/Tasks/TaskDialog.tsx

Lines changed: 97 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ import {
2828
CopyIcon,
2929
Folder,
3030
PencilIcon,
31+
Pin,
32+
PinOff,
3133
Tag,
3234
Trash2Icon,
3335
XIcon,
@@ -70,6 +72,8 @@ export const TaskDialog = ({
7072
onMarkDeleted,
7173
isOverdue,
7274
isUnsynced,
75+
isPinned,
76+
onTogglePin,
7377
}: EditTaskDialogProps) => {
7478
const editButtonRef = useRef<
7579
Partial<Record<FieldKey, HTMLButtonElement | null>>
@@ -199,7 +203,10 @@ export const TaskDialog = ({
199203
onSelectTask(task, index);
200204
}}
201205
>
202-
<TableCell className="py-2" onClick={(e) => e.stopPropagation()}>
206+
<TableCell
207+
className="py-3 md:py-2 align-top"
208+
onClick={(e) => e.stopPropagation()}
209+
>
203210
<input
204211
type="checkbox"
205212
checked={selectedTaskUUIDs.includes(task.uuid)}
@@ -211,59 +218,77 @@ export const TaskDialog = ({
211218
/>
212219
</TableCell>
213220

214-
{/* Display task details */}
215-
<TableCell className="py-2">
216-
<span
217-
className={`px-3 py-1 rounded-md font-semibold ${
218-
task.status === 'pending' && isOverdue(task.due)
219-
? 'bg-red-600/80 text-white'
220-
: 'dark:text-white text-black'
221-
}`}
222-
>
223-
{task.id}
224-
</span>
225-
</TableCell>
226-
<TableCell className="flex items-center space-x-2 py-2">
227-
{task.priority === 'H' && (
228-
<div className="flex items-center justify-center w-3 h-3 bg-red-500 rounded-full border-0 min-w-3"></div>
229-
)}
230-
{task.priority === 'M' && (
231-
<div className="flex items-center justify-center w-3 h-3 bg-yellow-500 rounded-full border-0 min-w-3"></div>
232-
)}
233-
{task.priority != 'H' && task.priority != 'M' && (
234-
<div className="flex items-center justify-center w-3 h-3 bg-green-500 rounded-full border-0 min-w-3"></div>
235-
)}
236-
<span className="text-s text-foreground">{task.description}</span>
237-
{task.project != '' && (
238-
<Badge variant={'secondary'}>
239-
<Folder className="pr-2" />
240-
{task.project === '' ? '' : task.project}
241-
</Badge>
242-
)}
243-
</TableCell>
244-
<TableCell className="py-2">
245-
<Badge
246-
className={
247-
task.status === 'pending' && isOverdue(task.due)
248-
? 'bg-orange-500 text-white'
249-
: ''
250-
}
251-
variant={
252-
task.status === 'deleted'
253-
? 'destructive'
254-
: task.status === 'completed'
255-
? 'default'
256-
: 'secondary'
257-
}
258-
>
259-
{task.status === 'pending' && isOverdue(task.due)
260-
? 'O'
261-
: task.status === 'completed'
262-
? 'C'
263-
: task.status === 'deleted'
264-
? 'D'
265-
: 'P'}
266-
</Badge>
221+
{/* Desktop: single row layout, Mobile: 2-row layout */}
222+
<TableCell className="py-2" colSpan={3}>
223+
<div className="flex items-center gap-4">
224+
<span
225+
className={`px-3 py-1 rounded-md font-semibold ${
226+
task.status === 'pending' && isOverdue(task.due)
227+
? 'bg-red-600/80 text-white'
228+
: 'dark:text-white text-black'
229+
}`}
230+
>
231+
{task.id}
232+
</span>
233+
<div className="flex items-center space-x-2 flex-1 min-w-0">
234+
{task.priority === 'H' && (
235+
<div className="flex items-center justify-center w-3 h-3 bg-red-500 rounded-full border-0 min-w-3"></div>
236+
)}
237+
{task.priority === 'M' && (
238+
<div className="flex items-center justify-center w-3 h-3 bg-yellow-500 rounded-full border-0 min-w-3"></div>
239+
)}
240+
{task.priority != 'H' && task.priority != 'M' && (
241+
<div className="flex items-center justify-center w-3 h-3 bg-green-500 rounded-full border-0 min-w-3"></div>
242+
)}
243+
<span className="text-s text-foreground">
244+
{task.description}
245+
</span>
246+
{task.project != '' && (
247+
<Badge variant={'secondary'}>
248+
<Folder className="pr-2" />
249+
{task.project === '' ? '' : task.project}
250+
</Badge>
251+
)}
252+
</div>
253+
<div className="flex items-center gap-2">
254+
<Badge
255+
className={
256+
task.status === 'pending' && isOverdue(task.due)
257+
? 'bg-orange-500 text-white'
258+
: ''
259+
}
260+
variant={
261+
task.status === 'deleted'
262+
? 'destructive'
263+
: task.status === 'completed'
264+
? 'default'
265+
: 'secondary'
266+
}
267+
>
268+
{task.status === 'pending' && isOverdue(task.due)
269+
? 'O'
270+
: task.status === 'completed'
271+
? 'C'
272+
: task.status === 'deleted'
273+
? 'D'
274+
: 'P'}
275+
</Badge>
276+
<button
277+
onClick={(e) => {
278+
e.stopPropagation();
279+
onTogglePin(task.uuid);
280+
}}
281+
className="p-1 hover:bg-muted rounded transition-colors"
282+
aria-label={isPinned ? 'Unpin task' : 'Pin task'}
283+
>
284+
{isPinned ? (
285+
<Pin className="h-4 w-4 text-amber-500 fill-amber-500" />
286+
) : (
287+
<Pin className="h-4 w-4 text-muted-foreground" />
288+
)}
289+
</button>
290+
</div>
291+
</div>
267292
</TableCell>
268293
</TableRow>
269294
</DialogTrigger>
@@ -1688,6 +1713,24 @@ export const TaskDialog = ({
16881713
</div>
16891714

16901715
<DialogFooter className="flex flex-row justify-end pt-4">
1716+
<Button
1717+
variant="outline"
1718+
onClick={() => onTogglePin(task.uuid)}
1719+
className="mr-auto"
1720+
aria-label={isPinned ? 'Unpin task' : 'Pin task'}
1721+
>
1722+
{isPinned ? (
1723+
<>
1724+
<PinOff className="h-4 w-4 mr-1" />
1725+
Unpin
1726+
</>
1727+
) : (
1728+
<>
1729+
<Pin className="h-4 w-4 mr-1" />
1730+
Pin
1731+
</>
1732+
)}
1733+
</Button>
16911734
{task.status == 'pending' ? (
16921735
<Dialog>
16931736
<DialogTrigger asChild className="mr-5">

frontend/src/components/HomeComponents/Tasks/Tasks.tsx

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ import {
3737
getTimeSinceLastSync,
3838
hashKey,
3939
isOverdue,
40+
getPinnedTasks,
41+
togglePinnedTask,
4042
} from './tasks-utils';
4143
import Pagination from './Pagination';
4244
import { url } from '@/components/utils/URLs';
@@ -103,6 +105,7 @@ export const Tasks = (
103105
const [searchTerm, setSearchTerm] = useState('');
104106
const [debouncedTerm, setDebouncedTerm] = useState('');
105107
const [lastSyncTime, setLastSyncTime] = useState<number | null>(null);
108+
const [pinnedTasks, setPinnedTasks] = useState<Set<string>>(new Set());
106109
const [selectedTaskUUIDs, setSelectedTaskUUIDs] = useState<string[]>([]);
107110
const [unsyncedTaskUuids, setUnsyncedTaskUuids] = useState<Set<string>>(
108111
new Set()
@@ -201,6 +204,11 @@ export const Tasks = (
201204
}
202205
}, [props.email]);
203206

207+
// Load pinned tasks from localStorage
208+
useEffect(() => {
209+
setPinnedTasks(getPinnedTasks(props.email));
210+
}, [props.email]);
211+
204212
useEffect(() => {
205213
const interval = setInterval(() => {
206214
setLastSyncTime((prevTime) => prevTime);
@@ -479,6 +487,12 @@ export const Tasks = (
479487
);
480488
};
481489

490+
const handleTogglePin = (taskUuid: string) => {
491+
togglePinnedTask(props.email, taskUuid);
492+
// Update the local state to trigger re-render
493+
setPinnedTasks(getPinnedTasks(props.email));
494+
};
495+
482496
const handleSelectTask = (task: Task, index: number) => {
483497
setSelectedTask(task);
484498
setSelectedIndex(index);
@@ -739,11 +753,19 @@ export const Tasks = (
739753
}
740754
};
741755

742-
const sortWithOverdueOnTop = (tasks: Task[]) => {
756+
const sortWithPinnedAndOverdueOnTop = (tasks: Task[]) => {
743757
return [...tasks].sort((a, b) => {
758+
const aPinned = pinnedTasks.has(a.uuid);
759+
const bPinned = pinnedTasks.has(b.uuid);
760+
761+
// Pinned tasks always on top
762+
if (aPinned && !bPinned) return -1;
763+
if (!aPinned && bPinned) return 1;
764+
744765
const aOverdue = a.status === 'pending' && isOverdue(a.due);
745766
const bOverdue = b.status === 'pending' && isOverdue(b.due);
746767

768+
// Overdue tasks next (after pinned)
747769
if (aOverdue && !bOverdue) return -1;
748770
if (!aOverdue && bOverdue) return 1;
749771

@@ -795,9 +817,16 @@ export const Tasks = (
795817
filteredTasks = results.map((r) => r.item);
796818
}
797819

798-
filteredTasks = sortWithOverdueOnTop(filteredTasks);
820+
filteredTasks = sortWithPinnedAndOverdueOnTop(filteredTasks);
799821
setTempTasks(filteredTasks);
800-
}, [selectedProjects, selectedTags, selectedStatuses, tasks, debouncedTerm]);
822+
}, [
823+
selectedProjects,
824+
selectedTags,
825+
selectedStatuses,
826+
tasks,
827+
debouncedTerm,
828+
pinnedTasks,
829+
]);
801830

802831
const handleSaveTags = (task: Task, tags: string[]) => {
803832
const currentTags = tags || [];
@@ -1218,6 +1247,8 @@ export const Tasks = (
12181247
onMarkDeleted={handleMarkDelete}
12191248
isOverdue={isOverdue}
12201249
isUnsynced={unsyncedTaskUuids.has(task.uuid)}
1250+
isPinned={pinnedTasks.has(task.uuid)}
1251+
onTogglePin={handleTogglePin}
12211252
/>
12221253
))
12231254
)}

0 commit comments

Comments
 (0)