Skip to content

Commit 50d5418

Browse files
authored
Last sync timestamp feature (#133)
* Add last sync timestamp display feature - Add getTimeSinceLastSync utility function to format relative time - Store last sync timestamp in localStorage per user - Display "Last updated X ago" below Sync button - Auto-refresh display every 10 seconds - Add comprehensive tests for time formatting Fixes #124 * Fix prettier formatting issues * Fix Tasks component test by adding missing mock functions * Fix Prettier formatting in Tasks.test.tsx * Hash email in localStorage key for privacy - Add hashKey() function to create hash of key+email - Update localStorage to use hashed keys instead of plain email - Prevents storing email addresses directly in localStorage - Add comprehensive tests for hashKey function (5 new tests) - All 29 tests passing Addresses review feedback from @its-me-abhishek
1 parent 9217165 commit 50d5418

4 files changed

Lines changed: 216 additions & 18 deletions

File tree

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

Lines changed: 57 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ import {
4545
Props,
4646
sortTasks,
4747
sortTasksById,
48+
getTimeSinceLastSync,
49+
hashKey,
4850
} from './tasks-utils';
4951
import Pagination from './Pagination';
5052
import { url } from '@/components/utils/URLs';
@@ -105,6 +107,7 @@ export const Tasks = (
105107
);
106108
const [isEditingTags, setIsEditingTags] = useState(false);
107109
const [searchTerm, setSearchTerm] = useState('');
110+
const [lastSyncTime, setLastSyncTime] = useState<number | null>(null);
108111

109112
// Debounced search handler
110113
const debouncedSearch = debounce((value: string) => {
@@ -148,6 +151,25 @@ export const Tasks = (
148151
}
149152
}, [_selectedTask]);
150153

154+
// Load last sync time from localStorage on mount
155+
useEffect(() => {
156+
const hashedKey = hashKey('lastSyncTime', props.email);
157+
const storedLastSyncTime = localStorage.getItem(hashedKey);
158+
if (storedLastSyncTime) {
159+
setLastSyncTime(parseInt(storedLastSyncTime, 10));
160+
}
161+
}, [props.email]);
162+
163+
// Update the displayed time every 10 seconds
164+
useEffect(() => {
165+
const interval = setInterval(() => {
166+
// Force re-render by updating the state
167+
setLastSyncTime((prevTime) => prevTime);
168+
}, 10000); // Update every 10 seconds
169+
170+
return () => clearInterval(interval);
171+
}, []);
172+
151173
useEffect(() => {
152174
const fetchTasksForEmail = async () => {
153175
try {
@@ -205,6 +227,13 @@ export const Tasks = (
205227
setTasks(sortTasksById(updatedTasks, 'desc'));
206228
setTempTasks(sortTasksById(updatedTasks, 'desc'));
207229
});
230+
231+
// Store last sync timestamp using hashed key
232+
const currentTime = Date.now();
233+
const hashedKey = hashKey('lastSyncTime', user_email);
234+
localStorage.setItem(hashedKey, currentTime.toString());
235+
setLastSyncTime(currentTime);
236+
208237
toast.success(`Tasks synced successfully!`);
209238
} catch (error) {
210239
console.error('Error syncing tasks:', error);
@@ -669,9 +698,14 @@ export const Tasks = (
669698
</DialogContent>
670699
</Dialog>
671700
</div>
672-
<Button variant="outline" onClick={syncTasksWithTwAndDb}>
673-
Sync
674-
</Button>
701+
<div className="flex flex-col items-end gap-2">
702+
<Button variant="outline" onClick={syncTasksWithTwAndDb}>
703+
Sync
704+
</Button>
705+
<span className="text-xs text-muted-foreground">
706+
{getTimeSinceLastSync(lastSyncTime)}
707+
</span>
708+
</div>
675709
</div>
676710
</div>
677711

@@ -1220,21 +1254,26 @@ export const Tasks = (
12201254
</DialogContent>
12211255
</Dialog>
12221256
</div>
1223-
<Button
1224-
variant="outline"
1225-
onClick={async () => {
1226-
props.setIsLoading(true);
1227-
await syncTasksWithTwAndDb();
1228-
props.setIsLoading(false);
1229-
}}
1230-
disabled={props.isLoading}
1231-
>
1232-
{props.isLoading ? (
1233-
<Loader2 className="mx-1 size-5 animate-spin" />
1234-
) : (
1235-
'Sync'
1236-
)}
1237-
</Button>
1257+
<div className="flex flex-col items-end gap-2">
1258+
<Button
1259+
variant="outline"
1260+
onClick={async () => {
1261+
props.setIsLoading(true);
1262+
await syncTasksWithTwAndDb();
1263+
props.setIsLoading(false);
1264+
}}
1265+
disabled={props.isLoading}
1266+
>
1267+
{props.isLoading ? (
1268+
<Loader2 className="mx-1 size-5 animate-spin" />
1269+
) : (
1270+
'Sync'
1271+
)}
1272+
</Button>
1273+
<span className="text-xs text-muted-foreground">
1274+
{getTimeSinceLastSync(lastSyncTime)}
1275+
</span>
1276+
</div>
12381277
</div>
12391278
</div>
12401279
<div className="text-l ml-5 text-muted-foreground mt-5 mb-5">

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ jest.mock('../tasks-utils', () => {
2424
...originalModule, // Includes all real functions like sortTasksById
2525
markTaskAsCompleted: jest.fn(), // Overwrite this one with a mock
2626
markTaskAsDeleted: jest.fn(), // And this one
27+
getTimeSinceLastSync: jest
28+
.fn()
29+
.mockReturnValue('Last updated 5 minutes ago'), // Mock this new function
30+
hashKey: jest.fn().mockReturnValue('mockHashedKey'), // Mock the hash function
2731
};
2832
});
2933

frontend/src/components/HomeComponents/Tasks/__tests__/tasks-utils.test.ts

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import {
88
sortTasksById,
99
markTaskAsCompleted,
1010
markTaskAsDeleted,
11+
getTimeSinceLastSync,
12+
hashKey,
1113
} from '../tasks-utils';
1214
import { Task } from '@/components/utils/types';
1315

@@ -278,3 +280,116 @@ describe('markTaskAsDeleted', () => {
278280
});
279281
});
280282
});
283+
284+
describe('getTimeSinceLastSync', () => {
285+
let originalDateNow: () => number;
286+
287+
beforeAll(() => {
288+
originalDateNow = Date.now;
289+
});
290+
291+
afterAll(() => {
292+
Date.now = originalDateNow;
293+
});
294+
295+
it('returns "Never synced" when lastSyncTimestamp is null', () => {
296+
expect(getTimeSinceLastSync(null)).toBe('Never synced');
297+
});
298+
299+
it('returns correct message for seconds ago', () => {
300+
const now = 1000000000000;
301+
Date.now = jest.fn(() => now);
302+
const lastSync = now - 30000; // 30 seconds ago
303+
expect(getTimeSinceLastSync(lastSync)).toBe('Last updated 30 seconds ago');
304+
});
305+
306+
it('returns correct message for 1 second ago', () => {
307+
const now = 1000000000000;
308+
Date.now = jest.fn(() => now);
309+
const lastSync = now - 1000; // 1 second ago
310+
expect(getTimeSinceLastSync(lastSync)).toBe('Last updated 1 second ago');
311+
});
312+
313+
it('returns correct message for minutes ago', () => {
314+
const now = 1000000000000;
315+
Date.now = jest.fn(() => now);
316+
const lastSync = now - 5 * 60 * 1000; // 5 minutes ago
317+
expect(getTimeSinceLastSync(lastSync)).toBe('Last updated 5 minutes ago');
318+
});
319+
320+
it('returns correct message for 1 minute ago', () => {
321+
const now = 1000000000000;
322+
Date.now = jest.fn(() => now);
323+
const lastSync = now - 60 * 1000; // 1 minute ago
324+
expect(getTimeSinceLastSync(lastSync)).toBe('Last updated 1 minute ago');
325+
});
326+
327+
it('returns correct message for hours ago', () => {
328+
const now = 1000000000000;
329+
Date.now = jest.fn(() => now);
330+
const lastSync = now - 3 * 60 * 60 * 1000; // 3 hours ago
331+
expect(getTimeSinceLastSync(lastSync)).toBe('Last updated 3 hours ago');
332+
});
333+
334+
it('returns correct message for 1 hour ago', () => {
335+
const now = 1000000000000;
336+
Date.now = jest.fn(() => now);
337+
const lastSync = now - 60 * 60 * 1000; // 1 hour ago
338+
expect(getTimeSinceLastSync(lastSync)).toBe('Last updated 1 hour ago');
339+
});
340+
341+
it('returns correct message for days ago', () => {
342+
const now = 1000000000000;
343+
Date.now = jest.fn(() => now);
344+
const lastSync = now - 2 * 24 * 60 * 60 * 1000; // 2 days ago
345+
expect(getTimeSinceLastSync(lastSync)).toBe('Last updated 2 days ago');
346+
});
347+
348+
it('returns correct message for 1 day ago', () => {
349+
const now = 1000000000000;
350+
Date.now = jest.fn(() => now);
351+
const lastSync = now - 24 * 60 * 60 * 1000; // 1 day ago
352+
expect(getTimeSinceLastSync(lastSync)).toBe('Last updated 1 day ago');
353+
});
354+
});
355+
356+
describe('hashKey', () => {
357+
it('generates a consistent hash for the same key and email', () => {
358+
const key = 'lastSyncTime';
359+
const email = 'test@example.com';
360+
const hash1 = hashKey(key, email);
361+
const hash2 = hashKey(key, email);
362+
expect(hash1).toBe(hash2);
363+
});
364+
365+
it('generates different hashes for different emails', () => {
366+
const key = 'lastSyncTime';
367+
const email1 = 'test1@example.com';
368+
const email2 = 'test2@example.com';
369+
const hash1 = hashKey(key, email1);
370+
const hash2 = hashKey(key, email2);
371+
expect(hash1).not.toBe(hash2);
372+
});
373+
374+
it('generates different hashes for different keys', () => {
375+
const key1 = 'lastSyncTime';
376+
const key2 = 'otherKey';
377+
const email = 'test@example.com';
378+
const hash1 = hashKey(key1, email);
379+
const hash2 = hashKey(key2, email);
380+
expect(hash1).not.toBe(hash2);
381+
});
382+
383+
it('returns a string', () => {
384+
const hash = hashKey('lastSyncTime', 'test@example.com');
385+
expect(typeof hash).toBe('string');
386+
});
387+
388+
it('does not contain the original email', () => {
389+
const email = 'test@example.com';
390+
const hash = hashKey('lastSyncTime', email);
391+
expect(hash).not.toContain(email);
392+
expect(hash).not.toContain('test');
393+
expect(hash).not.toContain('@');
394+
});
395+
});

frontend/src/components/HomeComponents/Tasks/tasks-utils.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,3 +143,43 @@ export const handleDate = (v: string) => {
143143
}
144144
return true;
145145
};
146+
147+
export const getTimeSinceLastSync = (
148+
lastSyncTimestamp: number | null
149+
): string => {
150+
if (!lastSyncTimestamp) {
151+
return 'Never synced';
152+
}
153+
154+
const now = Date.now();
155+
const diffMs = now - lastSyncTimestamp;
156+
const diffSeconds = Math.floor(diffMs / 1000);
157+
const diffMinutes = Math.floor(diffSeconds / 60);
158+
const diffHours = Math.floor(diffMinutes / 60);
159+
const diffDays = Math.floor(diffHours / 24);
160+
161+
if (diffSeconds < 60) {
162+
return `Last updated ${diffSeconds} second${diffSeconds !== 1 ? 's' : ''} ago`;
163+
} else if (diffMinutes < 60) {
164+
return `Last updated ${diffMinutes} minute${diffMinutes !== 1 ? 's' : ''} ago`;
165+
} else if (diffHours < 24) {
166+
return `Last updated ${diffHours} hour${diffHours !== 1 ? 's' : ''} ago`;
167+
} else {
168+
return `Last updated ${diffDays} day${diffDays !== 1 ? 's' : ''} ago`;
169+
}
170+
};
171+
172+
/**
173+
* Simple hash function for creating a hash of email + key
174+
* This prevents storing plain email addresses in localStorage
175+
*/
176+
export const hashKey = (key: string, email: string): string => {
177+
const str = key + email;
178+
let hash = 0;
179+
for (let i = 0; i < str.length; i++) {
180+
const char = str.charCodeAt(i);
181+
hash = (hash << 5) - hash + char;
182+
hash = hash & hash; // Convert to 32-bit integer
183+
}
184+
return Math.abs(hash).toString(36);
185+
};

0 commit comments

Comments
 (0)