Repository navigation
feat: add repository creation timeline to overview dashboard #355
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
Merged
Ri1tik
merged 2 commits into
AOSSIE-Org:main
from
vianmangal:codex/repository-creation-timeline
Oct 25, 2026
Merged
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,119 @@ | ||
| import React, { useMemo, useState } from 'react' | ||
| import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts' | ||
| import { C } from './UI' | ||
| import { buildRepositoryCreationTimeline } from '../utils/repositoryCreationTimeline' | ||
| import { formatNumber } from '../utils/formatNumber' | ||
|
|
||
| export function CreationTooltip({ active, payload }) { | ||
| if (!active || !payload?.length) return null | ||
| const { period, created, cumulative } = payload[0].payload | ||
| return ( | ||
| <div style={{ ...C.card, padding: 12, fontSize: 12 }}> | ||
| <strong>{period}</strong> | ||
| <p>Repositories created: {formatNumber(created)}</p> | ||
| <p>Cumulative loaded repositories: {formatNumber(cumulative)}</p> | ||
| </div> | ||
| ) | ||
| } | ||
|
|
||
| export default function RepositoryCreationTimeline({ repos }) { | ||
| const [granularity, setGranularity] = useState('yearly') | ||
| const timeline = useMemo( | ||
| () => buildRepositoryCreationTimeline(repos, granularity), | ||
| [repos, granularity] | ||
| ) | ||
| const datedRepos = timeline.at(-1)?.cumulative || 0 | ||
| const excludedRepos = repos.length - datedRepos | ||
|
|
||
| return ( | ||
| <section | ||
| aria-label="Repository creation timeline" | ||
| style={{ ...C.card, marginBottom: 20, minWidth: 0 }} | ||
| > | ||
| <div style={{ | ||
| display: 'flex', | ||
| justifyContent: 'space-between', | ||
| alignItems: 'center', | ||
| flexWrap: 'wrap', | ||
| gap: 12, | ||
| marginBottom: 12, | ||
| }}> | ||
| <h2 style={{ fontSize: 16, fontWeight: 600 }}>Repository Creation Timeline</h2> | ||
| <label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12 }}> | ||
| Group by | ||
| <select | ||
| value={granularity} | ||
| onChange={e => setGranularity(e.target.value)} | ||
| style={{ ...C.select, outline: 'revert' }} | ||
| > | ||
| <option value="yearly">Year</option> | ||
| <option value="monthly">Month</option> | ||
| </select> | ||
| </label> | ||
| </div> | ||
| <p style={{ fontSize: 12, color: 'var(--text2)', marginBottom: 16 }}> | ||
| Based on {formatNumber(repos.length)} loaded {repos.length === 1 ? 'repository' : 'repositories'} | ||
| {' '}in the selected organization scope. | ||
| {' '}This is not a complete historical total and does not include deleted repositories. | ||
| {excludedRepos > 0 && ( | ||
| <> | ||
| {' '}{formatNumber(excludedRepos)} {excludedRepos === 1 | ||
| ? 'repository without a valid creation date is' | ||
| : 'repositories without valid creation dates are'} excluded. | ||
| </> | ||
| )} | ||
| </p> | ||
| {timeline.length ? ( | ||
| <> | ||
| <ResponsiveContainer width="100%" height={260}> | ||
| <BarChart data={timeline} accessibilityLayer margin={{ top: 8, right: 12, bottom: 8, left: 0 }}> | ||
| <CartesianGrid stroke="var(--border)" vertical={false} /> | ||
| <XAxis dataKey="period" tick={{ fill: 'var(--text2)', fontSize: 11 }} minTickGap={24} /> | ||
| <YAxis allowDecimals={false} tick={{ fill: 'var(--text2)', fontSize: 11 }} width={44} /> | ||
| <Tooltip content={<CreationTooltip />} cursor={{ fill: 'var(--surface2)' }} /> | ||
| <Bar | ||
| dataKey="created" | ||
| name="Repositories created" | ||
| fill="var(--accent)" | ||
| radius={[3, 3, 0, 0]} | ||
| maxBarSize={48} | ||
| isAnimationActive={false} | ||
| /> | ||
| </BarChart> | ||
| </ResponsiveContainer> | ||
| <details style={{ marginTop: 12, fontSize: 12 }}> | ||
| <summary style={{ cursor: 'pointer', color: 'var(--text2)' }}>View timeline data</summary> | ||
| <div style={{ maxHeight: 240, overflow: 'auto', marginTop: 12 }}> | ||
| <table style={{ width: '100%', textAlign: 'left', borderCollapse: 'collapse' }}> | ||
| <caption style={{ textAlign: 'left', marginBottom: 8 }}>Repository creation counts (UTC)</caption> | ||
| <thead> | ||
| <tr> | ||
| <th scope="col">Period</th> | ||
| <th scope="col">Created</th> | ||
| <th scope="col">Cumulative loaded repositories</th> | ||
| </tr> | ||
| </thead> | ||
| <tbody> | ||
| {timeline.map(row => ( | ||
| <tr key={row.period} style={{ borderTop: '1px solid var(--border)' }}> | ||
| <th scope="row" style={{ padding: '6px 0', fontWeight: 400 }}>{row.period}</th> | ||
| <td>{formatNumber(row.created)}</td> | ||
| <td>{formatNumber(row.cumulative)}</td> | ||
| </tr> | ||
| ))} | ||
| </tbody> | ||
| </table> | ||
| </div> | ||
| </details> | ||
| </> | ||
| ) : ( | ||
| <p | ||
| role="status" | ||
| style={{ color: 'var(--text2)', padding: '32px 0', textAlign: 'center', fontSize: 13 }} | ||
| > | ||
| No repository creation dates available for this selection. | ||
| </p> | ||
| )} | ||
| </section> | ||
| ) | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,168 @@ | ||
| import React, { cloneElement } from 'react' | ||
| import { describe, it, expect, vi, beforeEach } from 'vitest' | ||
| import { render, screen, within } from '@testing-library/react' | ||
| import userEvent from '@testing-library/user-event' | ||
| import { MemoryRouter } from 'react-router-dom' | ||
| import OverviewPage from './OverviewPage' | ||
| import RepositoryCreationTimeline, { CreationTooltip } from '../components/RepositoryCreationTimeline' | ||
|
|
||
| const app = vi.hoisted(() => ({ state: {} })) | ||
| vi.mock('../context/AppContext', () => ({ useApp: () => app.state })) | ||
| vi.mock('../context/ThemeContext', () => ({ useTheme: () => ({ theme: 'dark' }) })) | ||
| vi.mock('../components/AnalysisBanner', () => ({ default: () => null })) | ||
| vi.mock('../components/SocialShareButton', () => ({ default: () => null })) | ||
| vi.mock('recharts', async importOriginal => ({ | ||
| ...await importOriginal(), | ||
| // jsdom has no layout; keep the real chart and give it a measurable size. | ||
| ResponsiveContainer: ({ children }) => cloneElement(children, { width: 800, height: 260 }), | ||
| })) | ||
|
|
||
| function readRows() { | ||
| return within(screen.getByRole('table', { hidden: true })) | ||
| .getAllByRole('row', { hidden: true }).slice(1) | ||
| .map(row => [...row.children].map(cell => cell.textContent)) | ||
| } | ||
|
|
||
| describe('Overview repository creation timeline', () => { | ||
| beforeEach(() => { | ||
| const totalRepos = [ | ||
| { id: 1, orgLogin: 'alpha', created_at: '2025-12-01T00:00:00Z' }, | ||
| { id: 2, orgLogin: 'alpha', created_at: '2026-02-01T00:00:00Z' }, | ||
| { id: 3, orgLogin: 'beta', created_at: '2026-02-05T00:00:00Z' }, | ||
| ].map(repo => ({ | ||
| ...repo, | ||
| name: `repo-${repo.id}`, | ||
| healthScore: 50, | ||
| stargazers_count: 0, | ||
| forks_count: 0, | ||
| })) | ||
| app.state = { | ||
| orgs: [{ login: 'alpha' }, { login: 'beta' }], | ||
| // Contributor analysis may contain fewer repositories than the loaded list. | ||
| model: { totalRepos, allRepos: [totalRepos[0]] }, | ||
| totalRepo: 500, isComplete: false, loading: false, | ||
| } | ||
| }) | ||
|
|
||
| it('uses all loaded repositories, switches periods and follows the existing organization filter', async () => { | ||
| const user = userEvent.setup() | ||
| render(<MemoryRouter><OverviewPage /></MemoryRouter>) | ||
| await user.click(screen.getByText('View timeline data')) | ||
|
|
||
| expect(readRows()).toEqual([['2025', '1', '1'], ['2026', '2', '3']]) | ||
| expect(screen.getByText(/Based on 3 loaded repositories/)).toBeInTheDocument() | ||
| expect(screen.getByText(/does not include deleted repositories/)).toBeInTheDocument() | ||
|
|
||
| await user.selectOptions(screen.getByLabelText('Group by'), 'monthly') | ||
| expect(readRows()).toEqual([ | ||
| ['2025-12', '1', '1'], ['2026-01', '0', '1'], ['2026-02', '2', '3'], | ||
| ]) | ||
|
|
||
| await user.selectOptions(screen.getByLabelText('Filter stats by organization'), 'beta') | ||
| expect(readRows()).toEqual([['2026-02', '1', '1']]) | ||
| expect(screen.getByText(/Based on 1 loaded repository/)).toBeInTheDocument() | ||
|
|
||
| await user.selectOptions(screen.getByLabelText('Filter stats by organization'), 'All Organizations') | ||
| expect(readRows().at(-1)).toEqual(['2026-02', '2', '3']) | ||
| }) | ||
|
|
||
| it('handles loading and missing models before mounting the chart', () => { | ||
| app.state.loading = true | ||
| const { rerender } = render(<MemoryRouter><OverviewPage /></MemoryRouter>) | ||
| expect(screen.queryByLabelText('Repository creation timeline')).not.toBeInTheDocument() | ||
|
|
||
| app.state.loading = false | ||
| rerender(<MemoryRouter><OverviewPage /></MemoryRouter>) | ||
| expect(screen.getByLabelText('Repository creation timeline')).toBeInTheDocument() | ||
|
|
||
| app.state.model = null | ||
| rerender(<MemoryRouter><OverviewPage /></MemoryRouter>) | ||
| expect(screen.queryByLabelText('Repository creation timeline')).not.toBeInTheDocument() | ||
| }) | ||
|
|
||
| it('updates when a new analysis replaces the loaded repositories', async () => { | ||
| const { rerender } = render(<MemoryRouter><OverviewPage /></MemoryRouter>) | ||
| await userEvent.click(screen.getByText('View timeline data')) | ||
| app.state.model = { | ||
| totalRepos: [{ id: 4, name: 'new', orgLogin: 'alpha', created_at: '2020-01-01T00:00:00Z' }], | ||
| } | ||
| app.state.isComplete = true | ||
| rerender(<MemoryRouter><OverviewPage /></MemoryRouter>) | ||
| expect(readRows()).toEqual([['2020', '1', '1']]) | ||
| }) | ||
| }) | ||
|
|
||
| describe('Timeline edge states and tooltip', () => { | ||
| it('supports keyboard focus and arrow-key tooltips before and after changing grouping', async () => { | ||
| const user = userEvent.setup() | ||
| const repos = [ | ||
| { created_at: '2025-12-01T00:00:00Z' }, | ||
| { created_at: '2026-02-01T00:00:00Z' }, | ||
| { created_at: '2026-02-05T00:00:00Z' }, | ||
| ] | ||
| render(<RepositoryCreationTimeline repos={repos} />) | ||
| const grouping = screen.getByRole('combobox', { name: 'Group by' }) | ||
| const chart = screen.getByRole('application') | ||
| const wrapper = chart.parentElement | ||
| // Recharts uses the wrapper's bounds to position keyboard-driven tooltips. | ||
| vi.spyOn(wrapper, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 0, 800, 260)) | ||
| Object.defineProperty(wrapper, 'offsetWidth', { value: 800 }) | ||
|
|
||
| await user.tab() | ||
| expect(grouping).toHaveFocus() | ||
| await user.tab() | ||
| expect(chart).toHaveFocus() | ||
| expect(await screen.findByText('2025', { selector: 'strong' })).toBeInTheDocument() | ||
| expect(screen.getByText('Repositories created: 1')).toBeInTheDocument() | ||
|
|
||
| await user.keyboard('{ArrowRight}') | ||
| expect(await screen.findByText('2026', { selector: 'strong' })).toBeInTheDocument() | ||
| expect(screen.getByText('Repositories created: 2')).toBeInTheDocument() | ||
| expect(screen.getByText('Cumulative loaded repositories: 3')).toBeInTheDocument() | ||
|
|
||
| await user.keyboard('{ArrowLeft}') | ||
| expect(await screen.findByText('2025', { selector: 'strong' })).toBeInTheDocument() | ||
| expect(screen.getByText('Cumulative loaded repositories: 1')).toBeInTheDocument() | ||
|
|
||
| await user.tab({ shift: true }) | ||
| expect(grouping).toHaveFocus() | ||
| // jsdom does not implement native select arrow-key defaults; selectOptions | ||
| // supplies the selection change while focus and chart keys remain real. | ||
| await user.selectOptions(grouping, 'monthly') | ||
| expect(grouping).toHaveValue('monthly') | ||
| await user.tab() | ||
| expect(chart).toHaveFocus() | ||
| await user.keyboard('{ArrowRight}') | ||
| expect(await screen.findByText('2026-01', { selector: 'strong' })).toBeInTheDocument() | ||
| expect(screen.getByText('Repositories created: 0')).toBeInTheDocument() | ||
| expect(screen.getByText('Cumulative loaded repositories: 1')).toBeInTheDocument() | ||
|
|
||
| await user.keyboard('{ArrowRight}') | ||
| expect(await screen.findByText('2026-02', { selector: 'strong' })).toBeInTheDocument() | ||
| expect(screen.getByText('Repositories created: 2')).toBeInTheDocument() | ||
| expect(screen.getByText('Cumulative loaded repositories: 3')).toBeInTheDocument() | ||
| }) | ||
|
|
||
| it('shows an empty state and explains omitted invalid dates', () => { | ||
| const { rerender } = render(<RepositoryCreationTimeline repos={[{ created_at: null }]} />) | ||
| expect(screen.getByRole('status')).toHaveTextContent('No repository creation dates available') | ||
| expect(screen.getByText(/1 repository without a valid creation date is excluded/)).toBeInTheDocument() | ||
| expect(screen.queryByText('View timeline data')).not.toBeInTheDocument() | ||
| rerender(<RepositoryCreationTimeline repos={[]} />) | ||
| expect(screen.getByText(/Based on 0 loaded repositories/)).toBeInTheDocument() | ||
| }) | ||
|
|
||
| it('shows period count and cumulative loaded count in the tooltip, including zero-count periods', () => { | ||
| const { rerender } = render( | ||
| <CreationTooltip | ||
| active | ||
| payload={[{ payload: { period: '2026-01', created: 0, cumulative: 12 } }]} | ||
| /> | ||
| ) | ||
| expect(screen.getByText('2026-01')).toBeInTheDocument() | ||
| expect(screen.getByText('Repositories created: 0')).toBeInTheDocument() | ||
| expect(screen.getByText('Cumulative loaded repositories: 12')).toBeInTheDocument() | ||
| rerender(<CreationTooltip active={false} payload={[]} />) | ||
| expect(screen.queryByText('2026-01')).not.toBeInTheDocument() | ||
| }) | ||
| }) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| export function buildRepositoryCreationTimeline(repos, granularity = 'yearly') { | ||
| const monthly = granularity === 'monthly' | ||
| const counts = new Map() | ||
|
|
||
| for (const repo of repos) { | ||
| if (typeof repo.created_at !== 'string' || !repo.created_at.trim()) continue | ||
| const date = new Date(repo.created_at) | ||
| if (!Number.isFinite(date.getTime())) continue | ||
|
|
||
| // UTC keeps month boundaries consistent regardless of the viewer's timezone. | ||
| const period = monthly | ||
| ? date.getUTCFullYear() * 12 + date.getUTCMonth() | ||
| : date.getUTCFullYear() | ||
| counts.set(period, (counts.get(period) || 0) + 1) | ||
| } | ||
|
|
||
| if (!counts.size) return [] | ||
|
|
||
| const periods = [...counts.keys()].sort((a, b) => a - b) | ||
| const timeline = [] | ||
| let cumulative = 0 | ||
| for (let period = periods[0]; period <= periods[periods.length - 1]; period++) { | ||
| const created = counts.get(period) || 0 | ||
| cumulative += created | ||
| const label = monthly | ||
| ? `${Math.floor(period / 12)}-${String(period % 12 + 1).padStart(2, '0')}` | ||
| : String(period) | ||
| timeline.push({ period: label, created, cumulative }) | ||
| } | ||
| return timeline | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
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.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
🔎 Supported by static analysis
🏁 Script executed:
Repository: AOSSIE-Org/OrgExplorer
Length of output: 31645
Add required keyboard accessibility coverage for the timeline.
The test file must include accessibility testing. The current tests verify the table and grouping result, but they do not verify keyboard focus or keyboard operation for the chart or the
Group byselect. Add a test that focuses the chart, exercises itsaccessibilityLayerkeyboard behavior, and reaches and operates the grouping control by keyboard.🤖 Prompt for AI Agents