Skip to content
24 changes: 22 additions & 2 deletions src/components/GoalTracker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -131,6 +135,15 @@ export function useGoalTracker() {
}
}, [loadGoals]);

// SWR Step 1: instant hydration from localStorage before network resolves
useEffect(() => {
const cached = getSnapshot<Goal[]>("goals");
if (cached) {
setGoals(cached.data);
setLoading(false); // show stale goals immediately, no spinner
}
}, []);

useEffect(() => {
loadGoals()
.then(async (fetchedGoals) => {
Expand All @@ -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<Goal[]>("goals");
if (cached) {
setGoals(cached.data);
setSyncError(null);
} else {
setSyncError("Failed to load goals. Please try again.");
}
})
.finally(() => {
setLoading(false);
Expand Down
32 changes: 29 additions & 3 deletions src/components/StreakTracker.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -40,6 +41,7 @@ interface FreezeData {

export function useStreakTracker() {
const { selectedAccount } = useAccount();
const cacheKey = selectedAccount ? `streak-${selectedAccount}` : "streak-default";
const [data, setData] = useState<StreakData | null>(null);
const [contributionData, setContributionData] = useState<ContributionData | null>(null);
const [freezeDates, setFreezeDates] = useState<string[]>([]);
Expand Down Expand Up @@ -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);
Expand All @@ -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();
Expand Down Expand Up @@ -1169,4 +1195,4 @@ export function calculateMonthlyTrend(contrib: ContributionData | undefined | nu
}

return { isValid: true, thisMonth, lastMonth, text, colorClass };
}
}
36 changes: 36 additions & 0 deletions src/lib/localCache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
const CACHE_PREFIX = "devtrack_cache_";

interface CachedSnapshot<T> {
data: T;
timestamp: number;
}

export function saveSnapshot<T>(key: string, data: T): void {
try {
const payload: CachedSnapshot<T> = { 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<T>(key: string): CachedSnapshot<T> | 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<T>;
} 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
}
}
Loading