diff --git a/src/components/ContributionRecommender.tsx b/src/components/ContributionRecommender.tsx new file mode 100644 index 0000000..f8d4d6c --- /dev/null +++ b/src/components/ContributionRecommender.tsx @@ -0,0 +1,686 @@ +import React, { useEffect, useRef } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { + Box, + Paper, + Typography, + Chip, + Link, + CircularProgress, + Alert, + Tooltip, + IconButton, +} from '@mui/material'; +import { useTheme } from '@mui/material/styles'; +import { + Sparkles, + RefreshCw, + ExternalLink, + Star, + BrainCircuit, + Search, + Trophy, + Zap, +} from 'lucide-react'; +import { Octokit } from '@octokit/core'; +import { + useContributionRecommender, + type Recommendation, + type SkillProfile, +} from '../hooks/useContributionRecommender'; + +// ─── Language color map (mirrors Tracker.tsx) ─────────────────────────────── + +const LANGUAGE_COLORS: Record = { + JavaScript: '#f1e05a', + TypeScript: '#3178c6', + Python: '#3572A5', + Java: '#b07219', + HTML: '#e34c26', + CSS: '#563d7c', + C: '#555555', + 'C++': '#f34b7d', + 'C#': '#178600', + PHP: '#4F5D95', + Ruby: '#701516', + Go: '#00ADD8', + Rust: '#dea584', + Kotlin: '#A97BFF', + Swift: '#F05138', + Shell: '#89e051', + Vue: '#41b883', + Dart: '#00B4AB', +}; + +const getLangColor = (lang: string) => LANGUAGE_COLORS[lang] ?? '#9ca3af'; + +// ─── Agent step labels ─────────────────────────────────────────────────────── + +const AGENT_STEPS = [ + { icon: BrainCircuit, label: 'Building skill profile…' }, + { icon: Search, label: 'Scouting open issues…' }, + { icon: Trophy, label: 'Ranking by relevance…' }, +]; + +// ─── Sub-components ────────────────────────────────────────────────────────── + +const SkillBadge: React.FC<{ lang: string }> = ({ lang }) => ( + + } + sx={{ + fontSize: '0.72rem', + fontWeight: 600, + height: 24, + border: '1px solid', + borderColor: 'divider', + bgcolor: 'background.paper', + '& .MuiChip-label': { pr: 1 }, + }} + /> +); + +const DifficultyBadge: React.FC<{ difficulty: 'Beginner' | 'Intermediate' }> = ({ + difficulty, +}) => ( + +); + +interface RecommendationCardProps { + rec: Recommendation; + index: number; +} + +const RecommendationCard: React.FC = ({ rec, index }) => { + const theme = useTheme(); + const isDark = theme.palette.mode === 'dark'; + + const accentColors = ['#6366f1', '#0ea5e9', '#10b981']; + const accent = accentColors[index % accentColors.length]; + + return ( + + + {/* Repo header */} + + + + + {rec.repoName} + + + + + + {rec.repoStars >= 1000 + ? `${(rec.repoStars / 1000).toFixed(1)}k` + : rec.repoStars} + + + + + {/* Issue title */} + + #{rec.issueNumber} {rec.issueTitle} + + + {/* Why it matches */} + + + + {rec.matchReason} + + + + {/* Labels + Difficulty + CTA */} + + {rec.labels.slice(0, 3).map((label) => ( + + ))} + + + + View Issue + + + + + + + ); +}; + +// ─── Loading skeleton ───────────────────────────────────────────────────────── + +const LoadingSkeleton: React.FC<{ agentStep: 0 | 1 | 2 | 3 }> = ({ agentStep }) => { + const theme = useTheme(); + const isDark = theme.palette.mode === 'dark'; + const stepIndex = Math.max(0, agentStep - 1); + const StepIcon = agentStep > 0 ? AGENT_STEPS[stepIndex]?.icon : null; + const stepLabel = agentStep > 0 ? AGENT_STEPS[stepIndex]?.label : 'Initializing…'; + + return ( + + {/* Agent step indicator */} + + + {StepIcon && } + + {stepLabel} + + + {[1, 2, 3].map((step) => ( + + ))} + + + + {/* Shimmer cards */} + {[0, 1, 2].map((i) => ( + + + + + + + + + + {[70, 90, 55].map((w) => ( + + ))} + + + ))} + + ); +}; + +// ─── Skill Profile Header ───────────────────────────────────────────────────── + +const SkillProfileRow: React.FC<{ profile: SkillProfile }> = ({ profile }) => { + const ACTIVITY_COLORS: Record = { + high: '#2ea44f', + medium: '#b08800', + low: '#cf222e', + }; + + return ( + + + + Your skills: + + {profile.topLanguages.map((lang) => ( + + ))} + + + + {profile.activityLevel} activity + + + + + ); +}; + +// ─── Main Component ─────────────────────────────────────────────────────────── + +interface ContributionRecommenderProps { + username: string; + getOctokit: () => Octokit | null; +} + +const ContributionRecommender: React.FC = ({ + username, + getOctokit, +}) => { + const theme = useTheme(); + const isDark = theme.palette.mode === 'dark'; + const hasRunRef = useRef(false); + + const { + recommendations, + skillProfile, + recommenderLoading, + recommenderError, + agentStep, + runRecommender, + } = useContributionRecommender(getOctokit); + + // Auto-run once when a username is available + useEffect(() => { + if (username && !hasRunRef.current) { + hasRunRef.current = true; + runRecommender(username); + } + }, [username, runRecommender]); + + // Reset auto-run flag if username changes + useEffect(() => { + hasRunRef.current = false; + }, [username]); + + if (!username) return null; + + return ( + + {/* Gradient top accent bar */} + + + + {/* Header */} + + + + + + + + AI Contribution Recommender + + + Agentic · 3-step pipeline · GitHub Search API + + + + + + + runRecommender(username, true)} + sx={{ + color: 'text.secondary', + '&:hover': { color: '#6366f1' }, + transition: 'color 0.2s', + }} + > + + + + + + + + + {/* Skill profile */} + {skillProfile && } + + {/* Loading state */} + + {recommenderLoading && ( + + + + )} + + + {/* Error state */} + {!recommenderLoading && recommenderError && ( + + runRecommender(username, true)} + sx={{ fontSize: '0.78rem', fontWeight: 700 }} + > + Retry + + } + > + {recommenderError} + + + )} + + {/* Recommendations */} + {!recommenderLoading && recommendations.length > 0 && ( + + + Top {recommendations.length} Recommended Issues for You + + + + {recommendations.map((rec, i) => ( + + ))} + + + + + Powered by GitHub Search API · Ranked by skill match, recency & repo popularity + + + )} + + {/* Empty state (after load, no error, no results) */} + {!recommenderLoading && !recommenderError && recommendations.length === 0 && ( + + + + Enter a GitHub username above and fetch your data to get AI-powered recommendations. + + + )} + + + ); +}; + +export default ContributionRecommender; diff --git a/src/hooks/useContributionRecommender.ts b/src/hooks/useContributionRecommender.ts new file mode 100644 index 0000000..8a3f464 --- /dev/null +++ b/src/hooks/useContributionRecommender.ts @@ -0,0 +1,438 @@ +import { useState, useCallback, useRef } from 'react'; +import { Octokit } from '@octokit/core'; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export interface SkillProfile { + topLanguages: string[]; + domains: string[]; + activityLevel: 'low' | 'medium' | 'high'; + repoCount: number; +} + +export interface Recommendation { + id: number; + issueTitle: string; + issueUrl: string; + issueNumber: number; + repoName: string; + repoUrl: string; + repoStars: number; + labels: string[]; + matchReason: string; + matchedLanguage: string; + difficulty: 'Beginner' | 'Intermediate'; + score: number; + createdAt: string; +} + +interface GitHubRepo { + name: string; + language: string | null; + stargazers_count: number; + forks_count: number; + pushed_at: string; + html_url: string; + fork: boolean; +} + +interface GitHubSearchIssue { + id: number; + number: number; + title: string; + html_url: string; + labels: Array<{ name: string }>; + repository_url: string; + created_at: string; + state: string; +} + +interface ScoutedIssue extends GitHubSearchIssue { + _matchedLanguage: string; + _langRank: number; +} + +// ─── Agent Step 1: Skill Profiler ───────────────────────────────────────────── + +const DOMAIN_KEYWORDS: Record = { + frontend: ['react', 'vue', 'angular', 'next', 'svelte', 'ui', 'css', 'html', 'tailwind', 'vite'], + backend: ['api', 'server', 'express', 'fastapi', 'django', 'flask', 'spring', 'node', 'graphql'], + devops: ['docker', 'k8s', 'kubernetes', 'ci', 'deploy', 'terraform', 'aws', 'cloud', 'infra'], + mobile: ['android', 'ios', 'flutter', 'react-native', 'swift', 'kotlin'], + data: ['ml', 'ai', 'data', 'pandas', 'numpy', 'tensorflow', 'pytorch', 'jupyter'], +}; + +const inferDomains = (repos: GitHubRepo[]): string[] => { + const domainScores: Record = {}; + + repos.forEach((repo) => { + const name = repo.name.toLowerCase(); + Object.entries(DOMAIN_KEYWORDS).forEach(([domain, keywords]) => { + const matches = keywords.filter((kw) => name.includes(kw)).length; + if (matches > 0) { + domainScores[domain] = (domainScores[domain] || 0) + matches; + } + }); + }); + + return Object.entries(domainScores) + .sort(([, a], [, b]) => b - a) + .slice(0, 2) + .map(([domain]) => domain); +}; + +const buildSkillProfile = (repos: GitHubRepo[]): SkillProfile => { + // Count language frequency weighted by recency + const langScore: Record = {}; + const now = Date.now(); + + repos + .filter((r) => !r.fork && r.language) + .forEach((repo) => { + const lang = repo.language as string; + const ageMs = now - new Date(repo.pushed_at).getTime(); + const recencyBonus = Math.max(0, 1 - ageMs / (1000 * 60 * 60 * 24 * 365)); // decay over 1 year + langScore[lang] = (langScore[lang] || 0) + 1 + recencyBonus + repo.stargazers_count * 0.01; + }); + + const topLanguages = Object.entries(langScore) + .sort(([, a], [, b]) => b - a) + .slice(0, 4) + .map(([lang]) => lang); + + const domains = inferDomains(repos); + + // Activity level: based on number of recently pushed repos (last 90 days) + const ninetyDaysAgo = Date.now() - 90 * 24 * 60 * 60 * 1000; + const recentlyActive = repos.filter( + (r) => new Date(r.pushed_at).getTime() > ninetyDaysAgo + ).length; + + const activityLevel: SkillProfile['activityLevel'] = + recentlyActive >= 5 ? 'high' : recentlyActive >= 2 ? 'medium' : 'low'; + + return { + topLanguages, + domains, + activityLevel, + repoCount: repos.length, + }; +}; + +// ─── Agent Step 2: Issue Scout ──────────────────────────────────────────────── + +const SEARCH_LABELS = ['good first issue', 'help wanted']; + +const buildSearchQuery = (language: string, label: string): string => { + return `label:"${label}" language:${language} is:open is:public is:issue state:open`; +}; + +const scoutIssues = async ( + octokit: Octokit, + skillProfile: SkillProfile +): Promise => { + const queries: Array<{ language: string; langRank: number; label: string; query: string }> = []; + + // Build search combinations: top 3 languages × both labels + skillProfile.topLanguages.slice(0, 3).forEach((lang, langRank) => { + SEARCH_LABELS.forEach((label) => { + queries.push({ language: lang, langRank, label, query: buildSearchQuery(lang, label) }); + }); + }); + + const searchRequests = queries.map(({ query, language, langRank }) => + octokit + .request('GET /search/issues', { + q: query, + per_page: 8, + sort: 'updated', + order: 'desc', + }) + .then((res) => res.data.items.map(issue => ({ + ...issue, + _matchedLanguage: language, + _langRank: langRank + })) as ScoutedIssue[]) + ); + + const results = await Promise.allSettled(searchRequests); + + // If all search queries failed (e.g. rate limit, auth error), bubble up the error + const allRejected = results.length > 0 && results.every(r => r.status === 'rejected'); + if (allRejected) { + const firstError = (results[0] as PromiseRejectedResult).reason; + throw firstError; + } + + const seenIds = new Map(); + + results.forEach((result) => { + if (result.status === 'fulfilled') { + result.value.forEach((issue) => { + const existing = seenIds.get(issue.id); + // If an issue appears in multiple queries, keep the one with the better (lower) language rank + if (!existing || issue._langRank < existing._langRank) { + seenIds.set(issue.id, issue); + } + }); + } + }); + + return Array.from(seenIds.values()); +}; + +// ─── Agent Step 3: Relevance Ranker ────────────────────────────────────────── + +const extractRepoInfo = async ( + octokit: Octokit, + repositoryUrl: string +): Promise<{ stars: number; repoName: string; repoUrl: string }> => { + try { + // repo URL format: https://api.github.com/repos/owner/name + const parts = repositoryUrl.split('/'); + const owner = parts[parts.length - 2]; + const repo = parts[parts.length - 1]; + + const res = await octokit.request('GET /repos/{owner}/{repo}', { owner, repo }); + return { + stars: res.data.stargazers_count, + repoName: `${owner}/${repo}`, + repoUrl: res.data.html_url, + }; + } catch { + const parts = repositoryUrl.split('/'); + const owner = parts[parts.length - 2]; + const repo = parts[parts.length - 1]; + return { stars: 0, repoName: `${owner}/${repo}`, repoUrl: repositoryUrl }; + } +}; + +const computeMatchReason = ( + issue: GitHubSearchIssue, + matchedLanguage: string, + skillProfile: SkillProfile, + langRank: number +): string => { + const isTopLang = langRank === 0; + const hasGoodFirstIssue = issue.labels.some((l) => + l.name.toLowerCase().includes('good first issue') + ); + const hasHelpWanted = issue.labels.some((l) => + l.name.toLowerCase().includes('help wanted') + ); + + if (isTopLang && hasGoodFirstIssue) { + return `${matchedLanguage} is your #1 language — perfect beginner-friendly entry point`; + } + if (isTopLang && hasHelpWanted) { + return `${matchedLanguage} is your primary language; this project needs contributors like you`; + } + if (langRank === 1) { + return `${matchedLanguage} is your #2 language — great way to deepen your expertise`; + } + if (skillProfile.domains.includes('frontend') && matchedLanguage === 'TypeScript') { + return `TypeScript aligns with your frontend domain activity`; + } + return `${matchedLanguage} is in your top languages — strong match with your coding profile`; +}; + +const scoreIssue = ( + issue: GitHubSearchIssue, + langRank: number, + stars: number +): number => { + let score = 0; + + // Language rank bonus (top language = highest score) + score += Math.max(0, (3 - langRank)) * 30; + + // Label quality + const labelNames = issue.labels.map((l) => l.name.toLowerCase()); + if (labelNames.includes('good first issue')) score += 25; + if (labelNames.includes('help wanted')) score += 15; + + // Star signal (capped) + score += Math.min(stars / 1000, 20); + + // Recency bonus (last 30 days = full 20pts) + const daysOld = + (Date.now() - new Date(issue.created_at).getTime()) / (1000 * 60 * 60 * 24); + score += Math.max(0, 20 - daysOld * 0.5); + + return score; +}; + +const rankIssues = async ( + octokit: Octokit, + rawIssues: ScoutedIssue[], + skillProfile: SkillProfile +): Promise => { + // Fetch repo info for top 15 candidates (to avoid too many requests) + const candidates = rawIssues.slice(0, 15); + + const enriched = await Promise.allSettled( + candidates.map(async (issue) => { + const { stars, repoName, repoUrl } = await extractRepoInfo( + octokit, + issue.repository_url + ); + + // Use the language and rank preserved from the scout query + const matchedLanguage = issue._matchedLanguage; + const langRank = issue._langRank; + const labelNames = issue.labels.map((l) => l.name.toLowerCase()); + + const score = scoreIssue(issue, langRank, stars); + const matchReason = computeMatchReason(issue, matchedLanguage, skillProfile, langRank); + const hasGoodFirstIssue = labelNames.some((l) => l.includes('good first issue')); + + const rec: Recommendation = { + id: issue.id, + issueTitle: issue.title, + issueUrl: issue.html_url, + issueNumber: issue.number, + repoName, + repoUrl, + repoStars: stars, + labels: issue.labels.map((l) => l.name), + matchReason, + matchedLanguage, + difficulty: hasGoodFirstIssue ? 'Beginner' : 'Intermediate', + score, + createdAt: issue.created_at, + }; + + return rec; + }) + ); + + const ranked: Recommendation[] = enriched + .filter((r) => r.status === 'fulfilled') + .map((r) => (r as PromiseFulfilledResult).value) + .sort((a, b) => b.score - a.score) + .slice(0, 3); + + return ranked; +}; + +// ─── Main Hook ──────────────────────────────────────────────────────────────── + +export const useContributionRecommender = (getOctokit: () => Octokit | null) => { + const [recommendations, setRecommendations] = useState([]); + const [skillProfile, setSkillProfile] = useState(null); + const [recommenderLoading, setRecommenderLoading] = useState(false); + const [recommenderError, setRecommenderError] = useState(''); + const [agentStep, setAgentStep] = useState<0 | 1 | 2 | 3>(0); + + // Cache: avoid re-running for the same username in the same session + const cacheRef = useRef<{ + username: string; + recommendations: Recommendation[]; + skillProfile: SkillProfile; + } | null>(null); + + // Guard against race conditions from multiple runs + const runRequestIdRef = useRef(0); + + const runRecommender = useCallback( + async (username: string, force = false) => { + const octokit = getOctokit(); + if (!octokit || !username.trim()) return; + + // Return cached result if available and not forced + if (!force && cacheRef.current?.username === username) { + setRecommendations(cacheRef.current.recommendations); + setSkillProfile(cacheRef.current.skillProfile); + return; + } + + const currentRequestId = ++runRequestIdRef.current; + + setRecommenderLoading(true); + setRecommenderError(''); + setRecommendations([]); + setSkillProfile(null); + + try { + // ── Step 1: Skill Profiler ── + setAgentStep(1); + const reposRes = await octokit.request('GET /users/{username}/repos', { + username, + per_page: 30, + sort: 'pushed', + type: 'owner', + }); + + const repos = reposRes.data as GitHubRepo[]; + if (repos.length === 0) { + setRecommenderError('No public repositories found to build your skill profile.'); + return; + } + + const profile = buildSkillProfile(repos); + if (currentRequestId !== runRequestIdRef.current) return; + setSkillProfile(profile); + + if (profile.topLanguages.length === 0) { + setRecommenderError( + 'Could not detect languages from your repositories. Make sure your repos have languages set on GitHub.' + ); + return; + } + + // ── Step 2: Issue Scout ── + if (currentRequestId !== runRequestIdRef.current) return; + setAgentStep(2); + const rawIssues = await scoutIssues(octokit, profile); + + if (rawIssues.length === 0) { + setRecommenderError( + 'No matching open issues found right now. Try again later or add a GitHub token for better results.' + ); + return; + } + + // ── Step 3: Relevance Ranker ── + if (currentRequestId !== runRequestIdRef.current) return; + setAgentStep(3); + const ranked = await rankIssues(octokit, rawIssues, profile); + + if (currentRequestId !== runRequestIdRef.current) return; + setRecommendations(ranked); + + // Cache the result + cacheRef.current = { username, recommendations: ranked, skillProfile: profile }; + } catch (err: unknown) { + if (currentRequestId !== runRequestIdRef.current) return; + + const error = err as { status?: number; message?: string }; + if (error.status === 403) { + setRecommenderError( + 'GitHub API rate limit hit. Add a Personal Access Token in the tracker to get recommendations.' + ); + } else if (error.status === 404) { + setRecommenderError('GitHub user not found. Please verify the username.'); + } else { + setRecommenderError( + 'Unable to generate recommendations. Please check your connection and try again.' + ); + } + } finally { + if (currentRequestId === runRequestIdRef.current) { + setRecommenderLoading(false); + setAgentStep(0); + } + } + }, + [getOctokit] + ); + + return { + recommendations, + skillProfile, + recommenderLoading, + recommenderError, + agentStep, + runRecommender, + }; +}; diff --git a/src/index.css b/src/index.css index 31fb929..afa03a6 100644 --- a/src/index.css +++ b/src/index.css @@ -1,176 +1,159 @@ -@tailwind base; -@tailwind components; -@tailwind utilities; - -@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(96, 165, 250, 0.78) 0, - rgba(96, 165, 250, 0.78) 14px, - rgba(96, 165, 250, 0.08) 14px, - rgba(96, 165, 250, 0.08) 26px - ); - background-size: 28px 2px; - animation: how-it-works-dash-flow 10s linear infinite; - box-shadow: 0 0 18px rgba(59, 130, 246, 0.24); -} - -.how-it-works-flow-line.light { - background-image: repeating-linear-gradient( - 90deg, - rgba(59, 130, 246, 0.6) 0, - rgba(59, 130, 246, 0.6) 12px, - rgba(59, 130, 246, 0.12) 12px, - rgba(59, 130, 246, 0.12) 24px - ); - box-shadow: 0 0 14px rgba(59, 130, 246, 0.14); -} - -.how-it-works-flow-line.vertical { - width: 2px; - height: 100%; - background-image: repeating-linear-gradient( - 180deg, - rgba(96, 165, 250, 0.78) 0, - rgba(96, 165, 250, 0.78) 14px, - rgba(96, 165, 250, 0.08) 14px, - rgba(96, 165, 250, 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(59, 130, 246, 0.6) 0, - rgba(59, 130, 246, 0.6) 12px, - rgba(59, 130, 246, 0.12) 12px, - rgba(59, 130, 246, 0.12) 24px - ); -} - - -.icon-merged { - color: #2ea44f; /* Or use your theme color */ -} -.icon-pr-open { - color: #0969da; -} -.icon-pr-closed { - color: #cf222e; -} -.icon-issue-open { - color: #2ea44f; -} -.icon-issue-closed { - color: #cf222e; -} - -/* Navbar Container */ -.navbar { - display: flex; - justify-content: space-between; - align-items: center; - padding: 1rem 2rem; - flex-wrap: wrap; - width: 100%; -} - -/* Navigation Links */ -.nav-links { - display: flex; - align-items: center; - gap: 1.5rem; - list-style: none; - flex-wrap: wrap; -} - -/* Nav Link Styling */ -.nav-links a { - text-decoration: none; - font-size: 1rem; - transition: 0.3s ease; -} - -/* Better Hover Effect */ -.nav-links a:hover { - opacity: 0.8; -} - -/* Tablet Responsiveness */ -@media screen and (max-width: 992px) { - .navbar { - padding: 1rem; - } - - .nav-links { - gap: 1rem; - } - - .nav-links a { - font-size: 0.95rem; - } -} - -/* Mobile Responsiveness */ -@media screen and (max-width: 768px) { - - .navbar { - flex-direction: column; - align-items: center; - text-align: center; - } - - .nav-links { - justify-content: center; - margin-top: 1rem; - gap: 0.8rem; - } - - .nav-links a { - font-size: 0.9rem; - } -} - -/* Small Mobile Devices */ -@media screen and (max-width: 480px) { - - .navbar { - padding: 0.8rem; - } - - .nav-links { - flex-direction: column; - width: 100%; - } - - .nav-links a { - width: 100%; - display: block; - padding: 0.5rem 0; - } -} +@tailwind base; +@tailwind components; +@tailwind utilities; + +@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(96, 165, 250, 0.78) 0, + rgba(96, 165, 250, 0.78) 14px, + rgba(96, 165, 250, 0.08) 14px, + rgba(96, 165, 250, 0.08) 26px + ); + background-size: 28px 2px; + animation: how-it-works-dash-flow 10s linear infinite; + box-shadow: 0 0 18px rgba(59, 130, 246, 0.24); +} + +.how-it-works-flow-line.light { + background-image: repeating-linear-gradient( + 90deg, + rgba(59, 130, 246, 0.6) 0, + rgba(59, 130, 246, 0.6) 12px, + rgba(59, 130, 246, 0.12) 12px, + rgba(59, 130, 246, 0.12) 24px + ); + box-shadow: 0 0 14px rgba(59, 130, 246, 0.14); +} + +.how-it-works-flow-line.vertical { + width: 2px; + height: 100%; + background-image: repeating-linear-gradient( + 180deg, + rgba(96, 165, 250, 0.78) 0, + rgba(96, 165, 250, 0.78) 14px, + rgba(96, 165, 250, 0.08) 14px, + rgba(96, 165, 250, 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(59, 130, 246, 0.6) 0, + rgba(59, 130, 246, 0.6) 12px, + rgba(59, 130, 246, 0.12) 12px, + rgba(59, 130, 246, 0.12) 24px + ); +} + + +.icon-merged { + color: #2ea44f; /* Or use your theme color */ +} +.icon-pr-open { + color: #0969da; +} +.icon-pr-closed { + color: #cf222e; +} +.icon-issue-open { + color: #2ea44f; +} +.icon-issue-closed { + color: #cf222e; +} + +html.theme-transitioning, +html.theme-transitioning *, +html.theme-transitioning *::before, +html.theme-transitioning *::after { + transition-property: background-color, border-color, color, fill, stroke, box-shadow, opacity, transform; + transition-duration: 600ms; + transition-timing-function: ease-in-out; +} + +@media (prefers-reduced-motion: reduce) { + html.theme-transitioning, + html.theme-transitioning *, + html.theme-transitioning *::before, + html.theme-transitioning *::after { + transition-duration: 0ms; + } +} + +@keyframes theme-icon-pop { + 0% { + opacity: 0; + transform: rotate(-90deg) scale(0.75); + } + + 100% { + opacity: 1; + transform: rotate(0deg) scale(1); + } +} + +.theme-toggle-icon { + animation: theme-icon-pop 500ms ease-out; +} + +/* ── Contribution Recommender Widget ─────────────────────────── */ + +@keyframes recommender-pulse { + 0%, 100% { + opacity: 1; + transform: scale(1); + } + 50% { + opacity: 0.4; + transform: scale(0.75); + } +} + +@keyframes recommender-shimmer { + 0% { + left: -100%; + } + 100% { + left: 200%; + } +} + +.recommender-card-hover { + transition: transform 0.25s ease, box-shadow 0.25s ease, border-color 0.25s ease; +} + +.recommender-card-hover:hover { + transform: translateY(-2px); +} + +.agent-step-bar { + transition: background-color 0.4s ease; +} diff --git a/src/pages/Tracker/Tracker.tsx b/src/pages/Tracker/Tracker.tsx index d617261..c5b901c 100644 --- a/src/pages/Tracker/Tracker.tsx +++ b/src/pages/Tracker/Tracker.tsx @@ -1,5 +1,4 @@ -import { CodingPersonaWidget } from '../../components/CodingPersonaWidget'; -import React, { useState, useEffect } from "react" +import React, { useState, useEffect, useContext } from "react" import { IssueOpenedIcon, IssueClosedIcon, @@ -11,7 +10,6 @@ import { Container, Box, TextField, - Button, Paper, Table, TableBody, @@ -30,14 +28,12 @@ import { MenuItem, FormControl, InputLabel, + Tooltip, } from "@mui/material"; import { useTheme } from "@mui/material/styles"; import { useGitHubAuth } from "../../hooks/useGitHubAuth"; import { useGitHubData } from "../../hooks/useGitHubData"; -import { useGitHubProfile } from "../../hooks/useProfileData"; -import { useGitHubRepositories } from "../../hooks/useGithubRepos"; -import { useGitHubActivity } from "../../hooks/useGithubActivity"; - +import ContributionRecommender from "../../components/ContributionRecommender"; import { KeyIcon } from "lucide-react"; import Dashboard from "../../components/Dashboard"; @@ -54,16 +50,46 @@ interface GitHubItem { html_url: string; } +const LANGUAGE_COLORS: Record = { + JavaScript: "#f1e05a", + TypeScript: "#3178c6", + Python: "#3572A5", + Java: "#b07219", + HTML: "#e34c26", + CSS: "#563d7c", + C: "#555555", + "C++": "#f34b7d", + "C#": "#178600", + PHP: "#4F5D95", + Ruby: "#701516", + Go: "#00ADD8", + Rust: "#dea584", + Kotlin: "#A97BFF", + Swift: "#F05138", +}; + +const getLanguageFromRepo = (repoName: string): string => { + const lowerRepo = repoName.toLowerCase(); + + if (lowerRepo.includes("react") || lowerRepo.includes("js")) return "JavaScript"; + if (lowerRepo.includes("ts") || lowerRepo.includes("typescript")) return "TypeScript"; + if (lowerRepo.includes("python") || lowerRepo.includes("py")) return "Python"; + if (lowerRepo.includes("java")) return "Java"; + if (lowerRepo.includes("html")) return "HTML"; + if (lowerRepo.includes("css")) return "CSS"; + + return "Unknown"; +}; const Home: React.FC = () => { const theme = useTheme(); + const userContext = useContext(UserContext); const { username, setUsername, token, setToken, - error: authError, getOctokit, } = useGitHubAuth(); @@ -72,14 +98,18 @@ const Home: React.FC = () => { prs, totalIssues, totalPrs, + contributionScore, loading, error: dataError, + dailyActivity, + dailyActivityLoaded, fetchData, } = useGitHubData(getOctokit); const [tab, setTab] = useState(0); const [page, setPage] = useState(0); - const [hasFetched, setHasFetched] = useState(false); + const [submittedUsername, setSubmittedUsername] = useState(""); + const [issueFilter, setIssueFilter] = useState("all"); const [prFilter, setPrFilter] = useState("all"); const [searchTitle, setSearchTitle] = useState(""); @@ -87,24 +117,37 @@ const Home: React.FC = () => { const [startDate, setStartDate] = useState(""); const [endDate, setEndDate] = useState(""); - // Fetch data when tab or page changes + // Fetch data after submit, then refresh when tab or page changes. useEffect(() => { - if (username) { - fetchData(username, page + 1, ROWS_PER_PAGE); + if (submittedUsername) { + fetchData(submittedUsername, page + 1, ROWS_PER_PAGE); } - }, [tab, page]); + }, [fetchData, page, submittedUsername, tab]); const handleSubmit = (e: React.FormEvent): void => { e.preventDefault(); - setPage(0); - fetchProfile(username); - fetchRepositories(username); - fetchActivity(username); - fetchData(username, 1, ROWS_PER_PAGE); - setHasFetched(true); + 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); + } }; const handlePageChange = (_: unknown, newPage: number) => { + setPage(newPage); }; @@ -174,6 +217,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", + }, + ]; // const profileStats = useProfileData( // issues, @@ -254,7 +323,6 @@ const Home: React.FC = () => { label="GitHub Username" value={username} onChange={(e) => setUsername(e.target.value)} - required sx={{ flex: 1, minWidth: 150 }} /> { value={token} onChange={(e) => setToken(e.target.value)} type="password" - required sx={{ flex: 1, minWidth: 150 }} helperText={ - + sx={{ fontSize: "0.75rem", textDecoration: "none", display: "inline-flex", alignItems: "center", gap: 0.5 }} + > Generate new token - - - - • - - - + + + sx={{ fontSize: "0.75rem", textDecoration: "none" }} + > Learn more - + } /> + {dailyActivityLoaded && } + + {submittedUsername && ( + + )} + {/* Filters */} { State + - {(authError || dataError) && ( + {dataError && ( - {authError || dataError} + {dataError} )} + + + + + Contribution Score + + + {contributionScore.total} + + + + + {scoreItems.map((item) => ( + + + {item.label} + + + {item.count} + + + {item.points} pts - {item.weight} + + + ))} + + + + {loading ? ( {[1,2,3,4,5].map((row)=>( @@ -476,8 +599,38 @@ const Home: React.FC = () => { - {item.repository_url.split("/").slice(-1)[0]} - + {(() => { + const repoName = item.repository_url.split("/").slice(-1)[0]; + const language = getLanguageFromRepo(repoName); + const color = LANGUAGE_COLORS[language] || "#9ca3af"; + + return ( + + + + {repoName} + + + ); + })()} + {item.pull_request?.merged_at ? "merged" : item.state} @@ -504,6 +657,7 @@ const Home: React.FC = () => { )} + ); };