diff --git a/package.json b/package.json index f2d89f5..3506b5b 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,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": { @@ -45,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 new file mode 100644 index 0000000..e28358f --- /dev/null +++ b/src/components/Dashboard.tsx @@ -0,0 +1,142 @@ +import React from 'react'; +import { + PieChart, + Pie, + Cell, + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + Legend, + ResponsiveContainer +} from 'recharts'; +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: GitHubItem[]; + theme: Theme; +} + +const Dashboard: React.FC = ({ totalIssues, totalPrs, data, theme }) => { + + // Data for Pie Chart + const pieData = [ + { name: 'Issues', value: totalIssues }, + { name: 'Pull Requests', value: totalPrs }, + ]; + + // Use theme-aware colors + const COLORS = [theme.palette.primary.main, theme.palette.secondary.main]; + + // Data for Bar Chart (Top 5 Repositories) - Improved safety + const repoCounts: { [key: string]: number } = {}; + data.forEach(item => { + 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) + .map(([name, count]) => ({ name, count })) + .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) + + + + + {pieData.map((_entry, index) => ( + + ))} + + + + + + + + + {/* Bar Chart: Activity by Repository */} + + + + Top Repositories (Current View) + + {barData.length > 0 ? ( + + + + + + + + + + ) : ( + + No repository data found in this view. + + )} + + + + + ); +}; + +export default Dashboard; 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 a0ebe10..29f3cf5 100644 --- a/src/hooks/useGitHubData.ts +++ b/src/hooks/useGitHubData.ts @@ -1,16 +1,57 @@ -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: Octokit, + username: string, + type: string, + page = 1, + per_page = 10, + filters: FetchFilters = {} + ) => { + 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 fetchPaginated = async (octokit: any, username: string, type: string, page = 1, per_page = 10) => { - const q = `author:${username} is:${type}`; const response = await octokit.request('GET /search/issues', { q, sort: 'created', @@ -26,34 +67,42 @@ export const useGitHubData = (getOctokit: () => any) => { }; const fetchData = useCallback( - async (username: string, page = 1, perPage = 10) => { - + 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 { + // 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', page, perPage), - fetchPaginated(octokit, username, 'pr', page, perPage), + fetchPaginated(octokit, username, 'issue', activeTab === 'issue' ? page : 1, perPage, filters), + fetchPaginated(octokit, username, 'pr', activeTab === 'pr' ? page : 1, perPage, filters), ]); - setIssues(issueRes.items); - setPrs(prRes.items); - setTotalIssues(issueRes.total); - 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 - } else { - setError(err.message || 'Failed to fetch data'); + // 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: 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 2bd4d30..b60609e 100644 --- a/src/pages/Tracker/Tracker.tsx +++ b/src/pages/Tracker/Tracker.tsx @@ -1,4 +1,6 @@ import React, { useState, useEffect } from "react" +import Dashboard from "../../components/Dashboard"; +import { useDebounce } from "../../hooks/useDebounce"; import { IssueOpenedIcon, IssueClosedIcon, @@ -45,7 +47,7 @@ interface GitHubItem { html_url: string; } -const Home: React.FC = () => { +const Tracker: React.FC = () => { const theme = useTheme(); @@ -78,17 +80,40 @@ const Home: React.FC = () => { const [startDate, setStartDate] = useState(""); const [endDate, setEndDate] = useState(""); - // Fetch data when username, tab, or page changes + // 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) { - fetchData(username, page + 1, ROWS_PER_PAGE); + const type = tab === 0 ? "issue" : "pr"; + const filters = { + search: debouncedSearch, + repo: debouncedRepo, + startDate: startDate, + endDate: endDate, + state: tab === 0 ? issueFilter : prFilter, + }; + fetchData(username, page + 1, ROWS_PER_PAGE, type, filters); } - }, [tab, page]); + }, [username, tab, page, issueFilter, prFilter, debouncedSearch, debouncedRepo, startDate, endDate, fetchData]); const handleSubmit = (e: React.FormEvent): void => { e.preventDefault(); - setPage(0); - fetchData(username, 1, ROWS_PER_PAGE); + 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) => { @@ -98,44 +123,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 +144,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 +176,16 @@ const Home: React.FC = () => { + {/* Dashboard Summary */} + {username && ( + + )} + {/* Filters */} { ); }; -export default Home; +export default Tracker;