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() {