diff --git a/src/hooks/useGitHubAuth.ts b/src/hooks/useGitHubAuth.ts index a0c24b2..8422c6c 100644 --- a/src/hooks/useGitHubAuth.ts +++ b/src/hooks/useGitHubAuth.ts @@ -1,19 +1,20 @@ -import { useState, useMemo } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { Octokit } from '@octokit/core'; export const useGitHubAuth = () => { const [username, setUsername] = useState(''); const [token, setToken] = useState(''); + const octokitRef = useRef(new Octokit()); - const octokit = useMemo(() => { - if (!username) return null; + useEffect(() => { if(token){ - return new Octokit({ auth: token }); + octokitRef.current = new Octokit({ auth: token }); + return; } - return new Octokit(); - }, [username, token]); + octokitRef.current = new Octokit(); + }, [token]); - const getOctokit = () => octokit; + const getOctokit = useCallback(() => octokitRef.current, []); return { username, diff --git a/src/hooks/useGitHubData.ts b/src/hooks/useGitHubData.ts index b19907c..3415dbc 100644 --- a/src/hooks/useGitHubData.ts +++ b/src/hooks/useGitHubData.ts @@ -21,6 +21,169 @@ interface FetchFilters { state?: string; } +interface ContributionScore { + mergedPrs: number; + openPrs: number; + closedPrs: number; + issuesCreated: number; + total: number; +} + +interface FetchPaginatedResult { + items: GitHubItem[]; + total: number; +} + +const SCORE_WEIGHTS = { + mergedPr: 5, + openPr: 2, + closedPr: 1, + issueCreated: 1, +}; + +const emptyContributionScore: ContributionScore = { + mergedPrs: 0, + openPrs: 0, + closedPrs: 0, + issuesCreated: 0, + total: 0, +}; + +const fetchPaginated = async ( + octokit: Octokit, + username: string, + type: 'issue' | 'pr', + page = 1, + perPage = 10, + filters: FetchFilters = {} +): Promise => { + 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}`; + } + + if (filters.state === 'merged' && type === 'pr') { + q += ` is:merged`; + } + + const response = await octokit.request( + 'GET /search/issues', + { + q, + sort: 'created', + order: 'desc', + per_page: perPage, + page, + } + ); + + return { + items: response.data.items as GitHubItem[], + total: response.data.total_count, + }; +}; + +const buildScoreQuery = ( + username: string, + type: 'issue' | 'pr', + qualifiers: string[], + filters: FetchFilters = {} +) => { + let q = `author:${username} is:${type} ${qualifiers.join(' ')}`; + + 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}`; + } + + return q.trim(); +}; + +const fetchCount = async (octokit: Octokit, q: string) => { + const response = await octokit.request('GET /search/issues', { + q, + per_page: 1, + page: 1, + }); + + return response.data.total_count; +}; + +const fetchContributionScore = async ( + octokit: Octokit, + username: string, + filters: FetchFilters = {} +): Promise => { + const [ + mergedPrs, + openPrs, + closedPrs, + issuesCreated, + ] = await Promise.all([ + fetchCount( + octokit, + buildScoreQuery(username, 'pr', ['is:merged'], filters) + ), + fetchCount( + octokit, + buildScoreQuery(username, 'pr', ['is:open'], filters) + ), + fetchCount( + octokit, + buildScoreQuery( + username, + 'pr', + ['is:closed', '-is:merged'], + filters + ) + ), + fetchCount( + octokit, + buildScoreQuery(username, 'issue', [], filters) + ), + ]); + + return { + mergedPrs, + openPrs, + closedPrs, + issuesCreated, + total: + mergedPrs * SCORE_WEIGHTS.mergedPr + + openPrs * SCORE_WEIGHTS.openPr + + closedPrs * SCORE_WEIGHTS.closedPr + + issuesCreated * SCORE_WEIGHTS.issueCreated, + }; +}; + export const useGitHubData = ( getOctokit: () => Octokit | null ) => { @@ -30,67 +193,14 @@ export const useGitHubData = ( const [error, setError] = useState(''); const [totalIssues, setTotalIssues] = useState(0); const [totalPrs, setTotalPrs] = useState(0); + const [contributionScore, setContributionScore] = + useState(emptyContributionScore); const [rateLimited, setRateLimited] = useState(false); // Prevent stale responses overwriting latest data const lastRequestId = useRef(0); const abortControllerRef = useRef(null); - const fetchPaginated = async ( - octokit: Octokit, - username: string, - type: 'issue' | 'pr', - page = 1, - perPage = 10, - filters: FetchFilters = {}, - signal?: AbortSignal - ) => { - 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}`; - } - - if (filters.state === 'merged' && type === 'pr') { - q += ` is:merged`; - } - - const response = await octokit.request( - 'GET /search/issues', - { - q, - sort: 'created', - order: 'desc', - per_page: perPage, - page, - request: { - signal, - }, - } - ); - - return { - items: response.data.items as GitHubItem[], - total: response.data.total_count, - }; - }; - const fetchData = useCallback( async ( username: string, @@ -125,7 +235,7 @@ export const useGitHubData = ( const shouldFetchPrs = activeTab === 'pr' || activeTab === 'both'; - const requests: Promise[] = []; + const requests: Promise[] = []; if (shouldFetchIssues) { requests.push( @@ -214,6 +324,25 @@ export const useGitHubData = ( ); } + try { + const score = await fetchContributionScore( + octokit, + username, + filters + ); + + if (requestId === lastRequestId.current) { + setContributionScore(score); + } + } catch { + if (requestId === lastRequestId.current) { + setContributionScore(emptyContributionScore); + setError( + 'Some GitHub data could not be fetched completely.' + ); + } + } + setRateLimited(false); } catch (err: unknown) { if (requestId !== lastRequestId.current || abortControllerRef.current !== controller) { @@ -287,6 +416,7 @@ export const useGitHubData = ( prs, totalIssues, totalPrs, + contributionScore, loading, error, rateLimited, diff --git a/src/pages/Tracker/Tracker.tsx b/src/pages/Tracker/Tracker.tsx index 04df12d..d6c11d1 100644 --- a/src/pages/Tracker/Tracker.tsx +++ b/src/pages/Tracker/Tracker.tsx @@ -27,6 +27,7 @@ import { MenuItem, FormControl, InputLabel, + Typography, } from "@mui/material"; import { useTheme } from "@mui/material/styles"; import { useGitHubAuth } from "../../hooks/useGitHubAuth"; @@ -66,6 +67,7 @@ const Home: React.FC = () => { prs, totalIssues, totalPrs, + contributionScore, loading, error: dataError, fetchData, @@ -73,6 +75,7 @@ const Home: React.FC = () => { const [tab, setTab] = useState(0); const [page, setPage] = useState(0); + const [submittedUsername, setSubmittedUsername] = useState(""); const [issueFilter, setIssueFilter] = useState("all"); const [prFilter, setPrFilter] = useState("all"); @@ -81,34 +84,33 @@ const Home: React.FC = () => { const [startDate, setStartDate] = useState(""); const [endDate, setEndDate] = useState(""); - const [debouncedUsername, setDebouncedUsername] = useState(username); - + // Fetch data after submit, then refresh when tab or page changes. useEffect(() => { - if (!username) { - setDebouncedUsername(""); + if (submittedUsername) { + fetchData(submittedUsername, page + 1, ROWS_PER_PAGE); + } + }, [fetchData, page, submittedUsername, tab]); + + const handleSubmit = (e: React.FormEvent): void => { + e.preventDefault(); + const trimmedUsername = username.trim(); + + if (!trimmedUsername) { return; } - const handler = setTimeout(() => { - setDebouncedUsername(username); - }, 500); - return () => { - clearTimeout(handler); - }; - }, [username]); + if (page !== 0) { + setPage(0); + } - // Fetch data when debouncedUsername, tab, or page changes - useEffect(() => { - if (debouncedUsername && debouncedUsername.trim().length >= 1) { - fetchData(debouncedUsername, page + 1, ROWS_PER_PAGE); + if (submittedUsername !== trimmedUsername) { + setSubmittedUsername(trimmedUsername); + return; } - }, [debouncedUsername, tab, page]); - const handleSubmit = (e: React.FormEvent): void => { - e.preventDefault(); - setPage(0); - setDebouncedUsername(username); - fetchData(username, 1, ROWS_PER_PAGE); + if (page === 0) { + fetchData(trimmedUsername, 1, ROWS_PER_PAGE); + } }; const handlePageChange = (_: unknown, newPage: number) => { @@ -182,6 +184,32 @@ const Home: React.FC = () => { const currentRawData = tab === 0 ? issues : prs; const currentFilteredData = filterData(currentRawData, tab === 0 ? issueFilter : prFilter); const totalCount = tab === 0 ? totalIssues : totalPrs; + const scoreItems = [ + { + label: "Merged PRs", + count: contributionScore.mergedPrs, + points: contributionScore.mergedPrs * 5, + weight: "+5 each", + }, + { + label: "Open PRs", + count: contributionScore.openPrs, + points: contributionScore.openPrs * 2, + weight: "+2 each", + }, + { + label: "Closed PRs", + count: contributionScore.closedPrs, + points: contributionScore.closedPrs, + weight: "+1 each", + }, + { + label: "Issues Created", + count: contributionScore.issuesCreated, + points: contributionScore.issuesCreated, + weight: "+1 each", + }, + ]; return ( @@ -320,6 +348,68 @@ const Home: React.FC = () => { )} + + + + + Contribution Score + + + {contributionScore.total} + + + + + {scoreItems.map((item) => ( + + + {item.label} + + + {item.count} + + + {item.points} pts - {item.weight} + + + ))} + + + + {loading ? (