diff --git a/src/components/RepositoryAnalyticsDashboard.tsx b/src/components/RepositoryAnalyticsDashboard.tsx new file mode 100644 index 0000000..df8c6ae --- /dev/null +++ b/src/components/RepositoryAnalyticsDashboard.tsx @@ -0,0 +1,734 @@ +import { useMemo } from 'react'; +import { + Area, + AreaChart, + Bar, + BarChart, + Cell, + CartesianGrid, + ComposedChart, + Legend, + Line, + Pie, + PieChart, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts'; +import { Box, Grid, Paper, Theme, Typography } from '@mui/material'; +import { + Activity, + Database, + GitCommitVertical, + GitFork, + Languages, + Star, + TrendingUp, +} from 'lucide-react'; + +interface RepositorySummary { + id: number; + name: string; + full_name: string; + html_url: string; + created_at: string; + updated_at: string; + pushed_at: string; + stargazers_count: number; + forks_count: number; + language: string | null; + fork: boolean; + archived: boolean; +} + +interface WeeklyCommitPoint { + week: number; + commits: number; +} + +interface DashboardProps { + totalIssues: number; + totalPrs: number; + repositories: RepositorySummary[]; + weeklyCommitActivity: WeeklyCommitPoint[]; + analyticsLoading: boolean; + analyticsError: string; + theme: Theme; +} + +// Use CSS variables so the tracker section follows the website theme +const COLORS = [ + 'var(--color-primary)', + 'var(--color-accent)', + 'var(--color-success)', + 'var(--color-danger)', + 'var(--color-accent)', + 'var(--color-primary)' +]; + +const formatMonthLabel = (date: Date) => + date.toLocaleDateString(undefined, { month: 'short', year: 'numeric' }); + +const formatWeekLabel = (week: number) => + new Date(week * 1000).toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + }); + +const RepositoryAnalyticsDashboard = ({ + totalIssues, + totalPrs, + repositories, + weeklyCommitActivity, + analyticsLoading, + analyticsError, + theme, +}: DashboardProps) => { + const totalContributions = totalIssues + totalPrs; + + const analytics = useMemo(() => { + const repoCount = repositories.length; + + const totalStars = repositories.reduce((sum, repository) => sum + repository.stargazers_count, 0); + const totalForks = repositories.reduce((sum, repository) => sum + repository.forks_count, 0); + + const languageCounts = new Map(); + const monthlyBuckets = new Map(); + + const sortedRepositories = [...repositories].sort( + (left, right) => new Date(left.created_at).getTime() - new Date(right.created_at).getTime() + ); + + sortedRepositories.forEach((repository) => { + const language = repository.language ?? 'Unknown'; + languageCounts.set(language, (languageCounts.get(language) ?? 0) + 1); + + const createdAt = new Date(repository.created_at); + const monthKey = `${createdAt.getFullYear()}-${String(createdAt.getMonth() + 1).padStart(2, '0')}`; + + if (!monthlyBuckets.has(monthKey)) { + monthlyBuckets.set(monthKey, { + label: formatMonthLabel(createdAt), + order: createdAt.getFullYear() * 12 + createdAt.getMonth(), + created: 0, + stars: 0, + forks: 0, + }); + } + + const monthBucket = monthlyBuckets.get(monthKey); + + if (monthBucket) { + monthBucket.created += 1; + monthBucket.stars += repository.stargazers_count; + monthBucket.forks += repository.forks_count; + } + }); + + let cumulativeRepositories = 0; + let cumulativeStars = 0; + let cumulativeForks = 0; + + const repositoryGrowth = Array.from(monthlyBuckets.values()) + .sort((left, right) => left.order - right.order) + .map((bucket) => { + cumulativeRepositories += bucket.created; + cumulativeStars += bucket.stars; + cumulativeForks += bucket.forks; + + return { + label: bucket.label, + repositories: cumulativeRepositories, + stars: cumulativeStars, + forks: cumulativeForks, + }; + }); + + const languageDistribution = Array.from(languageCounts.entries()) + .map(([name, value]) => ({ name, value })) + .sort((left, right) => right.value - left.value) + .slice(0, 6); + + const topRepositories = [...repositories] + .sort((left, right) => { + if (right.stargazers_count !== left.stargazers_count) { + return right.stargazers_count - left.stargazers_count; + } + + return right.forks_count - left.forks_count; + }) + .slice(0, 6) + .map((repository) => ({ + name: repository.name, + stars: repository.stargazers_count, + forks: repository.forks_count, + language: repository.language ?? 'Unknown', + })); + + const mostUsedLanguage = languageDistribution[0]?.name ?? 'Unknown'; + + return { + repoCount, + totalStars, + totalForks, + mostUsedLanguage, + repositoryGrowth, + languageDistribution, + topRepositories, + }; + }, [repositories]); + + const summaryCards = [ + { + label: 'Total repositories', + value: analytics.repoCount, + icon: Database, + accentStart: 'var(--color-primary)', + accentEnd: 'var(--color-accent)', + }, + { + label: 'Total stars', + value: analytics.totalStars, + icon: Star, + accentStart: 'var(--color-danger)', + accentEnd: 'var(--color-danger)', + }, + // forks card intentionally removed to keep the summary concise and horizontal + { + label: 'Most used language', + value: analytics.mostUsedLanguage, + icon: Languages, + accentStart: 'var(--color-accent)', + accentEnd: 'var(--color-accent)', + }, + { + label: 'Total contributions', + value: totalContributions, + icon: Activity, + accentStart: 'var(--color-primary)', + accentEnd: 'var(--color-accent)', + }, + ]; + + const hasRepositoryData = analytics.repoCount > 0; + const hasCommitData = weeklyCommitActivity.length > 0; + + if (analyticsLoading && !hasRepositoryData) { + return ( + + + Loading repository analytics... + + + ); + } + + if (!hasRepositoryData && totalContributions === 0) { + return ( + + + Enter a GitHub username to view repository growth analytics. + + + ); + } + + return ( + + + + + + + + Repo Tracker + + + Growth analytics + + + + + + + Track stars, forks, languages, and contribution momentum in one place. + + + This dashboard combines repository metadata with recent commit participation so you can spot growth, usage, and activity patterns without leaving the tracker. + + + + + + + Repos + + + {analytics.repoCount} + + + + + Stars + + + {analytics.totalStars} + + + + + Forks + + + {analytics.totalForks} + + + + + + + + {analyticsError ? ( + + + {analyticsError} + + + ) : null} + + + {summaryCards.map((card) => { + const Icon = card.icon; + + return ( + + + + + {card.label} + + { + (() => { + const isLanguage = card.label && card.label.toLowerCase().includes('language'); + const valueFont = isLanguage + ? { xs: '1rem', md: 'clamp(1.1rem, 2.2vw, 1.6rem)' } + : { xs: '1.6rem', md: '2.25rem' }; + return ( + + {card.value} + + ); + })() + } + + + + + + + ); + })} + + + + + + + + + Repository growth timeline + + + Cumulative repositories, stars, and forks over time. + + + + + + + + {hasRepositoryData ? ( + + + + + + + + + + + + + ) : ( + + No repository timeline data available. + + )} + + + + + + + + Language usage + + + Programming language distribution across owned repositories. + + + + {analytics.languageDistribution.length > 0 ? ( + <> + + + {/* compute language total for legend percentages */} + {/* Pie: remove in-chart labels; use bottom legend instead */} + + {analytics.languageDistribution.map((entry, index) => ( + + ))} + + + {/* Legend rendered below the chart (custom) */} + + + + {/* Custom legend placed inside the card so it always stays within bounds */} + + {(() => { + const languageTotal = analytics.languageDistribution.reduce((s, e) => s + (e.value ?? 0), 0); + const topThree = analytics.languageDistribution.slice(0, 3); + const othersCount = analytics.languageDistribution.length - topThree.length; + + return ( + <> + {topThree.map((entry, index) => { + const pct = languageTotal > 0 ? Math.round(((entry.value ?? 0) / languageTotal) * 100) : 0; + const color = COLORS[index % COLORS.length]; + return ( + + + + {entry.name} {pct}% + + + ); + })} + + {othersCount > 0 && ( + + + + +{othersCount} others + + + )} + + ); + })()} + + + ) : ( + + No language data available. + + )} + + + + + + + + Commit activity trends + + + Recent weekly contribution intensity across the most active repositories. + + + + {hasCommitData ? ( + + ({ label: formatWeekLabel(entry.week), commits: entry.commits }))}> + + + + + + + + + + + + + + ) : ( + + Commit participation data is not available yet. + + )} + + + + + + + + Star and fork growth + + + Compare the most popular repositories in the current profile. + + + + {analytics.topRepositories.length > 0 ? ( + + + + {/* hide x-axis ticks to avoid overlap; show name on hover via Tooltip */} + + + `Repository: ${label}`} + formatter={(value, name) => [value, name]} + /> + + + + + + ) : ( + + No starred repository data found. + + )} + + + + + + + + + Repository intelligence + + + Quick signals extracted from the current GitHub profile. + + + + + + Weekly commit trend + + + + Growth timeline + + + + + + ); +}; + +export default RepositoryAnalyticsDashboard; \ No newline at end of file diff --git a/src/hooks/useGitHubData.ts b/src/hooks/useGitHubData.ts index 101f43d..1ec4a3c 100644 --- a/src/hooks/useGitHubData.ts +++ b/src/hooks/useGitHubData.ts @@ -13,6 +13,26 @@ interface GitHubItem { html_url: string; } +interface RepositorySummary { + id: number; + name: string; + full_name: string; + html_url: string; + created_at: string; + updated_at: string; + pushed_at: string; + stargazers_count: number; + forks_count: number; + language: string | null; + fork: boolean; + archived: boolean; +} + +interface WeeklyCommitPoint { + week: number; + commits: number; +} + interface FetchFilters { search?: string; repo?: string; @@ -31,8 +51,12 @@ export const useGitHubData = ( ) => { const [issues, setIssues] = useState([]); const [prs, setPrs] = useState([]); + const [repositories, setRepositories] = useState([]); + const [weeklyCommitActivity, setWeeklyCommitActivity] = useState([]); const [loading, setLoading] = useState(false); + const [analyticsLoading, setAnalyticsLoading] = useState(false); const [error, setError] = useState(''); + const [analyticsError, setAnalyticsError] = useState(''); const [totalIssues, setTotalIssues] = useState(0); const [totalPrs, setTotalPrs] = useState(0); const [rateLimited, setRateLimited] = useState(false); @@ -40,6 +64,55 @@ export const useGitHubData = ( // Prevent stale responses overwriting latest data const lastRequestId = useRef(0); + const sleep = (delay: number) => new Promise((resolve) => setTimeout(resolve, delay)); + + const fetchAllRepositories = async (octokit: Octokit, username: string) => { + const allRepositories: RepositorySummary[] = []; + let page = 1; + + while (true) { + const response = await octokit.request('GET /users/{username}/repos', { + username, + per_page: 100, + page, + sort: 'updated', + direction: 'desc', + }); + + const pageRepositories = response.data as RepositorySummary[]; + allRepositories.push(...pageRepositories); + + if (pageRepositories.length < 100) { + break; + } + + page += 1; + } + + return allRepositories; + }; + + const fetchCommitParticipation = async ( + octokit: Octokit, + owner: string, + repo: string + ) => { + for (let attempt = 0; attempt < 3; attempt += 1) { + const response = await octokit.request('GET /repos/{owner}/{repo}/stats/participation', { + owner, + repo, + }); + + if (response.status !== 202 && Array.isArray(response.data?.weeks)) { + return response.data.weeks as Array<{ w: number; c: number }>; + } + + await sleep(1000 * (attempt + 1)); + } + + return [] as Array<{ w: number; c: number }>; + }; + const fetchPaginated = async ( octokit: Octokit, username: string, @@ -252,14 +325,90 @@ export const useGitHubData = ( [getOctokit, rateLimited] ); + const fetchRepositoryAnalytics = useCallback( + async (username: string) => { + const octokit = getOctokit(); + + if (!octokit || !username.trim()) { + return; + } + + setAnalyticsLoading(true); + setAnalyticsError(''); + + try { + const allRepositories = await fetchAllRepositories(octokit, username); + const nonForkRepositories = allRepositories.filter( + (repository) => !repository.archived && !repository.fork + ); + const analyticsRepositories = nonForkRepositories.length > 0 + ? nonForkRepositories + : allRepositories.filter((repository) => !repository.archived); + + setRepositories(analyticsRepositories); + + const topRepositories = [...analyticsRepositories] + .sort((left, right) => { + if (right.stargazers_count !== left.stargazers_count) { + return right.stargazers_count - left.stargazers_count; + } + + return new Date(right.pushed_at).getTime() - new Date(left.pushed_at).getTime(); + }) + .slice(0, 5); + + const weeklyTotals = new Map(); + + await Promise.all( + topRepositories.map(async (repository) => { + try { + const participation = await fetchCommitParticipation( + octokit, + repository.full_name.split('/')[0], + repository.name + ); + + participation.forEach((entry) => { + weeklyTotals.set( + entry.w, + (weeklyTotals.get(entry.w) ?? 0) + entry.c + ); + }); + } catch { + return; + } + }) + ); + + const sortedWeeklyActivity = Array.from(weeklyTotals.entries()) + .sort((left, right) => left[0] - right[0]) + .map(([week, commits]) => ({ week, commits })); + + setWeeklyCommitActivity(sortedWeeklyActivity); + } catch { + setRepositories([]); + setWeeklyCommitActivity([]); + setAnalyticsError('Unable to load repository analytics.'); + } finally { + setAnalyticsLoading(false); + } + }, + [getOctokit] + ); + return { issues, prs, + repositories, + weeklyCommitActivity, totalIssues, totalPrs, loading, + analyticsLoading, error, + analyticsError, rateLimited, fetchData, + fetchRepositoryAnalytics, }; }; diff --git a/src/index.css b/src/index.css index 724c9fd..e1b0347 100644 --- a/src/index.css +++ b/src/index.css @@ -89,10 +89,107 @@ .icon-issue-closed { color: #cf222e; } -html { - scroll-behavior: smooth; + +/* Scoped theme for the Repo Tracker section only */ +.repo-tracker-theme { + --color-primary: #0f3f91; /* deep site blue */ + --color-primary-rgb: 15,63,145; + --color-accent: #17a2d8; + --color-accent-rgb: 23,162,216; + --color-success: #10b981; + --color-success-rgb: 16,185,129; + --color-danger: #f97316; + --color-danger-rgb: 249,115,22; + --color-background: linear-gradient(135deg,#0f3f91 0%, #0ea5e9 50%, #06b6d4 100%); + --color-surface: #ffffff; + --color-text: #071127; + --color-muted: #64748b; +} + +@keyframes how-it-works-dash-flow { + from { + background-position: 0 0; + } + + to { + background-position: 72px 0; + } +} + +@keyframes how-it-works-dash-flow-vertical { + from { + background-position: 0 0; + } + + to { + background-position: 0 72px; + } +} + +.how-it-works-flow-line { + display: block; + height: 2px; + border-radius: 9999px; + background-image: repeating-linear-gradient( + 90deg, + rgba(var(--color-accent-rgb), 0.78) 0, + rgba(var(--color-accent-rgb), 0.78) 14px, + rgba(var(--color-accent-rgb), 0.08) 14px, + rgba(var(--color-accent-rgb), 0.08) 26px + ); + background-size: 28px 2px; + animation: how-it-works-dash-flow 10s linear infinite; + box-shadow: 0 0 18px rgba(var(--color-primary-rgb), 0.24); } -section { - scroll-margin-top: 90px; +.how-it-works-flow-line.light { + background-image: repeating-linear-gradient( + 90deg, + rgba(var(--color-primary-rgb), 0.6) 0, + rgba(var(--color-primary-rgb), 0.6) 12px, + rgba(var(--color-primary-rgb), 0.12) 12px, + rgba(var(--color-primary-rgb), 0.12) 24px + ); + box-shadow: 0 0 14px rgba(var(--color-primary-rgb), 0.14); +} + +.how-it-works-flow-line.vertical { + width: 2px; + height: 100%; + background-image: repeating-linear-gradient( + 180deg, + rgba(var(--color-accent-rgb), 0.78) 0, + rgba(var(--color-accent-rgb), 0.78) 14px, + rgba(var(--color-accent-rgb), 0.08) 14px, + rgba(var(--color-accent-rgb), 0.08) 26px + ); + background-size: 2px 28px; + animation: how-it-works-dash-flow-vertical 10s linear infinite; +} + +.how-it-works-flow-line.vertical.light { + background-image: repeating-linear-gradient( + 180deg, + rgba(var(--color-primary-rgb), 0.6) 0, + rgba(var(--color-primary-rgb), 0.6) 12px, + rgba(var(--color-primary-rgb), 0.12) 12px, + rgba(var(--color-primary-rgb), 0.12) 24px + ); +} + + +.icon-merged { + color: var(--color-success); +} +.icon-pr-open { + color: var(--color-primary); +} +.icon-pr-closed { + color: var(--color-danger); +} +.icon-issue-open { + color: var(--color-success); +} +.icon-issue-closed { + color: var(--color-danger); } diff --git a/src/pages/Tracker/Tracker.tsx b/src/pages/Tracker/Tracker.tsx index c5b901c..da63b20 100644 --- a/src/pages/Tracker/Tracker.tsx +++ b/src/pages/Tracker/Tracker.tsx @@ -35,8 +35,7 @@ import { useGitHubAuth } from "../../hooks/useGitHubAuth"; import { useGitHubData } from "../../hooks/useGitHubData"; import ContributionRecommender from "../../components/ContributionRecommender"; import { KeyIcon } from "lucide-react"; -import Dashboard from "../../components/Dashboard"; - +import RepositoryAnalyticsDashboard from "../../components/RepositoryAnalyticsDashboard"; const ROWS_PER_PAGE = 10; @@ -100,10 +99,15 @@ const Home: React.FC = () => { totalPrs, contributionScore, loading, + repositories, + weeklyCommitActivity, + analyticsLoading, + analyticsError, error: dataError, dailyActivity, dailyActivityLoaded, fetchData, + fetchRepositoryAnalytics, } = useGitHubData(getOctokit); const [tab, setTab] = useState(0); @@ -116,6 +120,7 @@ const Home: React.FC = () => { const [selectedRepo, setSelectedRepo] = useState(""); const [startDate, setStartDate] = useState(""); const [endDate, setEndDate] = useState(""); + const isRepoTrackerTab = tab === 2; // Fetch data after submit, then refresh when tab or page changes. useEffect(() => { @@ -126,24 +131,18 @@ const Home: React.FC = () => { const handleSubmit = (e: React.FormEvent): void => { e.preventDefault(); + setPage(0); + const trimmedUsername = username.trim(); if (!trimmedUsername) { return; } - if (page !== 0) { - setPage(0); - } - - if (submittedUsername !== trimmedUsername) { - setSubmittedUsername(trimmedUsername); - return; - } - - if (page === 0) { - fetchData(trimmedUsername, 1, ROWS_PER_PAGE); - } + void Promise.all([ + fetchData(trimmedUsername, 1, ROWS_PER_PAGE), + fetchRepositoryAnalytics(trimmedUsername), + ]); }; const handlePageChange = (_: unknown, newPage: number) => { @@ -430,6 +429,7 @@ const Home: React.FC = () => { > + State @@ -468,89 +468,21 @@ const Home: React.FC = () => { )} - - - - - Contribution Score - - - {contributionScore.total} - - - - - {scoreItems.map((item) => ( - - - {item.label} - - - {item.count} - - - {item.points} pts - {item.weight} - - - ))} - - - - - {loading ? ( - - {[1,2,3,4,5].map((row)=>( - - - - - - - ))} + {isRepoTrackerTab ? ( +
+ +
+ ) : loading ? ( + + ) : ( <>