From a5a7a01da09f3372eb05412a59fb1c1972550a2e Mon Sep 17 00:00:00 2001 From: Amirhossein Khalili Date: Thu, 30 Apr 2026 16:13:35 +0330 Subject: [PATCH] feat(cache): add stale get caching for report filters and summaries --- src/api/cache.ts | 147 +++++++++++++++++++++++++++++++++++++++++ src/api/clients.ts | 86 ++++++++++++++---------- src/api/projects.ts | 64 ++++++++++-------- src/api/rates.ts | 34 ++++++++-- src/api/reports.ts | 22 +++--- src/api/tags.ts | 17 +++-- src/api/timeEntries.ts | 14 +++- src/api/workspaces.ts | 103 +++++++++++++++-------------- 8 files changed, 356 insertions(+), 131 deletions(-) create mode 100644 src/api/cache.ts diff --git a/src/api/cache.ts b/src/api/cache.ts new file mode 100644 index 0000000..43283ca --- /dev/null +++ b/src/api/cache.ts @@ -0,0 +1,147 @@ +import { buildApiError, authFetch } from "./client" +import { getAccessToken, SESSION_CHANGED_EVENT } from "../lib/session" + +const CACHE_PREFIX = "api-get-cache:v2:" + +interface CacheEntry { + expiresAt: number + data: T + namespaces: string[] +} + +const memoryCache = new Map>() + +const cloneData = (value: T): T => { + if (typeof structuredClone === "function") { + return structuredClone(value) + } + return JSON.parse(JSON.stringify(value)) as T +} + +const decodeBase64Url = (value: string) => { + const normalized = value.replace(/-/g, "+").replace(/_/g, "/") + const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "=") + return atob(padded) +} + +const getCurrentUserCacheKey = () => { + const token = getAccessToken() + if (!token) return "anon" + + try { + const payload = JSON.parse(decodeBase64Url(token.split(".")[1] || "")) + return String(payload.user_id || payload.sub || "anon") + } catch { + return "anon" + } +} + +const normalizeEndpoint = (endpoint: string) => { + const base = endpoint.startsWith("http") ? undefined : "https://cache.local" + const url = new URL(endpoint, base) + const entries = Array.from(url.searchParams.entries()).sort(([aKey, aValue], [bKey, bValue]) => { + if (aKey === bKey) return aValue.localeCompare(bValue) + return aKey.localeCompare(bKey) + }) + const normalizedSearch = new URLSearchParams() + entries.forEach(([key, value]) => normalizedSearch.append(key, value)) + const queryString = normalizedSearch.toString() + return `${url.pathname}${queryString ? `?${queryString}` : ""}` +} + +const buildStorageKey = (endpoint: string) => `${CACHE_PREFIX}${getCurrentUserCacheKey()}:${normalizeEndpoint(endpoint)}` + +const readStoredEntry = (key: string): CacheEntry | null => { + const cached = memoryCache.get(key) + if (cached) { + if (cached.expiresAt > Date.now()) { + return cached as CacheEntry + } + memoryCache.delete(key) + } + + const raw = sessionStorage.getItem(key) + if (!raw) return null + + try { + const parsed = JSON.parse(raw) as CacheEntry + if (parsed.expiresAt <= Date.now()) { + sessionStorage.removeItem(key) + return null + } + memoryCache.set(key, parsed as CacheEntry) + return parsed + } catch { + sessionStorage.removeItem(key) + return null + } +} + +const writeEntry = (key: string, entry: CacheEntry) => { + memoryCache.set(key, entry as CacheEntry) + sessionStorage.setItem(key, JSON.stringify(entry)) +} + +export const invalidateApiCache = (namespaces: string[]) => { + if (!namespaces.length) return + + const namespaceSet = new Set(namespaces) + const removeIfMatches = (key: string, entry: CacheEntry) => { + if (entry.namespaces.some((namespace) => namespaceSet.has(namespace))) { + memoryCache.delete(key) + sessionStorage.removeItem(key) + } + } + + memoryCache.forEach((entry, key) => removeIfMatches(key, entry)) + for (let index = sessionStorage.length - 1; index >= 0; index -= 1) { + const key = sessionStorage.key(index) + if (!key || !key.startsWith(CACHE_PREFIX)) continue + try { + const parsed = JSON.parse(sessionStorage.getItem(key) || "null") as CacheEntry | null + if (parsed) { + removeIfMatches(key, parsed) + } + } catch { + sessionStorage.removeItem(key) + } + } +} + +export const clearApiCache = () => { + memoryCache.clear() + for (let index = sessionStorage.length - 1; index >= 0; index -= 1) { + const key = sessionStorage.key(index) + if (key?.startsWith(CACHE_PREFIX)) { + sessionStorage.removeItem(key) + } + } +} + +export const cachedGetJson = async ( + endpoint: string, + options: { ttlMs: number; namespaces: string[]; bypass?: boolean }, +): Promise => { + const storageKey = buildStorageKey(endpoint) + if (!options.bypass) { + const cached = readStoredEntry(storageKey) + if (cached) { + return cloneData(cached.data) + } + } + + const response = await authFetch(endpoint) + if (!response.ok) { + throw await buildApiError(response) + } + + const data = (await response.json()) as T + writeEntry(storageKey, { + expiresAt: Date.now() + options.ttlMs, + data, + namespaces: options.namespaces, + }) + return cloneData(data) +} + +window.addEventListener(SESSION_CHANGED_EVENT, clearApiCache) diff --git a/src/api/clients.ts b/src/api/clients.ts index 480b2c5..291b407 100644 --- a/src/api/clients.ts +++ b/src/api/clients.ts @@ -1,12 +1,26 @@ -import { authFetch } from "./client"; - -export const getClients = async ( - workspaceId: string, - search: string = "", - ordering: string = "", - limit: number = 10, - offset: number = 0 -) => { +import { authFetch } from "./client"; +import { cachedGetJson, invalidateApiCache } from "./cache"; + +export interface Client { + id: string + name: string + notes?: string +} + +interface PaginatedResponse { + count: number + next: string | null + previous: string | null + results: T[] +} + +export const getClients = async ( + workspaceId: string, + search: string = "", + ordering: string = "", + limit: number = 10, + offset: number = 0 +): Promise> => { const queryParams = new URLSearchParams({ workspace: workspaceId, limit: limit.toString(), @@ -16,12 +30,11 @@ export const getClients = async ( if (search) queryParams.append("search", search); if (ordering) queryParams.append("ordering", ordering); - const response = await authFetch(`/api/clients/?${queryParams.toString()}`); - if (!response.ok) { - throw new Error("Failed to fetch clients"); - } - return response.json(); -}; + return cachedGetJson>(`/api/clients/?${queryParams.toString()}`, { + ttlMs: 5 * 60 * 1000, + namespaces: ["clients"], + }); +}; export const createClient = async (workspaceId: string, data: { name: string; notes: string }) => { const response = await authFetch("/api/clients/", { @@ -32,12 +45,14 @@ export const createClient = async (workspaceId: string, data: { name: string; no }), }); - if (!response.ok) { - const errorData = await response.json().catch(() => null); - throw new Error(errorData?.detail || errorData?.message || "Failed to create client"); - } - return response.json(); -}; + if (!response.ok) { + const errorData = await response.json().catch(() => null); + throw new Error(errorData?.detail || errorData?.message || "Failed to create client"); + } + const payload = await response.json(); + invalidateApiCache(["clients", "reports"]); + return payload; +}; export const updateClient = async (id: string, data: { name?: string; notes?: string }) => { const response = await authFetch(`/api/clients/${id}/`, { @@ -45,25 +60,28 @@ export const updateClient = async (id: string, data: { name?: string; notes?: st body: JSON.stringify(data), }); - if (!response.ok) { - const errorData = await response.json().catch(() => null); - throw new Error(errorData?.detail || errorData?.message || "Failed to update client"); - } - return response.json(); -}; + if (!response.ok) { + const errorData = await response.json().catch(() => null); + throw new Error(errorData?.detail || errorData?.message || "Failed to update client"); + } + const payload = await response.json(); + invalidateApiCache(["clients", "reports"]); + return payload; +}; export const deleteClient = async (id: string) => { const response = await authFetch(`/api/clients/${id}/`, { method: "DELETE", }); - if (!response.ok) { - const errorData = await response.json().catch(() => null); - throw new Error(errorData?.detail || errorData?.message || "Failed to delete client"); - } - - if (response.status === 204) { - return { success: true }; + if (!response.ok) { + const errorData = await response.json().catch(() => null); + throw new Error(errorData?.detail || errorData?.message || "Failed to delete client"); + } + invalidateApiCache(["clients", "reports"]); + + if (response.status === 204) { + return { success: true }; } return response.json().catch(() => ({ success: true })); diff --git a/src/api/projects.ts b/src/api/projects.ts index a9dc218..1185bed 100644 --- a/src/api/projects.ts +++ b/src/api/projects.ts @@ -1,4 +1,5 @@ import { authFetch } from "./client"; +import { cachedGetJson, invalidateApiCache } from "./cache"; interface AuditUser { id: string; @@ -56,10 +57,10 @@ export const getProjects = async ( if (params.is_archived !== undefined) queryParams.append("is_archived", params.is_archived.toString()); if (params.ordering !== undefined) queryParams.append("ordering", params.ordering.toString()); - const response = await authFetch(`/api/projects/?${queryParams.toString()}`); - - if (!response.ok) throw new Error("Failed to fetch projects"); - const data = await response.json(); + const data = await cachedGetJson(`/api/projects/?${queryParams.toString()}`, { + ttlMs: 5 * 60 * 1000, + namespaces: ["projects"], + }); if (Array.isArray(data)) return data; if (Array.isArray(data?.items)) { return { @@ -89,12 +90,14 @@ export const createProject = async ( body: JSON.stringify(data), }); - if (!response.ok) { - const errorData = await response.json().catch(() => null); - throw new Error(errorData?.detail || errorData?.message || "Failed to create project"); - } - return response.json(); -}; + if (!response.ok) { + const errorData = await response.json().catch(() => null); + throw new Error(errorData?.detail || errorData?.message || "Failed to create project"); + } + const payload = await response.json(); + invalidateApiCache(["projects", "reports"]); + return payload; +}; export const updateProject = async ( id: string, @@ -105,25 +108,28 @@ export const updateProject = async ( body: JSON.stringify(data), }); - if (!response.ok) { - const errorData = await response.json().catch(() => null); - throw new Error(errorData?.detail || errorData?.message || "Failed to update project"); - } - return response.json(); -}; + if (!response.ok) { + const errorData = await response.json().catch(() => null); + throw new Error(errorData?.detail || errorData?.message || "Failed to update project"); + } + const payload = await response.json(); + invalidateApiCache(["projects", "reports"]); + return payload; +}; export const deleteProject = async (id: string) => { const response = await authFetch(`/api/projects/${id}/`, { method: "DELETE", }); - if (!response.ok) { - const errorData = await response.json().catch(() => null); - throw new Error(errorData?.detail || errorData?.message || "Failed to delete project"); - } - - if (response.status === 204) return { success: true }; - return response.json().catch(() => ({ success: true })); + if (!response.ok) { + const errorData = await response.json().catch(() => null); + throw new Error(errorData?.detail || errorData?.message || "Failed to delete project"); + } + invalidateApiCache(["projects", "reports"]); + + if (response.status === 204) return { success: true }; + return response.json().catch(() => ({ success: true })); }; export const toggleArchiveProject = async (id: string) => { @@ -131,9 +137,11 @@ export const toggleArchiveProject = async (id: string) => { method: "POST", }); - if (!response.ok) { - const errorData = await response.json().catch(() => null); - throw new Error(errorData?.detail || errorData?.message || `Failed to archive project`); - } - return response.json(); + if (!response.ok) { + const errorData = await response.json().catch(() => null); + throw new Error(errorData?.detail || errorData?.message || `Failed to archive project`); + } + const payload = await response.json(); + invalidateApiCache(["projects", "reports"]); + return payload; }; diff --git a/src/api/rates.ts b/src/api/rates.ts index 99ddc9c..4ad5b5c 100644 --- a/src/api/rates.ts +++ b/src/api/rates.ts @@ -1,4 +1,5 @@ import { authFetch } from "./client"; +import { cachedGetJson, invalidateApiCache } from "./cache"; export interface RateUser { id: string; @@ -55,13 +56,35 @@ const ensurePaginated = async (response: Response): Promise { - const response = await authFetch("/api/price-units/"); - return ensurePaginated(response); + const data = await cachedGetJson("/api/price-units/", { + ttlMs: 5 * 60 * 1000, + namespaces: ["price-units"], + }); + if (Array.isArray(data)) { + return { count: data.length, next: null, previous: null, results: data }; + } + return { + count: data.count || data.results?.length || 0, + next: data.next || null, + previous: data.previous || null, + results: data.results || [], + }; }; export const getWorkspaceUserRates = async (workspaceId: string) => { - const response = await authFetch(`/api/workspace-user-rates/?workspace=${workspaceId}`); - return ensurePaginated(response); + const data = await cachedGetJson(`/api/workspace-user-rates/?workspace=${workspaceId}`, { + ttlMs: 5 * 60 * 1000, + namespaces: ["workspace-rates"], + }); + if (Array.isArray(data)) { + return { count: data.length, next: null, previous: null, results: data }; + } + return { + count: data.count || data.results?.length || 0, + next: data.next || null, + previous: data.previous || null, + results: data.results || [], + }; }; export const createWorkspaceUserRate = async (data: { @@ -78,6 +101,7 @@ export const createWorkspaceUserRate = async (data: { const errorData = await response.json().catch(() => null); throw new Error(errorData?.detail || errorData?.message || "Failed to save workspace user rate"); } + invalidateApiCache(["workspace-rates", "reports"]); return response.json() as Promise; }; @@ -93,6 +117,7 @@ export const updateWorkspaceUserRate = async ( const errorData = await response.json().catch(() => null); throw new Error(errorData?.detail || errorData?.message || "Failed to update workspace user rate"); } + invalidateApiCache(["workspace-rates", "reports"]); return response.json() as Promise; }; @@ -104,4 +129,5 @@ export const deleteWorkspaceUserRate = async (rateId: string) => { const errorData = await response.json().catch(() => null); throw new Error(errorData?.detail || errorData?.message || "Failed to delete workspace user rate"); } + invalidateApiCache(["workspace-rates", "reports"]); }; diff --git a/src/api/reports.ts b/src/api/reports.ts index d28bec3..f313a91 100644 --- a/src/api/reports.ts +++ b/src/api/reports.ts @@ -1,4 +1,5 @@ import { authFetch } from "./client"; +import { cachedGetJson } from "./cache"; export type ReportPeriod = | "this_week" @@ -150,15 +151,17 @@ const toQueryString = (filters: ReportFilters) => { }; export const getChartReport = async (filters: ReportFilters): Promise => { - const response = await authFetch(`/api/reports/chart/?${toQueryString(filters)}`); - if (!response.ok) throw new Error("Failed to load chart report"); - return response.json(); + return cachedGetJson(`/api/reports/chart/?${toQueryString(filters)}`, { + ttlMs: 60 * 1000, + namespaces: ["reports"], + }); }; export const getTableReport = async (filters: ReportFilters): Promise => { - const response = await authFetch(`/api/reports/table/?${toQueryString(filters)}`); - if (!response.ok) throw new Error("Failed to load table report"); - return response.json(); + return cachedGetJson(`/api/reports/table/?${toQueryString(filters)}`, { + ttlMs: 60 * 1000, + namespaces: ["reports"], + }); }; export const getDayDetailsReport = async ( @@ -166,9 +169,10 @@ export const getDayDetailsReport = async ( day: string, ): Promise => { const query = `${toQueryString(filters)}&day=${encodeURIComponent(day)}`; - const response = await authFetch(`/api/reports/day-details/?${query}`); - if (!response.ok) throw new Error("Failed to load day details"); - return response.json(); + return cachedGetJson(`/api/reports/day-details/?${query}`, { + ttlMs: 30 * 1000, + namespaces: ["reports"], + }); }; export const createReportExport = async ( diff --git a/src/api/tags.ts b/src/api/tags.ts index c05124d..3fb643d 100644 --- a/src/api/tags.ts +++ b/src/api/tags.ts @@ -1,4 +1,5 @@ import { authFetch } from "./client"; +import { cachedGetJson, invalidateApiCache } from "./cache"; interface AuditUser { id: string; @@ -36,9 +37,10 @@ export const getTags = async ( if (params.search) query.append("search", params.search); if (params.ordering) query.append("ordering", params.ordering); - const response = await authFetch(`/api/tags/?${query.toString()}`); - if (!response.ok) throw new Error("Failed to fetch tags"); - return response.json(); + return cachedGetJson(`/api/tags/?${query.toString()}`, { + ttlMs: 5 * 60 * 1000, + namespaces: ["tags"], + }); }; export const createTag = async (workspaceId: string, data: { name: string; color: string }) => { @@ -50,7 +52,9 @@ export const createTag = async (workspaceId: string, data: { name: string; color }), }); if (!response.ok) throw new Error("Failed to create tag"); - return response.json(); + const payload = await response.json(); + invalidateApiCache(["tags", "reports"]); + return payload; }; export const updateTag = async (id: string, data: Partial>) => { @@ -59,7 +63,9 @@ export const updateTag = async (id: string, data: Partial { @@ -67,4 +73,5 @@ export const deleteTag = async (id: string) => { method: "DELETE", }); if (!response.ok) throw new Error("Failed to delete tag"); + invalidateApiCache(["tags", "reports"]); }; diff --git a/src/api/timeEntries.ts b/src/api/timeEntries.ts index b3afc9b..91b67f6 100644 --- a/src/api/timeEntries.ts +++ b/src/api/timeEntries.ts @@ -1,4 +1,5 @@ import { authFetch } from "./client"; +import { invalidateApiCache } from "./cache"; export interface TimeEntryProjectDetails { id: string; @@ -109,7 +110,9 @@ export const createTimeEntry = async (payload: TimeEntryPayload) => { body: JSON.stringify(payload), }); if (!response.ok) throw new Error("Failed to create time entry"); - return response.json(); + const data = await response.json(); + invalidateApiCache(["reports"]); + return data; }; export const updateTimeEntry = async (id: string, payload: TimeEntryPayload) => { @@ -118,7 +121,9 @@ export const updateTimeEntry = async (id: string, payload: TimeEntryPayload) => body: JSON.stringify(payload), }); if (!response.ok) throw new Error("Failed to update time entry"); - return response.json(); + const data = await response.json(); + invalidateApiCache(["reports"]); + return data; }; export const stopTimeEntry = async (id: string, endTime?: string) => { @@ -127,7 +132,9 @@ export const stopTimeEntry = async (id: string, endTime?: string) => { body: JSON.stringify(endTime ? { end_time: endTime } : {}), }); if (!response.ok) throw new Error("Failed to stop time entry"); - return response.json(); + const data = await response.json(); + invalidateApiCache(["reports"]); + return data; }; export const deleteTimeEntry = async (id: string) => { @@ -135,4 +142,5 @@ export const deleteTimeEntry = async (id: string) => { method: "DELETE", }); if (!response.ok) throw new Error("Failed to delete time entry"); + invalidateApiCache(["reports"]); }; diff --git a/src/api/workspaces.ts b/src/api/workspaces.ts index ee2c11f..b7345a9 100644 --- a/src/api/workspaces.ts +++ b/src/api/workspaces.ts @@ -1,4 +1,5 @@ -import { authFetch } from "./client"; +import { authFetch } from "./client"; +import { cachedGetJson, invalidateApiCache } from "./cache"; export interface Workspace { id: string; @@ -52,13 +53,10 @@ const toQueryString = (params?: Record) => { export const fetchWorkspaces = async (params?: Record): Promise> => { const query = toQueryString(params); const url = `/api/workspaces/${query ? `?${query}` : ''}`; - const response = await authFetch(url); - - if (!response.ok) { - throw new Error("Failed to fetch workspaces"); - } - - const data = await response.json(); + const data = await cachedGetJson(url, { + ttlMs: 60 * 1000, + namespaces: ["workspaces"], + }); if (Array.isArray(data)) { return { count: data.length, next: null, previous: null, results: data }; @@ -109,12 +107,14 @@ export const createWorkspace = async (data: { body, }); - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.error || JSON.stringify(errorData) || 'Failed to create workspace'); - } - return await response.json(); -}; + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || JSON.stringify(errorData) || 'Failed to create workspace'); + } + const payload = await response.json(); + invalidateApiCache(["workspaces", "workspace-memberships", "reports"]); + return payload; +}; export const updateWorkspace = async ( id: string, @@ -144,30 +144,32 @@ export const updateWorkspace = async ( body, }); - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.error || JSON.stringify(errorData) || 'Failed to update workspace'); - } - return await response.json(); -}; + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || JSON.stringify(errorData) || 'Failed to update workspace'); + } + const payload = await response.json(); + invalidateApiCache(["workspaces"]); + return payload; +}; export const deleteWorkspace = async (id: string): Promise => { const response = await authFetch(`/api/workspaces/${id}/`, { method: 'DELETE', }); - if (!response.ok) { - throw new Error('Failed to delete workspace'); - } -}; - + if (!response.ok) { + throw new Error('Failed to delete workspace'); + } + invalidateApiCache(["workspaces", "workspace-memberships", "workspace-rates", "reports"]); +}; + export const fetchWorkspaceMemberships = async (params?: Record): Promise> => { const queryParams = toQueryString(params); - const response = await authFetch(`/api/workspace-memberships/?${queryParams.toString()}`); - - if (!response.ok) throw new Error("Failed to fetch workspace memberships"); - - const data = await response.json(); + const data = await cachedGetJson(`/api/workspace-memberships/?${queryParams.toString()}`, { + ttlMs: 5 * 60 * 1000, + namespaces: ["workspace-memberships"], + }); if (Array.isArray(data)) { return { count: data.length, next: null, previous: null, results: data }; @@ -187,23 +189,26 @@ export const addWorkspaceMembership = async (data: { workspace: string; user: st body: JSON.stringify(data), }); - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.error || JSON.stringify(errorData) || 'Failed to add workspace membership'); - } - - return await response.json(); -}; + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.error || JSON.stringify(errorData) || 'Failed to add workspace membership'); + } + + const payload = await response.json(); + invalidateApiCache(["workspace-memberships", "reports"]); + return payload; +}; export const removeWorkspaceMembership = async (membershipId: string): Promise => { const response = await authFetch(`/api/workspace-memberships/${membershipId}/`, { method: 'DELETE', }); - if (!response.ok) { - throw new Error('Failed to remove workspace membership'); - } -}; + if (!response.ok) { + throw new Error('Failed to remove workspace membership'); + } + invalidateApiCache(["workspace-memberships", "reports"]); +}; export const updateWorkspaceMembership = async (membershipId: string | number, data: { role: string }) => { const response = await authFetch(`/api/workspace-memberships/${membershipId}/`, { @@ -211,10 +216,12 @@ export const updateWorkspaceMembership = async (membershipId: string | number, d body: JSON.stringify(data), }); - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.error || JSON.stringify(errorData) || 'Failed to update membership'); - } - - return await response.json(); -}; + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.error || JSON.stringify(errorData) || 'Failed to update membership'); + } + + const payload = await response.json(); + invalidateApiCache(["workspace-memberships", "reports"]); + return payload; +};