diff --git a/src/components/GoalTracker.tsx b/src/components/GoalTracker.tsx index 41c6b0fa4..c438315e6 100644 --- a/src/components/GoalTracker.tsx +++ b/src/components/GoalTracker.tsx @@ -41,7 +41,14 @@ const RECURRENCE_LABELS: Record = { export function useGoalTracker() { const [goals, setGoals] = useState([]); - const [loading, setLoading] = useState(true); + const [loading, setLoading] = useState(() => { + if (typeof window !== "undefined") { + try { + return !window.localStorage.getItem("devtrack:swr:goals"); + } catch (e) {} + } + return true; + }); const [syncing, setSyncing] = useState(false); const [syncError, setSyncError] = useState(null); const [lastUpdated, setLastUpdated] = useState(null); @@ -62,6 +69,26 @@ export function useGoalTracker() { const initialLoadDoneRef = useRef(false); const loadGoals = useCallback(async () => { + const cacheKey = "devtrack:swr:goals"; + + if (typeof window !== "undefined") { + try { + const cached = window.localStorage.getItem(cacheKey); + if (cached) { + const parsed = JSON.parse(cached); + if (parsed && parsed.data) { + setGoals(parsed.data); + if (parsed.timestamp) { + setLastUpdated(new Date(parsed.timestamp)); + setMinutesAgo(Math.floor((Date.now() - parsed.timestamp) / 60000)); + } + } + } + } catch (e) { + // ignore + } + } + const response = await fetch("/api/goals"); if (!response.ok) { throw new Error(`Failed to load goals (HTTP ${response.status})`); @@ -69,6 +96,13 @@ export function useGoalTracker() { const data: { goals: Goal[] } = await response.json(); const fetchedGoals = data.goals ?? []; setGoals(fetchedGoals); + + if (typeof window !== "undefined") { + try { + window.localStorage.setItem(cacheKey, JSON.stringify({ data: fetchedGoals, timestamp: Date.now() })); + } catch (e) {} + } + return fetchedGoals; }, []); diff --git a/src/components/PRMetrics.tsx b/src/components/PRMetrics.tsx index fb40d8461..3677a2e56 100644 --- a/src/components/PRMetrics.tsx +++ b/src/components/PRMetrics.tsx @@ -59,14 +59,39 @@ export default function PRMetrics() { const [staleThresholdDays, setStaleThresholdDays] = useState(14); const fetchMetrics = useCallback(() => { - setLoading(true); - setError(null); - const url = selectedAccount !== null ? `/api/metrics/prs?accountId=${encodeURIComponent(selectedAccount)}&range=${range}` : `/api/metrics/prs?range=${range}`; + const cacheKey = `devtrack:swr:prs:${selectedAccount || 'default'}:${range}`; + let hasStale = false; + + if (typeof window !== "undefined") { + try { + const cached = window.localStorage.getItem(cacheKey); + if (cached) { + const parsed = JSON.parse(cached); + if (parsed && parsed.data) { + setMetrics(parsed.data); + if (parsed.timestamp) { + setLastUpdated(new Date(parsed.timestamp)); + setMinutesAgo(Math.floor((Date.now() - parsed.timestamp) / 60000)); + } + setLoading(false); + hasStale = true; + } + } + } catch (e) { + // Ignore parsing errors or local storage disabled + } + } + + if (!hasStale) { + setLoading(true); + } + setError(null); + fetch(url) .then((r) => { if (!r.ok) throw new Error("API error"); @@ -76,9 +101,23 @@ export default function PRMetrics() { setMetrics(data); setLastUpdated(new Date()); setMinutesAgo(0); + + if (typeof window !== "undefined") { + try { + window.localStorage.setItem(cacheKey, JSON.stringify({ data, timestamp: Date.now() })); + } catch (e) { + // Ignore quota exceeded or storage disabled + } + } + }) + .catch(() => { + if (!hasStale) { + setError("We couldn't load your PR analytics right now. Please try again in a moment."); + } }) - .catch(() => setError("We couldn't load your PR analytics right now. Please try again in a moment.")) - .finally(() => setLoading(false)); + .finally(() => { + if (!hasStale) setLoading(false); + }); }, [selectedAccount, range]); useEffect(() => { diff --git a/src/components/StreakTracker.tsx b/src/components/StreakTracker.tsx index 3b0d5e0fd..30760aa28 100644 --- a/src/components/StreakTracker.tsx +++ b/src/components/StreakTracker.tsx @@ -83,7 +83,37 @@ export function useStreakTracker() { }, []); const fetchStreak = useCallback(async () => { - setLoading(true); + const cacheKeyStreak = `devtrack:swr:streak:${selectedAccount || 'default'}`; + const cacheKeyContrib = `devtrack:swr:contrib:${selectedAccount || 'default'}`; + let hasStale = false; + + if (typeof window !== "undefined") { + try { + const cachedStreak = window.localStorage.getItem(cacheKeyStreak); + const cachedContrib = window.localStorage.getItem(cacheKeyContrib); + if (cachedStreak && cachedContrib) { + const parsedStreak = JSON.parse(cachedStreak); + const parsedContrib = JSON.parse(cachedContrib); + if (parsedStreak && parsedStreak.data && parsedContrib && parsedContrib.data) { + setData(parsedStreak.data); + setContributionData(parsedContrib.data); + setFreezeDates(parsedStreak.data.freezeDates || []); + if (parsedStreak.timestamp) { + setLastUpdated(new Date(parsedStreak.timestamp)); + setMinutesAgo(Math.floor((Date.now() - parsedStreak.timestamp) / 60000)); + } + setLoading(false); + hasStale = true; + } + } + } catch (e) { + // Ignore parsing errors or local storage disabled + } + } + + if (!hasStale) { + setLoading(true); + } setError(null); try { @@ -111,13 +141,25 @@ export function useStreakTracker() { setData(streakData); setContributionData(contribData); setFreezeDates(streakData.freezeDates || []); + + setLastUpdated(new Date()); + setMinutesAgo(0); + + if (typeof window !== "undefined") { + try { + window.localStorage.setItem(cacheKeyStreak, JSON.stringify({ data: streakData, timestamp: Date.now() })); + window.localStorage.setItem(cacheKeyContrib, JSON.stringify({ data: contribData, timestamp: Date.now() })); + } catch (e) { + // Ignore quota exceeded or storage disabled + } + } } catch (err) { console.error("Failed to fetch streak data:", err); - setError("We couldn't load your streak data right now. Please try again in a moment."); + if (!hasStale) { + setError("We couldn't load your streak data right now. Please try again in a moment."); + } } finally { - setLoading(false); - setLastUpdated(new Date()); - setMinutesAgo(0); + if (!hasStale) setLoading(false); } }, [selectedAccount]);