diff --git a/src/App.jsx b/src/App.jsx index e5e12d6..422fd24 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -8,6 +8,7 @@ import HomePage from './pages/HomePage' import OverviewPage from './pages/OverviewPage' import RepositoriesPage from './pages/RepositoriesPage' import ContributorsPage from './pages/ContributorsPage' +import ProgressTrackerPage from './pages/ProgressTrackerPage' import NetworkPage from './pages/NetworkPage' import AnalyticsPage from './pages/AnalyticsPage' import GovernancePage from './pages/GovernancePage' @@ -33,6 +34,7 @@ function AppContent() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/components/Navbar.jsx b/src/components/Navbar.jsx index aa6b7a2..1223878 100644 --- a/src/components/Navbar.jsx +++ b/src/components/Navbar.jsx @@ -11,6 +11,7 @@ const LINKS = [ { to: '/overview', label: 'Overview' }, { to: '/repositories', label: 'Repositories' }, { to: '/contributors', label: 'Contributors' }, + { to: '/progress', label: 'Progress Tracker' }, { to: '/network', label: 'Network' }, { to: '/analytics', label: 'Analytics' }, { to: '/governance', label: 'Governance' }, diff --git a/src/pages/ProgressTrackerPage.jsx b/src/pages/ProgressTrackerPage.jsx new file mode 100644 index 0000000..69ec46d --- /dev/null +++ b/src/pages/ProgressTrackerPage.jsx @@ -0,0 +1,768 @@ +import React, { useState, useEffect, useMemo, useRef } from 'react' +import { useNavigate } from 'react-router-dom' +import { useApp } from '../context/AppContext' +import { C, PageTitle, Spinner, StatCard } from '../components/UI' +import { + FiArrowLeft, FiCalendar, FiCheckSquare, FiSquare, + FiExternalLink, FiDownload, FiInfo, FiAlertTriangle, FiRefreshCw, FiGrid +} from 'react-icons/fi' + +// Reusable pagination helper +async function fetchAllPages(initialUrl, headers, signal) { + let items = [] + let url = initialUrl + for (let page = 1; page <= 10; page++) { + const res = await fetch(url, { headers, signal }) + if (res.status === 403) { + throw new Error('RATE_LIMIT') + } + if (!res.ok) { + throw new Error(`HTTP_${res.status}`) + } + const data = await res.json() + items = items.concat(data.items || []) + + const linkHeader = res.headers.get('Link') + if (!linkHeader) break + + const match = linkHeader.match(/<([^>]+)>;\s*rel="next"/) + if (!match) break + + url = match[1] + } + return items +} + +const getFullRepoFromUrl = (url) => { + if (!url) return '' + const parts = url.split('/') + return parts.slice(-2).join('/') +} + +export default function ProgressTrackerPage() { + const navigate = useNavigate() + const { model, orgs, pat, pullsData } = useApp() + + const contributors = useMemo(() => model?.contributors ?? [], [model]) + + // Date controls + const [startDate, setStartDate] = useState('2026-05-18') // Default to GSoC Coding Period + const [endDate, setEndDate] = useState('2026-08-24') + + // Selected contributors + const [selectedLogins, setSelectedLogins] = useState(new Set()) + const [searchFilter, setSearchFilter] = useState('') + + // Contribution data cache map + const [cache, setCache] = useState({}) + const [loadingMap, setLoadingMap] = useState({}) + const [errorMap, setErrorMap] = useState({}) + + // Detail Modal State + const [modalData, setModalData] = useState(null) + const triggerRef = useRef(null) + const modalRef = useRef(null) + + // Orgs to search + const searchOrgs = useMemo(() => { + let list = orgs.map(o => o.login) + if (!list.length) { + try { + const rawRecent = localStorage.getItem('oe_recent') + if (rawRecent) { + const recent = JSON.parse(rawRecent) + if (Array.isArray(recent) && recent.length && typeof recent[0] === 'string') { + list = recent[0].split(',').map(s => s.trim()).filter(Boolean) + } + } + } catch (e) { + console.error('Failed to parse oe_recent from localStorage:', e) + } + } + return list + }, [orgs]) + + // Setup default contributor selection (top 10 active) + useEffect(() => { + if (contributors.length > 0 && selectedLogins.size === 0) { + const topLogins = contributors.slice(0, 10).map(c => c.login) + setSelectedLogins(new Set(topLogins)) + } + }, [contributors]) + + // Calculate weeks dynamically + const weeks = useMemo(() => { + if (!startDate || !endDate) return [] + const list = [] + let current = new Date(startDate + 'T00:00:00.000Z') + const endLimit = new Date(endDate + 'T23:59:59.999Z') + + if (isNaN(current.getTime()) || isNaN(endLimit.getTime()) || current >= endLimit) { + return [] + } + + let wNum = 1 + while (current < endLimit) { + const weekStart = new Date(current) + const weekEnd = new Date(current.getTime() + 6 * 24 * 60 * 60 * 1000) + weekEnd.setUTCHours(23, 59, 59, 999) + + list.push({ + number: wNum++, + start: weekStart, + end: weekEnd > endLimit ? endLimit : weekEnd, + }) + current = new Date(current.getTime() + 7 * 24 * 60 * 60 * 1000) + } + return list + }, [startDate, endDate]) + + // AbortController refs to cancel pending fetches + const abortControllersRef = useRef({}) + const inFlightRef = useRef({}) + + // Clear cache and abort any running fetches when range changes + useEffect(() => { + setCache({}) + setLoadingMap({}) + setErrorMap({}) + Object.values(abortControllersRef.current).forEach(ctrl => ctrl.abort()) + abortControllersRef.current = {} + inFlightRef.current = {} + }, [startDate, endDate]) + + // Cleanup fetches on unmount + useEffect(() => { + return () => { + Object.values(abortControllersRef.current).forEach(ctrl => ctrl.abort()) + } + }, []) + + // Handle accessible dialog lifecycle (Esc key, backdrop, focus trap, and return focus) + useEffect(() => { + if (!modalData) return + + const handleKeyDown = (e) => { + if (e.key === 'Escape') { + setModalData(null) + triggerRef.current?.focus() + } + } + window.addEventListener('keydown', handleKeyDown) + + // Focus first focusable item in modal + const modalElement = modalRef.current + const focusable = modalElement?.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' + ) + if (focusable && focusable.length > 0) { + focusable[0].focus() + } + + const handleTab = (e) => { + if (e.key !== 'Tab') return + if (!focusable || focusable.length === 0) return + + const first = focusable[0] + const last = focusable[focusable.length - 1] + + if (e.shiftKey) { + if (document.activeElement === first) { + e.preventDefault() + last.focus() + } + } else { + if (document.activeElement === last) { + e.preventDefault() + first.focus() + } + } + } + modalElement?.addEventListener('keydown', handleTab) + + return () => { + window.removeEventListener('keydown', handleKeyDown) + modalElement?.removeEventListener('keydown', handleTab) + } + }, [modalData]) + + // Trigger parallel data fetching for selected contributors + useEffect(() => { + if (!searchOrgs.length) return + + selectedLogins.forEach(login => { + // Skip if already in cache or currently loading (using ref for in-flight safety) + if (cache[login] || inFlightRef.current[login]) return + + const ctrl = new AbortController() + abortControllersRef.current[login] = ctrl + inFlightRef.current[login] = true + + setLoadingMap(prev => ({ ...prev, [login]: true })) + setErrorMap(prev => ({ ...prev, [login]: '' })) + + const encodedUser = encodeURIComponent(login) + const orgQuery = searchOrgs.map(org => `org:${encodeURIComponent(org)}`).join('+') + const dateRangeQuery = `+created:${startDate}..${endDate}` + const url = `https://api.github.com/search/issues?q=author:${encodedUser}+${orgQuery}${dateRangeQuery}&per_page=100` + const mergedUrl = `https://api.github.com/search/issues?q=author:${encodedUser}+is:pr+is:merged+${orgQuery}${dateRangeQuery}&per_page=100` + + const headers = { Accept: 'application/vnd.github.v3+json' } + if (pat) { + headers.Authorization = `token ${pat}` + } + + Promise.all([ + fetchAllPages(url, headers, ctrl.signal), + fetchAllPages(mergedUrl, headers, ctrl.signal) + ]).then(([items, mergedItems]) => { + const mergedKeys = new Set( + mergedItems.map(item => { + const repo = getFullRepoFromUrl(item.repository_url) + return `${repo}/${item.number}` + }) + ) + + const localPulls = Object.values(pullsData || {}).flat() + const parsed = items.map(item => { + const repoName = item.repository_url ? item.repository_url.split('/').pop() : 'Unknown' + const fullRepo = getFullRepoFromUrl(item.repository_url) + const isPR = Boolean(item.pull_request) + let isMerged = false + if (isPR) { + if (item.pull_request?.merged_at !== undefined && item.pull_request?.merged_at !== null) { + isMerged = true + } else { + const localMatch = localPulls.find(p => p.number === item.number && p.base?.repo?.full_name === fullRepo) + if (localMatch) { + isMerged = Boolean(localMatch.merged_at) + } else { + isMerged = mergedKeys.has(`${fullRepo}/${item.number}`) + } + } + } + + return { + id: item.id, + number: item.number, + title: item.title, + html_url: item.html_url, + state: item.state, + created_at: item.created_at, + repoName, + fullRepo, + isPR, + isMerged + } + }) + + setCache(prev => ({ ...prev, [login]: parsed })) + setLoadingMap(prev => ({ ...prev, [login]: false })) + delete inFlightRef.current[login] + delete abortControllersRef.current[login] + }).catch(err => { + // Safe check for abort cancelations + setLoadingMap(prev => ({ ...prev, [login]: false })) + delete inFlightRef.current[login] + delete abortControllersRef.current[login] + if (err.name === 'AbortError') return + + console.error(`Failed loading progress for ${login}:`, err) + setErrorMap(prev => ({ ...prev, [login]: err.message === 'RATE_LIMIT' ? 'Rate Limit' : 'Failed' })) + }) + }) + }, [selectedLogins, searchOrgs, pat, pullsData, startDate, endDate, cache]) + + // Contributor selection handlers + const toggleSelect = (login) => { + const isSelected = selectedLogins.has(login) + if (isSelected) { + if (abortControllersRef.current[login]) { + abortControllersRef.current[login].abort() + delete abortControllersRef.current[login] + } + delete inFlightRef.current[login] + } + setSelectedLogins(prev => { + const next = new Set(prev) + if (next.has(login)) { + next.delete(login) + } else { + next.add(login) + } + return next + }) + } + + const selectPreset = (type) => { + if (type === 'top5') { + setSelectedLogins(new Set(contributors.slice(0, 5).map(c => c.login))) + } else if (type === 'top10') { + setSelectedLogins(new Set(contributors.slice(0, 10).map(c => c.login))) + } else { + // Abort all in-flight requests when clearing selections + Object.values(abortControllersRef.current).forEach(ctrl => ctrl.abort()) + abortControllersRef.current = {} + inFlightRef.current = {} + setLoadingMap({}) + setSelectedLogins(new Set()) + } + } + + // Filter contributor list in sidebar + const filteredContributors = useMemo(() => { + return contributors.filter(c => c.login.toLowerCase().includes(searchFilter.toLowerCase())) + }, [contributors, searchFilter]) + + // Precompute weekly stats for performance optimization + const precomputedStats = useMemo(() => { + const stats = {} + if (weeks.length === 0) return stats + + const weekRanges = weeks.map(w => ({ + number: w.number, + start: w.start.getTime(), + end: w.end.getTime() + })) + + selectedLogins.forEach(login => { + const list = cache[login] || [] + const parsedItems = list.map(item => ({ + ...item, + time: new Date(item.created_at).getTime() + })) + + stats[login] = weekRanges.map(w => { + const weekItems = parsedItems.filter(item => item.time >= w.start && item.time <= w.end) + const mergedPRs = weekItems.filter(item => item.isPR && item.isMerged) + const openPRs = weekItems.filter(item => item.isPR && item.state === 'open') + const closedPRs = weekItems.filter(item => item.isPR && !item.isMerged && item.state === 'closed') + const issuesList = weekItems.filter(item => !item.isPR) + + return { + weekNumber: w.number, + merged: mergedPRs.length, + open: openPRs.length, + closed: closedPRs.length, + issues: issuesList.length, + items: weekItems + } + }) + }) + + return stats + }, [cache, weeks, selectedLogins]) + + // Look up stats from the precomputed map + const getWeeklyStats = (login, week) => { + const userStats = precomputedStats[login] || [] + return userStats.find(s => s.weekNumber === week.number) || { + merged: 0, + open: 0, + closed: 0, + issues: 0, + items: [] + } + } + + // Export Matrix to CSV + const exportCSV = () => { + if (selectedLogins.size === 0 || weeks.length === 0) return + + let csvContent = 'data:text/csv;charset=utf-8,' + // Header + const weekHeaders = weeks.map(w => `Week ${w.number} (${w.start.toISOString().slice(0, 10)})`).join(',') + csvContent += `Contributor,Total PRs,Total Issues,${weekHeaders}\n` + + selectedLogins.forEach(login => { + const list = cache[login] || [] + const prCount = list.filter(i => i.isPR).length + const issueCount = list.filter(i => !i.isPR).length + + const weekStats = weeks.map(w => { + const stats = getWeeklyStats(login, w) + return `"${stats.merged} Merged / ${stats.open} Open / ${stats.issues} Issues"` + }).join(',') + + csvContent += `${login},${prCount},${issueCount},${weekStats}\n` + }) + + const encodedUri = encodeURI(csvContent) + const link = Object.assign(document.createElement('a'), { + href: encodedUri, + download: `weekly-progress-matrix-${new Date().toISOString().slice(0, 10)}.csv` + }) + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + } + + if (!model) { + return ( +
+ +

Please search and select an organization first...

+
+ ) + } + + return ( +
+
+ +
+ + + Export Progress CSV + + } + /> + + {/* Configuration Header Card */} +
+
+
+ + Matrix Window Configurator +
+
+ + +
+
+ +
+
+ + setStartDate(e.target.value)} + style={C.input} + /> +
+ to +
+ + setEndDate(e.target.value)} + style={C.input} + /> +
+
+
+ + {/* Main Workspace Layout */} +
+ + {/* Left Panel: Contributor List Selector */} +
+
+ Select Contributors + + {selectedLogins.size} + +
+ +
+ + + +
+ + setSearchFilter(e.target.value)} + style={{ ...C.input, width: '100%', padding: '6px 10px', fontSize: 12, marginBottom: 12 }} + /> + +
+ {filteredContributors.map(c => { + const checked = selectedLogins.has(c.login) + return ( +
toggleSelect(c.login)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + toggleSelect(c.login) + } + }} + role="checkbox" + tabIndex={0} + aria-checked={checked} + style={{ + display: 'flex', alignItems: 'center', gap: 8, padding: '6px 8px', + borderRadius: 4, cursor: 'pointer', fontSize: 12, + background: checked ? 'var(--surface2)' : 'transparent', + outline: 'none' + }} + className="hover:bg-(--surface2) focus:bg-(--surface2) transition" + > + {checked ? : } + + {c.login} + +
+ ) + })} +
+
+ + {/* Right Panel: Matrix Grid */} +
+ {selectedLogins.size === 0 ? ( +
+ +

Please select at least one contributor in the sidebar panel to view progress.

+
+ ) : weeks.length === 0 ? ( +
+ +

Invalid date range. Check start/end configurations.

+
+ ) : ( + + + + + {weeks.map(w => ( + + ))} + + + + {Array.from(selectedLogins).map((login, index) => { + const contrib = contributors.find(c => c.login === login) + const isLoading = loadingMap[login] + const hasErr = errorMap[login] + const dataItems = cache[login] || [] + + return ( + + + + {weeks.map(w => { + if (isLoading) { + return ( + + ) + } + + if (hasErr) { + return ( + + ) + } + + const stats = getWeeklyStats(login, w) + const total = stats.merged + stats.open + stats.issues + + return ( + + ) + })} + + ) + })} + +
CONTRIBUTOR +
Week {w.number}
+ + {w.start.toLocaleString('default', { month: 'short', day: 'numeric' })} + +
+ {contrib?.avatar_url ? ( + {login} + ) : ( +
+ )} +
+
{login}
+ {dataItems.length} elements +
+
+ + + {hasErr} + 0 ? 'rgba(59,130,246,.02)' : 'transparent' + }} + > + {total > 0 ? ( + + ) : ( + - + )} +
+ )} +
+
+ + {/* Weekly Details Modal Popup */} + {modalData && ( +
{ + setModalData(null) + triggerRef.current?.focus() + }} + style={{ + position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, + background: 'rgba(0,0,0,.6)', zIndex: 1000, + display: 'flex', alignItems: 'center', justifyContent: 'center', + backdropFilter: 'blur(4px)' + }} + > +
e.stopPropagation()} + role="dialog" + aria-modal="true" + aria-label={`Weekly Contributions Details for ${modalData.contributor}`} + style={{ + ...C.card, width: '100%', maxWidth: 700, maxHeight: '80vh', + display: 'flex', flexDirection: 'column', gap: 16, padding: 24, + boxShadow: '0 10px 30px rgba(0,0,0,.3)', border: '1px solid var(--border)' + }} + className="fade-up" + > + +
+
+

Weekly Contributions

+ + @{modalData.contributor} • Week {modalData.week} ({modalData.range}) + +
+ +
+ +
+ {modalData.items.map(item => { + const type = item.isPR ? (item.isMerged ? 'Merged PR' : item.state === 'open' ? 'Open PR' : 'Closed PR') : 'Issue' + const typeColor = type.includes('Merged') ? 'var(--green)' : type.includes('Open') ? 'var(--blue)' : 'var(--amber)' + const typeBg = type.includes('Merged') ? 'rgba(34,197,94,.1)' : type.includes('Open') ? 'rgba(59,130,246,.1)' : 'rgba(245,158,11,.1)' + + return ( +
+
+
+ {type.toUpperCase()} + {item.repoName} #{item.number} +
+
{item.title}
+
+ + GitHub + +
+ ) + })} +
+
+
+ )} +
+ ) +}