diff --git a/frontend/src/components/HomeComponents/Tasks/TaskDialog.tsx b/frontend/src/components/HomeComponents/Tasks/TaskDialog.tsx index 234f2efa..954ab290 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, @@ -70,6 +72,8 @@ export const TaskDialog = ({ onMarkDeleted, isOverdue, isUnsynced, + isPinned, + onTogglePin, }: EditTaskDialogProps) => { const editButtonRef = useRef< Partial> @@ -199,7 +203,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 */} + +
+ + {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'} + + +
+
@@ -1688,6 +1713,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 632c07ff..2c963860 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/__tests__/TaskDialog.test.tsx b/frontend/src/components/HomeComponents/Tasks/__tests__/TaskDialog.test.tsx index 990e48d2..709c3ae4 100644 --- a/frontend/src/components/HomeComponents/Tasks/__tests__/TaskDialog.test.tsx +++ b/frontend/src/components/HomeComponents/Tasks/__tests__/TaskDialog.test.tsx @@ -106,6 +106,8 @@ describe('TaskDialog Component', () => { onMarkDeleted: jest.fn(), isOverdue: jest.fn(() => false), isUnsynced: false, + isPinned: false, + onTogglePin: jest.fn(), }; beforeEach(() => { @@ -690,6 +692,94 @@ describe('TaskDialog Component', () => { }); }); + 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'); + }); + }); + describe('Testing Shortcuts', () => { beforeEach(() => { Element.prototype.scrollIntoView = jest.fn(); @@ -699,10 +789,8 @@ describe('TaskDialog Component', () => { render(); const dialog = await screen.findByRole('dialog'); - fireEvent.keyDown(dialog, { key: 'ArrowDown' }); - // description → due const dueRow = screen.getByText('Due:').closest('tr'); expect(dueRow).toHaveClass('bg-black/15'); }); @@ -734,9 +822,7 @@ describe('TaskDialog Component', () => { render(); const dialog = screen.getByRole('dialog'); - fireEvent.keyDown(dialog, { key: 'Enter' }); - fireEvent.keyDown(dialog, { key: 'ArrowDown' }); const descriptionRow = screen.getByText('Description:').closest('tr'); @@ -759,8 +845,8 @@ describe('TaskDialog Component', () => { ); const dialog = screen.getByRole('dialog'); - fireEvent.keyDown(dialog, { key: 'Enter' }); + const descriptionInput = screen.getByLabelText('description'); expect(descriptionInput).toBeInTheDocument(); diff --git a/frontend/src/components/HomeComponents/Tasks/__tests__/Tasks.test.tsx b/frontend/src/components/HomeComponents/Tasks/__tests__/Tasks.test.tsx index 5232b324..b8339728 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(), }; }); @@ -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(); + }); + }); }); 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 50879366..232bac3b 100644 --- a/frontend/src/components/HomeComponents/Tasks/tasks-utils.ts +++ b/frontend/src/components/HomeComponents/Tasks/tasks-utils.ts @@ -289,3 +289,40 @@ export const hashKey = (key: string, email: string): string => { } return Math.abs(hash).toString(36); }; + +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(); + } +}; + +export const savePinnedTasks = ( + email: string, + pinnedUuids: Set +): void => { + const hashedKey = hashKey('pinnedTasks', email); + localStorage.setItem(hashedKey, JSON.stringify([...pinnedUuids])); +}; + +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; +}; + +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 2a3cbb08..54d4482b 100644 --- a/frontend/src/components/utils/types.ts +++ b/frontend/src/components/utils/types.ts @@ -159,6 +159,8 @@ export interface EditTaskDialogProps { onMarkDeleted: (uuid: string) => void; isOverdue: (due?: string) => boolean; isUnsynced: boolean; + isPinned: boolean; + onTogglePin: (uuid: string) => void; } export interface UseTaskDialogKeyboardProps {