diff --git a/docs/app/dashboard/page.tsx b/docs/app/dashboard/page.tsx index abd55ec..949b51e 100644 --- a/docs/app/dashboard/page.tsx +++ b/docs/app/dashboard/page.tsx @@ -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 (
@@ -72,10 +128,11 @@ export default function DashboardPage() {

@@ -141,12 +198,33 @@ export default function DashboardPage() { - {/* Rate limit */} - {stats.rateLimit && ( -
- API rate limit: {stats.rateLimit.remaining}/{stats.rateLimit.limit} remaining + {/* Footer: cache status + rate limit */} +
+
+ {lastFetchedAt && ( + <> + + Updated {formatTimeAgo(lastFetchedAt)} + {isFromCache && ( + + cached + + )} + + {nextRefreshAt && ( + + · Next refresh in {formatCountdown(nextRefreshAt)} + + )} + + )}
- )} + {stats.rateLimit && ( +
+ API rate limit: {stats.rateLimit.remaining}/{stats.rateLimit.limit} remaining +
+ )} +
diff --git a/docs/app/hooks/useGitHubStats.ts b/docs/app/hooks/useGitHubStats.ts index d99acb8..b35e8db 100644 --- a/docs/app/hooks/useGitHubStats.ts +++ b/docs/app/hooks/useGitHubStats.ts @@ -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; } 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(initialStats); const controllerRef = useRef(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 => { // 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("", signal), + try { + const [ + repoResult, + commitsResult, + languagesResult, + activityResult, + openPRsCount, + closedPRsCount, + closedIssuesCount, + releasesCount, + contributorsCount, + branchesCount, + tagsCount, + stargazersResult, + ] = await Promise.all([ + ghFetch("", 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("/languages", signal), + ghFetch("/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("/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("/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( - "/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( + "/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 }; } diff --git a/docs/app/hooks/useGitHubStatsCache.ts b/docs/app/hooks/useGitHubStatsCache.ts new file mode 100644 index 0000000..b529ae6 --- /dev/null +++ b/docs/app/hooks/useGitHubStatsCache.ts @@ -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(null); + const [lastFetchedAt, setLastFetchedAt] = useState(null); + const [nextRefreshAt, setNextRefreshAt] = useState(null); + const [isFromCache, setIsFromCache] = useState(false); + + const intervalRef = useRef | 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, + }; +}