diff --git a/src/components/GoalTracker.tsx b/src/components/GoalTracker.tsx index d2eecbe22..df32c9bb7 100644 --- a/src/components/GoalTracker.tsx +++ b/src/components/GoalTracker.tsx @@ -9,6 +9,7 @@ import { buildPublicGoalShareUrl } from "@/lib/goals/share"; import GoalHistory from "@/components/GoalHistory"; import EmptyState from "@/components/EmptyState"; import WidgetSkeleton, { SkeletonBlock } from "./WidgetSkeleton"; +import { saveSnapshot, getSnapshot } from "@/lib/localCache"; type Recurrence = "none" | "weekly" | "monthly"; @@ -90,9 +91,12 @@ export function useGoalTracker() { const data: { goals: Goal[] } = await response.json(); const fetchedGoals = data.goals ?? []; setGoals(fetchedGoals); + + // SWR: persist fresh snapshot for offline / instant-hydration use + saveSnapshot("goals", fetchedGoals); + return fetchedGoals; }, []); - const handleSync = useCallback(async () => { setSyncing(true); setSyncError(null); @@ -131,6 +135,15 @@ export function useGoalTracker() { } }, [loadGoals]); + // SWR Step 1: instant hydration from localStorage before network resolves + useEffect(() => { + const cached = getSnapshot("goals"); + if (cached) { + setGoals(cached.data); + setLoading(false); // show stale goals immediately, no spinner + } + }, []); + useEffect(() => { loadGoals() .then(async (fetchedGoals) => { @@ -145,7 +158,14 @@ export function useGoalTracker() { } }) .catch(() => { - setSyncError("Failed to load goals. Please try again."); + // SWR fallback: if the live fetch fails, keep showing the cached snapshot + const cached = getSnapshot("goals"); + if (cached) { + setGoals(cached.data); + setSyncError(null); + } else { + setSyncError("Failed to load goals. Please try again."); + } }) .finally(() => { setLoading(false); diff --git a/src/components/StreakTracker.tsx b/src/components/StreakTracker.tsx index 51f8ab70d..a695a53d6 100644 --- a/src/components/StreakTracker.tsx +++ b/src/components/StreakTracker.tsx @@ -1,5 +1,6 @@ "use client"; import SectionHeader from "./SectionHeader"; +import { saveSnapshot, getSnapshot } from "@/lib/localCache"; import { useCallback, useEffect, useState, useRef } from "react"; import { useAccount } from "@/components/AccountContext"; import { useDashboardWidgetA11y } from "@/components/dashboard/DashboardWidgetA11yContext"; @@ -40,6 +41,7 @@ interface FreezeData { export function useStreakTracker() { const { selectedAccount } = useAccount(); + const cacheKey = selectedAccount ? `streak-${selectedAccount}` : "streak-default"; const [data, setData] = useState(null); const [contributionData, setContributionData] = useState(null); const [freezeDates, setFreezeDates] = useState([]); @@ -112,15 +114,28 @@ export function useStreakTracker() { setData(streakData); setContributionData(contribData); setFreezeDates(streakData.freezeDates || []); + + // SWR: persist fresh snapshot for offline / instant-hydration use + saveSnapshot(cacheKey, { streak: streakData, contribution: contribData }); } 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."); + + // SWR fallback: if the live fetch fails, fall back to the cached snapshot + const cached = getSnapshot<{ streak: StreakData; contribution: ContributionData }>(cacheKey); + if (cached) { + setData(cached.data.streak); + setContributionData(cached.data.contribution); + setFreezeDates(cached.data.streak.freezeDates || []); + setError(null); + } else { + setError("We couldn't load your streak data right now. Please try again in a moment."); + } } finally { setLoading(false); setLastUpdated(new Date()); setMinutesAgo(0); } - }, [selectedAccount]); + }, [selectedAccount, cacheKey]); const fetchFreeze = useCallback(() => { setFreezeLoading(true); @@ -134,6 +149,17 @@ export function useStreakTracker() { .finally(() => setFreezeLoading(false)); }, []); + // SWR Step 1: instant hydration from localStorage before network resolves + useEffect(() => { + const cached = getSnapshot<{ streak: StreakData; contribution: ContributionData }>(cacheKey); + if (cached) { + setData(cached.data.streak); + setContributionData(cached.data.contribution); + setFreezeDates(cached.data.streak.freezeDates || []); + setLoading(false); // show stale data immediately, no spinner + } + }, [cacheKey]); + useEffect(() => { fetchStreak(); fetchFreeze(); @@ -1169,4 +1195,4 @@ export function calculateMonthlyTrend(contrib: ContributionData | undefined | nu } return { isValid: true, thisMonth, lastMonth, text, colorClass }; -} \ No newline at end of file +} diff --git a/src/lib/localCache.ts b/src/lib/localCache.ts new file mode 100644 index 000000000..fb5a6a0b7 --- /dev/null +++ b/src/lib/localCache.ts @@ -0,0 +1,36 @@ +const CACHE_PREFIX = "devtrack_cache_"; + +interface CachedSnapshot { + data: T; + timestamp: number; +} + +export function saveSnapshot(key: string, data: T): void { + try { + const payload: CachedSnapshot = { data, timestamp: Date.now() }; + localStorage.setItem(CACHE_PREFIX + key, JSON.stringify(payload)); + } catch (err) { + // localStorage full, disabled, or unavailable (SSR) — fail silently + console.warn(`[localCache] Could not save snapshot for "${key}"`, err); + } +} + +export function getSnapshot(key: string): CachedSnapshot | null { + try { + if (typeof window === "undefined") return null; // SSR guard + const raw = localStorage.getItem(CACHE_PREFIX + key); + if (!raw) return null; + return JSON.parse(raw) as CachedSnapshot; + } catch (err) { + console.warn(`[localCache] Could not read snapshot for "${key}"`, err); + return null; + } +} + +export function clearSnapshot(key: string): void { + try { + localStorage.removeItem(CACHE_PREFIX + key); + } catch { + // ignore + } +} \ No newline at end of file