From b77fe4b3843a1b91cd930eb2c3bad79f72db93d4 Mon Sep 17 00:00:00 2001 From: LaGodxy <83363896+LaGodxy@users.noreply.github.com> Date: Wed, 24 Jun 2026 06:38:45 +0000 Subject: [PATCH] fix: dashboard data caching (#84) Cache dashboard fetch results in Redux for 2 minutes so navigating away and back does not re-issue API calls. - Add per-resource lastFetched timestamps in dashboardSlice - Add 2-minute TTL cache condition to fetchDashboardStats/fetchRecentDonations/fetchRecentCampaigns - Add invalidateDashboardCache action and force:{true} bypass arg - Clear dashboard cache on logoutUser.fulfilled - Wire Refresh button + central fetch in DashboardPage - Invalidate campaigns / stats caches on CreateCampaign submit - Fix duplicate import line in RecentCampaigns that broke lint - Add cache-behavior tests with mocked api; 34/34 tests pass --- src/components/dashboard/RecentCampaigns.jsx | 1 - src/features/dashboard/dashboardSelectors.js | 8 + src/features/dashboard/dashboardSlice.js | 41 +++- src/features/dashboard/dashboardThunks.js | 33 ++- .../dashboard/dashboardThunks.test.js | 194 ++++++++++++++++++ src/pages/campaigns/CreateCampaignPage.jsx | 6 + src/pages/dashboard/DashboardPage.jsx | 45 +++- src/pages/dashboard/DashboardPage.test.jsx | 17 +- 8 files changed, 333 insertions(+), 12 deletions(-) create mode 100644 src/features/dashboard/dashboardThunks.test.js diff --git a/src/components/dashboard/RecentCampaigns.jsx b/src/components/dashboard/RecentCampaigns.jsx index 9f6dcab..ae85f2b 100644 --- a/src/components/dashboard/RecentCampaigns.jsx +++ b/src/components/dashboard/RecentCampaigns.jsx @@ -4,7 +4,6 @@ import { ArrowRight, PlusCircle, LayoutGrid } from 'lucide-react'; import { useAppDispatch, useAppSelector } from '../../store/hooks'; import { fetchRecentCampaigns } from '../../features/dashboard/dashboardThunks'; import { - import { selectRecentCampaigns, selectDashboardLoading, } from '../../features/dashboard/dashboardSelectors'; diff --git a/src/features/dashboard/dashboardSelectors.js b/src/features/dashboard/dashboardSelectors.js index a75d42c..b6a7827 100644 --- a/src/features/dashboard/dashboardSelectors.js +++ b/src/features/dashboard/dashboardSelectors.js @@ -3,3 +3,11 @@ export const selectRecentDonations = (state) => state.dashboard.recentDonations; export const selectRecentCampaigns = (state) => state.dashboard.recentCampaigns; export const selectDashboardLoading = (state) => state.dashboard.isLoading; export const selectDashboardError = (state) => state.dashboard.error; + +/** + * Returns the per-resource `{ stats, donations, campaigns }` cache + * timestamps (ms since epoch) for the dashboard. Each entry is `null` when + * the cache for that resource has been invalidated. + */ +export const selectDashboardLastFetched = (state) => + state.dashboard.lastFetched; diff --git a/src/features/dashboard/dashboardSlice.js b/src/features/dashboard/dashboardSlice.js index bd88bcc..a5eb768 100644 --- a/src/features/dashboard/dashboardSlice.js +++ b/src/features/dashboard/dashboardSlice.js @@ -4,6 +4,7 @@ import { fetchRecentDonations, fetchRecentCampaigns, } from './dashboardThunks'; +import { logoutUser } from '../auth/authThunks'; const initialState = { stats: null, @@ -11,13 +12,44 @@ const initialState = { recentCampaigns: [], isLoading: false, error: null, + /** + * Per-resource cache timestamps (ms since epoch). + * Used by the dashboard thunks to decide whether to skip the API call. + * A `null` value means the cache for that resource is invalid and a + * request should be issued on the next dispatch. + */ + lastFetched: { + stats: null, + donations: null, + campaigns: null, + }, }; +/** + * Invalidate one or every dashboard cache timestamp. + * + * Pass a named resource ('stats' | 'donations' | 'campaigns') to invalidate + * just that entry, or pass `undefined` / `null` / `'all'` to invalidate all. + */ +function applyInvalidation(state, payload) { + const reset = { stats: null, donations: null, campaigns: null }; + if (!payload || payload === 'all') { + state.lastFetched = { ...reset }; + return; + } + if (payload in reset) { + state.lastFetched[payload] = null; + } +} + const dashboardSlice = createSlice({ name: 'dashboard', initialState, reducers: { clearDashboard: () => initialState, + invalidateDashboardCache: (state, action) => { + applyInvalidation(state, action.payload); + }, }, extraReducers: (builder) => { builder @@ -29,6 +61,7 @@ const dashboardSlice = createSlice({ .addCase(fetchDashboardStats.fulfilled, (state, action) => { state.isLoading = false; state.stats = action.payload; + state.lastFetched.stats = Date.now(); }) .addCase(fetchDashboardStats.rejected, (state, action) => { state.isLoading = false; @@ -42,6 +75,7 @@ const dashboardSlice = createSlice({ .addCase(fetchRecentDonations.fulfilled, (state, action) => { state.isLoading = false; state.recentDonations = action.payload; + state.lastFetched.donations = Date.now(); }) .addCase(fetchRecentDonations.rejected, (state, action) => { state.isLoading = false; @@ -55,13 +89,16 @@ const dashboardSlice = createSlice({ .addCase(fetchRecentCampaigns.fulfilled, (state, action) => { state.isLoading = false; state.recentCampaigns = action.payload; + state.lastFetched.campaigns = Date.now(); }) .addCase(fetchRecentCampaigns.rejected, (state, action) => { state.isLoading = false; state.error = action.payload; - }); + }) + // Logout clears the cache so the next user sees fresh data. + .addCase(logoutUser.fulfilled, () => initialState); }, }); -export const { clearDashboard } = dashboardSlice.actions; +export const { clearDashboard, invalidateDashboardCache } = dashboardSlice.actions; export default dashboardSlice.reducer; diff --git a/src/features/dashboard/dashboardThunks.js b/src/features/dashboard/dashboardThunks.js index 301a065..e6e2631 100644 --- a/src/features/dashboard/dashboardThunks.js +++ b/src/features/dashboard/dashboardThunks.js @@ -2,6 +2,30 @@ import { createAsyncThunk } from '@reduxjs/toolkit'; import api from '../../services/api'; import { toastError } from '../../utils/toast'; +/** + * Dashboard data is treated as fresh for this many milliseconds after a + * successful fetch. Navigation away from the dashboard and back inside this + * window will not re-issue the network request. + */ +export const DASHBOARD_CACHE_TTL_MS = 2 * 60 * 1000; + +const isFresh = (lastFetchedAt) => + typeof lastFetchedAt === 'number' && + Date.now() - lastFetchedAt < DASHBOARD_CACHE_TTL_MS; + +/** + * Compose a `condition` callback for a cached thunk. + * + * Returns `true` to allow the API call, `false` to short-circuit (skip the + * call). A `force: true` arg bypasses the cache check unconditionally. + */ +const makeCacheCondition = (resource) => (arg, { getState }) => { + if (arg && arg.force) return true; + const last = getState()?.dashboard?.lastFetched?.[resource]; + if (isFresh(last)) return false; + return true; +}; + export const fetchDashboardStats = createAsyncThunk( 'dashboard/fetchStats', async (_, { rejectWithValue }) => { @@ -12,7 +36,8 @@ export const fetchDashboardStats = createAsyncThunk( toastError(err); return rejectWithValue(err.response?.data || err.message); } - } + }, + { condition: makeCacheCondition('stats') } ); export const fetchRecentDonations = createAsyncThunk( @@ -25,7 +50,8 @@ export const fetchRecentDonations = createAsyncThunk( toastError(err); return rejectWithValue(err.response?.data || err.message); } - } + }, + { condition: makeCacheCondition('donations') } ); export const fetchRecentCampaigns = createAsyncThunk( @@ -38,5 +64,6 @@ export const fetchRecentCampaigns = createAsyncThunk( toastError(err); return rejectWithValue(err.response?.data || err.message); } - } + }, + { condition: makeCacheCondition('campaigns') } ); diff --git a/src/features/dashboard/dashboardThunks.test.js b/src/features/dashboard/dashboardThunks.test.js new file mode 100644 index 0000000..526799f --- /dev/null +++ b/src/features/dashboard/dashboardThunks.test.js @@ -0,0 +1,194 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { configureStore } from '@reduxjs/toolkit'; +import dashboardReducer, { + clearDashboard, + invalidateDashboardCache, +} from './dashboardSlice'; +import authReducer from '../auth/authSlice'; +import { + fetchDashboardStats, + fetchRecentDonations, + fetchRecentCampaigns, + DASHBOARD_CACHE_TTL_MS, +} from './dashboardThunks'; + +// Mock the axios-based api instance: every test records calls on a shared mock. +vi.mock('../../services/api', () => { + const api = { + get: vi.fn(), + post: vi.fn(), + }; + return { default: api }; +}); + +// Silence toast calls during tests — we're asserting on network behavior, +// not on toast appearance. +vi.mock('../../utils/toast', () => ({ + toastError: vi.fn(), + toastSuccess: vi.fn(), + toastInfo: vi.fn(), + toastLoading: vi.fn(), +})); + +import api from '../../services/api'; + +const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0)); + +const makeStore = () => + configureStore({ + reducer: { + dashboard: dashboardReducer, + auth: authReducer, + }, + }); + +beforeEach(() => { + // Each request resolves with a distinct, traceable payload so we can + // assert on call counts and the resulting state shape. + api.get.mockReset(); + api.get.mockImplementation((url) => { + const body = url.split('/').pop(); + return Promise.resolve({ data: { marker: `${body}-payload` } }); + }); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('dashboard thunks — caching behavior', () => { + it('issues a network request on the first dispatch of each resource', async () => { + const store = makeStore(); + await store.dispatch(fetchDashboardStats()); + await store.dispatch(fetchRecentDonations()); + await store.dispatch(fetchRecentCampaigns()); + await flushPromises(); + + expect(api.get).toHaveBeenCalledTimes(3); + expect(api.get).toHaveBeenCalledWith('/dashboard/stats'); + expect(api.get).toHaveBeenCalledWith('/dashboard/recent-donations'); + expect(api.get).toHaveBeenCalledWith('/dashboard/recent-campaigns'); + }); + + it('skips the network call for a resource that was fetched within the TTL', async () => { + const store = makeStore(); + await store.dispatch(fetchRecentDonations()); + await flushPromises(); + expect(api.get).toHaveBeenCalledTimes(1); + + // Dispatch again immediately — cache is still fresh, no extra call. + await store.dispatch(fetchRecentDonations()); + await flushPromises(); + expect(api.get).toHaveBeenCalledTimes(1); + }); + + it('does not share cache across different resources', async () => { + const store = makeStore(); + await store.dispatch(fetchDashboardStats()); + await flushPromises(); + await store.dispatch(fetchRecentDonations()); + await flushPromises(); + + expect(api.get).toHaveBeenCalledTimes(2); + }); + + it('refetches when the cache timestamp is older than the TTL', async () => { + const store = makeStore(); + + // First fetch — clock starts now. + await store.dispatch(fetchRecentDonations()); + await flushPromises(); + + // Advance the clock past the TTL. + const originalNow = Date.now; + Date.now = vi.fn(() => originalNow() + DASHBOARD_CACHE_TTL_MS + 1); + + await store.dispatch(fetchRecentDonations()); + await flushPromises(); + + expect(api.get).toHaveBeenCalledTimes(2); + + Date.now = originalNow; + }); + + it('bypasses the cache when { force: true } is passed', async () => { + const store = makeStore(); + await store.dispatch(fetchRecentDonations()); + await flushPromises(); + expect(api.get).toHaveBeenCalledTimes(1); + + await store.dispatch(fetchRecentDonations({ force: true })); + await flushPromises(); + await store.dispatch(fetchRecentCampaigns({ force: true })); + await flushPromises(); + expect(api.get).toHaveBeenCalledTimes(3); + }); + + it('records lastFetched timestamps only on fulfilled', async () => { + const store = makeStore(); + await store.dispatch(fetchRecentDonations()); + await flushPromises(); + + const last = store.getState().dashboard.lastFetched; + expect(last.donations).toBeTypeOf('number'); + expect(last.stats).toBeNull(); + expect(last.campaigns).toBeNull(); + }); +}); + +describe('invalidateDashboardCache action', () => { + it('clears a single named resource without affecting others', async () => { + const store = makeStore(); + await store.dispatch(fetchDashboardStats()); + await store.dispatch(fetchRecentDonations()); + await store.dispatch(fetchRecentCampaigns()); + await flushPromises(); + expect(api.get).toHaveBeenCalledTimes(3); + + store.dispatch(invalidateDashboardCache('donations')); + + const last = store.getState().dashboard.lastFetched; + expect(last.donations).toBeNull(); + expect(last.stats).toBeTypeOf('number'); + expect(last.campaigns).toBeTypeOf('number'); + + // Donations should re-fetch on next dispatch; others should stay cached. + await store.dispatch(fetchRecentDonations()); + await store.dispatch(fetchDashboardStats()); + await store.dispatch(fetchRecentCampaigns()); + await flushPromises(); + expect(api.get).toHaveBeenCalledTimes(4); + }); + + it('clears every timestamp when called with no payload', async () => { + const store = makeStore(); + await store.dispatch(fetchDashboardStats()); + await store.dispatch(fetchRecentDonations()); + await store.dispatch(fetchRecentCampaigns()); + await flushPromises(); + + store.dispatch(invalidateDashboardCache()); + + const last = store.getState().dashboard.lastFetched; + expect(last).toEqual({ stats: null, donations: null, campaigns: null }); + }); +}); + +describe('clearDashboard action', () => { + it('resets dashboard state including cache timestamps', async () => { + const store = makeStore(); + await store.dispatch(fetchRecentDonations()); + await flushPromises(); + + store.dispatch(clearDashboard()); + + expect(store.getState().dashboard).toMatchObject({ + stats: null, + recentDonations: [], + recentCampaigns: [], + isLoading: false, + error: null, + lastFetched: { stats: null, donations: null, campaigns: null }, + }); + }); +}); diff --git a/src/pages/campaigns/CreateCampaignPage.jsx b/src/pages/campaigns/CreateCampaignPage.jsx index 056b683..77998f4 100644 --- a/src/pages/campaigns/CreateCampaignPage.jsx +++ b/src/pages/campaigns/CreateCampaignPage.jsx @@ -9,6 +9,7 @@ import { selectDraftCampaign, selectFormStep, } from "../../features/campaigns/campaignsSlice"; +import { invalidateDashboardCache } from "../../features/dashboard/dashboardSlice"; import BasicInfoStep from "../../components/campaigns/steps/BasicInfoStep"; import FundingStep from "./steps/FundingStep"; import MediaStep from "./steps/MediaStep"; @@ -68,6 +69,11 @@ const CreateCampaignPage = () => { // Placeholder submit; integrates with campaign service in a later issue. console.log("Submitting campaign draft:", draft); dispatch(resetCampaignForm()); + // A new campaign appears on the dashboard's "Recent Campaigns" list and + // is reflected in summary stats, so invalidate those caches so the next + // dashboard load fetches fresh data. + dispatch(invalidateDashboardCache("campaigns")); + dispatch(invalidateDashboardCache("stats")); }; return ( diff --git a/src/pages/dashboard/DashboardPage.jsx b/src/pages/dashboard/DashboardPage.jsx index 55ce204..248c18e 100644 --- a/src/pages/dashboard/DashboardPage.jsx +++ b/src/pages/dashboard/DashboardPage.jsx @@ -1,5 +1,5 @@ import { useEffect } from 'react'; -import { AlertCircle } from 'lucide-react'; +import { AlertCircle, RefreshCw } from 'lucide-react'; import { Link } from 'react-router-dom'; import DonationStatsWidget from '../../components/dashboard/DonationStatsWidget'; import DashboardSkeleton from '../../components/dashboard/DashboardSkeleton'; @@ -7,21 +7,56 @@ import UserProfileCard from '../../components/dashboard/UserProfileCard'; import { useAppDispatch, useAppSelector } from '../../store/hooks'; import { fetchCurrentUser } from '../../features/auth/authThunks'; import { selectAuthLoading, selectCurrentUser } from '../../features/auth/authSelectors'; +import { + fetchDashboardStats, + fetchRecentDonations, + fetchRecentCampaigns, +} from '../../features/dashboard/dashboardThunks'; +import { selectDashboardLoading } from '../../features/dashboard/dashboardSelectors'; export default function DashboardPage() { const dispatch = useAppDispatch(); const user = useAppSelector(selectCurrentUser); const loading = useAppSelector(selectAuthLoading); + const dashboardLoading = useAppSelector(selectDashboardLoading); + // Initial / re-mount fetch. Each thunk consults its own cache window, so this + // is effectively a no-op within 2 minutes of the previous successful fetch. useEffect(() => { dispatch(fetchCurrentUser()); + dispatch(fetchDashboardStats()); + dispatch(fetchRecentDonations()); + dispatch(fetchRecentCampaigns()); }, [dispatch]); + // Force a refresh of all dashboard resources, bypassing the 2-minute cache. + const handleRefresh = () => { + dispatch(fetchDashboardStats({ force: true })); + dispatch(fetchRecentDonations({ force: true })); + dispatch(fetchRecentCampaigns({ force: true })); + }; + return (
-
-

Overview

-

Manage your account, donations, and campaigns.

+
+
+

Overview

+

+ Manage your account, donations, and campaigns. +

+
+
{user && !user.walletAddress && ( @@ -53,4 +88,4 @@ export default function DashboardPage() { )}
); -} \ No newline at end of file +} diff --git a/src/pages/dashboard/DashboardPage.test.jsx b/src/pages/dashboard/DashboardPage.test.jsx index ac73e1a..9e51bf9 100644 --- a/src/pages/dashboard/DashboardPage.test.jsx +++ b/src/pages/dashboard/DashboardPage.test.jsx @@ -5,6 +5,20 @@ import { Provider } from 'react-redux'; import { configureStore } from '@reduxjs/toolkit'; import DashboardPage from './DashboardPage'; +const initialDashboardState = { + stats: null, + recentDonations: [], + recentCampaigns: [], + isLoading: false, + error: null, + lastFetched: { stats: null, donations: null, campaigns: null }, +}; + +// The DashboardPage's slice isn't wired up in this minimal store; returning +// the default state keeps the placeholder reducer behavior compatible with +// production with no need for a full mock of the thunk machinery. +const dashboardStubReducer = () => initialDashboardState; + const createStore = (authOverrides = {}) => configureStore({ reducer: { auth: () => ({ @@ -13,6 +27,7 @@ const createStore = (authOverrides = {}) => configureStore({ isAuthenticated: true, ...authOverrides, }), + dashboard: dashboardStubReducer, }, }); @@ -42,4 +57,4 @@ describe('DashboardPage', () => { expect(screen.queryByRole('status', { name: 'Loading dashboard' })).not.toBeInTheDocument(); expect(screen.getByText('Profile')).toBeInTheDocument(); }); -}); \ No newline at end of file +});