diff --git a/src/components/DashboardHeader.tsx b/src/components/DashboardHeader.tsx index 2d222a106..03983f027 100644 --- a/src/components/DashboardHeader.tsx +++ b/src/components/DashboardHeader.tsx @@ -18,8 +18,8 @@ import SignOutButton from "@/components/SignOutButton"; import ThemeToggle from "@/components/ThemeToggle"; import UserAvatar from "@/components/UserAvatar"; import KeyboardShortcuts from "@/components/KeyboardShortcuts"; -import OnboardingTour from "@/components/OnboardingTour"; -import { Moon, Sun } from "lucide-react"; +import OnboardingTour, { REPLAY_TOUR_EVENT } from "@/components/OnboardingTour"; +import { CircleHelp, Moon, Sun } from "lucide-react"; import { toast } from "sonner"; import { useRealtimeSync } from "@/hooks/useRealtimeSync"; import { Button, buttonVariants } from "@/components/ui/button"; @@ -51,7 +51,7 @@ const STALE_TIMES: Record = { "/api/metrics/repos": 5 * 60 * 1000, "/api/metrics/languages": 5 * 60 * 1000, "/api/notifications": 1 * 60 * 1000, - "default": 2 * 60 * 1000, + default: 2 * 60 * 1000, }; function getStaleTime(url: string): number { @@ -101,7 +101,9 @@ export function DashboardSyncProvider({ children }: { children: ReactNode }) { useEffect(() => { if (process.env.NODE_ENV === "test") return; const handleSync = () => { - console.log("[Client Cache] Invalidating dashboard metrics cache due to sync event."); + console.log( + "[Client Cache] Invalidating dashboard metrics cache due to sync event." + ); clientCache.clear(); }; @@ -116,13 +118,22 @@ export function DashboardSyncProvider({ children }: { children: ReactNode }) { const originalFetch = window.fetch; window.fetch = async (input, init) => { - const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; - const isGet = !init || !init.method || init.method.toUpperCase() === "GET"; + const url = + typeof input === "string" + ? input + : input instanceof URL + ? input.toString() + : input.url; + const isGet = + !init || !init.method || init.method.toUpperCase() === "GET"; if (isDashboardDataRequest(input)) { if (!isGet) { // Clear cache on write requests (POST, PUT, DELETE, PATCH) - console.log("[Client Cache] Invalidate cache due to mutation request:", url); + console.log( + "[Client Cache] Invalidate cache due to mutation request:", + url + ); clientCache.clear(); return originalFetch(input, init); } @@ -150,7 +161,10 @@ export function DashboardSyncProvider({ children }: { children: ReactNode }) { const nowTime = new Date(); setLastSynced(nowTime); - localStorage.setItem("devtrack-last-synced", nowTime.toISOString()); + localStorage.setItem( + "devtrack-last-synced", + nowTime.toISOString() + ); } return response; } catch (error) { @@ -258,7 +272,7 @@ export default function DashboardHeader() { const { isLive: isHeaderLive } = useRealtimeSync( "users", ["UPDATE"], - loadSettings, + loadSettings ); useEffect(() => { if (!session?.githubLogin) return; @@ -285,20 +299,30 @@ export default function DashboardHeader() { if (nightOwlCommitsCount >= 1) setIsNightOwl(true); if (earlyBirdCommitsCount >= 1) setIsEarlyBird(true); } catch (err) { - console.error("Failed to compile milestone hour distribution profiles:", err); + console.error( + "Failed to compile milestone hour distribution profiles:", + err + ); } } evaluateCodingDistributionMilestones(); + const handleReplayTour = () => { + window.dispatchEvent(new Event(REPLAY_TOUR_EVENT)); + }; }, [session]); const [menuOpen, setMenuOpen] = useState(false); + const handleReplayTour = () => { + window.dispatchEvent(new Event(REPLAY_TOUR_EVENT)); + }; const { lastSynced } = useDashboardSync(); const [now, setNow] = useState(() => Date.now()); // Extract a fallback username parameter from active session data strings - const displayName = session?.user?.name || session?.githubLogin || "Developer"; + const displayName = + session?.user?.name || session?.githubLogin || "Developer"; useEffect(() => { if (!lastSynced) return; @@ -318,7 +342,6 @@ export default function DashboardHeader() {
- {/* Left Section */}
@@ -327,7 +350,9 @@ export default function DashboardHeader() { - {greeting}, {displayName}! + + {greeting}, {displayName}! +
{isNightOwl && (

Dashboard overview

@@ -360,7 +387,10 @@ export default function DashboardHeader() {

coding activity at a glance

@@ -370,7 +400,9 @@ export default function DashboardHeader() { aria-atomic="true" className="mt-1 flex items-center gap-1.5 text-xs text-[var(--muted-foreground)]" > - {minutesAgo <= 0 ? "Synced just now" : `Synced ${minutesAgo} min ago`} + {minutesAgo <= 0 + ? "Synced just now" + : `Synced ${minutesAgo} min ago`} {isHeaderLive && ( + +
-
@@ -500,7 +543,7 @@ export default function DashboardHeader() {
- {!seenOnboarding && } + ); -} \ No newline at end of file +} diff --git a/src/components/OnboardingTour.tsx b/src/components/OnboardingTour.tsx index f4b8701b0..59b1419f2 100644 --- a/src/components/OnboardingTour.tsx +++ b/src/components/OnboardingTour.tsx @@ -1,80 +1,220 @@ "use client"; -import { useEffect, useCallback } from "react"; +import { useCallback, useEffect, useRef } from "react"; import { driver } from "driver.js"; import "driver.js/dist/driver.css"; +const LOCAL_TOUR_KEY = "devtrack-dashboard-onboarding-completed"; +export const REPLAY_TOUR_EVENT = "devtrack:replay-dashboard-tour"; + const TOUR_STEPS = [ { element: "#widget-contribution-graph", popover: { title: "Contribution Graph", - description: "See your daily GitHub commit activity. Switch between 7, 14, 30, and 90 day views.", + description: + "See your daily GitHub contribution activity and understand how consistently you are coding.", }, }, { - element: "#widget-streak", + element: "#widget-streak-tracker", popover: { - title: "Streak Tracker", - description: "Your current commit streak — how many days in a row you've pushed code.", + title: "Coding Streak", + description: + "Track how many consecutive days you have recorded GitHub activity.", }, }, { element: "#widget-pr-metrics", popover: { - title: "PR Analytics", - description: "Average review time, merge rate, and open vs closed pull request counts.", + title: "Pull Request Analytics", + description: + "Review your pull request activity, merge performance, and collaboration metrics.", + }, + }, + { + element: "#widget-language-breakdown", + popover: { + title: "Language Breakdown", + description: + "Understand which programming languages you use most across your repositories.", }, }, { element: "#widget-top-repos", popover: { title: "Top Repositories", - description: "Your most active repos ranked by commits. Click column headers to sort.", + description: + "View your most active repositories and quickly identify where most of your work happens.", }, }, { - element: "#widget-goals", + element: "#widget-goal-tracker", popover: { - title: "Weekly Goals", - description: "Set coding targets and track your progress automatically.", + title: "Set Your First Goal", + description: + "Create a coding goal and let DevTrack automatically monitor your progress.", }, }, ]; async function markTourSeen() { + try { + window.localStorage.setItem(LOCAL_TOUR_KEY, "true"); + } catch { + // localStorage may be unavailable in restricted browser environments. + } + try { await fetch("/api/user/settings", { method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ seen_onboarding: true }), + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + seen_onboarding: true, + }), }); } catch { - // silent fail — not critical + // The local completion flag prevents the tour from repeatedly appearing + // when the remote settings request is temporarily unavailable. } } -export default function OnboardingTour() { - const startTour = useCallback(() => { - const driverObj = driver({ - showProgress: true, +interface OnboardingTourProps { + autoStart?: boolean; +} + +export default function OnboardingTour({ + autoStart = true, +}: OnboardingTourProps) { + const automaticStartHandled = useRef(false); + + const startTour = useCallback((forceReplay = false) => { + if (typeof window === "undefined") return; + + if (!forceReplay) { + try { + const alreadyCompleted = + window.localStorage.getItem(LOCAL_TOUR_KEY) === "true"; + + if (alreadyCompleted) return; + } catch { + // Continue using the remote setting when localStorage is unavailable. + } + } + + const availableSteps = TOUR_STEPS.filter(({ element }) => + document.querySelector(element) + ); + + if (availableSteps.length === 0) return; + + let completionSaved = false; + let focusGoalAfterClose = false; + + const saveCompletion = () => { + if (completionSaved) return; + + completionSaved = true; + void markTourSeen(); + }; + + const driverObject = driver({ animate: true, allowClose: true, - steps: TOUR_STEPS, + allowScroll: true, + showProgress: true, + progressText: "{{current}} of {{total}}", + nextBtnText: "Next", + prevBtnText: "Back", + doneBtnText: "Set first goal", + overlayOpacity: 0.65, + stagePadding: 8, + stageRadius: 12, + popoverOffset: 12, + steps: availableSteps, + + onPopoverRender: (popover) => { + if (popover.footerButtons.querySelector(".devtrack-tour-skip-button")) { + return; + } + + const skipButton = document.createElement("button"); + + skipButton.type = "button"; + skipButton.textContent = "Skip tour"; + skipButton.className = + "driver-popover-footer-btn devtrack-tour-skip-button"; + + skipButton.addEventListener("click", () => { + driverObject.destroy(); + }); + + popover.footerButtons.appendChild(skipButton); + }, + + onDoneClick: () => { + focusGoalAfterClose = true; + driverObject.destroy(); + }, + onDestroyStarted: () => { - markTourSeen(); - driverObj.destroy(); + saveCompletion(); + driverObject.destroy(); + + if (focusGoalAfterClose) { + window.setTimeout(() => { + const goalWidget = document.querySelector( + "#widget-goal-tracker" + ); + + goalWidget?.scrollIntoView({ + behavior: "smooth", + block: "center", + }); + + const firstGoalControl = goalWidget?.querySelector( + 'button, a[href], input, select, textarea, [tabindex]:not([tabindex="-1"])' + ); + + firstGoalControl?.focus({ + preventScroll: true, + }); + }, 150); + } }, }); - driverObj.drive(); + driverObject.drive(); }, []); useEffect(() => { - if (typeof window !== "undefined" && window.navigator.webdriver) return; - const timer = setTimeout(startTour, 800); - return () => clearTimeout(timer); + const handleReplay = () => { + startTour(true); + }; + + window.addEventListener(REPLAY_TOUR_EVENT, handleReplay); + + return () => { + window.removeEventListener(REPLAY_TOUR_EVENT, handleReplay); + }; }, [startTour]); + useEffect(() => { + if (!autoStart || automaticStartHandled.current) return; + if (window.navigator.webdriver) return; + + automaticStartHandled.current = true; + + const timer = window.setTimeout(() => { + startTour(false); + }, 1000); + + return () => { + window.clearTimeout(timer); + }; + }, [autoStart, startTour]); + return null; } diff --git a/src/components/dashboard/DashboardWidgetShell.tsx b/src/components/dashboard/DashboardWidgetShell.tsx index 869f0dbe0..0bb62a15f 100644 --- a/src/components/dashboard/DashboardWidgetShell.tsx +++ b/src/components/dashboard/DashboardWidgetShell.tsx @@ -43,6 +43,7 @@ export default function DashboardWidgetShell({ return (