Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 8 additions & 7 deletions src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import GovernancePage from './pages/GovernancePage'
import SettingsPage from './pages/SettingsPage'
import Footer from './components/layout/Footer'
import Support from './pages/Support'
import RequireAnalysis from './components/RequireAnalysis'

function Layout({ children }) {
return (
Expand All @@ -32,13 +33,13 @@ function AppContent() {
<Layout>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/overview" element={<OverviewPage />} />
<Route path="/repositories" element={<RepositoriesPage />} />
<Route path="/contributors" element={<ContributorsPage />} />
<Route path="/contributors/:username" element={<ContributorProfilePage />} />
<Route path="/network" element={<NetworkPage />} />
<Route path="/analytics" element={<AnalyticsPage />} />
<Route path="/governance" element={<GovernancePage />} />
<Route path="/overview" element={<RequireAnalysis><OverviewPage /></RequireAnalysis>} />
<Route path="/repositories" element={<RequireAnalysis><RepositoriesPage /></RequireAnalysis>} />
<Route path="/contributors" element={<RequireAnalysis><ContributorsPage /></RequireAnalysis>} />
<Route path="/contributors/:username" element={<RequireAnalysis><ContributorProfilePage /></RequireAnalysis>} />
<Route path="/network" element={<RequireAnalysis><NetworkPage /></RequireAnalysis>} />
<Route path="/analytics" element={<RequireAnalysis><AnalyticsPage /></RequireAnalysis>} />
<Route path="/governance" element={<RequireAnalysis><GovernancePage /></RequireAnalysis>} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/support-us" element={<Support />} />
<Route path="*" element={<Navigate to="/" replace />} />
Expand Down
27 changes: 27 additions & 0 deletions src/components/RequireAnalysis.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { Navigate } from 'react-router-dom'
import { useApp } from '../context/AppContext'

/**
* Guards the routes that can only render once an organization has been analyzed.
*
* The cached analysis is restored asynchronously on startup, so this waits for
* `hydrating` to settle before deciding anything — otherwise a reload would
* redirect away a fraction of a second before its own data arrived.
*
* When there is genuinely nothing to show (no cache, or an expired one), send
* people to the organization picker, which is the only place an analysis can be
* started. Previously those routes rendered an empty page between the navbar and
* the footer, and /network threw "Cannot read properties of null".
*
* `loading` keeps the children mounted while explore() refetches — it clears the
* model first, and each page shows its own skeleton during that window.
*/
export default function RequireAnalysis({ children }) {
const { model, loading, hydrating } = useApp()

if (hydrating) return null

if (!model && !loading) return <Navigate to="/" replace />

return children
}
67 changes: 67 additions & 0 deletions src/components/RequireAnalysis.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import { MemoryRouter, Routes, Route } from 'react-router-dom'
import RequireAnalysis from './RequireAnalysis'

const app = vi.hoisted(() => ({
state: { model: null, loading: false, hydrating: false },
}))

vi.mock('../context/AppContext', () => ({ useApp: () => app.state }))

function renderGuarded() {
return render(
<MemoryRouter initialEntries={['/overview']}>
<Routes>
<Route path="/" element={<div>org picker</div>} />
<Route
path="/overview"
element={<RequireAnalysis><div>analysis dashboard</div></RequireAnalysis>}
/>
</Routes>
</MemoryRouter>
)
}

describe('RequireAnalysis', () => {
beforeEach(() => {
app.state = { model: null, loading: false, hydrating: false }
})

it('redirects to the org picker when there is no analysis to show', () => {
renderGuarded()

expect(screen.getByText('org picker')).toBeInTheDocument()
expect(screen.queryByText('analysis dashboard')).not.toBeInTheDocument()
})

it('renders the page once an analysis is loaded', () => {
app.state = { model: { totalRepos: [] }, loading: false, hydrating: false }

renderGuarded()

expect(screen.getByText('analysis dashboard')).toBeInTheDocument()
})

it('waits while the cached analysis is being restored', () => {
// A reload restores from IndexedDB asynchronously — redirecting here would
// bounce the user away a moment before their own data arrived.
app.state = { model: null, loading: false, hydrating: true }

renderGuarded()

expect(screen.queryByText('org picker')).not.toBeInTheDocument()
expect(screen.queryByText('analysis dashboard')).not.toBeInTheDocument()
})

it('keeps the page mounted while explore() refetches', () => {
// explore() clears the model before refetching; the page shows its own
// skeleton during that window rather than being redirected away.
app.state = { model: null, loading: true, hydrating: false }

renderGuarded()

expect(screen.getByText('analysis dashboard')).toBeInTheDocument()
expect(screen.queryByText('org picker')).not.toBeInTheDocument()
})
})
60 changes: 58 additions & 2 deletions src/context/AppContext.jsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { createContext, useContext, useState, useCallback, useEffect, useMemo } from 'react'
import { createContext, useContext, useState, useCallback, useEffect, useMemo, useRef } from 'react'
import { fetchOrg, fetchRepos, fetchContributors, fetchIssues, fetchRateLimit, fetchPulls } from '../services/github'
import { buildAnalyticalModel, getTopRepositories } from '../services/analytics'
import { saveAnalysis, loadAnalysis } from '../services/cache'

const Ctx = createContext(null)

Expand Down Expand Up @@ -41,6 +42,61 @@ export function AppProvider({ children }) {
const [isComplete, setIsComplete] = useState(false)
const [auditComplete, setAuditComplete] = useState(false)
const [lastOrgNames, setLastOrgNames] = useState([])
// True until the cached analysis has been read, so routes that need a model
// wait for the restore instead of bouncing to the picker on first paint.
const [hydrating, setHydrating] = useState(true)
// Set when state came straight from the cache, so the write-back effect can
// skip it. Re-saving an untouched restore would stamp a fresh savedAt on
// every page load and the entry would never reach its TTL.
const restoredFromCache = useRef(false)

// Restore the last analysis on startup. The model is held in memory, so
// without this a reload, bookmark or shared link loses it entirely.
useEffect(() => {
let cancelled = false

loadAnalysis()
.then(cached => {
if (cancelled || !cached) return

restoredFromCache.current = true

setOrgs(cached.orgs || [])
setModel(cached.model)
setTotalRepo(cached.totalRepo || 0)
setIsComplete(!!cached.isComplete)
setLastOrgNames(cached.lastOrgNames || [])
setIssuesData(cached.issuesData || {})
setPullsData(cached.pullsData || {})
setAuditComplete(!!cached.auditComplete)
setAdvanceAnalyticsComplete(!!cached.advanceAnalyticsComplete)
})
.finally(() => {
if (!cancelled) setHydrating(false)
})

return () => { cancelled = true }
}, [])

// Persist the analysis whenever it changes, including audit and analytics
// results — those are the most expensive data to refetch.
useEffect(() => {
if (hydrating || !model) return

// Skip the write that would immediately follow a restore.
if (restoredFromCache.current) {
restoredFromCache.current = false
return
}

saveAnalysis({
orgs, model, totalRepo, isComplete, lastOrgNames,
issuesData, pullsData, auditComplete, advanceAnalyticsComplete
})
}, [
hydrating, orgs, model, totalRepo, isComplete, lastOrgNames,
issuesData, pullsData, auditComplete, advanceAnalyticsComplete
])

useEffect(() => {
const handler = e => {
Expand Down Expand Up @@ -327,7 +383,7 @@ export function AppProvider({ children }) {
rateLimit, loading, loadMsg, govLoading, error, totalRepo,
runAdvanceAnalytics, refreshRateLimit, advanceAnalyticsLoading, advanceAnalyticsComplete,
runFullAnalytics,
isComplete, auditComplete, lastOrgNames,
isComplete, auditComplete, lastOrgNames, hydrating,
explore, runFullExplore, runAudit, runGovernanceAnalysis, setError, staleRepoStats
}}>
{children}
Expand Down
3 changes: 3 additions & 0 deletions src/pages/NetworkPage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,9 @@ export default function NetworkPage() {

const navigate = useNavigate()
if(loading) return <NetworkSkeleton />
// Matches the guard the other data pages already have: this page reads
// model.allRepos directly and threw a TypeError without it.
if (!model) return null

return (
<div style={{ padding: '32px 24px', maxWidth: 1100, margin: '0 auto' }} className="fade-up">
Expand Down
94 changes: 94 additions & 0 deletions src/services/cache.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/**
* Persistent cache for the last analysis.
*
* The analytical model is expensive to rebuild — it costs dozens of GitHub API
* calls against a 60 req/hr unauthenticated budget — but it lived only in React
* state, so every reload discarded it. Storing it in IndexedDB lets a refresh,
* a bookmark or a shared link restore the analysis with no API calls at all.
*
* IndexedDB is used rather than localStorage because a single model can run to
* several megabytes (239 repositories for an org the size of Vercel), well past
* the ~5MB localStorage quota.
*
* Every operation is best-effort: the cache is an optimisation, so a browser
* with IndexedDB unavailable or blocked (private windows, storage disabled,
* jsdom) degrades to the previous behaviour instead of breaking the app.
*/

const DB_NAME = 'orgexplorer'
const DB_VERSION = 1
const STORE = 'analysis'
const KEY = 'latest'

/** Matches the "1HR intelligent cache" the landing page advertises. */
export const CACHE_TTL_MS = 60 * 60 * 1000

function openDb() {
return new Promise((resolve, reject) => {
if (typeof indexedDB === 'undefined') {
reject(new Error('IndexedDB unavailable'))
return
}

const req = indexedDB.open(DB_NAME, DB_VERSION)

req.onupgradeneeded = () => {
const db = req.result
if (!db.objectStoreNames.contains(STORE)) db.createObjectStore(STORE)
}
req.onsuccess = () => resolve(req.result)
req.onerror = () => reject(req.error)
req.onblocked = () => reject(new Error('IndexedDB blocked'))
})
}

function withStore(mode, run) {
return openDb().then(db =>
new Promise((resolve, reject) => {
let result
const tx = db.transaction(STORE, mode)
const req = run(tx.objectStore(STORE))

if (req) req.onsuccess = () => { result = req.result }
tx.oncomplete = () => { db.close(); resolve(result) }
tx.onerror = () => { db.close(); reject(tx.error) }
tx.onabort = () => { db.close(); reject(tx.error) }
})
)
}

/** Persist the current analysis. Never throws. */
export async function saveAnalysis(payload) {
try {
await withStore('readwrite', store => store.put({ ...payload, savedAt: Date.now() }, KEY))
} catch {
// Best effort — a full or unavailable store must not break analysis.
}
}

/** Return the cached analysis, or null when absent, stale or unreadable. */
export async function loadAnalysis() {
try {
const record = await withStore('readonly', store => store.get(KEY))

if (!record?.model) return null

if (Date.now() - record.savedAt > CACHE_TTL_MS) {
await clearAnalysis()
return null
}

return record
} catch {
return null
}
}

/** Drop the cached analysis. Never throws. */
export async function clearAnalysis() {
try {
await withStore('readwrite', store => store.delete(KEY))
} catch {
// Best effort.
}
}
Loading