diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5e84573c..150d355e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -69,7 +69,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -376,6 +375,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -392,6 +392,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -408,6 +409,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -424,6 +426,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -440,6 +443,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -456,6 +460,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -472,6 +477,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -488,6 +494,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -504,6 +511,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -520,6 +528,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -536,6 +545,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -552,6 +562,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -568,6 +579,7 @@ "cpu": [ "mips64el" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -584,6 +596,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -600,6 +613,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -616,6 +630,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -632,6 +647,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -648,6 +664,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -664,6 +681,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -680,6 +698,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -696,6 +715,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -712,6 +732,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -728,6 +749,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -744,6 +766,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -760,6 +783,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -776,6 +800,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1005,7 +1030,6 @@ "resolved": "https://registry.npmjs.org/@firebase/app/-/app-0.14.13.tgz", "integrity": "sha512-H89Jeyp31+EZk9GPu6vaeL9mEmoXgM3nASB7UPBYYS/lqAks21mO1BU1dF8NbsVTL6tgGZkGUtiGJgxtDiwHkw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@firebase/component": "0.7.3", "@firebase/logger": "0.5.1", @@ -1072,7 +1096,6 @@ "resolved": "https://registry.npmjs.org/@firebase/app-compat/-/app-compat-0.5.13.tgz", "integrity": "sha512-pn3FvXwUR34kWPccDQfCKsNZcM2wD1OS+J1jeEgzM1ZNXoxR2NaF6e5DjDuRrnTwR6LN2XQQt0IqE6yKmgpCQg==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@firebase/app": "0.14.13", "@firebase/component": "0.7.3", @@ -1089,7 +1112,6 @@ "resolved": "https://registry.npmjs.org/@firebase/app-types/-/app-types-0.9.5.tgz", "integrity": "sha512-YevqTjvo7Iujsa9Dwowmd6dSoElhzmD63ZSrq6bzjvQ6POjYgNjOFHLmNIgJs48eNO093NCERibuFnxbfOvU7A==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@firebase/logger": "0.5.1" } @@ -1543,7 +1565,6 @@ "integrity": "sha512-LUdM4Wg7YM9Pq/49nGYySJA0CSQEKnGffFzWV8+6gXN7mGxn+FL1IqvFbuZUtAQcfZgHYDwCE1wwlK7rB7gl2g==", "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": "^2.1.0" }, @@ -1762,6 +1783,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1775,6 +1797,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1788,6 +1811,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1801,6 +1825,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1814,6 +1839,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1827,6 +1853,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1840,6 +1867,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1853,6 +1881,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1866,6 +1895,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1879,6 +1909,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1892,6 +1923,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1905,6 +1937,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1918,6 +1951,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1931,6 +1965,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1944,6 +1979,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1957,6 +1993,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1970,6 +2007,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1983,6 +2021,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1996,6 +2035,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2009,6 +2049,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2022,6 +2063,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2035,6 +2077,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2048,6 +2091,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2061,6 +2105,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2074,6 +2119,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2386,6 +2432,7 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, "license": "MIT" }, "node_modules/@types/json-schema": { @@ -2423,7 +2470,6 @@ "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2472,7 +2518,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2612,7 +2657,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2973,6 +3017,7 @@ "version": "0.27.7", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -3038,7 +3083,6 @@ "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3265,6 +3309,7 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -3438,6 +3483,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -4240,6 +4286,7 @@ "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, "funding": [ { "type": "github", @@ -4374,14 +4421,15 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, "license": "ISC" }, "node_modules/picomatch": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -4393,6 +4441,7 @@ "version": "8.5.14", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "dev": true, "funding": [ { "type": "opencollective", @@ -4485,7 +4534,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.1.tgz", "integrity": "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -4495,7 +4543,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.1.tgz", "integrity": "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -4591,6 +4638,7 @@ "version": "4.60.3", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.3.tgz", "integrity": "sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A==", + "dev": true, "license": "MIT", "dependencies": { "@types/estree": "1.0.8" @@ -4819,6 +4867,7 @@ "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -4910,8 +4959,8 @@ "version": "7.3.3", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.3.tgz", "integrity": "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -5115,7 +5164,6 @@ "integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/frontend/src/components/Routine/RoutineCard.jsx b/frontend/src/components/Routine/RoutineCard.jsx index bc84857b..8368603f 100644 --- a/frontend/src/components/Routine/RoutineCard.jsx +++ b/frontend/src/components/Routine/RoutineCard.jsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { MoreVertical, Trash2, Share2, Copy, Download, Loader2 } from "lucide-react"; import RoutineOverviewModal from "./RoutineOverviewModal"; import api from "../../api/axios.js"; +import { invalidate } from "../../utils/apiCache"; import { exportRoutineToPDF, generateRoutineSummary } from "../../utils/routineExport.js"; @@ -265,6 +266,8 @@ export default function RoutineCard({ `/routines/${routine._id}` ); + invalidate("/routines"); + if (isRoutineStarted) { handleStopRoutine(); diff --git a/frontend/src/context/AuthContext.jsx b/frontend/src/context/AuthContext.jsx index 42c0983a..5bcb1e6f 100644 --- a/frontend/src/context/AuthContext.jsx +++ b/frontend/src/context/AuthContext.jsx @@ -1,5 +1,6 @@ import { createContext, useEffect, useState } from "react"; import api from "../api/axios"; +import { clearCache } from "../utils/apiCache"; // create context component // eslint-disable-next-line react-refresh/only-export-components @@ -19,7 +20,7 @@ const AuthProvider = ({ children }) => { } setUser(null); localStorage.removeItem("activeRoutineTasks"); // specifically requested in issue #882 - + clearCache(); // drop cached API data so the next user starts clean }; // restore session on app load diff --git a/frontend/src/hooks/useDebounce.js b/frontend/src/hooks/useDebounce.js new file mode 100644 index 00000000..5f1030c3 --- /dev/null +++ b/frontend/src/hooks/useDebounce.js @@ -0,0 +1,22 @@ +import { useEffect, useState } from "react"; + +/** + * Returns a debounced copy of `value` that only updates after `delay` ms have + * passed without a change. Used to avoid recomputing/refetching on every + * keystroke (issue #11). + * + * @param {*} value value to debounce + * @param {number} delay debounce delay in ms (default 300) + */ +const useDebounce = (value, delay = 300) => { + const [debounced, setDebounced] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => setDebounced(value), delay); + return () => clearTimeout(timer); + }, [value, delay]); + + return debounced; +}; + +export default useDebounce; diff --git a/frontend/src/hooks/useTasks.js b/frontend/src/hooks/useTasks.js index 276b0ab9..19432c87 100644 --- a/frontend/src/hooks/useTasks.js +++ b/frontend/src/hooks/useTasks.js @@ -1,5 +1,12 @@ import { useCallback, useEffect, useState } from "react"; import api from "../api/axios"; +import { cachedGet, invalidate } from "../utils/apiCache"; + +// Mutations to tasks can change analytics-derived data too, so invalidate both. +const invalidateTasks = () => { + invalidate("/tasks"); + invalidate("/analytics"); +}; const DEFAULT_PAGE = 1; const DEFAULT_LIMIT = 100; @@ -23,7 +30,7 @@ const useTasks = ({ async (pageToFetch = page) => { try { setLoading(true); - const response = await api.get("/tasks", { + const response = await cachedGet("/tasks", { params: { page: pageToFetch, limit: initialLimit, @@ -61,6 +68,8 @@ const useTasks = ({ console.log("Task added:", response.data); + invalidateTasks(); + if (page === DEFAULT_PAGE) { await getTasks(DEFAULT_PAGE); } else { @@ -102,9 +111,11 @@ const useTasks = ({ try { await api.put(`/tasks/${id}`, updates); + invalidateTasks(); await getTasks(page); } catch (error) { console.log(error?.response?.data?.message || "Failed to update task"); + invalidateTasks(); await getTasks(page); } }; @@ -113,18 +124,23 @@ const useTasks = ({ const deleteTask = async (id) => { await api.delete(`/tasks/${id}`); setTasks((prev) => prev.filter((t) => t._id !== id)); + invalidateTasks(); await getTasks(page); }; // bulk delete tasks const bulkDelete = async (ids) => { await api.post("/tasks/bulk-delete", { ids }); + // bulk delete also pulls tasks out of routines on the backend + invalidateTasks(); + invalidate("/routines"); await getTasks(page); }; // bulk edit tasks const bulkUpdate = async (ids, updates) => { await Promise.all(ids.map((id) => api.put(`/tasks/${id}`, updates))); + invalidateTasks(); await getTasks(page); }; diff --git a/frontend/src/pages/Analytics.jsx b/frontend/src/pages/Analytics.jsx index bbe40fbe..60c7274b 100644 --- a/frontend/src/pages/Analytics.jsx +++ b/frontend/src/pages/Analytics.jsx @@ -14,7 +14,7 @@ import { Clock, Briefcase } from "lucide-react"; -import api from "../api/axios"; +import { cachedGet } from "../utils/apiCache"; import html2canvas from "html2canvas"; export default function Analytics() { @@ -28,7 +28,7 @@ export default function Analytics() { const fetchAnalytics = async () => { try { setLoading(true); - const res = await api.get("/analytics"); + const res = await cachedGet("/analytics"); if (res.data.success) { setStats(res.data.stats); } else { diff --git a/frontend/src/pages/DailyJournal.jsx b/frontend/src/pages/DailyJournal.jsx index 04c01326..c8494e8a 100644 --- a/frontend/src/pages/DailyJournal.jsx +++ b/frontend/src/pages/DailyJournal.jsx @@ -20,6 +20,8 @@ import { AlertTriangle } from "lucide-react"; import api from "../api/axios.js"; +import { cachedGet, invalidate } from "../utils/apiCache"; +import useDebounce from "../hooks/useDebounce"; import LoadingSpinner from "../components/common/LoadingSpinner"; const MOODS = [ @@ -56,6 +58,8 @@ export default function DailyJournal() { // Filters const [searchQuery, setSearchQuery] = useState(""); + // Server-side search fires per keystroke — debounce the value the request reads (#11) + const debouncedSearchQuery = useDebounce(searchQuery, 400); const [moodFilter, setMoodFilter] = useState(""); const [tagFilter, setTagFilter] = useState(""); const [startDate, setStartDate] = useState(""); @@ -82,9 +86,9 @@ export default function DailyJournal() { const fetchJournals = useCallback(async () => { try { setLoadingList(true); - const res = await api.get("/journal", { + const res = await cachedGet("/journal", { params: { - search: searchQuery, + search: debouncedSearchQuery, mood: moodFilter, tag: tagFilter, startDate, @@ -99,7 +103,7 @@ export default function DailyJournal() { } finally { setLoadingList(false); } - }, [searchQuery, moodFilter, tagFilter, startDate, endDate]); + }, [debouncedSearchQuery, moodFilter, tagFilter, startDate, endDate]); // Fetch journals on query change useEffect(() => { @@ -112,7 +116,7 @@ export default function DailyJournal() { try { setErrorMsg(""); setSuccessMsg(""); - const res = await api.get(`/journal/by-date/${selectedDate}`); + const res = await cachedGet(`/journal/by-date/${selectedDate}`); if (res.data.success && res.data.journal) { const entry = res.data.journal; setTitle(entry.title || ""); @@ -141,7 +145,7 @@ export default function DailyJournal() { const fetchAnalytics = async () => { try { setLoadingAnalytics(true); - const res = await api.get("/journal/analytics"); + const res = await cachedGet("/journal/analytics"); if (res.data.success) { setAnalytics(res.data.analytics); } @@ -187,7 +191,10 @@ export default function DailyJournal() { setContent(savedJournal.content || ""); setMood(savedJournal.mood || "neutral"); setTags(savedJournal.tags || []); - + + // Entry changed — drop cached journal/analytics reads so they refetch fresh + invalidate("/journal"); + invalidate("/analytics"); fetchJournals(); setTimeout(() => setSuccessMsg(""), 3000); } @@ -216,6 +223,8 @@ export default function DailyJournal() { setTags([]); setActiveEntryId(null); setIsEditing(false); + invalidate("/journal"); + invalidate("/analytics"); fetchJournals(); setTimeout(() => setSuccessMsg(""), 3000); } @@ -281,7 +290,7 @@ export default function DailyJournal() { const handleEditExisting = async (date) => { setSelectedDate(date); try { - const res = await api.get(`/journal/by-date/${date}`); + const res = await cachedGet(`/journal/by-date/${date}`); if (res.data.success && res.data.journal) { const entry = res.data.journal; setTitle(entry.title || ""); @@ -338,7 +347,7 @@ export default function DailyJournal() { // Open editor with current loaded entry values const handleStartEdit = async () => { try { - const res = await api.get(`/journal/by-date/${selectedDate}`); + const res = await cachedGet(`/journal/by-date/${selectedDate}`); if (res.data.success && res.data.journal) { const entry = res.data.journal; setTitle(entry.title || ""); diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx index ab79b417..e18faf46 100644 --- a/frontend/src/pages/Dashboard.jsx +++ b/frontend/src/pages/Dashboard.jsx @@ -10,6 +10,7 @@ import DashboardTasks from "../components/Dashboard/DashboardTasks"; import ReflectionSummary from "../components/Dashboard/ReflectionSummary"; import ContributionHeatmap from "../components/Dashboard/ContributionHeatmap"; import api from "../api/axios.js"; +import { cachedGet, invalidate } from "../utils/apiCache"; import useTasks from "../hooks/useTasks.js"; import useMixedTasks from "../hooks/useMixedTasks.js"; import { getGreeting } from "../utils/getGreeting"; @@ -132,7 +133,7 @@ export default function Dashboard() { const fetchRoutines = async () => { try { setLoadingRoutines(true); - const res = await api.get("/routines"); + const res = await cachedGet("/routines"); setSavedRoutines(res.data.routines || []); } catch (err) { console.error(err); @@ -145,7 +146,7 @@ export default function Dashboard() { const fetchTodayJournal = async () => { try { const todayStr = new Date().toLocaleDateString("en-CA"); - const res = await api.get(`/journal/by-date/${todayStr}`); + const res = await cachedGet(`/journal/by-date/${todayStr}`); if (res.data.success && res.data.journal) { setTodayJournal(res.data.journal); } else { @@ -189,6 +190,8 @@ export default function Dashboard() { const duplicatedRoutine = res.data.routine || res.data.routines?.[0]; + invalidate("/routines"); + // Optimistic UI update if (duplicatedRoutine) { setSavedRoutines((prevRoutines) => [ diff --git a/frontend/src/pages/ForgeMode.jsx b/frontend/src/pages/ForgeMode.jsx index 39a95b00..cb895e9e 100644 --- a/frontend/src/pages/ForgeMode.jsx +++ b/frontend/src/pages/ForgeMode.jsx @@ -5,6 +5,7 @@ import { Settings, Music, Moon, AlertCircle, RefreshCw, Layers } from "lucide-react"; import api from "../api/axios"; +import { cachedGet, invalidate } from "../utils/apiCache"; // Available copyright-free ambient soundscapes const SOUNDSCAPES = [ @@ -52,12 +53,12 @@ export default function ForgeMode() { setIsLoading(true); try { // Fetch user tasks library - const tasksRes = await api.get("/tasks"); + const tasksRes = await cachedGet("/tasks"); const fetchedTasks = tasksRes.data.tasks || []; setTasks(fetchedTasks); // Fetch routines to search for currently active routine task - const routinesRes = await api.get("/routines"); + const routinesRes = await cachedGet("/routines"); const fetchedRoutines = routinesRes.data.routines || []; // Auto-load scheduled task if active routine exists @@ -237,6 +238,9 @@ export default function ForgeMode() { actualDuration: elapsedMinutes }); } + // Completing a task here changes the task list + analytics elsewhere + invalidate("/tasks"); + invalidate("/analytics"); } catch (err) { console.error("Failed to log focus task success to MERN backend:", err); } diff --git a/frontend/src/pages/RoutineBuilder.jsx b/frontend/src/pages/RoutineBuilder.jsx index 5e092a9b..aa6f0818 100644 --- a/frontend/src/pages/RoutineBuilder.jsx +++ b/frontend/src/pages/RoutineBuilder.jsx @@ -16,6 +16,7 @@ import { useNavigate } from "react-router-dom"; import { ArrowLeft, Download, Loader2 } from "lucide-react"; import { toPng } from "html-to-image"; import api from "../api/axios.js"; +import { cachedGet, invalidate } from "../utils/apiCache"; import EmptyState from "../components/EmptyState"; import { useScrollThenOpen } from "../hooks/useScrollThenOpen.js"; import { routineTemplates } from '../utils/routineTemplate'; @@ -120,7 +121,7 @@ export default function RoutineBuilder() { const fetchRoutines = async () => { try { setLoadingRoutines(true); - const res = await api.get("/routines"); + const res = await cachedGet("/routines"); setSavedRoutines( Array.isArray(res.data.routines) ? res.data.routines : [] ); @@ -151,6 +152,7 @@ export default function RoutineBuilder() { }); const createdRoutine = res.data.routine || res.data.routines?.[0]; + invalidate("/routines"); if (createdRoutine) { setSavedRoutines((prevRoutines) => [ createdRoutine, diff --git a/frontend/src/pages/Tasks.jsx b/frontend/src/pages/Tasks.jsx index 047d95fb..ad34f4a5 100644 --- a/frontend/src/pages/Tasks.jsx +++ b/frontend/src/pages/Tasks.jsx @@ -1,6 +1,7 @@ import { useMemo, useState } from "react"; import { useNavigate } from "react-router-dom"; import useTasks from "../hooks/useTasks"; +import useDebounce from "../hooks/useDebounce"; import TaskItem from "../components/Task/TaskItem"; import TaskFormModal from "../components/Task/TaskFormModal"; import KanbanBoard from "../components/Task/KanbanBoard"; @@ -44,6 +45,9 @@ export default function Tasks() { const [taskError, setTaskError] = useState(""); const [selectedCategories, setSelectedCategories] = useState([]); const [searchTerm, setSearchTerm] = useState(""); + // Filtering is client-side, so debounce the term the filter reads off of — + // the input stays instant, but we avoid re-filtering on every keystroke (#11). + const debouncedSearchTerm = useDebounce(searchTerm, 300); const [statusFilter, setStatusFilter] = useState("all"); const [selectedIds, setSelectedIds] = useState([]); const [isNotesOpen, setIsNotesOpen] = useState(false); @@ -149,7 +153,7 @@ export default function Tasks() { }; const filteredTasks = useMemo(() => { - const normalizedSearchTerm = searchTerm.trim().toLowerCase(); + const normalizedSearchTerm = debouncedSearchTerm.trim().toLowerCase(); return tasks.filter((task) => { const title = String(task.title ?? "").toLowerCase(); @@ -171,7 +175,7 @@ export default function Tasks() { return matchesSearch && matchesCategory && matchesStatus; }); - }, [searchTerm, selectedCategories, statusFilter, tasks]); + }, [debouncedSearchTerm, selectedCategories, statusFilter, tasks]); const totalPages = pagination.totalPages; const hasPreviousPage = page > 1; diff --git a/frontend/src/utils/apiCache.js b/frontend/src/utils/apiCache.js new file mode 100644 index 00000000..a8b726b7 --- /dev/null +++ b/frontend/src/utils/apiCache.js @@ -0,0 +1,86 @@ +import api from "../api/axios"; + +/** + * Lightweight in-memory GET cache for the axios instance. + * + * Goals (issue #11): + * - Reuse responses across page navigations instead of refetching on every mount. + * - De-duplicate concurrent identical requests (two components mounting at once + * share a single in-flight request). + * - Stay simple — no external libraries. Writes invalidate the relevant keys so + * we never serve stale data after a mutation. + * + * Use `cachedGet` for read endpoints and call `invalidate(prefix)` after any + * mutation that changes the underlying data. `clearCache()` wipes everything + * (e.g. on logout). + */ + +const DEFAULT_TTL = 60_000; // 60s — long enough to cover a navigation round-trip + +const cache = new Map(); // key -> { response, time } +const pending = new Map(); // key -> Promise (in-flight de-duplication) + +// Build a stable cache key from url + sorted query params. +const buildKey = (url, params) => { + if (!params) return url; + const query = Object.keys(params) + .sort() + .map((k) => `${k}=${params[k]}`) + .join("&"); + return query ? `${url}?${query}` : url; +}; + +/** + * Cached GET. Returns the axios response (so callers keep using `res.data`). + * @param {string} url + * @param {object} config axios config (only `params` participates in the key) + * @param {{ ttl?: number, force?: boolean }} opts + */ +export const cachedGet = (url, config = {}, opts = {}) => { + const { ttl = DEFAULT_TTL, force = false } = opts; + const key = buildKey(url, config.params); + const now = Date.now(); + + if (!force) { + const hit = cache.get(key); + if (hit && now - hit.time < ttl) { + return Promise.resolve(hit.response); + } + const inflight = pending.get(key); + if (inflight) return inflight; + } + + const request = api + .get(url, config) + .then((response) => { + cache.set(key, { response, time: Date.now() }); + pending.delete(key); + return response; + }) + .catch((error) => { + pending.delete(key); + throw error; + }); + + pending.set(key, request); + return request; +}; + +/** + * Drop every cached/in-flight entry whose key starts with `prefix`. + * e.g. invalidate("/tasks") clears "/tasks?page=1&limit=100" too. + */ +export const invalidate = (prefix) => { + for (const key of cache.keys()) { + if (key.startsWith(prefix)) cache.delete(key); + } + for (const key of pending.keys()) { + if (key.startsWith(prefix)) pending.delete(key); + } +}; + +/** Wipe the whole cache (e.g. on logout, so the next user starts clean). */ +export const clearCache = () => { + cache.clear(); + pending.clear(); +};