diff --git a/README.md b/README.md index 2201f2c9..9449079e 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,9 @@ - **Fully Browser-Based** — Runs entirely in the browser with GitHub's REST API. No backend server required. - **Organization Dashboard** — Overview of repositories, stars, contributors, creation timeline, tech stack, and activity metrics. + The creation timeline groups loaded repositories by UTC month or year, follows the organization filter, + and shows period counts and cumulative totals on hover. These totals cover loaded repositories; + deleted repositories and complete historical totals are not represented. - **Repository Analytics** — Deep insights into repository health, commit frequency, contributor density, issue trends, and language composition. diff --git a/src/components/RepositoryCreationTimeline.jsx b/src/components/RepositoryCreationTimeline.jsx new file mode 100644 index 00000000..87f31a7b --- /dev/null +++ b/src/components/RepositoryCreationTimeline.jsx @@ -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 ( +
+ {period} +

Repositories created: {formatNumber(created)}

+

Cumulative loaded repositories: {formatNumber(cumulative)}

+
+ ) +} + +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 ( +
+
+

Repository Creation Timeline

+ +
+

+ 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. + + )} +

+ {timeline.length ? ( + <> + + + + + + } cursor={{ fill: 'var(--surface2)' }} /> + + + +
+ View timeline data +
+ + + + + + + + + + + {timeline.map(row => ( + + + + + + ))} + +
Repository creation counts (UTC)
PeriodCreatedCumulative loaded repositories
{row.period}{formatNumber(row.created)}{formatNumber(row.cumulative)}
+
+
+ + ) : ( +

+ No repository creation dates available for this selection. +

+ )} +
+ ) +} diff --git a/src/pages/OverviewPage.jsx b/src/pages/OverviewPage.jsx index 64a69894..b4bbe2be 100644 --- a/src/pages/OverviewPage.jsx +++ b/src/pages/OverviewPage.jsx @@ -9,6 +9,7 @@ import AnalysisBanner from '../components/AnalysisBanner' import { OverviewSkeleton } from '../components/Orgexplorerskeletons' import {formatNumber} from '../utils/formatNumber' import { useTheme } from '../context/ThemeContext' +import RepositoryCreationTimeline from '../components/RepositoryCreationTimeline' const LANG_COLORS = ['#22c55e', '#f5c518', '#3b82f6', '#ef4444', '#a855f7', '#f97316', '#06b6d4'] const fmt = n => n > 999 ? (n / 1000).toFixed(1) + 'k' : String(n) @@ -218,6 +219,8 @@ export default function OverviewPage() { /> + + {/* Language + top repos */}
diff --git a/src/pages/OverviewPage.test.jsx b/src/pages/OverviewPage.test.jsx new file mode 100644 index 00000000..fc3aee74 --- /dev/null +++ b/src/pages/OverviewPage.test.jsx @@ -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() + 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() + expect(screen.queryByLabelText('Repository creation timeline')).not.toBeInTheDocument() + + app.state.loading = false + rerender() + expect(screen.getByLabelText('Repository creation timeline')).toBeInTheDocument() + + app.state.model = null + rerender() + expect(screen.queryByLabelText('Repository creation timeline')).not.toBeInTheDocument() + }) + + it('updates when a new analysis replaces the loaded repositories', async () => { + const { rerender } = render() + 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() + 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() + 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() + 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() + 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( + + ) + expect(screen.getByText('2026-01')).toBeInTheDocument() + expect(screen.getByText('Repositories created: 0')).toBeInTheDocument() + expect(screen.getByText('Cumulative loaded repositories: 12')).toBeInTheDocument() + rerender() + expect(screen.queryByText('2026-01')).not.toBeInTheDocument() + }) +}) diff --git a/src/utils/repositoryCreationTimeline.js b/src/utils/repositoryCreationTimeline.js new file mode 100644 index 00000000..77fa302d --- /dev/null +++ b/src/utils/repositoryCreationTimeline.js @@ -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 +} diff --git a/src/utils/repositoryCreationTimeline.test.js b/src/utils/repositoryCreationTimeline.test.js new file mode 100644 index 00000000..09c1ef95 --- /dev/null +++ b/src/utils/repositoryCreationTimeline.test.js @@ -0,0 +1,60 @@ +import { describe, it, expect } from 'vitest' +import { buildRepositoryCreationTimeline } from './repositoryCreationTimeline' + +const repo = createdAt => ({ created_at: createdAt }) + +describe('buildRepositoryCreationTimeline', () => { + it('sorts unsorted repositories, counts each period and includes quiet years', () => { + const repos = [ + repo('2026-03-02T00:00:00Z'), + repo('2024-12-31T23:00:00Z'), + repo('2026-01-01T00:00:00Z'), + ] + const original = [...repos] + + expect(buildRepositoryCreationTimeline(repos)).toEqual([ + { period: '2024', created: 1, cumulative: 1 }, + { period: '2025', created: 0, cumulative: 1 }, + { period: '2026', created: 2, cumulative: 3 }, + ]) + expect(repos).toEqual(original) + }) + + it('fills monthly gaps across a year boundary without resetting the cumulative total', () => { + expect(buildRepositoryCreationTimeline([ + repo('2025-12-01T00:00:00Z'), repo('2026-02-01T00:00:00Z'), + ], 'monthly')).toEqual([ + { period: '2025-12', created: 1, cumulative: 1 }, + { period: '2026-01', created: 0, cumulative: 1 }, + { period: '2026-02', created: 1, cumulative: 2 }, + ]) + }) + + it('uses UTC rather than the timestamp offset for month and year boundaries', () => { + const repos = [repo('2026-01-01T00:30:00+05:30')] + expect(buildRepositoryCreationTimeline(repos, 'monthly')).toEqual([ + { period: '2025-12', created: 1, cumulative: 1 }, + ]) + expect(buildRepositoryCreationTimeline(repos)).toEqual([ + { period: '2025', created: 1, cumulative: 1 }, + ]) + }) + + it('ignores absent, empty, invalid and non-string dates', () => { + const invalid = [repo(null), repo(undefined), repo(''), repo(' '), repo('bad-date'), repo(0)] + expect(buildRepositoryCreationTimeline(invalid)).toEqual([]) + expect(buildRepositoryCreationTimeline([...invalid, repo('2026-01-01T00:00:00Z')])).toEqual([ + { period: '2026', created: 1, cumulative: 1 }, + ]) + expect(buildRepositoryCreationTimeline([])).toEqual([]) + }) + + it('preserves the full loaded history rather than limiting the chart to recent periods', () => { + const result = buildRepositoryCreationTimeline([ + repo('2024-01-01T00:00:00Z'), repo('2026-03-01T00:00:00Z'), + ], 'monthly') + expect(result).toHaveLength(27) + expect(result[0]).toEqual({ period: '2024-01', created: 1, cumulative: 1 }) + expect(result.at(-1)).toEqual({ period: '2026-03', created: 1, cumulative: 2 }) + }) +})