diff --git a/client/src/__test__/views/Home.test.tsx b/client/src/__test__/views/Home.test.tsx index 04db8a7c..e0510e9a 100644 --- a/client/src/__test__/views/Home.test.tsx +++ b/client/src/__test__/views/Home.test.tsx @@ -8,6 +8,7 @@ import api from '../../api-service/api'; import { TaskResponse } from '../../types/TaskResponse'; import { NoteResponse } from '../../types/NoteResponse'; import Home from '../../views/Home'; +import { clearHomeCache } from '../../utils/HomeCache'; // filepath: client/src/views/Home/index.test.tsx @@ -40,6 +41,7 @@ vi.mock('../../utils/TranslatorUtils', () => ({ })); vi.mock('react-router', () => ({ + useLocation: () => ({ state: null }), NavLink: ({ to, children }: { to: string, children: React.ReactNode }) => ( {children} ) @@ -227,6 +229,7 @@ describe('Home Component', () => { beforeEach(() => { // Reset mocks and setup default responses + clearHomeCache(); vi.clearAllMocks(); localStorage.clear(); (api.getJSON as any).mockImplementation((url: string) => { @@ -984,4 +987,4 @@ describe('Home Component', () => { // First note should have preview with the first two lines expect(noteCards[0].textContent).toBe('Line 1\nLine 2'); });*/ -}); \ No newline at end of file +}); diff --git a/client/src/__test__/views/HomeNavigation.test.tsx b/client/src/__test__/views/HomeNavigation.test.tsx new file mode 100644 index 00000000..109a5fe7 --- /dev/null +++ b/client/src/__test__/views/HomeNavigation.test.tsx @@ -0,0 +1,201 @@ +import React from 'react'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter, Route, Routes, useNavigate } from 'react-router'; +import AuthContext from '../../context/AuthContext'; +import FilterProvider from '../../context/FilterProvider'; +import SidebarContext from '../../context/SidebarContext'; +import api from '../../api-service/api'; +import Home from '../../views/Home'; +import { + cacheHomeItems, + clearHomeCache, + getCachedHomeItems +} from '../../utils/HomeCache'; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { language: 'en', changeLanguage: vi.fn() } + }) +})); + +vi.mock('../../api-service/api', () => ({ + default: { + getJSON: vi.fn(), + postJSON: vi.fn(), + patchJSON: vi.fn(), + putJSON: vi.fn(), + deleteNoContent: vi.fn() + } +})); + +vi.mock('../../lang-service/LangHandler', () => ({ + handleDefaultLang: vi.fn() +})); + +vi.mock('../../utils/TranslatorUtils', () => ({ + translateServerResponse: (text: string) => text, + translateTaskResponse: (tasks: unknown[]) => tasks +})); + +const user = { + userId: 9, + name: 'Test User', + email: 'test@example.com', + admin: false, + createdAt: new Date(), + gravatarImageUrl: '', + lang: 'en', + lastLogin: '', + theme: 'light' +}; + +const authValue = { + signed: true, + loading: false, + user, + checkCurrentAuthUser: vi.fn(), + signIn: vi.fn(), + signOut: vi.fn(), + register: vi.fn(), + isAdmin: false, + updateUser: vi.fn() +}; + +const task = (id: number, description: string) => ({ + id, + description, + completed: false, + urls: [], + tags: [], + lastUpdate: '2026-09-25', + highPriority: false, + dueDateFmt: '', + dueDate: '' +}); + +function FormRoute() { + const navigate = useNavigate(); + + return ( + <> + + + + ); +} + +function TestApp() { + return ( + + + + + + + } /> + } /> + + + + + + ); +} + +function NavigateToForm() { + const navigate = useNavigate(); + return ; +} + +const getItemCalls = () => (api.getJSON as ReturnType).mock.calls + .filter(([url]) => String(url).includes('/home/items')); + +const getTagCalls = () => (api.getJSON as ReturnType).mock.calls + .filter(([url]) => String(url).includes('/tasks/tags')); + +describe('Home navigation cache', () => { + beforeEach(() => { + clearHomeCache(); + localStorage.clear(); + vi.clearAllMocks(); + (api.getJSON as ReturnType).mockImplementation((url: string) => { + if (url.includes('/tasks/tags')) { + return Promise.resolve(['work']); + } + if (url.includes('/home/items')) { + return Promise.resolve({ tasks: [task(1, 'Saved from server')], notes: [] }); + } + return Promise.resolve([]); + }); + }); + + afterEach(() => { + clearHomeCache(); + }); + + test('keeps cached responses separate from the API and Home view', () => { + const response = { tasks: [task(1, 'Cached')], notes: [] }; + cacheHomeItems(user.userId, '/home/items', response); + + response.tasks[0].lastUpdate = 'changed by Home'; + response.tasks[0].tags.push('changed by API'); + + const firstRead = getCachedHomeItems(user.userId, '/home/items'); + expect(firstRead?.tasks[0].lastUpdate).toBe('2026-09-25'); + expect(firstRead?.tasks[0].tags).toEqual([]); + + firstRead!.tasks[0].lastUpdate = 'changed by Home'; + firstRead!.tasks[0].urls.push('changed by Home'); + + const secondRead = getCachedHomeItems(user.userId, '/home/items'); + expect(secondRead?.tasks[0].lastUpdate).toBe('2026-09-25'); + expect(secondRead?.tasks[0].urls).toEqual([]); + }); + + test('returns to cached home data without fetching when a form is cancelled', async () => { + render(); + + await waitFor(() => expect(getItemCalls()).toHaveLength(1)); + await waitFor(() => expect(getTagCalls()).toHaveLength(1)); + expect(await screen.findByText('Saved from server')).toBeDefined(); + + fireEvent.click(screen.getByRole('button', { name: 'Add task' })); + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + + expect(await screen.findByText('Saved from server')).toBeDefined(); + expect(getItemCalls()).toHaveLength(1); + expect(getTagCalls()).toHaveLength(1); + }); + + test('refreshes home items and tags after a form is saved', async () => { + let itemRequest = 0; + (api.getJSON as ReturnType).mockImplementation((url: string) => { + if (url.includes('/tasks/tags')) { + return Promise.resolve(itemRequest === 0 ? ['work'] : ['work', 'new']); + } + if (url.includes('/home/items')) { + itemRequest += 1; + return Promise.resolve({ + tasks: [task(1, itemRequest === 1 ? 'Before save' : 'After save')], + notes: [] + }); + } + return Promise.resolve([]); + }); + + render(); + expect(await screen.findByText('Before save')).toBeDefined(); + await waitFor(() => expect(getTagCalls()).toHaveLength(1)); + + fireEvent.click(screen.getByRole('button', { name: 'Add task' })); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + + expect(await screen.findByText('After save')).toBeDefined(); + await waitFor(() => { + expect(getItemCalls()).toHaveLength(2); + expect(getTagCalls()).toHaveLength(2); + }); + }); +}); diff --git a/client/src/utils/HomeCache.ts b/client/src/utils/HomeCache.ts new file mode 100644 index 00000000..932462e6 --- /dev/null +++ b/client/src/utils/HomeCache.ts @@ -0,0 +1,75 @@ +import { HomeItemsResponse } from '../types/HomeItemsResponse'; + +const itemsByUser = new Map(); +const tagsByUser = new Map(); + +const copyHomeItems = (items: HomeItemsResponse): HomeItemsResponse => ({ + tasks: items.tasks.map(task => ({ ...task, tags: [...task.tags], urls: [...task.urls] })), + notes: items.notes.map(note => ({ ...note, tags: [...note.tags] })) +}); + +/** + * Return the last home response for this user and filter, if one is available. + * + * @param {number} userId - The signed-in user's ID. + * @param {string} itemsUrl - The URL identifying the current filter. + * @returns {HomeItemsResponse | undefined} A cached response, if available. + */ +export const getCachedHomeItems = ( + userId: number, + itemsUrl: string +): HomeItemsResponse | undefined => { + const cached = itemsByUser.get(userId); + return cached?.itemsUrl === itemsUrl + ? copyHomeItems(cached.items) + : undefined; +}; + +/** + * Store the latest home response for this user and filter. + * + * @param {number} userId - The signed-in user's ID. + * @param {string} itemsUrl - The URL identifying the current filter. + * @param {HomeItemsResponse} items - The response returned by the home API. + */ +export const cacheHomeItems = ( + userId: number, + itemsUrl: string, + items: HomeItemsResponse +): void => { + itemsByUser.set(userId, { + itemsUrl, + items: copyHomeItems(items) + }); +}; + +/** + * Return the last tag list loaded for this user, if one is available. + * + * @param {number} userId - The signed-in user's ID. + * @returns {string[] | undefined} A cached tag list, if available. + */ +export const getCachedHomeTags = (userId: number): string[] | undefined => { + const tags = tagsByUser.get(userId); + return tags ? [...tags] : undefined; +}; + +/** + * Store the latest tag list loaded for this user. + * + * @param {number} userId - The signed-in user's ID. + * @param {string[]} tags - The tags returned by the home API. + */ +export const cacheHomeTags = (userId: number, tags: string[]): void => { + tagsByUser.set(userId, [...tags]); +}; + +/** + * Clear the in-memory home data cache. + * + * This is exported so tests can start with an empty cache. + */ +export const clearHomeCache = (): void => { + itemsByUser.clear(); + tagsByUser.clear(); +}; diff --git a/client/src/views/Home/index.tsx b/client/src/views/Home/index.tsx index 79a107e1..fa8f4183 100644 --- a/client/src/views/Home/index.tsx +++ b/client/src/views/Home/index.tsx @@ -24,12 +24,18 @@ import FilterContext from '../../context/FilterContext'; import ContentHeader from '../../components/ContentHeader'; import AlertError from '../../components/AlertError'; import { CheckSquare, JournalText, ThreeDotsVertical } from 'react-bootstrap-icons'; -import { NavLink } from 'react-router'; +import { NavLink, useLocation } from 'react-router'; import ModalMarkdown from '../../components/ModalMarkdown'; import TaskTitle from '../../components/TaskTitle'; import TaskTimeLeft from '../../components/TaskTimeLeft'; import TaskTag from '../../components/TaskTag'; import NoteTitle from '../../components/NoteTitle'; +import { + cacheHomeItems, + cacheHomeTags, + getCachedHomeItems, + getCachedHomeTags +} from '../../utils/HomeCache'; const OPEN_NOTE_ID_KEY = 'OPEN_NOTE_ID'; const SEARCH_DEBOUNCE_MS = 300; @@ -45,6 +51,9 @@ function Home(): React.ReactNode { const { user } = useContext(AuthContext); const { filterText, selectedOption, setFilterText, setSelectedOption } = useContext(FilterContext); const { i18n, t } = useTranslation(); + const location = useLocation(); + const refreshAfterSave + = (location.state as { refreshHome?: boolean } | null)?.refreshHome === true; const [errorMessage, setErrorMessage] = useState(''); const [tags, setTags] = useState([]); const [name, setName] = useState(user?.name ? user?.name : 'User'); @@ -72,10 +81,21 @@ function Home(): React.ReactNode { } }; - const loadTags = async (): Promise => { + const loadTags = async (forceRefresh = false): Promise => { + if (!forceRefresh && user) { + const cachedTags = getCachedHomeTags(user.userId); + if (cachedTags) { + setTags(cachedTags); + return; + } + } + try { const response: string[] = await api.getJSON(`${ApiConfig.homeUrl}/tasks/tags`); setTags(response); + if (user) { + cacheHomeTags(user.userId, response); + } } catch (e) { handleError(e); @@ -106,7 +126,7 @@ function Home(): React.ReactNode { try { await api.deleteNoContent(`${ApiConfig.tasksUrl}/${taskIdParam}`); await loadItems(filterText, selectedOption); - await loadTags(); + await loadTags(true); } catch (e) { handleError(e); @@ -122,7 +142,7 @@ function Home(): React.ReactNode { try { await api.deleteNoContent(`${ApiConfig.notesUrl}/${noteIdParam}`); await loadItems(filterText, selectedOption); - await loadTags(); + await loadTags(true); } catch (e) { handleError(e); @@ -275,40 +295,60 @@ function Home(): React.ReactNode { * @param {string} text - The search text. * @param {string | undefined} option - The selected filter option. */ - const loadItems = async (text: string, option: string | undefined): Promise => { + const loadItems = async ( + text: string, + option: string | undefined, + forceRefresh = true + ): Promise => { + const itemsUrl = buildItemsUrl(text, option); + if (!forceRefresh && user) { + const cachedItems = getCachedHomeItems(user.userId, itemsUrl); + if (cachedItems) { + updateHomeItems(cachedItems); + return; + } + } + try { - const response: HomeItemsResponse = await api.getJSON(buildItemsUrl(text, option)); - - const translatedTasks = translateTaskResponse(response.tasks ?? [], i18n.language); - translatedTasks.sort((t1, t2) => { - if (t1.completed === t2.completed) { - if (t1.highPriority === t2.highPriority) { - return 0; - } - if (t1.highPriority) { - return -1; - } - return 1; + const response: HomeItemsResponse = await api.getJSON(itemsUrl); + if (user) { + cacheHomeItems(user.userId, itemsUrl, response); + } + + updateHomeItems(response); + } + catch (e) { + handleError(e); + } + }; + + const updateHomeItems = (response: HomeItemsResponse): void => { + const translatedTasks = translateTaskResponse(response.tasks ?? [], i18n.language); + translatedTasks.sort((t1, t2) => { + if (t1.completed === t2.completed) { + if (t1.highPriority === t2.highPriority) { + return 0; } - if (t1.completed) { + if (t1.highPriority) { return -1; } return 1; - }); + } + if (t1.completed) { + return -1; + } + return 1; + }); - const fetchedNotes = [...(response.notes ?? [])]; - fetchedNotes.sort((n1, n2) => (n1.id > n2.id) ? -1 : 1); + const fetchedNotes = [...(response.notes ?? [])]; + fetchedNotes.sort((n1, n2) => (n1.id > n2.id) ? -1 : 1); - setTasks(translatedTasks.filter((task: TaskResponse) => !task.completed)); - setCompletedTasks(translatedTasks.filter((task: TaskResponse) => task.completed)); + setTasks(translatedTasks.filter((task: TaskResponse) => !task.completed)); + setCompletedTasks(translatedTasks.filter((task: TaskResponse) => task.completed)); - const { active, archived } = partitionNotes(fetchedNotes); - setNotes(active); - setArchivedNotes(archived); - } - catch (e) { - handleError(e); - } + const { active, archived } = partitionNotes(fetchedNotes); + setNotes(active); + setArchivedNotes(archived); }; const cleanText = (text: string): string => { @@ -426,15 +466,15 @@ function Home(): React.ReactNode { useEffect(() => { handleDefaultLang(user?.lang); setName(user?.name ?? 'User'); - loadTags(); - }, [user]); + void loadTags(refreshAfterSave); + }, [user, refreshAfterSave]); useEffect(() => { const timer = setTimeout(() => { - void loadItems(filterText, selectedOption); + void loadItems(filterText, selectedOption, refreshAfterSave); }, SEARCH_DEBOUNCE_MS); return () => clearTimeout(timer); - }, [filterText, selectedOption, user]); + }, [filterText, selectedOption, user, refreshAfterSave]); useEffect(() => { const openNoteId = localStorage.getItem(OPEN_NOTE_ID_KEY); diff --git a/client/src/views/NoteAdd/index.tsx b/client/src/views/NoteAdd/index.tsx index f1bcaf25..3a7c4ad6 100644 --- a/client/src/views/NoteAdd/index.tsx +++ b/client/src/views/NoteAdd/index.tsx @@ -384,7 +384,7 @@ function NoteAdd(): React.ReactNode { if (saved) { clearDraft(); resetInputs(); - navigate('/home'); + navigate('/home', { state: { refreshHome: true } }); } return saved; diff --git a/client/src/views/TaskAdd/index.tsx b/client/src/views/TaskAdd/index.tsx index 3f0d6a86..603eee05 100644 --- a/client/src/views/TaskAdd/index.tsx +++ b/client/src/views/TaskAdd/index.tsx @@ -273,7 +273,7 @@ function TaskAdd(): React.ReactNode { clearDraft(); form.reset(); resetInputs(); - navigate('/home'); + navigate('/home', { state: { refreshHome: true } }); } } else if (action === 'edit') { @@ -294,7 +294,7 @@ function TaskAdd(): React.ReactNode { clearDraft(); form.reset(); resetInputs(); - navigate('/home'); + navigate('/home', { state: { refreshHome: true } }); } } };