Skip to content

Commit eb8a1b6

Browse files
feat: add manual Sync Data refresh button to dashboard header (#2406) (#3497)
* feat: add manual Sync Data refresh button to dashboard header (#2406) Signed-off-by: aaniya22 <aaniyaatomar@gmail.com> * test(header): mock next/navigation so SyncDataButton can render SyncDataButton calls useRouter(), which throws "invariant expected app router to be mounted" when rendered outside a Next app-router tree. Adding the button to DashboardHeader therefore broke all nine header tests. Mocks next/navigation rather than stubbing the button out, so the new markup stays inside the rendered tree and the existing assertions keep covering it. --------- Signed-off-by: aaniya22 <aaniyaatomar@gmail.com> Co-authored-by: Priyanshu Doshi <doshipriyanshu3@gmail.com>
1 parent a722878 commit eb8a1b6

3 files changed

Lines changed: 102 additions & 16 deletions

File tree

src/components/DashboardHeader.tsx

Lines changed: 48 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import SignOutButton from "@/components/SignOutButton";
1818
import ThemeToggle from "@/components/ThemeToggle";
1919
import UserAvatar from "@/components/UserAvatar";
2020
import KeyboardShortcuts from "@/components/KeyboardShortcuts";
21+
import SyncDataButton from "@/components/SyncDataButton";
2122
import OnboardingTour from "@/components/OnboardingTour";
2223
import { Moon, Sun } from "lucide-react";
2324
import { toast } from "sonner";
@@ -51,7 +52,7 @@ const STALE_TIMES: Record<string, number> = {
5152
"/api/metrics/repos": 5 * 60 * 1000,
5253
"/api/metrics/languages": 5 * 60 * 1000,
5354
"/api/notifications": 1 * 60 * 1000,
54-
"default": 2 * 60 * 1000,
55+
default: 2 * 60 * 1000,
5556
};
5657

5758
function getStaleTime(url: string): number {
@@ -101,7 +102,9 @@ export function DashboardSyncProvider({ children }: { children: ReactNode }) {
101102
useEffect(() => {
102103
if (process.env.NODE_ENV === "test") return;
103104
const handleSync = () => {
104-
console.log("[Client Cache] Invalidating dashboard metrics cache due to sync event.");
105+
console.log(
106+
"[Client Cache] Invalidating dashboard metrics cache due to sync event."
107+
);
105108
clientCache.clear();
106109
};
107110

@@ -116,13 +119,22 @@ export function DashboardSyncProvider({ children }: { children: ReactNode }) {
116119
const originalFetch = window.fetch;
117120

118121
window.fetch = async (input, init) => {
119-
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
120-
const isGet = !init || !init.method || init.method.toUpperCase() === "GET";
122+
const url =
123+
typeof input === "string"
124+
? input
125+
: input instanceof URL
126+
? input.toString()
127+
: input.url;
128+
const isGet =
129+
!init || !init.method || init.method.toUpperCase() === "GET";
121130

122131
if (isDashboardDataRequest(input)) {
123132
if (!isGet) {
124133
// Clear cache on write requests (POST, PUT, DELETE, PATCH)
125-
console.log("[Client Cache] Invalidate cache due to mutation request:", url);
134+
console.log(
135+
"[Client Cache] Invalidate cache due to mutation request:",
136+
url
137+
);
126138
clientCache.clear();
127139
return originalFetch(input, init);
128140
}
@@ -150,7 +162,10 @@ export function DashboardSyncProvider({ children }: { children: ReactNode }) {
150162

151163
const nowTime = new Date();
152164
setLastSynced(nowTime);
153-
localStorage.setItem("devtrack-last-synced", nowTime.toISOString());
165+
localStorage.setItem(
166+
"devtrack-last-synced",
167+
nowTime.toISOString()
168+
);
154169
}
155170
return response;
156171
} catch (error) {
@@ -258,7 +273,7 @@ export default function DashboardHeader() {
258273
const { isLive: isHeaderLive } = useRealtimeSync(
259274
"users",
260275
["UPDATE"],
261-
loadSettings,
276+
loadSettings
262277
);
263278
useEffect(() => {
264279
if (!session?.githubLogin) return;
@@ -285,7 +300,10 @@ export default function DashboardHeader() {
285300
if (nightOwlCommitsCount >= 1) setIsNightOwl(true);
286301
if (earlyBirdCommitsCount >= 1) setIsEarlyBird(true);
287302
} catch (err) {
288-
console.error("Failed to compile milestone hour distribution profiles:", err);
303+
console.error(
304+
"Failed to compile milestone hour distribution profiles:",
305+
err
306+
);
289307
}
290308
}
291309

@@ -298,7 +316,8 @@ export default function DashboardHeader() {
298316
const [now, setNow] = useState(() => Date.now());
299317

300318
// Extract a fallback username parameter from active session data strings
301-
const displayName = session?.user?.name || session?.githubLogin || "Developer";
319+
const displayName =
320+
session?.user?.name || session?.githubLogin || "Developer";
302321
useEffect(() => {
303322
if (!lastSynced) return;
304323

@@ -318,7 +337,6 @@ export default function DashboardHeader() {
318337
<div className="pointer-events-none absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-[var(--accent)]/40 to-transparent" />
319338
<div className="pointer-events-none absolute -right-10 -top-12 h-32 w-32 rounded-full bg-[var(--accent)]/10 blur-3xl" />
320339
<div className="relative z-10 flex min-w-0 flex-col gap-5 lg:flex-row lg:items-end lg:justify-between">
321-
322340
{/* Left Section */}
323341
<div className="min-w-0 pr-0">
324342
<div className="mb-1 flex min-w-0 flex-wrap items-center gap-2">
@@ -327,7 +345,9 @@ export default function DashboardHeader() {
327345
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-[var(--accent)] opacity-75"></span>
328346
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-[var(--accent)]"></span>
329347
</span>
330-
<span className="truncate">{greeting}, {displayName}!</span>
348+
<span className="truncate">
349+
{greeting}, {displayName}!
350+
</span>
331351
</div>
332352
{isNightOwl && (
333353
<div
@@ -351,7 +371,9 @@ export default function DashboardHeader() {
351371
<div className="min-w-0">
352372
<p
353373
className="text-[11px] font-semibold uppercase tracking-[0.24em] text-[var(--muted-foreground)]"
354-
style={{ fontFamily: "var(--font-jetbrains, ui-monospace, monospace)" }}
374+
style={{
375+
fontFamily: "var(--font-jetbrains, ui-monospace, monospace)",
376+
}}
355377
>
356378
Dashboard overview
357379
</p>
@@ -360,7 +382,10 @@ export default function DashboardHeader() {
360382
</h1>
361383
<p
362384
className="mt-2 max-w-xl text-sm leading-6 text-[var(--muted-foreground)]"
363-
style={{ fontFamily: "var(--font-jetbrains, ui-monospace, monospace)", letterSpacing: "0.06em" }}
385+
style={{
386+
fontFamily: "var(--font-jetbrains, ui-monospace, monospace)",
387+
letterSpacing: "0.06em",
388+
}}
364389
>
365390
coding activity at a glance
366391
</p>
@@ -370,7 +395,9 @@ export default function DashboardHeader() {
370395
aria-atomic="true"
371396
className="mt-1 flex items-center gap-1.5 text-xs text-[var(--muted-foreground)]"
372397
>
373-
{minutesAgo <= 0 ? "Synced just now" : `Synced ${minutesAgo} min ago`}
398+
{minutesAgo <= 0
399+
? "Synced just now"
400+
: `Synced ${minutesAgo} min ago`}
374401
{isHeaderLive && (
375402
<span
376403
title="Live — connected to Supabase Realtime"
@@ -395,8 +422,10 @@ export default function DashboardHeader() {
395422
{isPublic === true && session?.githubLogin && (
396423
<ShareProfileButton githubLogin={session.githubLogin} />
397424
)}
398-
399425
<div className="flex shrink-0 items-center gap-2 rounded-2xl border border-[var(--border)] bg-[var(--card-muted)]/50 p-2 shadow-sm backdrop-blur-sm">
426+
<div className="transition-transform duration-200 hover:scale-[1.05]">
427+
<SyncDataButton />
428+
</div>
400429
<div className="transition-transform duration-200 hover:scale-[1.05]">
401430
<KeyboardShortcuts />
402431
</div>
@@ -468,6 +497,9 @@ export default function DashboardHeader() {
468497
{menuOpen && (
469498
<div className="mt-4 space-y-3 rounded-2xl border border-[var(--border)] bg-[var(--card-muted)]/70 p-4 shadow-sm backdrop-blur-sm sm:hidden">
470499
<div className="flex flex-wrap items-center gap-2">
500+
<div className="transition-transform duration-200 hover:scale-[1.05]">
501+
<SyncDataButton />
502+
</div>
471503
<div className="transition-transform duration-200 hover:scale-[1.05]">
472504
<KeyboardShortcuts />
473505
</div>
@@ -503,4 +535,4 @@ export default function DashboardHeader() {
503535
{!seenOnboarding && <OnboardingTour />}
504536
</header>
505537
);
506-
}
538+
}

src/components/SyncDataButton.tsx

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
"use client";
2+
3+
import { useState, useCallback } from "react";
4+
import { useRouter } from "next/navigation";
5+
import { RefreshCw } from "lucide-react";
6+
import { toast } from "sonner";
7+
8+
export default function SyncDataButton() {
9+
const [isSyncing, setIsSyncing] = useState(false);
10+
const router = useRouter();
11+
12+
const handleSync = useCallback(async () => {
13+
if (isSyncing) return;
14+
setIsSyncing(true);
15+
try {
16+
window.dispatchEvent(new Event("devtrack:sync"));
17+
router.refresh();
18+
toast.success("Dashboard data synced");
19+
} catch (error) {
20+
console.error("Failed to sync dashboard data:", error);
21+
toast.error("Failed to sync data");
22+
} finally {
23+
setTimeout(() => setIsSyncing(false), 600);
24+
}
25+
}, [isSyncing, router]);
26+
27+
return (
28+
<button
29+
onClick={handleSync}
30+
disabled={isSyncing}
31+
title="Sync Data"
32+
aria-label="Sync dashboard data"
33+
className="inline-flex items-center justify-center rounded-lg border border-[var(--border)] bg-[var(--control)] p-2 text-[var(--muted-foreground)] transition-colors hover:bg-[var(--control-hover)] hover:text-[var(--foreground)] disabled:opacity-60"
34+
>
35+
<RefreshCw className={"h-4 w-4 " + (isSyncing ? "animate-spin" : "")} />
36+
</button>
37+
);
38+
}

test/components/DashboardHeader.test.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,22 @@ vi.mock("@/components/KeyboardShortcuts", () => ({
3333
default: () => <div>KeyboardShortcuts</div>,
3434
}));
3535

36+
// SyncDataButton calls useRouter(), which throws "invariant expected app router
37+
// to be mounted" outside a Next app-router tree. Render the real button so its
38+
// markup stays covered, but give it a router to hold on to.
39+
vi.mock("next/navigation", () => ({
40+
useRouter: () => ({
41+
refresh: vi.fn(),
42+
push: vi.fn(),
43+
replace: vi.fn(),
44+
back: vi.fn(),
45+
forward: vi.fn(),
46+
prefetch: vi.fn(),
47+
}),
48+
usePathname: () => "/dashboard",
49+
useSearchParams: () => new URLSearchParams(),
50+
}));
51+
3652
vi.mock("@/hooks/useRealtimeSync", () => ({
3753
useRealtimeSync: () => ({
3854
isLive: false,

0 commit comments

Comments
 (0)