diff --git a/src/hooks/useDebounce.ts b/src/hooks/useDebounce.ts index e3706e6b..7b6bc06c 100644 --- a/src/hooks/useDebounce.ts +++ b/src/hooks/useDebounce.ts @@ -1,17 +1,28 @@ -import { useState, useEffect } from 'react'; +import { useEffect, useState } from 'react'; -export function useDebounce(value: T, delay: number): T { +/** + * Custom hook for debouncing values + * Delays updating a state value until after the user stops changing it for a specified duration + * + * @param value - The value to debounce + * @param delay - The debounce delay in milliseconds (default: 300ms) + * @returns The debounced value + */ +export const useDebounce = ( + value: T, + delay: number = 300 +): T => { const [debouncedValue, setDebouncedValue] = useState(value); useEffect(() => { + // Set up the timeout const handler = setTimeout(() => { setDebouncedValue(value); }, delay); - return () => { - clearTimeout(handler); - }; + // Clean up the timeout if value changes before delay is complete + return () => clearTimeout(handler); }, [value, delay]); return debouncedValue; -} +}; \ No newline at end of file diff --git a/src/hooks/useGitHubData.ts b/src/hooks/useGitHubData.ts index f4c78cf6..2d86f86f 100644 --- a/src/hooks/useGitHubData.ts +++ b/src/hooks/useGitHubData.ts @@ -1,3 +1,4 @@ +import { useState, useCallback, useRef, useEffect } from 'react'; import { useState, useCallback, useRef } from 'react'; import { Octokit } from '@octokit/core'; @@ -32,221 +33,276 @@ export const useGitHubData = ( const [totalPrs, setTotalPrs] = useState(0); const [rateLimited, setRateLimited] = useState(false); - // Prevent stale responses overwriting latest data - const lastRequestId = useRef(0); - - const fetchPaginated = async ( - octokit: Octokit, - username: string, - type: 'issue' | 'pr', - page = 1, - perPage = 10, - filters: FetchFilters = {} - ) => { - let q = `author:${username} is:${type}`; - - if (filters.search) { - q += ` ${filters.search} in:title`; - } +// Prevent stale responses overwriting latest data +const lastRequestId = useRef(0); - if (filters.repo) { - q += ` repo:${filters.repo}`; - } +// Store AbortController to cancel in-flight requests +const abortControllerRef = useRef(null); - if (filters.startDate) { - q += ` created:>=${filters.startDate}`; - } +// Cleanup function to cancel any pending requests +const cancelPendingRequest = useCallback(() => { + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + abortControllerRef.current = null; + } +}, []); - if (filters.endDate) { - q += ` created:<=${filters.endDate}`; +// Cleanup on component unmount +useEffect(() => { + return () => { + cancelPendingRequest(); + }; +}, [cancelPendingRequest]); + +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, + }, } + ); - if (filters.state === 'open' || filters.state === 'closed') { - q += ` is:${filters.state}`; - } + return { + items: response.data.items as GitHubItem[], + total: response.data.total_count, + }; +}; + + const fetchData = useCallback( +async ( + username: string, + page = 1, + perPage = 10, + activeTab: 'issue' | 'pr' | 'both' = 'both', + filters: FetchFilters = {} +) => { - if (filters.state === 'merged' && type === 'pr') { - q += ` is:merged`; + // Validate inputs + if (!username || username.trim().length < 2) { + setError('Please enter a valid GitHub username.'); + setIssues([]); + setPrs([]); + setTotalIssues(0); + setTotalPrs(0); + return; + } + + const octokit = getOctokit(); + + if (!octokit || rateLimited) { + return; + } + + // Prevent stale responses + const requestId = ++lastRequestId.current; + + // Cancel any existing in-flight requests + cancelPendingRequest(); + + // Create new AbortController for this request + abortControllerRef.current = new AbortController(); + + const signal = abortControllerRef.current.signal; + + setLoading(true); + setError(''); + + try { + const shouldFetchIssues = + activeTab === 'issue' || activeTab === 'both'; + + const shouldFetchPrs = + activeTab === 'pr' || activeTab === 'both'; + + const requests: Promise[] = []; + + if (shouldFetchIssues) { + requests.push( + fetchPaginated( + octokit, + username, + 'issue', + page, + perPage, + filters, + signal + ) + ); } - const response = await octokit.request( - 'GET /search/issues', - { - q, - sort: 'created', - order: 'desc', - per_page: perPage, + if (shouldFetchPrs) { + requests.push( + fetchPaginated( + octokit, + username, + 'pr', page, - } + perPage, + filters, + signal + ) ); + } - return { - items: response.data.items as GitHubItem[], - total: response.data.total_count, - }; - }; + const results = await Promise.allSettled(requests); - const fetchData = useCallback( - async ( - username: string, - page = 1, - perPage = 10, - activeTab: 'issue' | 'pr' | 'both' = 'both', - filters: FetchFilters = {} - ) => { - const octokit = getOctokit(); - - if (!octokit || !username.trim() || rateLimited) { - return; - } - - const requestId = ++lastRequestId.current; - - setLoading(true); - setError(''); - - try { - const shouldFetchIssues = - activeTab === 'issue' || activeTab === 'both'; - - const shouldFetchPrs = - activeTab === 'pr' || activeTab === 'both'; - - const requests: Promise[] = []; - - if (shouldFetchIssues) { - requests.push( - fetchPaginated( - octokit, - username, - 'issue', - page, - perPage, - filters - ) - ); - } - - if (shouldFetchPrs) { - requests.push( - fetchPaginated( - octokit, - username, - 'pr', - page, - perPage, - filters - ) - ); - } - - const results = await Promise.allSettled(requests); - - // Ignore stale requests - if (requestId !== lastRequestId.current) { - return; - } - - let resultIndex = 0; - - if (shouldFetchIssues) { - const issueResult = results[resultIndex]; - - if (issueResult.status === 'fulfilled') { - setIssues(issueResult.value.items); - setTotalIssues(issueResult.value.total); - } else { - setIssues([]); - setTotalIssues(0); - } - - resultIndex++; - } - - if (shouldFetchPrs) { - const prResult = results[resultIndex]; - - if (prResult.status === 'fulfilled') { - setPrs(prResult.value.items); - setTotalPrs(prResult.value.total); - } else { - setPrs([]); - setTotalPrs(0); - } - } - - const hasRejected = results.some( - (result) => result.status === 'rejected' - ); - - if (hasRejected) { - setError( - 'Some GitHub data could not be fetched completely.' - ); - } - - setRateLimited(false); - } catch (err: unknown) { - if (requestId !== lastRequestId.current) { - return; - } - - const error = err as { - status?: number; - message?: string; - }; - - const errorMessage = - error.message?.toLowerCase() || ''; - - if (error.status === 403) { - setError( - 'GitHub API rate limit exceeded. Please provide a PAT to continue.' - ); - setRateLimited(true); - } else if ( - errorMessage.includes('do not exist') - ) { - setError( - 'User not found. Please check the GitHub username.' - ); - } else if ( - errorMessage.includes('validation failed') - ) { - setError( - 'Invalid GitHub username or insufficient permissions.' - ); - } else if ( - error.status === 401 || - errorMessage.includes('permission') - ) { - setError( - 'Private repository detected. Please provide a PAT.' - ); - } else if (error.status === 404) { - setError('Resource not found.'); - } else { - setError( - 'Unable to fetch GitHub data. Please verify the username, token, or network connection.' - ); - } - } finally { - if (requestId === lastRequestId.current) { - setLoading(false); - } - } - }, - [getOctokit, rateLimited] + // Ignore stale or aborted requests + if ( + requestId !== lastRequestId.current || + signal.aborted + ) { + return; + } + + let resultIndex = 0; + + if (shouldFetchIssues) { + const issueResult = results[resultIndex]; + + if (issueResult.status === 'fulfilled') { + setIssues(issueResult.value.items); + setTotalIssues(issueResult.value.total); + } else { + setIssues([]); + setTotalIssues(0); + } + + resultIndex++; + } + + if (shouldFetchPrs) { + const prResult = results[resultIndex]; + + if (prResult.status === 'fulfilled') { + setPrs(prResult.value.items); + setTotalPrs(prResult.value.total); + } else { + setPrs([]); + setTotalPrs(0); + } + } + + const hasRejected = results.some( + (result) => result.status === 'rejected' ); - return { - issues, - prs, - totalIssues, - totalPrs, - loading, - error, - rateLimited, - fetchData, + if (hasRejected) { + setError( + 'Some GitHub data could not be fetched completely.' + ); + } + + setRateLimited(false); + +} catch (err: unknown) { + + // Ignore aborted requests + if ((err as Error).name === 'AbortError') { + return; + } + + // Ignore stale requests + if (requestId !== lastRequestId.current) { + return; + } + + const error = err as { + status?: number; + message?: string; }; -}; + + const errorMessage = + error.message?.toLowerCase() || ''; + + if (error.status === 403) { + setError( + 'GitHub API rate limit exceeded. Please provide a PAT to continue.' + ); + setRateLimited(true); + + } else if ( + errorMessage.includes('do not exist') + ) { + setError( + 'User not found. Please check the GitHub username.' + ); + + } else if ( + errorMessage.includes('validation failed') + ) { + setError( + 'Invalid GitHub username or insufficient permissions.' + ); + + } else if ( + error.status === 401 || + errorMessage.includes('permission') + ) { + setError( + 'Private repository detected. Please provide a PAT.' + ); + + } else if (error.status === 404) { + setError('Resource not found.'); + + } else { + setError( + 'Unable to fetch GitHub data. Please verify the username, token, or network connection.' + ); + } + +} finally { + if ( + requestId === lastRequestId.current && + !signal.aborted + ) { + setLoading(false); + } +} \ No newline at end of file diff --git a/src/pages/Tracker/Tracker.tsx b/src/pages/Tracker/Tracker.tsx index 2b2099e4..b03eb5a4 100644 --- a/src/pages/Tracker/Tracker.tsx +++ b/src/pages/Tracker/Tracker.tsx @@ -1,16 +1,16 @@ -import React, { useState, useEffect } from "react" +import React, { useState, useEffect } from "react"; import { IssueOpenedIcon, IssueClosedIcon, GitPullRequestIcon, GitPullRequestClosedIcon, GitMergeIcon, -} from '@primer/octicons-react'; +} from "@primer/octicons-react"; + import { Container, Box, TextField, - Button, Paper, Table, TableBody, @@ -29,10 +29,13 @@ import { FormControl, InputLabel, } from "@mui/material"; + import { useTheme } from "@mui/material/styles"; +import { KeyIcon } from "lucide-react"; + import { useGitHubAuth } from "../../hooks/useGitHubAuth"; import { useGitHubData } from "../../hooks/useGitHubData"; -import { KeyIcon } from "lucide-react"; +import { useDebounce } from "../../hooks/useDebounce"; const ROWS_PER_PAGE = 10; @@ -47,7 +50,6 @@ interface GitHubItem { } const Home: React.FC = () => { - const theme = useTheme(); const { @@ -72,36 +74,85 @@ const Home: React.FC = () => { const [tab, setTab] = useState(() => Number(localStorage.getItem('tracker_tab')) || 0); const [page, setPage] = useState(() => Number(localStorage.getItem('tracker_page')) || 0); - const [issueFilter, setIssueFilter] = useState(() => localStorage.getItem('tracker_issueFilter') || "all"); - const [prFilter, setPrFilter] = useState(() => localStorage.getItem('tracker_prFilter') || "all"); - const [searchTitle, setSearchTitle] = useState(() => localStorage.getItem('tracker_searchTitle') || ""); - const [selectedRepo, setSelectedRepo] = useState(() => localStorage.getItem('tracker_selectedRepo') || ""); - const [startDate, setStartDate] = useState(() => localStorage.getItem('tracker_startDate') || ""); - const [endDate, setEndDate] = useState(() => localStorage.getItem('tracker_endDate') || ""); + const [issueFilter, setIssueFilter] = useState("all"); + const [prFilter, setPrFilter] = useState("all"); + const [searchTitle, setSearchTitle] = useState(""); + const [selectedRepo, setSelectedRepo] = useState(""); + const [startDate, setStartDate] = useState(""); + const [endDate, setEndDate] = useState(""); + + // Debounced filters + const debouncedSearchTitle = useDebounce(searchTitle, 300); + const debouncedSelectedRepo = useDebounce(selectedRepo, 300); + const debouncedStartDate = useDebounce(startDate, 300); + const debouncedEndDate = useDebounce(endDate, 300); + + // Debounced username + const debouncedUsername = useDebounce(username, 400); + + // Auto-fetch data useEffect(() => { - localStorage.setItem('tracker_tab', String(tab)); - localStorage.setItem('tracker_page', String(page)); - localStorage.setItem('tracker_issueFilter', issueFilter); - localStorage.setItem('tracker_prFilter', prFilter); - localStorage.setItem('tracker_searchTitle', searchTitle); - localStorage.setItem('tracker_selectedRepo', selectedRepo); - localStorage.setItem('tracker_startDate', startDate); - localStorage.setItem('tracker_endDate', endDate); - }, [tab, page, issueFilter, prFilter, searchTitle, selectedRepo, startDate, endDate]); - - // Fetch data when username, tab, or page changes - useEffect(() => { - if (username) { - fetchData(username, page + 1, ROWS_PER_PAGE); + const trimmedUsername = debouncedUsername?.trim() ?? ""; + + if (trimmedUsername.length >= 2) { + setPage(0); + + fetchData( + trimmedUsername, + 1, + ROWS_PER_PAGE, + tab === 0 ? "issue" : "pr", + { + search: debouncedSearchTitle, + repo: debouncedSelectedRepo, + startDate: debouncedStartDate, + endDate: debouncedEndDate, + state: tab === 0 ? issueFilter : prFilter, + } + ); } - }, [tab, page]); + }, [ + debouncedUsername, + debouncedSearchTitle, + debouncedSelectedRepo, + debouncedStartDate, + debouncedEndDate, + issueFilter, + prFilter, + tab, + fetchData, + ]); - const handleSubmit = (e: React.FormEvent): void => { - e.preventDefault(); - setPage(0); - fetchData(username, 1, ROWS_PER_PAGE); - }; + // Pagination fetch + useEffect(() => { + if (debouncedUsername?.trim()) { + fetchData( + debouncedUsername, + page + 1, + ROWS_PER_PAGE, + tab === 0 ? "issue" : "pr", + { + search: debouncedSearchTitle, + repo: debouncedSelectedRepo, + startDate: debouncedStartDate, + endDate: debouncedEndDate, + state: tab === 0 ? issueFilter : prFilter, + } + ); + } + }, [ + page, + debouncedUsername, + debouncedSearchTitle, + debouncedSelectedRepo, + debouncedStartDate, + debouncedEndDate, + issueFilter, + prFilter, + tab, + fetchData, + ]); const handlePageChange = (_: unknown, newPage: number) => { setPage(newPage); @@ -110,232 +161,161 @@ 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) => { - if (item.pull_request) { - - if (item.pull_request.merged_at) - return ; - - if (item.state === 'closed') - return ; - - return ; + if (item.pull_request.merged_at) { + return ; + } + + if (item.state === "closed") { + return ( + + ); + } + + return ( + + ); } - if (item.state === 'closed') - return ; - - return ; - }; - -useEffect(() => { - if (!username) { - setSuggestions([]); - return; - } - - const fetchUsers = async () => { - try { - const res = await fetch( - `https://api.github.com/search/users?q=${username}` + if (item.state === "closed") { + return ( + ); - - const data = await res.json(); - - setSuggestions(data.items?.slice(0, 5) || []); - } catch (err) { - console.log(err); } + + return ( + + ); }; - - fetchUsers(); -}, [username]); - // Current data and filtered data according to tab and filters - const currentRawData = tab === 0 ? issues : prs; - const currentFilteredData = filterData(currentRawData, tab === 0 ? issueFilter : prFilter); + const currentData = tab === 0 ? issues : prs; const totalCount = tab === 0 ? totalIssues : totalPrs; return ( - - {/* Auth Form */} - -
- - - setUsername(e.target.value)} - required - /> - - {suggestions.length > 0 && ( - + {/* Auth Inputs */} + + + setUsername(e.target.value)} + placeholder="Start typing to search..." + sx={{ flex: 1, minWidth: 150 }} + /> + + setToken(e.target.value)} + type="password" + sx={{ flex: 1, minWidth: 150 }} + helperText={ + + - {suggestions.map((user) => ( - { - setUsername(user.login); - setSuggestions([]); - }} - sx={{ - px: 2, - py: 1.5, - cursor: "pointer", - borderBottom: "1px solid #eee", - "&:hover": { - backgroundColor: "#f5f5f5", - }, - }} - > - {user.login} - - ))} - - )} - - - setToken(e.target.value)} - type="password" - required - sx={{ flex: 1, minWidth: 150 }} - helperText={ + + Generate new token + + - - - Generate new token - - - - • - - - - Learn more - + • - } - /> - - -
+ + + Learn more + + + } + /> +
{/* Filters */} - + setSearchTitle(e.target.value)} sx={{ minWidth: 200 }} /> + setSelectedRepo(e.target.value)} sx={{ minWidth: 200 }} /> + { InputLabelProps={{ shrink: true }} sx={{ minWidth: 150 }} /> + { + - State + + State + + @@ -411,57 +406,84 @@ useEffect(() => { )} {loading ? ( - + ) : ( - - + - - Title - Repository - State + + + Repository + + + + State + + Created - {currentFilteredData.map((item) => ( + {currentData.map((item) => ( - - - {getStatusIcon(item)} - - {item.title} - + + {getStatusIcon(item)} + + + {item.title} + - - {item.repository_url.split("/").slice(-1)[0]} + { + item.repository_url + .split("/") + .slice(-1)[0] + } - {item.pull_request?.merged_at ? "merged" : item.state} + {item.pull_request?.merged_at + ? "merged" + : item.state} - {formatDate(item.created_at)} - + + {formatDate(item.created_at)} + ))} -
{ page={page} onPageChange={handlePageChange} rowsPerPage={ROWS_PER_PAGE} - rowsPerPageOptions={[ROWS_PER_PAGE]} + rowsPerPageOptions={[ + ROWS_PER_PAGE, + ]} /> -
)} @@ -480,4 +503,4 @@ useEffect(() => { ); }; -export default Home; +export default Home; \ No newline at end of file