From 6de67e9a58593ae6b8bf154f8f0d5bf8f5596853 Mon Sep 17 00:00:00 2001 From: ishwari418 Date: Fri, 15 May 2026 16:10:41 +0530 Subject: [PATCH 1/2] feat: implement analytics dashboard and fix server-side filtering --- package.json | 3 + src/components/Dashboard.tsx | 101 ++++++++++++++++++++++++++++++++++ src/hooks/useGitHubData.ts | 59 +++++++++++++++----- src/pages/Tracker/Tracker.tsx | 74 +++++++++---------------- 4 files changed, 175 insertions(+), 62 deletions(-) create mode 100644 src/components/Dashboard.tsx diff --git a/package.json b/package.json index f2d89f5..4d6e837 100644 --- a/package.json +++ b/package.json @@ -19,8 +19,10 @@ "@primer/octicons-react": "^19.15.5", "@vitejs/plugin-react": "^4.3.3", "axios": "^1.7.7", + "express": "^5.2.1", "framer-motion": "^12.23.12", "lucide-react": "^0.525.0", + "mongoose": "^9.6.2", "octokit": "^4.0.2", "postcss": "^8.4.47", "react": "^18.3.1", @@ -28,6 +30,7 @@ "react-hot-toast": "^2.4.1", "react-icons": "^5.3.0", "react-router-dom": "^6.28.0", + "recharts": "^3.8.1", "tailwindcss": "^3.4.14" }, "devDependencies": { diff --git a/src/components/Dashboard.tsx b/src/components/Dashboard.tsx new file mode 100644 index 0000000..a4918b0 --- /dev/null +++ b/src/components/Dashboard.tsx @@ -0,0 +1,101 @@ +import React from 'react'; +import { + PieChart, + Pie, + Cell, + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + Legend, + ResponsiveContainer +} from 'recharts'; +import { Paper, Typography, Box, Grid } from '@mui/material'; + +interface DashboardProps { + totalIssues: number; + totalPrs: number; + data: any[]; // Combined issues and PRs from the current view + theme: any; +} + +const Dashboard: React.FC = ({ totalIssues, totalPrs, data, theme }) => { + + // Data for Pie Chart + const pieData = [ + { name: 'Issues', value: totalIssues }, + { name: 'Pull Requests', value: totalPrs }, + ]; + + const COLORS = ['#0088FE', '#00C49F']; + + // Data for Bar Chart (Top 5 Repositories in current view) + const repoCounts: { [key: string]: number } = {}; + data.forEach(item => { + const repoName = item.repository_url.split('/').slice(-1)[0]; + repoCounts[repoName] = (repoCounts[repoName] || 0) + 1; + }); + + const barData = Object.entries(repoCounts) + .map(([name, count]) => ({ name, count })) + .sort((a, b) => b.count - a.count) + .slice(0, 5); + + return ( + + + {/* Pie Chart: Issues vs PRs */} + + + + Contribution Mix (Total) + + + + + {pieData.map((_entry, index) => ( + + ))} + + + + + + + + + {/* Bar Chart: Activity by Repository */} + + + + Top Repositories (Current View) + + + + + + + + + + + + + + + ); +}; + +export default Dashboard; diff --git a/src/hooks/useGitHubData.ts b/src/hooks/useGitHubData.ts index a0ebe10..6e6ae18 100644 --- a/src/hooks/useGitHubData.ts +++ b/src/hooks/useGitHubData.ts @@ -9,8 +9,27 @@ export const useGitHubData = (getOctokit: () => any) => { const [totalPrs, setTotalPrs] = useState(0); const [rateLimited, setRateLimited] = useState(false); - const fetchPaginated = async (octokit: any, username: string, type: string, page = 1, per_page = 10) => { - const q = `author:${username} is:${type}`; + const fetchPaginated = async ( + octokit: any, + username: string, + type: string, + page = 1, + per_page = 10, + filters: any = {} + ) => { + let q = `author:${username} is:${type}`; + + if (filters.search) q += ` ${filters.search} in:title`; + if (filters.repo) q += ` repo:${filters.repo}`; + if (filters.startDate) q += ` created:>=${filters.startDate}`; + if (filters.endDate) q += ` created:<=${filters.endDate}`; + + if (filters.state === 'open' || filters.state === 'closed') { + q += ` is:${filters.state}`; + } else if (filters.state === 'merged') { + q += ` is:merged`; + } + const response = await octokit.request('GET /search/issues', { q, sort: 'created', @@ -26,29 +45,39 @@ export const useGitHubData = (getOctokit: () => any) => { }; const fetchData = useCallback( - async (username: string, page = 1, perPage = 10) => { - + async (username: string, page = 1, perPage = 10, type?: 'issue' | 'pr', filters: any = {}) => { const octokit = getOctokit(); - if (!octokit || !username || rateLimited) return; setLoading(true); setError(''); try { - const [issueRes, prRes] = await Promise.all([ - fetchPaginated(octokit, username, 'issue', page, perPage), - fetchPaginated(octokit, username, 'pr', page, perPage), - ]); - - setIssues(issueRes.items); - setPrs(prRes.items); - setTotalIssues(issueRes.total); - setTotalPrs(prRes.total); + if (type) { + // Fetch only the requested type + const res = await fetchPaginated(octokit, username, type, page, perPage, filters); + if (type === 'issue') { + setIssues(res.items); + setTotalIssues(res.total); + } else { + setPrs(res.items); + setTotalPrs(res.total); + } + } else { + // Fetch both (used for initial load or if type not specified) + const [issueRes, prRes] = await Promise.all([ + fetchPaginated(octokit, username, 'issue', page, perPage, filters), + fetchPaginated(octokit, username, 'pr', page, perPage, filters), + ]); + setIssues(issueRes.items); + setTotalIssues(issueRes.total); + setPrs(prRes.items); + setTotalPrs(prRes.total); + } } catch (err: any) { if (err.status === 403) { setError('GitHub API rate limit exceeded. Please wait or use a token.'); - setRateLimited(true); // Prevent further fetches + setRateLimited(true); } else { setError(err.message || 'Failed to fetch data'); } diff --git a/src/pages/Tracker/Tracker.tsx b/src/pages/Tracker/Tracker.tsx index 2bd4d30..152fb2b 100644 --- a/src/pages/Tracker/Tracker.tsx +++ b/src/pages/Tracker/Tracker.tsx @@ -1,4 +1,5 @@ import React, { useState, useEffect } from "react" +import Dashboard from "../../components/Dashboard"; import { IssueOpenedIcon, IssueClosedIcon, @@ -45,7 +46,7 @@ interface GitHubItem { html_url: string; } -const Home: React.FC = () => { +const Tracker: React.FC = () => { const theme = useTheme(); @@ -78,17 +79,25 @@ const Home: React.FC = () => { const [startDate, setStartDate] = useState(""); const [endDate, setEndDate] = useState(""); - // Fetch data when username, tab, or page changes + // Fetch data when username, tab, page, or filters change useEffect(() => { if (username) { - fetchData(username, page + 1, ROWS_PER_PAGE); + const type = tab === 0 ? "issue" : "pr"; + const filters = { + search: searchTitle, + repo: selectedRepo, + startDate: startDate, + endDate: endDate, + state: tab === 0 ? issueFilter : prFilter, + }; + fetchData(username, page + 1, ROWS_PER_PAGE, type, filters); } - }, [tab, page]); + }, [tab, page, issueFilter, prFilter, searchTitle, selectedRepo, startDate, endDate]); const handleSubmit = (e: React.FormEvent): void => { e.preventDefault(); setPage(0); - fetchData(username, 1, ROWS_PER_PAGE); + // fetchData is already triggered by useEffect when page changes to 0 }; const handlePageChange = (_: unknown, newPage: number) => { @@ -98,44 +107,6 @@ const Home: React.FC = () => { const formatDate = (dateString: string): string => new Date(dateString).toLocaleDateString(); - const filterData = (data: GitHubItem[], filterType: string): GitHubItem[] => { - let filtered = [...data]; - if (["open", "closed", "merged"].includes(filterType)) { - filtered = filtered.filter((item) => { - if (filterType === "merged") { - return !!item.pull_request?.merged_at - } - else if (filterType === "closed") { - return item.state === "closed" && !item.pull_request?.merged_at - } - else { - //open - return item.state === "open" - } - }); - } - if (searchTitle) { - filtered = filtered.filter((item) => - item.title.toLowerCase().includes(searchTitle.toLowerCase()) - ); - } - if (selectedRepo) { - filtered = filtered.filter((item) => - item.repository_url.includes(selectedRepo) - ); - } - if (startDate) { - filtered = filtered.filter( - (item) => new Date(item.created_at) >= new Date(startDate) - ); - } - if (endDate) { - filtered = filtered.filter( - (item) => new Date(item.created_at) <= new Date(endDate) - ); - } - return filtered; - }; const getStatusIcon = (item: GitHubItem) => { @@ -157,9 +128,8 @@ const Home: React.FC = () => { }; - // Current data and filtered data according to tab and filters - const currentRawData = tab === 0 ? issues : prs; - const currentFilteredData = filterData(currentRawData, tab === 0 ? issueFilter : prFilter); + // Current data according to tab + const currentFilteredData = tab === 0 ? issues : prs; const totalCount = tab === 0 ? totalIssues : totalPrs; return ( @@ -190,6 +160,16 @@ const Home: React.FC = () => { + {/* Dashboard Summary */} + {username && ( + + )} + {/* Filters */} { ); }; -export default Home; +export default Tracker; From 257c23d27738e6ba94fe4d13c03e13666fe20df6 Mon Sep 17 00:00:00 2001 From: ishwari418 Date: Fri, 15 May 2026 16:44:59 +0530 Subject: [PATCH 2/2] Fic review comment --- package.json | 5 +- src/components/Dashboard.tsx | 81 ++++++++++++++----- src/hooks/useDebounce.ts | 17 ++++ src/hooks/useGitHubAuth.ts | 3 - src/hooks/useGitHubData.ts | 80 +++++++++++------- .../ContributorProfile/ContributorProfile.tsx | 10 ++- src/pages/Contributors/Contributors.tsx | 2 +- src/pages/Login/Login.tsx | 8 +- src/pages/Signup/Signup.tsx | 24 +----- src/pages/Tracker/Tracker.tsx | 28 +++++-- 10 files changed, 171 insertions(+), 87 deletions(-) create mode 100644 src/hooks/useDebounce.ts diff --git a/package.json b/package.json index 4d6e837..3506b5b 100644 --- a/package.json +++ b/package.json @@ -19,10 +19,8 @@ "@primer/octicons-react": "^19.15.5", "@vitejs/plugin-react": "^4.3.3", "axios": "^1.7.7", - "express": "^5.2.1", "framer-motion": "^12.23.12", "lucide-react": "^0.525.0", - "mongoose": "^9.6.2", "octokit": "^4.0.2", "postcss": "^8.4.47", "react": "^18.3.1", @@ -48,12 +46,15 @@ "eslint-plugin-react": "^7.37.2", "eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-react-refresh": "^0.4.14", + "express": "^5.2.1", "express-session": "^1.18.2", "globals": "^15.11.0", "jasmine": "^5.9.0", + "mongoose": "^9.6.2", "passport": "^0.7.0", "passport-local": "^1.0.0", "supertest": "^7.1.4", + "typescript-eslint": "^8.59.3", "vite": "^5.4.10" } } diff --git a/src/components/Dashboard.tsx b/src/components/Dashboard.tsx index a4918b0..e28358f 100644 --- a/src/components/Dashboard.tsx +++ b/src/components/Dashboard.tsx @@ -12,13 +12,23 @@ import { Legend, ResponsiveContainer } from 'recharts'; -import { Paper, Typography, Box, Grid } from '@mui/material'; +import { Paper, Typography, Box, Grid, Theme } from '@mui/material'; + +interface GitHubItem { + id: number; + title: string; + state: string; + created_at: string; + pull_request?: { merged_at: string | null }; + repository_url: string; + html_url: string; +} interface DashboardProps { totalIssues: number; totalPrs: number; - data: any[]; // Combined issues and PRs from the current view - theme: any; + data: GitHubItem[]; + theme: Theme; } const Dashboard: React.FC = ({ totalIssues, totalPrs, data, theme }) => { @@ -29,13 +39,22 @@ const Dashboard: React.FC = ({ totalIssues, totalPrs, data, them { name: 'Pull Requests', value: totalPrs }, ]; - const COLORS = ['#0088FE', '#00C49F']; + // Use theme-aware colors + const COLORS = [theme.palette.primary.main, theme.palette.secondary.main]; - // Data for Bar Chart (Top 5 Repositories in current view) + // Data for Bar Chart (Top 5 Repositories) - Improved safety const repoCounts: { [key: string]: number } = {}; data.forEach(item => { - const repoName = item.repository_url.split('/').slice(-1)[0]; - repoCounts[repoName] = (repoCounts[repoName] || 0) + 1; + if (item?.repository_url) { + const repoName = item.repository_url + .split('/') + .filter(Boolean) + .pop(); + + if (repoName) { + repoCounts[repoName] = (repoCounts[repoName] || 0) + 1; + } + } }); const barData = Object.entries(repoCounts) @@ -43,13 +62,25 @@ const Dashboard: React.FC = ({ totalIssues, totalPrs, data, them .sort((a, b) => b.count - a.count) .slice(0, 5); + const hasData = totalIssues > 0 || totalPrs > 0; + + if (!hasData) { + return ( + + + No data available. Enter a username to view analytics. + + + ); + } + return ( {/* Pie Chart: Issues vs PRs */} - + Contribution Mix (Total) @@ -60,7 +91,7 @@ const Dashboard: React.FC = ({ totalIssues, totalPrs, data, them cy="50%" innerRadius={60} outerRadius={80} - fill="#8884d8" + fill={theme.palette.primary.main} paddingAngle={5} dataKey="value" label @@ -69,7 +100,9 @@ const Dashboard: React.FC = ({ totalIssues, totalPrs, data, them ))} - + @@ -79,18 +112,26 @@ const Dashboard: React.FC = ({ totalIssues, totalPrs, data, them {/* Bar Chart: Activity by Repository */} - + Top Repositories (Current View) - - - - - - - - - + {barData.length > 0 ? ( + + + + + + + + + + ) : ( + + No repository data found in this view. + + )} diff --git a/src/hooks/useDebounce.ts b/src/hooks/useDebounce.ts new file mode 100644 index 0000000..e3706e6 --- /dev/null +++ b/src/hooks/useDebounce.ts @@ -0,0 +1,17 @@ +import { useState, useEffect } from 'react'; + +export function useDebounce(value: T, delay: number): T { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return () => { + clearTimeout(handler); + }; + }, [value, delay]); + + return debouncedValue; +} diff --git a/src/hooks/useGitHubAuth.ts b/src/hooks/useGitHubAuth.ts index 5284347..92d64af 100644 --- a/src/hooks/useGitHubAuth.ts +++ b/src/hooks/useGitHubAuth.ts @@ -4,7 +4,6 @@ import { Octokit } from '@octokit/core'; export const useGitHubAuth = () => { const [username, setUsername] = useState(''); const [token, setToken] = useState(''); - const [error, setError] = useState(''); const octokit = useMemo(() => { if (!username || !token) return null; @@ -18,8 +17,6 @@ export const useGitHubAuth = () => { setUsername, token, setToken, - error, - setError, getOctokit, }; }; diff --git a/src/hooks/useGitHubData.ts b/src/hooks/useGitHubData.ts index 6e6ae18..29f3cf5 100644 --- a/src/hooks/useGitHubData.ts +++ b/src/hooks/useGitHubData.ts @@ -1,21 +1,43 @@ -import { useState, useCallback } from 'react'; +import { useState, useCallback, useRef } from 'react'; +import { Octokit } from '@octokit/core'; -export const useGitHubData = (getOctokit: () => any) => { - const [issues, setIssues] = useState([]); - const [prs, setPrs] = useState([]); +interface GitHubItem { + id: number; + title: string; + state: string; + created_at: string; + pull_request?: { merged_at: string | null }; + repository_url: string; + html_url: string; +} + +interface FetchFilters { + search?: string; + repo?: string; + startDate?: string; + endDate?: string; + state?: string; +} + +export const useGitHubData = (getOctokit: () => Octokit | null) => { + const [issues, setIssues] = useState([]); + const [prs, setPrs] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); const [totalIssues, setTotalIssues] = useState(0); const [totalPrs, setTotalPrs] = useState(0); const [rateLimited, setRateLimited] = useState(false); + + // Track the latest request ID to prevent stale overwrites + const lastRequestId = useRef(0); const fetchPaginated = async ( - octokit: any, + octokit: Octokit, username: string, type: string, page = 1, per_page = 10, - filters: any = {} + filters: FetchFilters = {} ) => { let q = `author:${username} is:${type}`; @@ -45,44 +67,42 @@ export const useGitHubData = (getOctokit: () => any) => { }; const fetchData = useCallback( - async (username: string, page = 1, perPage = 10, type?: 'issue' | 'pr', filters: any = {}) => { + async (username: string, page = 1, perPage = 10, activeTab: 'issue' | 'pr' = 'issue', filters: FetchFilters = {}) => { const octokit = getOctokit(); if (!octokit || !username || rateLimited) return; + const requestId = ++lastRequestId.current; setLoading(true); setError(''); try { - if (type) { - // Fetch only the requested type - const res = await fetchPaginated(octokit, username, type, page, perPage, filters); - if (type === 'issue') { - setIssues(res.items); - setTotalIssues(res.total); - } else { - setPrs(res.items); - setTotalPrs(res.total); - } - } else { - // Fetch both (used for initial load or if type not specified) - const [issueRes, prRes] = await Promise.all([ - fetchPaginated(octokit, username, 'issue', page, perPage, filters), - fetchPaginated(octokit, username, 'pr', page, perPage, filters), - ]); + // We fetch BOTH even if one tab is active to keep totals synchronized as requested + const [issueRes, prRes] = await Promise.all([ + fetchPaginated(octokit, username, 'issue', activeTab === 'issue' ? page : 1, perPage, filters), + fetchPaginated(octokit, username, 'pr', activeTab === 'pr' ? page : 1, perPage, filters), + ]); + + // Only update state if this is still the latest request + if (requestId === lastRequestId.current) { setIssues(issueRes.items); setTotalIssues(issueRes.total); setPrs(prRes.items); setTotalPrs(prRes.total); } - } catch (err: any) { - if (err.status === 403) { - setError('GitHub API rate limit exceeded. Please wait or use a token.'); - setRateLimited(true); - } else { - setError(err.message || 'Failed to fetch data'); + } catch (err: unknown) { + if (requestId === lastRequestId.current) { + const error = err as { status?: number; message?: string }; + if (error.status === 403) { + setError('GitHub API rate limit exceeded. Please wait or use a token.'); + setRateLimited(true); + } else { + setError(error.message || 'Failed to fetch data'); + } } } finally { - setLoading(false); + if (requestId === lastRequestId.current) { + setLoading(false); + } } }, [getOctokit, rateLimited] diff --git a/src/pages/ContributorProfile/ContributorProfile.tsx b/src/pages/ContributorProfile/ContributorProfile.tsx index b4ab931..ed1b714 100644 --- a/src/pages/ContributorProfile/ContributorProfile.tsx +++ b/src/pages/ContributorProfile/ContributorProfile.tsx @@ -8,9 +8,15 @@ type PR = { repository_url: string; }; +type Profile = { + avatar_url: string; + login: string; + bio: string; +}; + export default function ContributorProfile() { const { username } = useParams(); - const [profile, setProfile] = useState(null); + const [profile, setProfile] = useState(null); const [prs, setPRs] = useState([]); const [loading, setLoading] = useState(true); @@ -28,7 +34,7 @@ export default function ContributorProfile() { ); const prsData = await prsRes.json(); setPRs(prsData.items); - } catch (error) { + } catch { toast.error("Failed to fetch user data."); } finally { setLoading(false); diff --git a/src/pages/Contributors/Contributors.tsx b/src/pages/Contributors/Contributors.tsx index 60270b1..d4fee52 100644 --- a/src/pages/Contributors/Contributors.tsx +++ b/src/pages/Contributors/Contributors.tsx @@ -37,7 +37,7 @@ const ContributorsPage = () => { withCredentials: false, }); setContributors(response.data); - } catch (err) { + } catch { setError("Failed to fetch contributors. Please try again later."); } finally { setLoading(false); diff --git a/src/pages/Login/Login.tsx b/src/pages/Login/Login.tsx index d6f21a7..f4aadb1 100644 --- a/src/pages/Login/Login.tsx +++ b/src/pages/Login/Login.tsx @@ -36,8 +36,12 @@ const Login: React.FC = () => { if (response.data.message === 'Login successful') { navigate("/home"); } - } catch (error: any) { - setMessage(error.response?.data?.message || "Something went wrong"); + } catch (error: unknown) { + if (axios.isAxiosError(error)) { + setMessage(error.response?.data?.message || "Something went wrong"); + } else { + setMessage("Something went wrong"); + } } finally { setIsLoading(false); } diff --git a/src/pages/Signup/Signup.tsx b/src/pages/Signup/Signup.tsx index d03a921..ff5a90b 100644 --- a/src/pages/Signup/Signup.tsx +++ b/src/pages/Signup/Signup.tsx @@ -32,27 +32,9 @@ const navigate = useNavigate(); // Navigate to login page after successful signup if (response.data.message === 'User created successfully') { - navigate("/login");} - - - // // Simulate API call (replace with your actual backend integration) - // try { - // // Mock successful signup - // setMessage("Account created successfully! Redirecting to login..."); - - // // In your actual implementation, integrate with your backend here: - // // const response = await fetch(`${backendUrl}/api/auth/signup`, { - // // method: 'POST', - // // headers: { 'Content-Type': 'application/json' }, - // // body: JSON.stringify(formData) - // // }); - - // setTimeout(() => { - // // Navigate to login page in your actual implementation - // console.log("Redirecting to login page..."); - // }, 2000); - - } catch (error) { + navigate("/login"); + } + } catch { setMessage("Something went wrong. Please try again."); } }; diff --git a/src/pages/Tracker/Tracker.tsx b/src/pages/Tracker/Tracker.tsx index 152fb2b..b60609e 100644 --- a/src/pages/Tracker/Tracker.tsx +++ b/src/pages/Tracker/Tracker.tsx @@ -1,5 +1,6 @@ import React, { useState, useEffect } from "react" import Dashboard from "../../components/Dashboard"; +import { useDebounce } from "../../hooks/useDebounce"; import { IssueOpenedIcon, IssueClosedIcon, @@ -79,25 +80,40 @@ const Tracker: React.FC = () => { const [startDate, setStartDate] = useState(""); const [endDate, setEndDate] = useState(""); + // Debounce search and repo inputs to avoid rapid API calls + const debouncedSearch = useDebounce(searchTitle, 500); + const debouncedRepo = useDebounce(selectedRepo, 500); + // Fetch data when username, tab, page, or filters change useEffect(() => { if (username) { const type = tab === 0 ? "issue" : "pr"; const filters = { - search: searchTitle, - repo: selectedRepo, + search: debouncedSearch, + repo: debouncedRepo, startDate: startDate, endDate: endDate, state: tab === 0 ? issueFilter : prFilter, }; fetchData(username, page + 1, ROWS_PER_PAGE, type, filters); } - }, [tab, page, issueFilter, prFilter, searchTitle, selectedRepo, startDate, endDate]); + }, [username, tab, page, issueFilter, prFilter, debouncedSearch, debouncedRepo, startDate, endDate, fetchData]); const handleSubmit = (e: React.FormEvent): void => { e.preventDefault(); - setPage(0); - // fetchData is already triggered by useEffect when page changes to 0 + if (page === 0) { + const type = tab === 0 ? "issue" : "pr"; + const filters = { + search: debouncedSearch, + repo: debouncedRepo, + startDate: startDate, + endDate: endDate, + state: tab === 0 ? issueFilter : prFilter, + }; + fetchData(username, 1, ROWS_PER_PAGE, type, filters); + } else { + setPage(0); + } }; const handlePageChange = (_: unknown, newPage: number) => { @@ -165,7 +181,7 @@ const Tracker: React.FC = () => { )}