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