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.
+
+ ) : (
+
+
+
+ | CONTRIBUTOR |
+ {weeks.map(w => (
+
+ Week {w.number}
+
+ {w.start.toLocaleString('default', { month: 'short', day: 'numeric' })}
+
+ |
+ ))}
+
+
+
+ {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 (
+
+
+ {contrib?.avatar_url ? (
+
+ ) : (
+
+ )}
+
+ {login}
+ {dataItems.length} elements
+
+ |
+
+ {weeks.map(w => {
+ if (isLoading) {
+ return (
+
+
+ |
+ )
+ }
+
+ if (hasErr) {
+ return (
+
+ {hasErr}
+ |
+ )
+ }
+
+ const stats = getWeeklyStats(login, w)
+ const total = stats.merged + stats.open + stats.issues
+
+ return (
+ 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
+
+
+ )
+ })}
+
+
+
+ )}
+
+ )
+}