Feat: Add localStorage cache + auto-refresh to dashboard

Prevents redundant GitHub API calls on page reload. Data is cached in
localStorage with a 5-minute TTL and auto-refreshed in the background.
Force-refresh button with 60s cooldown. Footer shows last-updated time
and countdown to next refresh.
This commit is contained in:
phranck
2026-02-06 00:03:03 +01:00
parent 13943706fc
commit ef493c7184
3 changed files with 489 additions and 196 deletions
+91 -13
View File
@@ -1,7 +1,7 @@
"use client";
import { useState, useCallback, useEffect } from "react";
import { useGitHubStats } from "../hooks/useGitHubStats";
import { useGitHubStatsCache } from "../hooks/useGitHubStatsCache";
import CloudBackground from "../components/CloudBackground";
import RainOverlay from "../components/RainOverlay";
import SpinnerLights from "../components/SpinnerLights";
@@ -15,16 +15,62 @@ import LanguageBar from "../components/LanguageBar";
import CommitList from "../components/CommitList";
import RepoInfo from "../components/RepoInfo";
/**
* Formats a relative time string like "2 min ago" or "just now".
*
* Uses simple second/minute thresholds — no need for Intl.RelativeTimeFormat
* since the maximum age before auto-refresh is 5 minutes.
*/
function formatTimeAgo(timestampMs: number): string {
const seconds = Math.floor((Date.now() - timestampMs) / 1000);
if (seconds < 5) return "just now";
if (seconds < 60) return `${seconds}s ago`;
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
if (remainingSeconds === 0) return `${minutes} min ago`;
return `${minutes} min ${remainingSeconds}s ago`;
}
/**
* Formats a countdown string like "3:12" from a future timestamp.
*
* Returns "now" if the target is in the past or within 1 second.
*/
function formatCountdown(targetMs: number): string {
const remainingSeconds = Math.max(0, Math.floor((targetMs - Date.now()) / 1000));
if (remainingSeconds <= 0) return "now";
const minutes = Math.floor(remainingSeconds / 60);
const seconds = remainingSeconds % 60;
return `${minutes}:${String(seconds).padStart(2, "0")}`;
}
/**
* Project Dashboard page — displays live GitHub metrics for the TUIKit repository.
*
* All data is fetched client-side via the GitHub REST API (no token required).
* Supports manual refresh via button. Rate limit is displayed in the footer.
* Data is cached in localStorage for 5 minutes. Page reloads within that window
* serve cached data without hitting the GitHub API. A background timer
* auto-refreshes every 5 minutes. A manual force-refresh button is available
* with a 60-second cooldown. Rate limit is displayed in the footer.
*/
export default function DashboardPage() {
const { refresh, ...stats } = useGitHubStats();
const {
forceRefresh,
lastFetchedAt,
nextRefreshAt,
canForceRefresh,
isFromCache,
...stats
} = useGitHubStatsCache();
const [showStargazers, setShowStargazers] = useState(false);
// Tick every second to update the "last updated" and countdown displays
const [, setTick] = useState(0);
useEffect(() => {
const timer = setInterval(() => setTick((prev) => prev + 1), 1000);
return () => clearInterval(timer);
}, []);
// Preload stargazer avatar images in background so the panel opens instantly
useEffect(() => {
if (!stats.stargazers || stats.stargazers.length === 0) return;
@@ -45,6 +91,16 @@ export default function DashboardPage() {
const toggleStargazers = useCallback(() => setShowStargazers((prev) => !prev), []);
const closeStargazers = useCallback(() => setShowStargazers(false), []);
// Compute cooldown remaining for the button tooltip
const cooldownRemaining = lastFetchedAt !== null
? Math.max(0, Math.ceil((60_000 - (Date.now() - lastFetchedAt)) / 1000))
: 0;
const refreshButtonDisabled = stats.loading || !canForceRefresh;
const refreshButtonTitle = !canForceRefresh && cooldownRemaining > 0
? `Available in ${cooldownRemaining}s`
: "Refresh data";
return (
<div className="relative min-h-screen">
<CloudBackground />
@@ -72,10 +128,11 @@ export default function DashboardPage() {
</p>
</div>
<button
onClick={refresh}
disabled={stats.loading}
onClick={forceRefresh}
disabled={refreshButtonDisabled}
title={refreshButtonTitle}
className="flex cursor-pointer items-center gap-2 rounded-full border border-border px-5 py-2 text-base font-medium text-foreground transition-all hover:border-accent/40 hover:bg-white/5 disabled:cursor-not-allowed disabled:opacity-50"
aria-label="Refresh data"
aria-label={refreshButtonTitle}
>
<span className={stats.loading ? "animate-spin-slow" : ""}>
<Icon name="refresh" size={16} />
@@ -88,7 +145,7 @@ export default function DashboardPage() {
{stats.error && (
<div className="mb-8 rounded-xl border border-red-500/30 bg-red-500/10 p-4 text-base text-red-400">
<strong>Error:</strong> {stats.error}
<button onClick={refresh} className="ml-3 text-accent underline transition-colors hover:text-foreground">
<button onClick={forceRefresh} className="ml-3 text-accent underline transition-colors hover:text-foreground">
Retry
</button>
</div>
@@ -141,12 +198,33 @@ export default function DashboardPage() {
<CommitList commits={stats.recentCommits} loading={stats.loading} />
</div>
{/* Rate limit */}
{stats.rateLimit && (
<div className="text-right font-mono text-sm text-muted/60">
API rate limit: {stats.rateLimit.remaining}/{stats.rateLimit.limit} remaining
{/* Footer: cache status + rate limit */}
<div className="flex flex-wrap items-center justify-between gap-4 font-mono text-sm text-muted/60">
<div className="flex items-center gap-3">
{lastFetchedAt && (
<>
<span>
Updated {formatTimeAgo(lastFetchedAt)}
{isFromCache && (
<span className="ml-1.5 rounded bg-white/5 px-1.5 py-0.5 text-xs text-muted/40">
cached
</span>
)}
</span>
{nextRefreshAt && (
<span className="text-muted/40">
· Next refresh in {formatCountdown(nextRefreshAt)}
</span>
)}
</>
)}
</div>
)}
{stats.rateLimit && (
<div>
API rate limit: {stats.rateLimit.remaining}/{stats.rateLimit.limit} remaining
</div>
)}
</div>
</main>
<SiteFooter />
+218 -183
View File
@@ -116,10 +116,12 @@ export interface GitHubStats {
rateLimit: { remaining: number; limit: number } | null;
}
/** Return type of the hook — stats plus a manual refresh function. */
/** Return type of the hook — stats plus manual refresh and data-fetch functions. */
export interface UseGitHubStatsReturn extends GitHubStats {
/** Re-fetch all data from the GitHub API. */
/** Re-fetch all data from the GitHub API (fire-and-forget, updates internal state). */
refresh: () => void;
/** Fetch all data and return it as a resolved promise (for external caching). */
fetchData: () => Promise<GitHubStats>;
}
const initialStats: GitHubStats = {
@@ -248,20 +250,42 @@ interface GitHubStargazerResponse {
html_url: string;
}
/** Options for configuring the `useGitHubStats` hook. */
export interface UseGitHubStatsOptions {
/**
* When `true`, suppresses the automatic fetch on mount.
*
* Useful when a caching layer wants to decide whether to fetch based on
* cached data freshness. The caller can trigger a fetch manually via
* `fetchData()` or `refresh()`.
*
* @default false
*/
skipInitialFetch?: boolean;
}
/**
* Fetches live GitHub stats for the TUIKit repository.
*
* Makes ~13 parallel API requests on mount. All data comes from the
* public GitHub REST API (no token required for public repos).
* Rate limit: 60 requests/hour per IP.
* Makes ~13 parallel API requests on mount (unless `skipInitialFetch` is set).
* All data comes from the public GitHub REST API (no token required for
* public repos). Rate limit: 60 requests/hour per IP.
*
* Returns stats plus a `refresh()` function for manual re-fetch.
* Returns stats plus a `refresh()` function for manual re-fetch and a
* `fetchData()` function that returns a promise with the assembled stats.
*/
export function useGitHubStats(): UseGitHubStatsReturn {
export function useGitHubStats(options?: UseGitHubStatsOptions): UseGitHubStatsReturn {
const skipInitialFetch = options?.skipInitialFetch ?? false;
const [stats, setStats] = useState<GitHubStats>(initialStats);
const controllerRef = useRef<AbortController | null>(null);
const fetchAll = useCallback(() => {
/**
* Fetches all GitHub stats and returns the result.
*
* Updates internal state AND returns the assembled `GitHubStats` object
* so external consumers (e.g. a caching wrapper) can store the data.
*/
const doFetch = useCallback(async (): Promise<GitHubStats> => {
// Abort any in-flight request
controllerRef.current?.abort();
const controller = new AbortController();
@@ -270,196 +294,207 @@ export function useGitHubStats(): UseGitHubStatsReturn {
setStats((prev) => ({ ...prev, loading: true, error: null }));
(async () => {
try {
const [
repoResult,
commitsResult,
languagesResult,
activityResult,
openPRsCount,
closedPRsCount,
closedIssuesCount,
releasesCount,
contributorsCount,
branchesCount,
tagsCount,
stargazersResult,
] = await Promise.all([
ghFetch<GitHubRepoResponse>("", signal),
try {
const [
repoResult,
commitsResult,
languagesResult,
activityResult,
openPRsCount,
closedPRsCount,
closedIssuesCount,
releasesCount,
contributorsCount,
branchesCount,
tagsCount,
stargazersResult,
] = await Promise.all([
ghFetch<GitHubRepoResponse>("", signal),
ghFetch<
Array<{
sha: string;
commit: {
message: string;
author: { name: string; date: string };
};
html_url: string;
}>
>("/commits?per_page=20", signal),
ghFetch<
Array<{
sha: string;
commit: {
message: string;
author: { name: string; date: string };
};
html_url: string;
}>
>("/commits?per_page=20", signal),
ghFetch<LanguageBreakdown>("/languages", signal),
ghFetch<LanguageBreakdown>("/languages", signal),
// Try fetching weekly activity; on failure or empty response, fall back to local cache if available
(async () => {
// Try fetching weekly activity; on failure or empty response, fall back to local cache if available
(async () => {
try {
const res = await ghFetch<WeeklyActivity[]>("/stats/commit_activity", signal);
// If GitHub returns an empty array, the stats endpoint may be pending (202) — try cache
if (Array.isArray(res.data) && res.data.length > 0) return res;
// Attempt to read cached weeklyActivity from public JSON
try {
const res = await ghFetch<WeeklyActivity[]>("/stats/commit_activity", signal);
// If GitHub returns an empty array, the stats endpoint may be pending (202) — try cache
if (Array.isArray(res.data) && res.data.length > 0) return res;
// Attempt to read cached weeklyActivity from public JSON
try {
const cacheResp = await fetch('/weekly-activity-cache.json', { signal });
if (cacheResp.ok) {
const cached = await cacheResp.json();
return { data: cached as WeeklyActivity[], remaining: res.remaining, limit: res.limit };
}
} catch {
// ignore cache read errors
const cacheResp = await fetch('/weekly-activity-cache.json', { signal });
if (cacheResp.ok) {
const cached = await cacheResp.json();
return { data: cached as WeeklyActivity[], remaining: res.remaining, limit: res.limit };
}
return { data: [] as WeeklyActivity[], remaining: res.remaining, limit: res.limit };
} catch {
// On network/API failure, attempt cache
try {
const cacheResp = await fetch('/weekly-activity-cache.json', { signal });
if (cacheResp.ok) {
const cached = await cacheResp.json();
return { data: cached as WeeklyActivity[], remaining: 0, limit: 60 };
}
} catch {
// ignore
}
return { data: [] as WeeklyActivity[], remaining: 0, limit: 60 };
// ignore cache read errors
}
})(),
ghCount("/pulls?state=open", signal),
ghCount("/pulls?state=closed", signal),
ghCount("/issues?state=closed", signal),
ghCount("/releases", signal),
ghCount("/contributors", signal),
ghCount("/branches", signal),
ghCount("/tags", signal),
return { data: [] as WeeklyActivity[], remaining: res.remaining, limit: res.limit };
} catch {
// On network/API failure, attempt cache
try {
const cacheResp = await fetch('/weekly-activity-cache.json', { signal });
if (cacheResp.ok) {
const cached = await cacheResp.json();
return { data: cached as WeeklyActivity[], remaining: 0, limit: 60 };
}
} catch {
// ignore
}
return { data: [] as WeeklyActivity[], remaining: 0, limit: 60 };
}
})(),
ghFetch<GitHubStargazerResponse[]>(
"/stargazers?per_page=100",
signal,
).catch(() => ({ data: [] as GitHubStargazerResponse[], remaining: 0, limit: 60 })),
]);
ghCount("/pulls?state=open", signal),
ghCount("/pulls?state=closed", signal),
ghCount("/issues?state=closed", signal),
ghCount("/releases", signal),
ghCount("/contributors", signal),
ghCount("/branches", signal),
ghCount("/tags", signal),
// Count total commits via Link header
const commitCountResponse = await fetch(
`${API}/commits?per_page=1`,
ghFetch<GitHubStargazerResponse[]>(
"/stargazers?per_page=100",
signal,
).catch(() => ({ data: [] as GitHubStargazerResponse[], remaining: 0, limit: 60 })),
]);
// Count total commits via Link header
const commitCountResponse = await fetch(
`${API}/commits?per_page=1`,
{ signal, headers: ghHeaders() },
);
const totalCommits = extractLastPage(commitCountResponse);
// Count merged PRs (GitHub search API)
let mergedPRs = 0;
try {
const searchResult = await fetch(
`https://api.github.com/search/issues?q=repo:${OWNER}/${REPO}+is:pr+is:merged&per_page=1`,
{ signal, headers: ghHeaders() },
);
const totalCommits = extractLastPage(commitCountResponse);
// Count merged PRs (GitHub search API)
let mergedPRs = 0;
try {
const searchResult = await fetch(
`https://api.github.com/search/issues?q=repo:${OWNER}/${REPO}+is:pr+is:merged&per_page=1`,
{ signal, headers: ghHeaders() },
);
if (searchResult.ok) {
const searchData = await searchResult.json();
mergedPRs = searchData.total_count ?? 0;
}
} catch {
/* search API can be rate-limited separately */
if (searchResult.ok) {
const searchData = await searchResult.json();
mergedPRs = searchData.total_count ?? 0;
}
// Fetch social cache to merge with stargazers
let socialCache: SocialCache = { generatedAt: null, entries: {} };
try {
const cacheResponse = await fetch("/social-cache.json", { signal });
if (cacheResponse.ok) {
socialCache = await cacheResponse.json();
}
} catch {
/* Cache not available, continue without social info */
}
const repo = repoResult.data;
const recentCommits: CommitEntry[] = commitsResult.data.map((commit) => {
const { title, body } = splitCommitMessage(commit.commit.message);
return {
sha: commit.sha.slice(0, 7),
title,
body,
author: commit.commit.author.name,
date: commit.commit.author.date,
url: commit.html_url,
};
});
setStats({
stars: repo.stargazers_count,
forks: repo.forks_count,
watchers: repo.subscribers_count,
openIssues: repo.open_issues_count,
size: repo.size,
defaultBranch: repo.default_branch,
license: repo.license?.spdx_id ?? null,
createdAt: repo.created_at,
updatedAt: repo.updated_at,
pushedAt: repo.pushed_at,
totalCommits,
openPRs: openPRsCount,
closedPRs: closedPRsCount,
mergedPRs,
closedIssues: closedIssuesCount,
releases: releasesCount,
contributors: contributorsCount,
branches: branchesCount,
tags: tagsCount,
recentCommits,
languages: languagesResult.data,
weeklyActivity: Array.isArray(activityResult.data)
? activityResult.data
: [],
stargazers: stargazersResult.data.map((user) => {
const cacheEntry = socialCache.entries[user.login];
return {
login: user.login,
avatarUrl: user.avatar_url,
profileUrl: user.html_url,
mastodon: cacheEntry?.mastodon
? { handle: cacheEntry.mastodon.handle, url: cacheEntry.mastodon.url }
: undefined,
twitter: cacheEntry?.twitter
? { handle: cacheEntry.twitter.handle, url: cacheEntry.twitter.url }
: undefined,
bluesky: cacheEntry?.bluesky
? { handle: cacheEntry.bluesky.handle, url: cacheEntry.bluesky.url }
: undefined,
};
}),
loading: false,
error: null,
rateLimit: {
remaining: repoResult.remaining,
limit: repoResult.limit,
},
});
} catch (err) {
if (signal.aborted) return;
setStats((prev) => ({
...prev,
loading: false,
error: err instanceof Error ? err.message : "Unknown error",
}));
} catch {
/* search API can be rate-limited separately */
}
})();
// Fetch social cache to merge with stargazers
let socialCache: SocialCache = { generatedAt: null, entries: {} };
try {
const cacheResponse = await fetch("/social-cache.json", { signal });
if (cacheResponse.ok) {
socialCache = await cacheResponse.json();
}
} catch {
/* Cache not available, continue without social info */
}
const repo = repoResult.data;
const recentCommits: CommitEntry[] = commitsResult.data.map((commit) => {
const { title, body } = splitCommitMessage(commit.commit.message);
return {
sha: commit.sha.slice(0, 7),
title,
body,
author: commit.commit.author.name,
date: commit.commit.author.date,
url: commit.html_url,
};
});
const result: GitHubStats = {
stars: repo.stargazers_count,
forks: repo.forks_count,
watchers: repo.subscribers_count,
openIssues: repo.open_issues_count,
size: repo.size,
defaultBranch: repo.default_branch,
license: repo.license?.spdx_id ?? null,
createdAt: repo.created_at,
updatedAt: repo.updated_at,
pushedAt: repo.pushed_at,
totalCommits,
openPRs: openPRsCount,
closedPRs: closedPRsCount,
mergedPRs,
closedIssues: closedIssuesCount,
releases: releasesCount,
contributors: contributorsCount,
branches: branchesCount,
tags: tagsCount,
recentCommits,
languages: languagesResult.data,
weeklyActivity: Array.isArray(activityResult.data)
? activityResult.data
: [],
stargazers: stargazersResult.data.map((user) => {
const cacheEntry = socialCache.entries[user.login];
return {
login: user.login,
avatarUrl: user.avatar_url,
profileUrl: user.html_url,
mastodon: cacheEntry?.mastodon
? { handle: cacheEntry.mastodon.handle, url: cacheEntry.mastodon.url }
: undefined,
twitter: cacheEntry?.twitter
? { handle: cacheEntry.twitter.handle, url: cacheEntry.twitter.url }
: undefined,
bluesky: cacheEntry?.bluesky
? { handle: cacheEntry.bluesky.handle, url: cacheEntry.bluesky.url }
: undefined,
};
}),
loading: false,
error: null,
rateLimit: {
remaining: repoResult.remaining,
limit: repoResult.limit,
},
};
setStats(result);
return result;
} catch (err) {
if (signal.aborted) throw err;
setStats((prev) => ({
...prev,
loading: false,
error: err instanceof Error ? err.message : "Unknown error",
}));
throw err;
}
}, []);
useEffect(() => {
fetchAll();
return () => controllerRef.current?.abort();
}, [fetchAll]);
/** Fire-and-forget refresh — triggers fetch but ignores the returned promise. */
const refresh = useCallback(() => {
doFetch().catch(() => {
/* errors are reflected in stats.error */
});
}, [doFetch]);
return { ...stats, refresh: fetchAll };
useEffect(() => {
if (!skipInitialFetch) {
refresh();
}
return () => controllerRef.current?.abort();
}, [refresh, skipInitialFetch]);
return { ...stats, refresh, fetchData: doFetch };
}
+180
View File
@@ -0,0 +1,180 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useGitHubStats, type GitHubStats } from "./useGitHubStats";
/** How often fresh data is fetched automatically (milliseconds). */
const REFRESH_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
/** Minimum time between force-refresh clicks (milliseconds). */
const FORCE_REFRESH_COOLDOWN_MS = 60 * 1000; // 60 seconds
/** Key used to persist cached stats in localStorage. */
const CACHE_KEY = "tuikit-dashboard-cache";
/** Shape of the localStorage cache entry. */
interface CacheEntry {
data: GitHubStats;
fetchedAt: number;
}
/** Return type of the caching wrapper hook. */
export interface UseGitHubStatsCacheReturn extends GitHubStats {
/** Bypass the cache and fetch fresh data (respects cooldown). */
forceRefresh: () => void;
/** Unix timestamp (ms) of the last successful fetch, or null if none yet. */
lastFetchedAt: number | null;
/** Unix timestamp (ms) when the next auto-refresh will fire, or null during initial load. */
nextRefreshAt: number | null;
/** Whether the force-refresh button is currently allowed (cooldown elapsed). */
canForceRefresh: boolean;
/** Whether the currently displayed data was served from localStorage cache. */
isFromCache: boolean;
}
// ---------------------------------------------------------------------------
// localStorage helpers — all reads/writes are wrapped in try/catch to handle
// Safari Private Mode, full storage, or disabled storage gracefully.
// ---------------------------------------------------------------------------
/** Read and validate the cached entry from localStorage. */
function readCache(): CacheEntry | null {
try {
const raw = localStorage.getItem(CACHE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as CacheEntry;
if (!parsed || typeof parsed.fetchedAt !== "number" || !parsed.data) {
localStorage.removeItem(CACHE_KEY);
return null;
}
return parsed;
} catch {
// Corrupt data or storage unavailable — clear and move on
try {
localStorage.removeItem(CACHE_KEY);
} catch {
/* ignore */
}
return null;
}
}
/** Persist stats to localStorage with a timestamp. */
function writeCache(data: GitHubStats): number {
const fetchedAt = Date.now();
try {
localStorage.setItem(CACHE_KEY, JSON.stringify({ data, fetchedAt }));
} catch {
/* Storage full or unavailable — silently continue without cache */
}
return fetchedAt;
}
/**
* Caching wrapper around `useGitHubStats` that prevents redundant API calls.
*
* On mount the hook checks localStorage for a recent cache entry (< 5 min old).
* If valid cached data exists it is served immediately — no GitHub API call.
* A background interval automatically refreshes data every 5 minutes.
*
* The `forceRefresh` function bypasses the cache but enforces a 60-second
* cooldown to prevent accidental rate-limit exhaustion.
*/
export function useGitHubStatsCache(): UseGitHubStatsCacheReturn {
// Skip the automatic fetch on mount — we decide whether to fetch based on cache freshness
const { fetchData, ...rawStats } = useGitHubStats({ skipInitialFetch: true });
const [overrideStats, setOverrideStats] = useState<GitHubStats | null>(null);
const [lastFetchedAt, setLastFetchedAt] = useState<number | null>(null);
const [nextRefreshAt, setNextRefreshAt] = useState<number | null>(null);
const [isFromCache, setIsFromCache] = useState(false);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const initializedRef = useRef(false);
// The stats to expose — override (cached) data takes priority while it's set
const activeStats = overrideStats ?? rawStats;
// -------------------------------------------------------------------------
// Core fetch + cache-write logic
// -------------------------------------------------------------------------
const doFetchAndCache = useCallback(async () => {
try {
const freshData = await fetchData();
const timestamp = writeCache(freshData);
setLastFetchedAt(timestamp);
setNextRefreshAt(timestamp + REFRESH_INTERVAL_MS);
setIsFromCache(false);
// Clear override so the live rawStats (now updated by useGitHubStats) show through
setOverrideStats(null);
} catch {
// Errors are already reflected in rawStats.error via useGitHubStats
setOverrideStats(null);
}
}, [fetchData]);
// -------------------------------------------------------------------------
// Mount: check cache — serve cached data or trigger a fresh fetch
// -------------------------------------------------------------------------
useEffect(() => {
if (initializedRef.current) return;
initializedRef.current = true;
const cached = readCache();
const now = Date.now();
if (cached && now - cached.fetchedAt < REFRESH_INTERVAL_MS) {
// Cache is fresh — serve it immediately, no API call needed
setOverrideStats({ ...cached.data, loading: false, error: null });
setLastFetchedAt(cached.fetchedAt);
setNextRefreshAt(cached.fetchedAt + REFRESH_INTERVAL_MS);
setIsFromCache(true);
} else {
// No valid cache — fetch fresh data now
doFetchAndCache();
}
}, [doFetchAndCache]);
// -------------------------------------------------------------------------
// Auto-refresh interval
// -------------------------------------------------------------------------
useEffect(() => {
intervalRef.current = setInterval(() => {
doFetchAndCache();
}, REFRESH_INTERVAL_MS);
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, [doFetchAndCache]);
// -------------------------------------------------------------------------
// Force refresh with cooldown
// -------------------------------------------------------------------------
const canForceRefresh = lastFetchedAt === null || Date.now() - lastFetchedAt >= FORCE_REFRESH_COOLDOWN_MS;
const forceRefresh = useCallback(() => {
if (lastFetchedAt !== null && Date.now() - lastFetchedAt < FORCE_REFRESH_COOLDOWN_MS) {
return; // Cooldown active — ignore
}
// Reset the interval so the next auto-refresh is a full REFRESH_INTERVAL_MS from now
if (intervalRef.current) clearInterval(intervalRef.current);
doFetchAndCache();
intervalRef.current = setInterval(() => {
doFetchAndCache();
}, REFRESH_INTERVAL_MS);
}, [lastFetchedAt, doFetchAndCache]);
return {
...activeStats,
forceRefresh,
lastFetchedAt,
nextRefreshAt,
canForceRefresh,
isFromCache,
};
}