From 87e4df47ff8148b388574d961521c5668b12562e Mon Sep 17 00:00:00 2001 From: ANIR1604 Date: Fri, 9 Jan 2026 02:55:37 +0530 Subject: [PATCH 1/7] 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 --- .../HomeComponents/Tasks/TaskDialog.tsx | 241 ++++++++++++++---- .../components/HomeComponents/Tasks/Tasks.tsx | 37 ++- .../HomeComponents/Tasks/tasks-utils.ts | 50 ++++ frontend/src/components/utils/types.ts | 2 + 4 files changed, 273 insertions(+), 57 deletions(-) diff --git a/frontend/src/components/HomeComponents/Tasks/TaskDialog.tsx b/frontend/src/components/HomeComponents/Tasks/TaskDialog.tsx index d2e5770e..1e7c8b10 100644 --- a/frontend/src/components/HomeComponents/Tasks/TaskDialog.tsx +++ b/frontend/src/components/HomeComponents/Tasks/TaskDialog.tsx @@ -28,6 +28,8 @@ import { CopyIcon, Folder, PencilIcon, + Pin, + PinOff, Tag, Trash2Icon, XIcon, @@ -66,6 +68,8 @@ export const TaskDialog = ({ onMarkDeleted, isOverdue, isUnsynced, + isPinned, + onTogglePin, }: EditTaskDialogProps) => { const handleDialogOpenChange = (open: boolean) => { if (open) { @@ -107,7 +111,10 @@ export const TaskDialog = ({ onSelectTask(task, index); }} > - e.stopPropagation()}> + e.stopPropagation()} + > - {/* Display task details */} - - - {task.id} - - - - {task.priority === 'H' && ( -
- )} - {task.priority === 'M' && ( -
- )} - {task.priority != 'H' && task.priority != 'M' && ( -
- )} - {task.description} - {task.project != '' && ( - - - {task.project === '' ? '' : task.project} - - )} -
- - - {task.status === 'pending' && isOverdue(task.due) - ? 'O' - : task.status === 'completed' - ? 'C' - : task.status === 'deleted' - ? 'D' - : 'P'} - + {/* Desktop: single row layout, Mobile: 2-row layout */} + +
+ {/* Mobile Row 1: ID, Description, Project */} +
+ + {task.id} + +
+ {task.priority === 'H' && ( +
+ )} + {task.priority === 'M' && ( +
+ )} + {task.priority != 'H' && task.priority != 'M' && ( +
+ )} + + {task.description} + +
+ {task.project != '' && ( + + + {task.project} + + )} +
+ + {/* Mobile Row 2: Tags, Status, Pin */} +
+ {task.tags && task.tags.length > 0 && ( +
+ {task.tags.slice(0, 2).map((tag, idx) => ( + + {tag} + + ))} + {task.tags.length > 2 && ( + + +{task.tags.length - 2} + + )} +
+ )} + + {task.status === 'pending' && isOverdue(task.due) + ? 'O' + : task.status === 'completed' + ? 'C' + : task.status === 'deleted' + ? 'D' + : 'P'} + + +
+
+ + {/* Desktop layout - hidden on mobile */} +
+ + {task.id} + +
+ {task.priority === 'H' && ( +
+ )} + {task.priority === 'M' && ( +
+ )} + {task.priority != 'H' && task.priority != 'M' && ( +
+ )} + + {task.description} + + {task.project != '' && ( + + + {task.project === '' ? '' : task.project} + + )} +
+
+ + {task.status === 'pending' && isOverdue(task.due) + ? 'O' + : task.status === 'completed' + ? 'C' + : task.status === 'deleted' + ? 'D' + : 'P'} + + +
+
@@ -1462,6 +1577,24 @@ export const TaskDialog = ({ + {task.status == 'pending' ? ( diff --git a/frontend/src/components/HomeComponents/Tasks/Tasks.tsx b/frontend/src/components/HomeComponents/Tasks/Tasks.tsx index 4a3fec6e..d7e91178 100644 --- a/frontend/src/components/HomeComponents/Tasks/Tasks.tsx +++ b/frontend/src/components/HomeComponents/Tasks/Tasks.tsx @@ -37,6 +37,8 @@ import { getTimeSinceLastSync, hashKey, isOverdue, + getPinnedTasks, + togglePinnedTask, } from './tasks-utils'; import Pagination from './Pagination'; import { url } from '@/components/utils/URLs'; @@ -103,6 +105,7 @@ export const Tasks = ( const [searchTerm, setSearchTerm] = useState(''); const [debouncedTerm, setDebouncedTerm] = useState(''); const [lastSyncTime, setLastSyncTime] = useState(null); + const [pinnedTasks, setPinnedTasks] = useState>(new Set()); const [selectedTaskUUIDs, setSelectedTaskUUIDs] = useState([]); const [unsyncedTaskUuids, setUnsyncedTaskUuids] = useState>( new Set() @@ -201,6 +204,11 @@ export const Tasks = ( } }, [props.email]); + // Load pinned tasks from localStorage + useEffect(() => { + setPinnedTasks(getPinnedTasks(props.email)); + }, [props.email]); + useEffect(() => { const interval = setInterval(() => { setLastSyncTime((prevTime) => prevTime); @@ -479,6 +487,12 @@ export const Tasks = ( ); }; + const handleTogglePin = (taskUuid: string) => { + togglePinnedTask(props.email, taskUuid); + // Update the local state to trigger re-render + setPinnedTasks(getPinnedTasks(props.email)); + }; + const handleSelectTask = (task: Task, index: number) => { setSelectedTask(task); setSelectedIndex(index); @@ -739,11 +753,19 @@ export const Tasks = ( } }; - const sortWithOverdueOnTop = (tasks: Task[]) => { + const sortWithPinnedAndOverdueOnTop = (tasks: Task[]) => { return [...tasks].sort((a, b) => { + const aPinned = pinnedTasks.has(a.uuid); + const bPinned = pinnedTasks.has(b.uuid); + + // Pinned tasks always on top + if (aPinned && !bPinned) return -1; + if (!aPinned && bPinned) return 1; + const aOverdue = a.status === 'pending' && isOverdue(a.due); const bOverdue = b.status === 'pending' && isOverdue(b.due); + // Overdue tasks next (after pinned) if (aOverdue && !bOverdue) return -1; if (!aOverdue && bOverdue) return 1; @@ -795,9 +817,16 @@ export const Tasks = ( filteredTasks = results.map((r) => r.item); } - filteredTasks = sortWithOverdueOnTop(filteredTasks); + filteredTasks = sortWithPinnedAndOverdueOnTop(filteredTasks); setTempTasks(filteredTasks); - }, [selectedProjects, selectedTags, selectedStatuses, tasks, debouncedTerm]); + }, [ + selectedProjects, + selectedTags, + selectedStatuses, + tasks, + debouncedTerm, + pinnedTasks, + ]); const handleSaveTags = (task: Task, tags: string[]) => { const currentTags = tags || []; @@ -1218,6 +1247,8 @@ export const Tasks = ( onMarkDeleted={handleMarkDelete} isOverdue={isOverdue} isUnsynced={unsyncedTaskUuids.has(task.uuid)} + isPinned={pinnedTasks.has(task.uuid)} + onTogglePin={handleTogglePin} /> )) )} diff --git a/frontend/src/components/HomeComponents/Tasks/tasks-utils.ts b/frontend/src/components/HomeComponents/Tasks/tasks-utils.ts index 50879366..ea376862 100644 --- a/frontend/src/components/HomeComponents/Tasks/tasks-utils.ts +++ b/frontend/src/components/HomeComponents/Tasks/tasks-utils.ts @@ -289,3 +289,53 @@ export const hashKey = (key: string, email: string): string => { } return Math.abs(hash).toString(36); }; + +/** + * Get the set of pinned task UUIDs from localStorage + */ +export const getPinnedTasks = (email: string): Set => { + const hashedKey = hashKey('pinnedTasks', email); + const stored = localStorage.getItem(hashedKey); + if (!stored) return new Set(); + try { + return new Set(JSON.parse(stored)); + } catch { + return new Set(); + } +}; + +/** + * Save the set of pinned task UUIDs to localStorage + */ +export const savePinnedTasks = ( + email: string, + pinnedUuids: Set +): void => { + const hashedKey = hashKey('pinnedTasks', email); + localStorage.setItem(hashedKey, JSON.stringify([...pinnedUuids])); +}; + +/** + * Toggle the pinned status of a task + * Returns the new pinned state + */ +export const togglePinnedTask = (email: string, taskUuid: string): boolean => { + const pinnedTasks = getPinnedTasks(email); + const isPinned = pinnedTasks.has(taskUuid); + + if (isPinned) { + pinnedTasks.delete(taskUuid); + } else { + pinnedTasks.add(taskUuid); + } + + savePinnedTasks(email, pinnedTasks); + return !isPinned; +}; + +/** + * Check if a task is pinned + */ +export const isTaskPinned = (email: string, taskUuid: string): boolean => { + return getPinnedTasks(email).has(taskUuid); +}; diff --git a/frontend/src/components/utils/types.ts b/frontend/src/components/utils/types.ts index e10f761e..8b983386 100644 --- a/frontend/src/components/utils/types.ts +++ b/frontend/src/components/utils/types.ts @@ -157,4 +157,6 @@ export interface EditTaskDialogProps { onMarkDeleted: (uuid: string) => void; isOverdue: (due?: string) => boolean; isUnsynced: boolean; + isPinned: boolean; + onTogglePin: (uuid: string) => void; } From 80c03ee943c45cc82b97fbf7cfc0a4dbff32d825 Mon Sep 17 00:00:00 2001 From: ANIR1604 Date: Fri, 9 Jan 2026 04:25:26 +0530 Subject: [PATCH 2/7] TC Passed --- .../HomeComponents/Tasks/TaskDialog.tsx | 94 +------------------ .../Tasks/__tests__/TaskDialog.test.tsx | 2 + 2 files changed, 4 insertions(+), 92 deletions(-) diff --git a/frontend/src/components/HomeComponents/Tasks/TaskDialog.tsx b/frontend/src/components/HomeComponents/Tasks/TaskDialog.tsx index 1e7c8b10..af7a232d 100644 --- a/frontend/src/components/HomeComponents/Tasks/TaskDialog.tsx +++ b/frontend/src/components/HomeComponents/Tasks/TaskDialog.tsx @@ -127,98 +127,8 @@ export const TaskDialog = ({
{/* Desktop: single row layout, Mobile: 2-row layout */} - -
- {/* Mobile Row 1: ID, Description, Project */} -
- - {task.id} - -
- {task.priority === 'H' && ( -
- )} - {task.priority === 'M' && ( -
- )} - {task.priority != 'H' && task.priority != 'M' && ( -
- )} - - {task.description} - -
- {task.project != '' && ( - - - {task.project} - - )} -
- - {/* Mobile Row 2: Tags, Status, Pin */} -
- {task.tags && task.tags.length > 0 && ( -
- {task.tags.slice(0, 2).map((tag, idx) => ( - - {tag} - - ))} - {task.tags.length > 2 && ( - - +{task.tags.length - 2} - - )} -
- )} - - {task.status === 'pending' && isOverdue(task.due) - ? 'O' - : task.status === 'completed' - ? 'C' - : task.status === 'deleted' - ? 'D' - : 'P'} - - -
-
- - {/* Desktop layout - hidden on mobile */} -
+ +
{ onMarkDeleted: jest.fn(), isOverdue: jest.fn(() => false), isUnsynced: false, + isPinned: false, + onTogglePin: jest.fn(), }; beforeEach(() => { From 8e5c20deea33b3d2ea420e4c7882d31e732274a3 Mon Sep 17 00:00:00 2001 From: ANIR1604 Date: Fri, 9 Jan 2026 17:21:49 +0530 Subject: [PATCH 3/7] 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 --- .../Tasks/__tests__/TaskDialog.test.tsx | 88 +++++ .../Tasks/__tests__/tasks-utils.test.ts | 309 ++++++++++++++++++ .../HomeComponents/Tasks/tasks-utils.ts | 13 - 3 files changed, 397 insertions(+), 13 deletions(-) diff --git a/frontend/src/components/HomeComponents/Tasks/__tests__/TaskDialog.test.tsx b/frontend/src/components/HomeComponents/Tasks/__tests__/TaskDialog.test.tsx index a2013d54..08ab2c05 100644 --- a/frontend/src/components/HomeComponents/Tasks/__tests__/TaskDialog.test.tsx +++ b/frontend/src/components/HomeComponents/Tasks/__tests__/TaskDialog.test.tsx @@ -691,4 +691,92 @@ describe('TaskDialog Component', () => { expect(screen.getByText('O')).toBeInTheDocument(); }); }); + + describe('Pin Functionality', () => { + test('should display pin button in dialog footer when task is not pinned', () => { + render(); + + const pinButton = screen.getByRole('button', { name: /pin task/i }); + expect(pinButton).toBeInTheDocument(); + expect(screen.getByText('Pin')).toBeInTheDocument(); + }); + + test('should display unpin button in dialog footer when task is pinned', () => { + render(); + + const unpinButton = screen.getByRole('button', { name: /unpin task/i }); + expect(unpinButton).toBeInTheDocument(); + expect(screen.getByText('Unpin')).toBeInTheDocument(); + }); + + test('should call onTogglePin when pin button is clicked', () => { + render(); + + const pinButton = screen.getByRole('button', { name: /pin task/i }); + fireEvent.click(pinButton); + + expect(defaultProps.onTogglePin).toHaveBeenCalledWith(mockTask.uuid); + }); + + test('should call onTogglePin when unpin button is clicked', () => { + render(); + + const unpinButton = screen.getByRole('button', { name: /unpin task/i }); + fireEvent.click(unpinButton); + + expect(defaultProps.onTogglePin).toHaveBeenCalledWith(mockTask.uuid); + }); + + test('should display pin icon in task row when task is not pinned', () => { + const { container } = render( + + ); + + const pinIcon = container.querySelector('.lucide-pin'); + expect(pinIcon).toBeInTheDocument(); + }); + + test('should display pin icon in task row when task is pinned', () => { + const { container } = render( + + ); + + const pinIcon = container.querySelector('.lucide-pin'); + expect(pinIcon).toBeInTheDocument(); + }); + + test('should call onTogglePin when pin icon in task row is clicked', () => { + const { container } = render( + + ); + + const pinIcon = container.querySelector('.lucide-pin'); + expect(pinIcon).toBeInTheDocument(); + + if (pinIcon?.parentElement) { + fireEvent.click(pinIcon.parentElement); + expect(defaultProps.onTogglePin).toHaveBeenCalledWith(mockTask.uuid); + } + }); + + test('should not open dialog when pin icon in task row is clicked', () => { + const { container } = render( + + ); + + const pinIcon = container.querySelector('.lucide-pin'); + + if (pinIcon?.parentElement) { + fireEvent.click(pinIcon.parentElement); + expect(defaultProps.onSelectTask).not.toHaveBeenCalled(); + } + }); + + test('pin button should have mr-auto class for left alignment', () => { + render(); + + const pinButton = screen.getByRole('button', { name: /pin task/i }); + expect(pinButton).toHaveClass('mr-auto'); + }); + }); }); diff --git a/frontend/src/components/HomeComponents/Tasks/__tests__/tasks-utils.test.ts b/frontend/src/components/HomeComponents/Tasks/__tests__/tasks-utils.test.ts index a68469aa..569242b6 100644 --- a/frontend/src/components/HomeComponents/Tasks/__tests__/tasks-utils.test.ts +++ b/frontend/src/components/HomeComponents/Tasks/__tests__/tasks-utils.test.ts @@ -13,6 +13,10 @@ import { hashKey, parseTaskwarriorDate, isOverdue, + getPinnedTasks, + savePinnedTasks, + togglePinnedTask, + isTaskPinned, } from '../tasks-utils'; import { Task } from '@/components/utils/types'; @@ -636,3 +640,308 @@ describe('isOverdue', () => { expect(isOverdue('invalid-date')).toBe(false); }); }); + +describe('Pin Functionality', () => { + const testEmail = 'test@example.com'; + const taskUuid1 = 'task-uuid-123'; + const taskUuid2 = 'task-uuid-456'; + const taskUuid3 = 'task-uuid-789'; + + beforeEach(() => { + localStorage.clear(); + }); + + afterEach(() => { + localStorage.clear(); + }); + + describe('getPinnedTasks', () => { + it('returns empty Set when no pinned tasks exist', () => { + const result = getPinnedTasks(testEmail); + expect(result).toBeInstanceOf(Set); + expect(result.size).toBe(0); + }); + + it('retrieves pinned tasks from localStorage', () => { + const pinnedUuids = [taskUuid1, taskUuid2]; + const hashedKey = hashKey('pinnedTasks', testEmail); + localStorage.setItem(hashedKey, JSON.stringify(pinnedUuids)); + + const result = getPinnedTasks(testEmail); + expect(result).toBeInstanceOf(Set); + expect(result.size).toBe(2); + expect(result.has(taskUuid1)).toBe(true); + expect(result.has(taskUuid2)).toBe(true); + }); + + it('returns empty Set when localStorage data is corrupted', () => { + const hashedKey = hashKey('pinnedTasks', testEmail); + localStorage.setItem(hashedKey, 'invalid-json'); + + const result = getPinnedTasks(testEmail); + expect(result).toBeInstanceOf(Set); + expect(result.size).toBe(0); + }); + + it('uses hashed key for privacy', () => { + const pinnedUuids = [taskUuid1]; + const hashedKey = hashKey('pinnedTasks', testEmail); + localStorage.setItem(hashedKey, JSON.stringify(pinnedUuids)); + + expect(localStorage.getItem('pinnedTasks')).toBeNull(); + expect(localStorage.getItem(hashedKey)).not.toBeNull(); + }); + + it('returns different pinned tasks for different users', () => { + const email1 = 'user1@example.com'; + const email2 = 'user2@example.com'; + + savePinnedTasks(email1, new Set([taskUuid1])); + savePinnedTasks(email2, new Set([taskUuid2])); + + const result1 = getPinnedTasks(email1); + const result2 = getPinnedTasks(email2); + + expect(result1.has(taskUuid1)).toBe(true); + expect(result1.has(taskUuid2)).toBe(false); + expect(result2.has(taskUuid2)).toBe(true); + expect(result2.has(taskUuid1)).toBe(false); + }); + }); + + describe('savePinnedTasks', () => { + it('saves pinned tasks to localStorage', () => { + const pinnedTasks = new Set([taskUuid1, taskUuid2]); + savePinnedTasks(testEmail, pinnedTasks); + + const hashedKey = hashKey('pinnedTasks', testEmail); + const stored = localStorage.getItem(hashedKey); + expect(stored).not.toBeNull(); + + const parsed = JSON.parse(stored!); + expect(parsed).toContain(taskUuid1); + expect(parsed).toContain(taskUuid2); + expect(parsed.length).toBe(2); + }); + + it('saves empty Set correctly', () => { + const pinnedTasks = new Set(); + savePinnedTasks(testEmail, pinnedTasks); + + const hashedKey = hashKey('pinnedTasks', testEmail); + const stored = localStorage.getItem(hashedKey); + expect(stored).toBe('[]'); + }); + + it('overwrites existing pinned tasks', () => { + savePinnedTasks(testEmail, new Set([taskUuid1, taskUuid2])); + savePinnedTasks(testEmail, new Set([taskUuid3])); + + const result = getPinnedTasks(testEmail); + expect(result.size).toBe(1); + expect(result.has(taskUuid3)).toBe(true); + expect(result.has(taskUuid1)).toBe(false); + expect(result.has(taskUuid2)).toBe(false); + }); + + it('converts Set to Array for JSON serialization', () => { + const pinnedTasks = new Set([taskUuid1, taskUuid2, taskUuid3]); + savePinnedTasks(testEmail, pinnedTasks); + + const hashedKey = hashKey('pinnedTasks', testEmail); + const stored = localStorage.getItem(hashedKey); + const parsed = JSON.parse(stored!); + + expect(Array.isArray(parsed)).toBe(true); + expect(parsed.length).toBe(3); + }); + }); + + describe('togglePinnedTask', () => { + it('pins an unpinned task', () => { + const result = togglePinnedTask(testEmail, taskUuid1); + + expect(result).toBe(true); + const pinnedTasks = getPinnedTasks(testEmail); + expect(pinnedTasks.has(taskUuid1)).toBe(true); + }); + + it('unpins a pinned task', () => { + savePinnedTasks(testEmail, new Set([taskUuid1])); + + const result = togglePinnedTask(testEmail, taskUuid1); + + expect(result).toBe(false); + const pinnedTasks = getPinnedTasks(testEmail); + expect(pinnedTasks.has(taskUuid1)).toBe(false); + }); + + it('returns true when pinning', () => { + const result = togglePinnedTask(testEmail, taskUuid1); + expect(result).toBe(true); + }); + + it('returns false when unpinning', () => { + togglePinnedTask(testEmail, taskUuid1); + const result = togglePinnedTask(testEmail, taskUuid1); + expect(result).toBe(false); + }); + + it('persists changes to localStorage', () => { + togglePinnedTask(testEmail, taskUuid1); + + const hashedKey = hashKey('pinnedTasks', testEmail); + const stored = localStorage.getItem(hashedKey); + expect(stored).not.toBeNull(); + + const parsed = JSON.parse(stored!); + expect(parsed).toContain(taskUuid1); + }); + + it('maintains other pinned tasks when toggling', () => { + savePinnedTasks(testEmail, new Set([taskUuid1, taskUuid2])); + togglePinnedTask(testEmail, taskUuid3); + + const pinnedTasks = getPinnedTasks(testEmail); + expect(pinnedTasks.has(taskUuid1)).toBe(true); + expect(pinnedTasks.has(taskUuid2)).toBe(true); + expect(pinnedTasks.has(taskUuid3)).toBe(true); + }); + + it('removes only the toggled task when unpinning', () => { + savePinnedTasks(testEmail, new Set([taskUuid1, taskUuid2, taskUuid3])); + togglePinnedTask(testEmail, taskUuid2); + + const pinnedTasks = getPinnedTasks(testEmail); + expect(pinnedTasks.has(taskUuid1)).toBe(true); + expect(pinnedTasks.has(taskUuid2)).toBe(false); + expect(pinnedTasks.has(taskUuid3)).toBe(true); + expect(pinnedTasks.size).toBe(2); + }); + + it('handles multiple toggles correctly', () => { + togglePinnedTask(testEmail, taskUuid1); + expect(isTaskPinned(testEmail, taskUuid1)).toBe(true); + + togglePinnedTask(testEmail, taskUuid1); + expect(isTaskPinned(testEmail, taskUuid1)).toBe(false); + + togglePinnedTask(testEmail, taskUuid1); + expect(isTaskPinned(testEmail, taskUuid1)).toBe(true); + }); + }); + + describe('isTaskPinned', () => { + it('returns false for unpinned task', () => { + const result = isTaskPinned(testEmail, taskUuid1); + expect(result).toBe(false); + }); + + it('returns true for pinned task', () => { + savePinnedTasks(testEmail, new Set([taskUuid1])); + const result = isTaskPinned(testEmail, taskUuid1); + expect(result).toBe(true); + }); + + it('returns false when no pinned tasks exist', () => { + const result = isTaskPinned(testEmail, taskUuid1); + expect(result).toBe(false); + }); + + it('returns false for task not in pinned list', () => { + savePinnedTasks(testEmail, new Set([taskUuid1, taskUuid2])); + const result = isTaskPinned(testEmail, taskUuid3); + expect(result).toBe(false); + }); + + it('returns correct status after toggling', () => { + expect(isTaskPinned(testEmail, taskUuid1)).toBe(false); + + togglePinnedTask(testEmail, taskUuid1); + expect(isTaskPinned(testEmail, taskUuid1)).toBe(true); + + togglePinnedTask(testEmail, taskUuid1); + expect(isTaskPinned(testEmail, taskUuid1)).toBe(false); + }); + + it('handles multiple pinned tasks correctly', () => { + savePinnedTasks(testEmail, new Set([taskUuid1, taskUuid2, taskUuid3])); + + expect(isTaskPinned(testEmail, taskUuid1)).toBe(true); + expect(isTaskPinned(testEmail, taskUuid2)).toBe(true); + expect(isTaskPinned(testEmail, taskUuid3)).toBe(true); + expect(isTaskPinned(testEmail, 'non-existent-uuid')).toBe(false); + }); + + it('is case-sensitive for UUIDs', () => { + savePinnedTasks(testEmail, new Set([taskUuid1.toLowerCase()])); + expect(isTaskPinned(testEmail, taskUuid1.toUpperCase())).toBe(false); + }); + }); + + describe('Integration Tests', () => { + it('complete pin workflow for single task', () => { + expect(isTaskPinned(testEmail, taskUuid1)).toBe(false); + + togglePinnedTask(testEmail, taskUuid1); + expect(isTaskPinned(testEmail, taskUuid1)).toBe(true); + + const pinnedTasks = getPinnedTasks(testEmail); + expect(pinnedTasks.has(taskUuid1)).toBe(true); + expect(pinnedTasks.size).toBe(1); + + togglePinnedTask(testEmail, taskUuid1); + expect(isTaskPinned(testEmail, taskUuid1)).toBe(false); + + const emptyPinned = getPinnedTasks(testEmail); + expect(emptyPinned.size).toBe(0); + }); + + it('complete pin workflow for multiple tasks', () => { + togglePinnedTask(testEmail, taskUuid1); + togglePinnedTask(testEmail, taskUuid2); + togglePinnedTask(testEmail, taskUuid3); + + expect(isTaskPinned(testEmail, taskUuid1)).toBe(true); + expect(isTaskPinned(testEmail, taskUuid2)).toBe(true); + expect(isTaskPinned(testEmail, taskUuid3)).toBe(true); + + const pinnedTasks = getPinnedTasks(testEmail); + expect(pinnedTasks.size).toBe(3); + + togglePinnedTask(testEmail, taskUuid2); + expect(isTaskPinned(testEmail, taskUuid2)).toBe(false); + expect(getPinnedTasks(testEmail).size).toBe(2); + }); + + it('handles multiple users independently', () => { + const email1 = 'user1@example.com'; + const email2 = 'user2@example.com'; + + togglePinnedTask(email1, taskUuid1); + togglePinnedTask(email2, taskUuid2); + + expect(isTaskPinned(email1, taskUuid1)).toBe(true); + expect(isTaskPinned(email1, taskUuid2)).toBe(false); + expect(isTaskPinned(email2, taskUuid1)).toBe(false); + expect(isTaskPinned(email2, taskUuid2)).toBe(true); + + expect(getPinnedTasks(email1).size).toBe(1); + expect(getPinnedTasks(email2).size).toBe(1); + }); + + it('persists across function calls', () => { + togglePinnedTask(testEmail, taskUuid1); + expect(isTaskPinned(testEmail, taskUuid1)).toBe(true); + + const retrieved = getPinnedTasks(testEmail); + expect(retrieved.has(taskUuid1)).toBe(true); + + savePinnedTasks(testEmail, new Set([taskUuid1, taskUuid2])); + expect(isTaskPinned(testEmail, taskUuid2)).toBe(true); + + const final = getPinnedTasks(testEmail); + expect(final.size).toBe(2); + }); + }); +}); diff --git a/frontend/src/components/HomeComponents/Tasks/tasks-utils.ts b/frontend/src/components/HomeComponents/Tasks/tasks-utils.ts index ea376862..232bac3b 100644 --- a/frontend/src/components/HomeComponents/Tasks/tasks-utils.ts +++ b/frontend/src/components/HomeComponents/Tasks/tasks-utils.ts @@ -290,9 +290,6 @@ export const hashKey = (key: string, email: string): string => { return Math.abs(hash).toString(36); }; -/** - * Get the set of pinned task UUIDs from localStorage - */ export const getPinnedTasks = (email: string): Set => { const hashedKey = hashKey('pinnedTasks', email); const stored = localStorage.getItem(hashedKey); @@ -304,9 +301,6 @@ export const getPinnedTasks = (email: string): Set => { } }; -/** - * Save the set of pinned task UUIDs to localStorage - */ export const savePinnedTasks = ( email: string, pinnedUuids: Set @@ -315,10 +309,6 @@ export const savePinnedTasks = ( localStorage.setItem(hashedKey, JSON.stringify([...pinnedUuids])); }; -/** - * Toggle the pinned status of a task - * Returns the new pinned state - */ export const togglePinnedTask = (email: string, taskUuid: string): boolean => { const pinnedTasks = getPinnedTasks(email); const isPinned = pinnedTasks.has(taskUuid); @@ -333,9 +323,6 @@ export const togglePinnedTask = (email: string, taskUuid: string): boolean => { return !isPinned; }; -/** - * Check if a task is pinned - */ export const isTaskPinned = (email: string, taskUuid: string): boolean => { return getPinnedTasks(email).has(taskUuid); }; From 8fdfc27f2d9f2d4eb997bc603d1ba69532027a0e Mon Sep 17 00:00:00 2001 From: ANIR1604 Date: Fri, 9 Jan 2026 23:17:00 +0530 Subject: [PATCH 4/7] Tests Restored --- .github/.cursorrules | 23 +++++ .../Tasks/__tests__/TaskDialog.test.tsx | 98 +++++++++++++++++++ 2 files changed, 121 insertions(+) create mode 100644 .github/.cursorrules diff --git a/.github/.cursorrules b/.github/.cursorrules new file mode 100644 index 00000000..e29644e4 --- /dev/null +++ b/.github/.cursorrules @@ -0,0 +1,23 @@ +You are a senior software engineer. Before making ANY changes: + +1. ANALYZE FIRST - Read all related code, understand the context and architecture +2. PLAN YOUR APPROACH - Outline what needs to change and why +3. MINIMAL CHANGES - Only modify what's necessary, preserve working code +4. MAINTAIN PATTERNS - Follow existing code style, naming conventions, and patterns +5. AVOID BREAKING CHANGES - Don't refactor unrelated code or change working functionality + +Process for each bug/feature: +- Identify root cause, don't guess +- Consider edge cases and side effects +- Make surgical, precise changes +- Preserve all existing functionality +- Test your changes mentally before suggesting + +Never: +- Rewrite working code unnecessarily +- Change variable names without reason +- Remove comments or important logic +- Introduce new dependencies without discussion +- Make assumptions - ask if unclear + +Think like a senior engineer: quality over speed, precision over quantity. \ No newline at end of file diff --git a/frontend/src/components/HomeComponents/Tasks/__tests__/TaskDialog.test.tsx b/frontend/src/components/HomeComponents/Tasks/__tests__/TaskDialog.test.tsx index 08ab2c05..709c3ae4 100644 --- a/frontend/src/components/HomeComponents/Tasks/__tests__/TaskDialog.test.tsx +++ b/frontend/src/components/HomeComponents/Tasks/__tests__/TaskDialog.test.tsx @@ -779,4 +779,102 @@ describe('TaskDialog Component', () => { expect(pinButton).toHaveClass('mr-auto'); }); }); + + describe('Testing Shortcuts', () => { + beforeEach(() => { + Element.prototype.scrollIntoView = jest.fn(); + }); + + test('ArrowDown moves focus to next field', async () => { + render(); + + const dialog = await screen.findByRole('dialog'); + fireEvent.keyDown(dialog, { key: 'ArrowDown' }); + + const dueRow = screen.getByText('Due:').closest('tr'); + expect(dueRow).toHaveClass('bg-black/15'); + }); + + test('Enter opens edit mode for focused field', async () => { + const editStateWithEditingOn = { + ...defaultProps.editState, + isEditing: true, + editedDescription: defaultProps.task.description, + }; + + render( + + ); + + await screen.findByRole('dialog'); + + const descriptionInput = screen.getByLabelText('description'); + expect(descriptionInput).toBeInTheDocument(); + expect(descriptionInput).toHaveAttribute('type', 'text'); + expect(descriptionInput).toHaveValue(defaultProps.task.description); + }); + + test('Arrow keys do not navigate while editing', () => { + render(); + + const dialog = screen.getByRole('dialog'); + fireEvent.keyDown(dialog, { key: 'Enter' }); + fireEvent.keyDown(dialog, { key: 'ArrowDown' }); + + const descriptionRow = screen.getByText('Description:').closest('tr'); + expect(descriptionRow).toBeInTheDocument(); + }); + + test('Escape exits edit mode before closing dialog', () => { + const editStateWithEditingOn = { + ...defaultProps.editState, + isEditing: true, + editedDescription: defaultProps.task.description, + }; + + render( + + ); + + const dialog = screen.getByRole('dialog'); + fireEvent.keyDown(dialog, { key: 'Enter' }); + + const descriptionInput = screen.getByLabelText('description'); + expect(descriptionInput).toBeInTheDocument(); + + fireEvent.keyDown(dialog, { key: 'Escape' }); + + expect(dialog).toBeInTheDocument(); + }); + + test('DateTimePicker is visible when any date field is in edit mode', async () => { + const editStateWithDueDateEditing = { + ...defaultProps.editState, + isEditingDueDate: true, + editedDueDate: defaultProps.task.due || '', + }; + + render( + + ); + + await screen.findByRole('dialog'); + + expect( + await screen.findByRole('button', { name: /calender-button/i }) + ).toBeInTheDocument(); + }); + }); }); From 5e3fbabfde2502ce5a1ff580f85bacd386ca1cbc Mon Sep 17 00:00:00 2001 From: Anirban Biswas <139000437+Rustix69@users.noreply.github.com> Date: Fri, 9 Jan 2026 23:29:07 +0530 Subject: [PATCH 5/7] Delete .github/.cursorrules --- .github/.cursorrules | 23 ----------------------- 1 file changed, 23 deletions(-) delete mode 100644 .github/.cursorrules diff --git a/.github/.cursorrules b/.github/.cursorrules deleted file mode 100644 index e29644e4..00000000 --- a/.github/.cursorrules +++ /dev/null @@ -1,23 +0,0 @@ -You are a senior software engineer. Before making ANY changes: - -1. ANALYZE FIRST - Read all related code, understand the context and architecture -2. PLAN YOUR APPROACH - Outline what needs to change and why -3. MINIMAL CHANGES - Only modify what's necessary, preserve working code -4. MAINTAIN PATTERNS - Follow existing code style, naming conventions, and patterns -5. AVOID BREAKING CHANGES - Don't refactor unrelated code or change working functionality - -Process for each bug/feature: -- Identify root cause, don't guess -- Consider edge cases and side effects -- Make surgical, precise changes -- Preserve all existing functionality -- Test your changes mentally before suggesting - -Never: -- Rewrite working code unnecessarily -- Change variable names without reason -- Remove comments or important logic -- Introduce new dependencies without discussion -- Make assumptions - ask if unclear - -Think like a senior engineer: quality over speed, precision over quantity. \ No newline at end of file From ac35ff32b302ebf8d7dd1ead56033d38c04b436b Mon Sep 17 00:00:00 2001 From: ANIR1604 Date: Fri, 9 Jan 2026 23:30:25 +0530 Subject: [PATCH 6/7] test: add pin functionality tests for Tasks component --- .../Tasks/__tests__/Tasks.test.tsx | 165 +++++++++++++++++- 1 file changed, 164 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/HomeComponents/Tasks/__tests__/Tasks.test.tsx b/frontend/src/components/HomeComponents/Tasks/__tests__/Tasks.test.tsx index 5232b324..c0e4e329 100644 --- a/frontend/src/components/HomeComponents/Tasks/__tests__/Tasks.test.tsx +++ b/frontend/src/components/HomeComponents/Tasks/__tests__/Tasks.test.tsx @@ -38,6 +38,8 @@ jest.mock('../tasks-utils', () => { .fn() .mockReturnValue('Last updated 5 minutes ago'), hashKey: jest.fn().mockReturnValue('mockHashedKey'), + getPinnedTasks: jest.fn().mockReturnValue(new Set()), + togglePinnedTask: jest.fn(), }; }); @@ -557,7 +559,7 @@ describe('Tasks Component', () => { }); }); - it('keeps tasks selected when bulk complete fails', async () => { + it('keeps tasks selected when bulk completet fails', async () => { const utils = require('../tasks-utils'); utils.bulkMarkTasksAsCompleted.mockResolvedValue(false); @@ -1361,4 +1363,165 @@ describe('Tasks Component', () => { expect(row).not.toHaveClass('border-l-red-500'); }); }); + + describe('Pin Functionality', () => { + test('should load pinned tasks from localStorage on mount', async () => { + const { getPinnedTasks } = require('../tasks-utils'); + const mockPinnedSet = new Set(['uuid-1', 'uuid-2']); + getPinnedTasks.mockReturnValue(mockPinnedSet); + + render(); + + await waitFor(() => { + expect(getPinnedTasks).toHaveBeenCalledWith(mockProps.email); + }); + }); + + test('should reload pinned tasks when email changes', async () => { + const { getPinnedTasks } = require('../tasks-utils'); + getPinnedTasks.mockReturnValue(new Set(['uuid-1'])); + + const { rerender } = render(); + + await waitFor(() => { + expect(getPinnedTasks).toHaveBeenCalledWith(mockProps.email); + }); + + getPinnedTasks.mockClear(); + getPinnedTasks.mockReturnValue(new Set(['uuid-2'])); + + rerender(); + + await waitFor(() => { + expect(getPinnedTasks).toHaveBeenCalledWith('newemail@example.com'); + }); + }); + + test('should call togglePinnedTask when handleTogglePin is invoked', async () => { + const { togglePinnedTask, getPinnedTasks } = require('../tasks-utils'); + getPinnedTasks.mockReturnValue(new Set()); + + render(); + + await waitFor(async () => { + expect(await screen.findByText('Task 1')).toBeInTheDocument(); + }); + + const task1Row = screen.getByText('Task 1').closest('tr'); + const pinButton = task1Row?.querySelector('button[aria-label*="Pin"]'); + + if (pinButton) { + fireEvent.click(pinButton); + + await waitFor(() => { + expect(togglePinnedTask).toHaveBeenCalledWith( + mockProps.email, + 'uuid-1' + ); + }); + } + }); + + test('should update pinnedTasks state after toggling pin', async () => { + const { getPinnedTasks } = require('../tasks-utils'); + getPinnedTasks + .mockReturnValueOnce(new Set()) + .mockReturnValueOnce(new Set(['uuid-1'])); + + render(); + + await waitFor(async () => { + expect(await screen.findByText('Task 1')).toBeInTheDocument(); + }); + + const task1Row = screen.getByText('Task 1').closest('tr'); + const pinButton = task1Row?.querySelector('button[aria-label*="Pin"]'); + + if (pinButton) { + fireEvent.click(pinButton); + + await waitFor(() => { + expect(getPinnedTasks).toHaveBeenCalledTimes(2); + }); + } + }); + + test('should pass isPinned prop correctly to TaskDialog', async () => { + const { getPinnedTasks } = require('../tasks-utils'); + getPinnedTasks.mockReturnValue(new Set(['uuid-1'])); + + render(); + + await waitFor(async () => { + expect(await screen.findByText('Task 1')).toBeInTheDocument(); + }); + + const task1Row = screen.getByText('Task 1').closest('tr'); + expect(task1Row).toBeInTheDocument(); + }); + + test('should sort pinned tasks to the top', async () => { + const { getPinnedTasks } = require('../tasks-utils'); + getPinnedTasks.mockReturnValue(new Set(['uuid-5'])); + + render(); + + await waitFor(async () => { + expect(await screen.findByText('Task 5')).toBeInTheDocument(); + }); + + const taskRows = screen.getAllByTestId(/task-row-/); + const firstTaskRow = taskRows[0]; + const firstTaskDescription = within(firstTaskRow).getByText(/Task \d+/); + + expect(firstTaskDescription.textContent).toBe('Task 5'); + }); + + test('should maintain pinned tasks at top when filters are applied', async () => { + const { getPinnedTasks } = require('../tasks-utils'); + getPinnedTasks.mockReturnValue(new Set(['uuid-2'])); + + render(); + + await waitFor(async () => { + expect(await screen.findByText('Task 2')).toBeInTheDocument(); + }); + + const statusFilter = screen.getByText('Mocked MultiSelect: Status'); + expect(statusFilter).toBeInTheDocument(); + + await waitFor(() => { + const taskRows = screen.getAllByTestId(/task-row-/); + expect(taskRows.length).toBeGreaterThan(0); + }); + }); + + test('should sort tasks with pinned first, then overdue, then others', async () => { + const { getPinnedTasks } = require('../tasks-utils'); + getPinnedTasks.mockReturnValue(new Set(['uuid-3'])); + + render(); + + await waitFor(async () => { + expect(await screen.findByText('Task 3')).toBeInTheDocument(); + }); + + const taskRows = screen.getAllByTestId(/task-row-/); + expect(taskRows.length).toBeGreaterThan(0); + }); + + test('should pass onTogglePin prop to TaskDialog', async () => { + const { getPinnedTasks } = require('../tasks-utils'); + getPinnedTasks.mockReturnValue(new Set()); + + render(); + + await waitFor(async () => { + expect(await screen.findByText('Task 1')).toBeInTheDocument(); + }); + + const task1Row = screen.getByText('Task 1').closest('tr'); + expect(task1Row).toBeInTheDocument(); + }); + }); }); From 33286613963d9e9b6e788a1985696515f2b345d1 Mon Sep 17 00:00:00 2001 From: ANIR1604 Date: Sat, 10 Jan 2026 23:02:55 +0530 Subject: [PATCH 7/7] Typo Fixed --- .../components/HomeComponents/Tasks/__tests__/Tasks.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/HomeComponents/Tasks/__tests__/Tasks.test.tsx b/frontend/src/components/HomeComponents/Tasks/__tests__/Tasks.test.tsx index c0e4e329..b8339728 100644 --- a/frontend/src/components/HomeComponents/Tasks/__tests__/Tasks.test.tsx +++ b/frontend/src/components/HomeComponents/Tasks/__tests__/Tasks.test.tsx @@ -559,7 +559,7 @@ describe('Tasks Component', () => { }); }); - it('keeps tasks selected when bulk completet fails', async () => { + it('keeps tasks selected when bulk complete fails', async () => { const utils = require('../tasks-utils'); utils.bulkMarkTasksAsCompleted.mockResolvedValue(false);