mirror of
https://github.com/phranck/TUIkit.git
synced 2026-06-20 09:54:37 +00:00
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:
+91
-13
@@ -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
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user