From a10f7952fe95d65342be6317b929128908d08a3d Mon Sep 17 00:00:00 2001 From: Shiva Gupta Date: Fri, 14 Nov 2025 01:18:18 +0530 Subject: [PATCH 1/3] feat: Implement local-first tracking for unsynced tasks Adds visual indicators for tasks modified locally but not yet synced with the backend. Implementation details: - Uses `localStorage` to track dirty UUIDs (keeps IndexedDB schema clean). - Adds "Unsynced" badge to task rows. - Adds a notification counter badge to the Sync button. - Masks temporary negative IDs with a dash in the UI. - Updates tasks optimistically for instant UI feedback. - Clears unsynced status automatically upon successful sync. --- .../components/HomeComponents/Tasks/Tasks.tsx | 403 +++++++++++++++--- .../Tasks/__tests__/Tasks.test.tsx | 96 ++++- 2 files changed, 427 insertions(+), 72 deletions(-) diff --git a/frontend/src/components/HomeComponents/Tasks/Tasks.tsx b/frontend/src/components/HomeComponents/Tasks/Tasks.tsx index b1d547f5..73a78445 100644 --- a/frontend/src/components/HomeComponents/Tasks/Tasks.tsx +++ b/frontend/src/components/HomeComponents/Tasks/Tasks.tsx @@ -61,6 +61,26 @@ import { } from './hooks'; import { debounce } from '@/components/utils/utils'; +const STORAGE_KEY = 'ccsync_unsynced_uuids'; + +const getUnsyncedFromStorage = (): Set => { + try { + const stored = localStorage.getItem(STORAGE_KEY); + return stored ? new Set(JSON.parse(stored)) : new Set(); + } catch (e) { + console.error('Failed to parse unsynced tasks', e); + return new Set(); + } +}; + +const saveUnsyncedToStorage = (uuids: Set) => { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(Array.from(uuids))); + } catch (e) { + console.error('Failed to save unsynced tasks', e); + } +}; + const db = new TasksDatabase(); export let syncTasksWithTwAndDb: () => any; @@ -82,6 +102,10 @@ export const Tasks = ( const [currentPage, setCurrentPage] = useState(1); const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc'); const [idSortOrder, setIdSortOrder] = useState<'asc' | 'desc'>('asc'); + const [unsyncedSet, setUnsyncedSet] = useState>(() => + getUnsyncedFromStorage() + ); + const [unsyncedCount, setUnsyncedCount] = useState(0); const [newTask, setNewTask] = useState({ description: '', @@ -154,6 +178,9 @@ export const Tasks = ( const paginate = (pageNumber: number) => setCurrentPage(pageNumber); const totalPages = Math.ceil(tempTasks.length / tasksPerPage) || 1; + useEffect(() => { + setUnsyncedCount(unsyncedSet.size); + }, [unsyncedSet]); useEffect(() => { const hashedKey = hashKey('tasksPerPage', props.email); const storedTasksPerPage = localStorage.getItem(hashedKey); @@ -244,6 +271,10 @@ export const Tasks = ( setTempTasks(sortTasksById(updatedTasks, 'desc')); }); + const emptySet = new Set(); + setUnsyncedSet(emptySet); + saveUnsyncedToStorage(emptySet); + // Store last sync timestamp using hashed key const currentTime = Date.now(); const hashedKey = hashKey('lastSyncTime', user_email); @@ -269,19 +300,37 @@ export const Tasks = ( ) { if (handleDate(newTask.due)) { try { - await addTaskToBackend({ - email, - encryptionSecret, - UUID, - description, - project, - priority, - due, - tags, - backendURL: url.backendURL, - }); + const tempTask: Task = { + ...newTask, + id: Math.floor(Math.random() * -1000000), + uuid: `temp-${Date.now()}`, + status: 'pending', + email: props.email, + entry: new Date().toISOString(), + modified: new Date().toISOString(), + urgency: 0, + start: '', + end: '', + wait: '', + depends: [], + recur: '', + rtype: '', + }; + + await db.tasks.add(tempTask); + + const newSet = new Set(unsyncedSet); + newSet.add(tempTask.uuid); + setUnsyncedSet(newSet); + saveUnsyncedToStorage(newSet); + + const updatedTasks = await db.tasks + .where('email') + .equals(props.email) + .toArray(); + setTasks(sortTasksById(updatedTasks, 'desc')); + setTempTasks(sortTasksById(updatedTasks, 'desc')); - console.log('Task added successfully!'); setNewTask({ description: '', priority: '', @@ -290,8 +339,30 @@ export const Tasks = ( tags: [], }); setIsAddTaskOpen(false); - } catch (error) { - console.error('Failed to add task:', error); + + try { + await addTaskToBackend({ + email, + encryptionSecret, + UUID, + description, + project, + priority, + due, + tags, + backendURL: url.backendURL, + }); + toast.success('Task added successfully!'); + console.log('Task added successfully!'); + } catch (error) { + console.error('Failed to add task. Please try again later.'); + toast.error( + 'Unable to sync task to server. It’s saved locally for now.' + ); + } + } catch (localError) { + console.error('Failed to save task locally'); + toast.error('Failed to save task locally.'); } } } @@ -348,18 +419,48 @@ export const Tasks = ( setEditedDescription(description); }; - const handleSaveClick = (task: Task) => { - task.description = editedDescription; - handleEditTaskOnBackend( - props.email, - props.encryptionSecret, - props.UUID, - task.description, - task.tags, - task.id.toString(), - task.project - ); - setIsEditing(false); + const handleSaveClick = async (task: Task) => { + try { + const updatedTask: Task = { + ...task, + description: editedDescription, + modified: new Date().toISOString(), + }; + + await db.tasks.put(updatedTask); + + const newSet = new Set(unsyncedSet); + newSet.add(task.uuid); + setUnsyncedSet(newSet); + saveUnsyncedToStorage(newSet); + + const updatedTasks = await db.tasks + .where('email') + .equals(props.email) + .toArray(); + setTasks(sortTasksById(updatedTasks, 'desc')); + setTempTasks(sortTasksById(updatedTasks, 'desc')); + + setIsEditing(false); + + try { + await handleEditTaskOnBackend( + props.email, + props.encryptionSecret, + props.UUID, + editedDescription, + task.tags || [], + task.id.toString(), + task.project + ); + toast.success('Description updated successfully.'); + } catch (backendError) { + console.error('Backend edit-task failed.'); + toast.error('Local save complete, but backend sync failed.'); + } + } catch (localError) { + console.error('Failed to save description locally.'); + } }; const handleProjectSaveClick = (task: Task) => { @@ -440,25 +541,56 @@ export const Tasks = ( setIsEditingTags(true); }; - const handleSaveTags = (task: Task) => { + const handleSaveTags = async (task: Task) => { + const updatedTags = editedTags.filter((tag) => tag.trim() !== ''); // Remove any empty tags const currentTags = task.tags || []; // Default to an empty array if tags are null const removedTags = currentTags.filter((tag) => !editedTags.includes(tag)); - const updatedTags = editedTags.filter((tag) => tag.trim() !== ''); // Remove any empty tags - const tagsToRemove = removedTags.map((tag) => `-${tag}`); // Prefix `-` for removed tags - const finalTags = [...updatedTags, ...tagsToRemove]; // Combine updated and removed tags - console.log(finalTags); - // Call the backend function with updated tags - handleEditTaskOnBackend( - props.email, - props.encryptionSecret, - props.UUID, - task.description, - finalTags, - task.id.toString(), - task.project - ); + const tagsForBackend = [ + ...updatedTags, + ...removedTags.map((tag) => `-${tag}`), + ]; + try { + const updatedTask: Task = { + ...task, + tags: updatedTags, + modified: new Date().toISOString(), + }; - setIsEditingTags(false); // Exit editing mode + await db.tasks.put(updatedTask); + + const newSet = new Set(unsyncedSet); + newSet.add(task.uuid); + setUnsyncedSet(newSet); + saveUnsyncedToStorage(newSet); + + const updatedTasks = await db.tasks + .where('email') + .equals(props.email) + .toArray(); + setTasks(sortTasksById(updatedTasks, 'desc')); + setTempTasks(sortTasksById(updatedTasks, 'desc')); + + setIsEditingTags(false); + + try { + await handleEditTaskOnBackend( + props.email, + props.encryptionSecret, + props.UUID, + task.description, + tagsForBackend, + task.id.toString(), + task.project + ); + toast.success('Tags updated sucessfully.'); + } catch (backendError) { + console.error('Backend edit-task failed.'); + toast.error('Local save complete, but backend sync failed.'); + } + } catch (localError) { + console.error('Failed to save tags locally'); + toast.error('Failed to save locally.'); + } }; const handleCancelTags = () => { @@ -497,7 +629,7 @@ export const Tasks = ( {/* Mobile-only Sync button (desktop already shows a Sync button with filters) */} @@ -745,8 +884,28 @@ export const Tasks = (
-
@@ -804,7 +963,7 @@ export const Tasks = ( {/* Display task details */} - {task.id} + {task.id < 0 ? '-' : task.id} @@ -827,6 +986,14 @@ export const Tasks = ( {task.project === '' ? '' : task.project} )} + {unsyncedSet.has(task.uuid) && ( + + Unsynced + + )} @@ -1173,14 +1395,69 @@ export const Tasks = ( diff --git a/frontend/src/components/HomeComponents/Tasks/__tests__/Tasks.test.tsx b/frontend/src/components/HomeComponents/Tasks/__tests__/Tasks.test.tsx index 4f3492c9..8106c7dd 100644 --- a/frontend/src/components/HomeComponents/Tasks/__tests__/Tasks.test.tsx +++ b/frontend/src/components/HomeComponents/Tasks/__tests__/Tasks.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, fireEvent } from '@testing-library/react'; +import { render, screen, fireEvent, within } from '@testing-library/react'; import { Tasks } from '../Tasks'; // Mock props for the Tasks component @@ -48,16 +48,40 @@ jest.mock('../hooks', () => ({ where: jest.fn(() => ({ equals: jest.fn(() => ({ // Mock 12 tasks to test pagination - toArray: jest.fn().mockResolvedValue( - Array.from({ length: 12 }, (_, i) => ({ - id: i + 1, - description: `Task ${i + 1}`, + toArray: jest.fn().mockResolvedValue([ + { + id: 1, + description: 'Normal Synced Task', + status: 'pending', + project: 'ProjectA', + tags: ['tag1'], + uuid: 'uuid-1', + }, + { + id: 2, + description: 'Edited Unsynced Task', + status: 'pending', + project: 'ProjectB', + tags: ['tag2'], + uuid: 'uuid-2', + }, + { + id: -12345, + description: 'New Temporary Task', + status: 'pending', + project: 'ProjectA', + tags: ['tag1'], + uuid: 'uuid-temp-3', + }, + ...Array.from({ length: 9 }, (_, i) => ({ + id: i + 4, + description: `Task ${i + 4}`, status: 'pending', project: i % 2 === 0 ? 'ProjectA' : 'ProjectB', tags: i % 3 === 0 ? ['tag1'] : ['tag2'], - uuid: `uuid-${i + 1}`, - })) - ), + uuid: `uuid-${i + 4}`, + })), + ]), })), })), }, @@ -79,6 +103,8 @@ jest.mock('../Pagination', () => { global.fetch = jest.fn().mockResolvedValue({ ok: true }); +const STORAGE_KEY = 'ccsync_unsynced_uuids'; + describe('Tasks Component', () => { const localStorageMock = (() => { let store: { [key: string]: string } = {}; @@ -100,6 +126,9 @@ describe('Tasks Component', () => { beforeEach(() => { localStorageMock.clear(); jest.clearAllMocks(); + + const unsyncedUuids = ['uuid-2', 'uuid-temp-3']; + localStorageMock.setItem(STORAGE_KEY, JSON.stringify(unsyncedUuids)); }); test('renders tasks component and the mocked BottomBar', async () => { @@ -123,7 +152,7 @@ describe('Tasks Component', () => { render(); - expect(await screen.findByText('Task 1')).toBeInTheDocument(); + expect(await screen.findByText('Normal Synced Task')).toBeInTheDocument(); expect(screen.getByLabelText('Show:')).toHaveValue('20'); }); @@ -144,4 +173,53 @@ describe('Tasks Component', () => { expect(screen.getByTestId('current-page')).toHaveTextContent('1'); }); + + test('renders the unsynced count badge on the sync button', async () => { + render(); + + expect(await screen.findByText('Task 12')).toBeInTheDocument(); + + const countBadges = screen.getAllByText('2'); + expect(countBadges.length).toBeGreaterThan(0); + + const syncButton = screen.getAllByText('Sync')[0].closest('button'); + expect(within(syncButton!).getByText('2')).toBeInTheDocument(); + }); + + test('renders an edited, unsynced task correctly', async () => { + render(); + + expect(await screen.findByText('Edited Unsynced Task')).toBeInTheDocument(); + expect(screen.getByText('Unsynced')).toBeInTheDocument(); + }); + + test('renders a new, temporary task correctly', async () => { + render(); + + expect(await screen.findByText('Task 12')).toBeInTheDocument(); + + const dropdown = screen.getByLabelText('Show:'); + fireEvent.change(dropdown, { target: { value: '50' } }); + + expect(await screen.findByText('New Temporary Task')).toBeInTheDocument(); + expect(screen.getAllByText('Unsynced')[0]).toBeInTheDocument(); + expect(screen.getByText('-')).toBeInTheDocument(); + expect(screen.queryByText('-12345')).not.toBeInTheDocument(); + }); + + test('renders a normal, synced task correctly', async () => { + render(); + + expect(await screen.findByText('Task 12')).toBeInTheDocument(); + + const dropdown = screen.getByLabelText('Show:'); + fireEvent.change(dropdown, { target: { value: '50' } }); + + const taskText = await screen.findByText('Normal Synced Task'); + const taskRow = taskText.closest('tr'); + + expect(taskRow).not.toBeNull(); + expect(within(taskRow!).getByText('1')).toBeInTheDocument(); + expect(within(taskRow!).queryByText('Unsynced')).not.toBeInTheDocument(); + }); }); From b7c802ccba011e17da239fa18980291c84016f04 Mon Sep 17 00:00:00 2001 From: Shiva Gupta Date: Fri, 14 Nov 2025 02:02:36 +0530 Subject: [PATCH 2/3] Ensure project edits also trigger unsynced status --- .../components/HomeComponents/Tasks/Tasks.tsx | 55 +++++++++++++++---- 1 file changed, 43 insertions(+), 12 deletions(-) diff --git a/frontend/src/components/HomeComponents/Tasks/Tasks.tsx b/frontend/src/components/HomeComponents/Tasks/Tasks.tsx index 73a78445..82ad6b7d 100644 --- a/frontend/src/components/HomeComponents/Tasks/Tasks.tsx +++ b/frontend/src/components/HomeComponents/Tasks/Tasks.tsx @@ -463,18 +463,49 @@ export const Tasks = ( } }; - const handleProjectSaveClick = (task: Task) => { - task.project = editedProject; - handleEditTaskOnBackend( - props.email, - props.encryptionSecret, - props.UUID, - task.description, - task.tags, - task.id.toString(), - task.project - ); - setIsEditingProject(false); + const handleProjectSaveClick = async (task: Task) => { + try { + const updatedTask: Task = { + ...task, + project: editedProject, + modified: new Date().toISOString(), + }; + + await db.tasks.put(updatedTask); + + const newSet = new Set(unsyncedSet); + newSet.add(task.uuid); + setUnsyncedSet(newSet); + saveUnsyncedToStorage(newSet); + + const updatedTasks = await db.tasks + .where('email') + .equals(props.email) + .toArray(); + setTasks(sortTasksById(updatedTasks, 'desc')); + setTempTasks(sortTasksById(updatedTasks, 'desc')); + + setIsEditingProject(false); + + try { + await handleEditTaskOnBackend( + props.email, + props.encryptionSecret, + props.UUID, + task.description, + task.tags || [], + task.id.toString(), + editedProject + ); + toast.success('Project updated successfully.'); + } catch (backendError) { + console.error('Backend edit-task failed.'); + toast.error('Local save complete, but backend sync failed.'); + } + } catch (localError) { + console.error('Failed to save project locally.'); + toast.error('Failed to save locally.'); + } }; const handleCancelClick = () => { From bc5cfd6d7a06a69e2a63905fde6859421b4cc738 Mon Sep 17 00:00:00 2001 From: Shiva Gupta Date: Sat, 15 Nov 2025 10:41:04 +0530 Subject: [PATCH 3/3] feat(tasks): Implement fuzzy search using Fuse.js Replaces the previous exact-match `.includes()` search with a more flexible and powerful fuzzy search. This allows users to find tasks even with partial matches or typos, improving workflow efficiency. Key changes: - Integrates `fuse.js` to search across the `description`, `project`, and `tags` fields. - Implements a dual-state (`searchInput` / `debouncedTerm`) pattern to keep the input responsive while debouncing the expensive search logic. - Consolidates all filtering (dropdown filters + search) into a single "Master Filter" `useEffect` to ensure all filters work together correctly. - Updates tests using `jest.fakeTimers` and `act` to validate the debounced fuzzy search behavior using a typo search term ("Norml"). Fixes: #162 --- **Stacked PR Notice** This PR is *stacked on top of PR #143*. Please merge **PR #143** first. --- frontend/package-lock.json | 10 ++++ frontend/package.json | 1 + .../components/HomeComponents/Tasks/Tasks.tsx | 46 ++++++++++--------- .../Tasks/__tests__/Tasks.test.tsx | 22 +++++---- 4 files changed, 49 insertions(+), 30 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8cf17b73..1ff07650 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -134,6 +134,7 @@ "framer-motion": "^11.3.8", "fs.realpath": "^1.0.0", "function-bind": "^1.1.2", + "fuse.js": "^7.1.0", "gensync": "^1.0.0-beta.2", "get-caller-file": "^2.0.5", "get-nonce": "^1.0.1", @@ -7748,6 +7749,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/fuse.js": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.1.0.tgz", + "integrity": "sha512-trLf4SzuuUxfusZADLINj+dE8clK1frKdmqiJNb1Es75fmI5oY6X2mxLVUciLLjxqw/xr72Dhy+lER6dGd02FQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=10" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 0241e8e4..34cd23c4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -128,6 +128,7 @@ "framer-motion": "^11.3.8", "fs.realpath": "^1.0.0", "function-bind": "^1.1.2", + "fuse.js": "^7.1.0", "gensync": "^1.0.0-beta.2", "get-caller-file": "^2.0.5", "get-nonce": "^1.0.1", diff --git a/frontend/src/components/HomeComponents/Tasks/Tasks.tsx b/frontend/src/components/HomeComponents/Tasks/Tasks.tsx index 82ad6b7d..c225cbf9 100644 --- a/frontend/src/components/HomeComponents/Tasks/Tasks.tsx +++ b/frontend/src/components/HomeComponents/Tasks/Tasks.tsx @@ -1,6 +1,7 @@ import { useEffect, useState, useCallback } from 'react'; import { Task } from '../../utils/types'; import { ReportsView } from './ReportsView'; +import Fuse from 'fuse.js'; import { Table, TableBody, @@ -130,29 +131,12 @@ export const Tasks = ( _selectedTask?.project || '' ); const [searchTerm, setSearchTerm] = useState(''); + const [debouncedTerm, setDebouncedTerm] = useState(''); const [lastSyncTime, setLastSyncTime] = useState(null); // Debounced search handler const debouncedSearch = debounce((value: string) => { - if (!value) { - setTempTasks( - selectedProjects.length === 0 && - selectedStatuses.length === 0 && - selectedTags.length === 0 - ? tasks - : tempTasks - ); - return; - } - const lowerValue = value.toLowerCase(); - const filtered = tasks.filter( - (task) => - task.description.toLowerCase().includes(lowerValue) || - (task.project && task.project.toLowerCase().includes(lowerValue)) || - (task.tags && - task.tags.some((tag) => tag.toLowerCase().includes(lowerValue))) - ); - setTempTasks(filtered); + setDebouncedTerm(value); setCurrentPage(1); }, 300); @@ -538,6 +522,8 @@ export const Tasks = ( tags: newTask.tags.filter((tag) => tag !== tagToRemove), }); }; + + // Master filter effect useEffect(() => { let filteredTasks = tasks; @@ -563,9 +549,25 @@ export const Tasks = ( ); } - // Sort + set - setTempTasks(sortTasksById(filteredTasks, 'desc')); - }, [selectedProjects, selectedTags, selectedStatuses, tasks]); + // Fuzzy search + if (debouncedTerm) { + const fuseOptions = { + keys: ['description', 'project', 'tags'], + threshold: 0.3, + }; + + const fuse = new Fuse(filteredTasks, fuseOptions); + const searchResults = fuse.search(debouncedTerm); + + filteredTasks = searchResults.map((res) => res.item); + } + + if (!debouncedTerm) { + setTempTasks(sortTasksById(filteredTasks, 'desc')); + } else { + setTempTasks(filteredTasks); + } + }, [selectedProjects, selectedTags, selectedStatuses, tasks, debouncedTerm]); const handleEditTagsClick = (task: Task) => { setEditedTags(task.tags || []); diff --git a/frontend/src/components/HomeComponents/Tasks/__tests__/Tasks.test.tsx b/frontend/src/components/HomeComponents/Tasks/__tests__/Tasks.test.tsx index 8106c7dd..ce23e68a 100644 --- a/frontend/src/components/HomeComponents/Tasks/__tests__/Tasks.test.tsx +++ b/frontend/src/components/HomeComponents/Tasks/__tests__/Tasks.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, fireEvent, within } from '@testing-library/react'; +import { render, screen, fireEvent, within, act } from '@testing-library/react'; import { Tasks } from '../Tasks'; // Mock props for the Tasks component @@ -207,19 +207,25 @@ describe('Tasks Component', () => { expect(screen.queryByText('-12345')).not.toBeInTheDocument(); }); - test('renders a normal, synced task correctly', async () => { - render(); + test('filters tasks with fuzzy search (handles typos)', async () => { + jest.useFakeTimers(); + render(); expect(await screen.findByText('Task 12')).toBeInTheDocument(); const dropdown = screen.getByLabelText('Show:'); fireEvent.change(dropdown, { target: { value: '50' } }); - const taskText = await screen.findByText('Normal Synced Task'); - const taskRow = taskText.closest('tr'); + const searchBar = screen.getByPlaceholderText('Search tasks...'); + fireEvent.change(searchBar, { target: { value: 'Norml' } }); + + act(() => { + jest.advanceTimersByTime(300); + }); + + expect(await screen.findByText('Normal Synced Task')).toBeInTheDocument(); + expect(screen.queryByText('Edited Unsynced Task')).not.toBeInTheDocument(); - expect(taskRow).not.toBeNull(); - expect(within(taskRow!).getByText('1')).toBeInTheDocument(); - expect(within(taskRow!).queryByText('Unsynced')).not.toBeInTheDocument(); + jest.useRealTimers(); }); });