-
Notifications
You must be signed in to change notification settings - Fork 146
feat: implement analytics dashboard and fix server-side filtering #255
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
6de67e9
257c23d
0c76301
57443bd
0dc6dbe
63cf6fd
0ba9174
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<DashboardProps> = ({ 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; | ||
| } | ||
| } | ||
| }); | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| 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 ( | ||
| <Paper elevation={1} sx={{ p: 4, mb: 4, textAlign: 'center', backgroundColor: theme.palette.background.paper }}> | ||
| <Typography variant="h6" color="textSecondary"> | ||
| No data available. Enter a username to view analytics. | ||
| </Typography> | ||
| </Paper> | ||
| ); | ||
| } | ||
|
|
||
| return ( | ||
| <Box sx={{ mb: 4 }}> | ||
| <Grid container spacing={3}> | ||
| {/* Pie Chart: Issues vs PRs */} | ||
| <Grid item xs={12} md={6}> | ||
| <Paper elevation={2} sx={{ p: 2, height: 350, backgroundColor: theme.palette.background.paper }}> | ||
| <Typography variant="h6" gutterBottom align="center" color="textPrimary"> | ||
| Contribution Mix (Total) | ||
| </Typography> | ||
| <ResponsiveContainer width="100%" height="90%"> | ||
| <PieChart> | ||
| <Pie | ||
| data={pieData} | ||
| cx="50%" | ||
| cy="50%" | ||
| innerRadius={60} | ||
| outerRadius={80} | ||
| fill={theme.palette.primary.main} | ||
| paddingAngle={5} | ||
| dataKey="value" | ||
| label | ||
| > | ||
| {pieData.map((_entry, index) => ( | ||
| <Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} /> | ||
| ))} | ||
| </Pie> | ||
| <Tooltip | ||
| contentStyle={{ backgroundColor: theme.palette.background.paper, color: theme.palette.text.primary }} | ||
| /> | ||
| <Legend /> | ||
| </PieChart> | ||
| </ResponsiveContainer> | ||
| </Paper> | ||
| </Grid> | ||
|
|
||
| {/* Bar Chart: Activity by Repository */} | ||
| <Grid item xs={12} md={6}> | ||
| <Paper elevation={2} sx={{ p: 2, height: 350, backgroundColor: theme.palette.background.paper }}> | ||
| <Typography variant="h6" gutterBottom align="center" color="textPrimary"> | ||
| Top Repositories (Current View) | ||
| </Typography> | ||
| {barData.length > 0 ? ( | ||
| <ResponsiveContainer width="100%" height="90%"> | ||
| <BarChart data={barData}> | ||
| <CartesianGrid strokeDasharray="3 3" stroke={theme.palette.divider} /> | ||
| <XAxis dataKey="name" stroke={theme.palette.text.secondary} /> | ||
| <YAxis stroke={theme.palette.text.secondary} /> | ||
| <Tooltip | ||
| contentStyle={{ backgroundColor: theme.palette.background.paper, color: theme.palette.text.primary }} | ||
| /> | ||
| <Bar dataKey="count" fill={theme.palette.primary.light} radius={[4, 4, 0, 0]} /> | ||
| </BarChart> | ||
| </ResponsiveContainer> | ||
| ) : ( | ||
| <Box display="flex" justifyContent="center" alignItems="center" height="100%"> | ||
| <Typography color="textSecondary">No repository data found in this view.</Typography> | ||
| </Box> | ||
| )} | ||
| </Paper> | ||
| </Grid> | ||
| </Grid> | ||
| </Box> | ||
| ); | ||
| }; | ||
|
|
||
| export default Dashboard; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| import { useState, useEffect } from 'react'; | ||
|
|
||
| export function useDebounce<T>(value: T, delay: number): T { | ||
| const [debouncedValue, setDebouncedValue] = useState<T>(value); | ||
|
|
||
| useEffect(() => { | ||
| const handler = setTimeout(() => { | ||
| setDebouncedValue(value); | ||
| }, delay); | ||
|
|
||
| return () => { | ||
| clearTimeout(handler); | ||
| }; | ||
| }, [value, delay]); | ||
|
|
||
| return debouncedValue; | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -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<GitHubItem[]>([]); | ||||||||||||||||||||
| const [prs, setPrs] = useState<GitHubItem[]>([]); | ||||||||||||||||||||
| 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,27 +67,39 @@ 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; | ||||||||||||||||||||
|
ishwari418 marked this conversation as resolved.
|
||||||||||||||||||||
|
|
||||||||||||||||||||
| if (!octokit || !username) return; | ||||||||||||||||||||
|
|
||||||||||||||||||||
|
Comment on lines
+72
to
75
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: cat -n src/hooks/useGitHubData.ts | head -200Repository: GitMetricsLab/github_tracker Length of output: 5737 Add
Add Suggested fix const fetchData = useCallback(
async (...) => {
...
},
- [getOctokit]
+ [getOctokit, rateLimited]
);📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||
| 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); | ||||||||||||||||||||
| setRateLimited(false); | ||||||||||||||||||||
| } catch (err: any) { | ||||||||||||||||||||
| // 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'); | ||||||||||||||||||||
| } | ||||||||||||||||||||
| const errorMessage = err.message?.toLowerCase() || ""; | ||||||||||||||||||||
| if (err.status === 403) { | ||||||||||||||||||||
| setError('GitHub API rate limit exceeded. Please provide a PAT to continue.'); | ||||||||||||||||||||
|
|
@@ -66,8 +119,11 @@ export const useGitHubData = (getOctokit: () => any) => { | |||||||||||||||||||
| 'Unable to fetch GitHub data. Please verify the username, token, or network connection.' | ||||||||||||||||||||
| ); | ||||||||||||||||||||
| } | ||||||||||||||||||||
| } | ||||||||||||||||||||
| } finally { | ||||||||||||||||||||
| setLoading(false); | ||||||||||||||||||||
| if (requestId === lastRequestId.current) { | ||||||||||||||||||||
| setLoading(false); | ||||||||||||||||||||
| } | ||||||||||||||||||||
| } | ||||||||||||||||||||
| }, | ||||||||||||||||||||
| [getOctokit] | ||||||||||||||||||||
|
|
||||||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -8,9 +8,15 @@ type PR = { | |||||||||||||||||||||
| repository_url: string; | ||||||||||||||||||||||
| }; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| type Profile = { | ||||||||||||||||||||||
| avatar_url: string; | ||||||||||||||||||||||
| login: string; | ||||||||||||||||||||||
| bio: string; | ||||||||||||||||||||||
| }; | ||||||||||||||||||||||
|
Comment on lines
+11
to
+15
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🌐 Web query:
💡 Result: Yes, in the GitHub REST API users response schema, the bio field is defined as nullable (string | null) [1][2][3][4]. Official documentation explicitly lists bio as required and of type string or null [1][4]. Citations:
Change The GitHub API's Proposed fix type Profile = {
avatar_url: string;
login: string;
- bio: string;
+ bio: string | null;
};📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||
|
|
||||||||||||||||||||||
| export default function ContributorProfile() { | ||||||||||||||||||||||
| const { username } = useParams(); | ||||||||||||||||||||||
| const [profile, setProfile] = useState<any>(null); | ||||||||||||||||||||||
| const [profile, setProfile] = useState<Profile | null>(null); | ||||||||||||||||||||||
| const [prs, setPRs] = useState<PR[]>([]); | ||||||||||||||||||||||
| 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); | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
head -75 package.json | tail -30Repository: GitMetricsLab/github_tracker
Length of output: 943
Fix invalid JSON and duplicate dependency keys in
devDependencies.Line 62 is missing a trailing comma after
"vite": "^5.4.10", makingpackage.jsonunparseable. Additionally,jasmine,passport,passport-local,supertest, andviteappear twice with conflicting or redundant versions—remove the first occurrences and keep only the second set.Suggested cleanup
🧰 Tools
🪛 Biome (2.4.15)
[error] 64-64: expected
,but instead found"jasmine"(parse)
[error] 57-57: The key jasmine was already declared.
(lint/suspicious/noDuplicateObjectKeys)
[error] 59-59: The key passport was already declared.
(lint/suspicious/noDuplicateObjectKeys)
[error] 60-60: The key passport-local was already declared.
(lint/suspicious/noDuplicateObjectKeys)
[error] 61-61: The key supertest was already declared.
(lint/suspicious/noDuplicateObjectKeys)
[error] 63-63: The key vite was already declared.
(lint/suspicious/noDuplicateObjectKeys)
🤖 Prompt for AI Agents