From a10f7952fe95d65342be6317b929128908d08a3d Mon Sep 17 00:00:00 2001 From: Shiva Gupta Date: Fri, 14 Nov 2025 01:18:18 +0530 Subject: [PATCH 1/2] 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/2] 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 = () => {