diff --git a/src/context/AppContext.jsx b/src/context/AppContext.jsx index 66f7de0..b6236c2 100644 --- a/src/context/AppContext.jsx +++ b/src/context/AppContext.jsx @@ -41,17 +41,24 @@ export function AppProvider({ children }) { const [advanceAnalyticsComplete, setAdvanceAnalyticsComplete] = useState(false) 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 [lastOrgNames, setLastOrgNames] = useState(() => { + try { + const stored = localStorage.getItem('oe_active_orgs') + if (!stored) return [] + const parsed = JSON.parse(stored) + if (Array.isArray(parsed)) { + return parsed.filter(item => typeof item === 'string' && item.trim().length > 0) + } + return [] + } catch { + return [] + } + }) + 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. + // Restore the last analysis on startup from IndexedDB useEffect(() => { let cancelled = false @@ -65,7 +72,9 @@ export function AppProvider({ children }) { setModel(cached.model) setTotalRepo(cached.totalRepo || 0) setIsComplete(!!cached.isComplete) - setLastOrgNames(cached.lastOrgNames || []) + if (cached.lastOrgNames?.length) { + setLastOrgNames(cached.lastOrgNames) + } setIssuesData(cached.issuesData || {}) setPullsData(cached.pullsData || {}) setAuditComplete(!!cached.auditComplete) @@ -78,12 +87,10 @@ export function AppProvider({ children }) { return () => { cancelled = true } }, []) - // Persist the analysis whenever it changes, including audit and analytics - // results — those are the most expensive data to refetch. + // Persist the analysis whenever it changes useEffect(() => { if (hydrating || !model) return - // Skip the write that would immediately follow a restore. if (restoredFromCache.current) { restoredFromCache.current = false return @@ -98,10 +105,18 @@ export function AppProvider({ children }) { issuesData, pullsData, auditComplete, advanceAnalyticsComplete ]) + useEffect(() => { + if (!hydrating && lastOrgNames.length > 0 && !model && !loading) { + explore(lastOrgNames) + } + }, [hydrating]) + useEffect(() => { const handler = e => { setRateLimit(e.detail) - localStorage.setItem('oe_rate_limit', JSON.stringify(e.detail)) + try { + localStorage.setItem('oe_rate_limit', JSON.stringify(e.detail)) + } catch {} } window.addEventListener('rate-limit-update', handler) @@ -115,7 +130,9 @@ export function AppProvider({ children }) { if (!rateLimit?.reset) return const timeout = setTimeout(() => { - localStorage.removeItem('oe_rate_limit') + try { + localStorage.removeItem('oe_rate_limit') + } catch {} setRateLimit(null) }, Math.max(0, rateLimit.reset * 1000 - Date.now())) @@ -132,7 +149,9 @@ export function AppProvider({ children }) { }, [pat]) const savePat = useCallback(token => { setPat(token) - token ? localStorage.setItem('oe_pat', token) : localStorage.removeItem('oe_pat') + try { + token ? localStorage.setItem('oe_pat', token) : localStorage.removeItem('oe_pat') + } catch {} }, []) // Multi-org explore @@ -143,6 +162,11 @@ export function AppProvider({ children }) { setOrgs([]); setIssuesData({}); setLastOrgNames(orgNames); + if (orgNames?.length) { + try { + localStorage.setItem('oe_active_orgs', JSON.stringify(orgNames)) + } catch {} + } setAuditComplete(false); setAdvanceAnalyticsComplete(false); try { @@ -183,10 +207,12 @@ export function AppProvider({ children }) { setIsComplete(!!pat) - // Save to recent searches - const prev = JSON.parse(localStorage.getItem('oe_recent') || '[]') - const entry = orgNames.join(', ') - localStorage.setItem('oe_recent', JSON.stringify([...new Set([entry, ...prev])].slice(0, 6))) + // Save to recent searches (best-effort) + try { + const prev = JSON.parse(localStorage.getItem('oe_recent') || '[]') + const entry = orgNames.join(', ') + localStorage.setItem('oe_recent', JSON.stringify([...new Set([entry, ...prev])].slice(0, 6))) + } catch {} return builtModel } catch (err) { setError(err.message === 'RATE_LIMIT' diff --git a/src/context/AppContext.test.jsx b/src/context/AppContext.test.jsx new file mode 100644 index 0000000..744fcb0 --- /dev/null +++ b/src/context/AppContext.test.jsx @@ -0,0 +1,85 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { renderHook, act } from '@testing-library/react' +import { AppProvider, useApp } from './AppContext' + +// Mock services to avoid network requests +vi.mock('../services/github', () => ({ + fetchOrg: vi.fn().mockResolvedValue({ login: 'test-org', public_repos: 5 }), + fetchRepos: vi.fn().mockResolvedValue([{ name: 'test-repo', orgLogin: 'test-org' }]), + fetchContributors: vi.fn().mockResolvedValue([]), + fetchIssues: vi.fn().mockResolvedValue([]), + fetchRateLimit: vi.fn().mockResolvedValue(null), + fetchPulls: vi.fn().mockResolvedValue([]) +})) + +vi.mock('../services/analytics', () => ({ + buildAnalyticalModel: vi.fn().mockReturnValue({ allRepos: [], totalRepos: [] }), + getTopRepositories: vi.fn().mockImplementation(repos => repos) +})) + +describe('AppContext - localStorage safety and validation', () => { + beforeEach(() => { + localStorage.clear() + vi.restoreAllMocks() + }) + + it('handles invalid or non-array oe_active_orgs gracefully', async () => { + // Test with string value + localStorage.setItem('oe_active_orgs', JSON.stringify('invalid_string')) + const { result: res1 } = renderHook(() => useApp(), { wrapper: AppProvider }) + expect(res1.current.lastOrgNames).toEqual([]) + + // Test with null + localStorage.setItem('oe_active_orgs', JSON.stringify(null)) + const { result: res2 } = renderHook(() => useApp(), { wrapper: AppProvider }) + expect(res2.current.lastOrgNames).toEqual([]) + + // Test with mixed array including invalid items + localStorage.setItem('oe_active_orgs', JSON.stringify(['valid-org', null, 123, ' ', 'another-org'])) + let res3 + await act(async () => { + res3 = renderHook(() => useApp(), { wrapper: AppProvider }) + }) + expect(res3.result.current.lastOrgNames).toEqual(['valid-org', 'another-org']) + }) + + it('isolates localStorage setItem failure in explore when saving oe_active_orgs', async () => { + const originalSetItem = localStorage.setItem + vi.spyOn(Storage.prototype, 'setItem').mockImplementation((key, val) => { + if (key === 'oe_active_orgs') { + throw new Error('QuotaExceededError') + } + return originalSetItem.call(localStorage, key, val) + }) + + const { result } = renderHook(() => useApp(), { wrapper: AppProvider }) + + let modelResult + await act(async () => { + modelResult = await result.current.explore(['test-org']) + }) + + expect(modelResult).toBeTruthy() + expect(result.current.orgs).toHaveLength(1) + }) + + it('isolates localStorage setItem failure in explore when saving oe_recent', async () => { + const originalSetItem = localStorage.setItem + vi.spyOn(Storage.prototype, 'setItem').mockImplementation((key, val) => { + if (key === 'oe_recent') { + throw new Error('QuotaExceededError') + } + return originalSetItem.call(localStorage, key, val) + }) + + const { result } = renderHook(() => useApp(), { wrapper: AppProvider }) + + let modelResult + await act(async () => { + modelResult = await result.current.explore(['test-org']) + }) + + expect(modelResult).toBeTruthy() + expect(result.current.error).toBe('') + }) +}) diff --git a/src/pages/AnalyticsPage.jsx b/src/pages/AnalyticsPage.jsx index 5b514a9..cdad998 100644 --- a/src/pages/AnalyticsPage.jsx +++ b/src/pages/AnalyticsPage.jsx @@ -1,6 +1,7 @@ import React, { useState, useMemo } from 'react' +import { useNavigate } from 'react-router-dom' import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, PieChart, Pie, Cell, RadialBarChart, RadialBar, PolarAngleAxis } from 'recharts' -import { FiDownload, FiRefreshCw } from 'react-icons/fi' +import { FiDownload, FiRefreshCw, FiDatabase } from 'react-icons/fi' import { useApp } from '../context/AppContext' import { C, PageTitle, InfoBox } from '../components/UI' import { buildTimeSeries, exportTrendsCSV } from '../services/analytics' @@ -9,6 +10,7 @@ import { IoChevronDown } from 'react-icons/io5' import { HiCheck, HiOutlineClock } from 'react-icons/hi' import { useAdvancedMetrics } from '../hooks/useSortedData' import AnalysisBanner from '../components/AnalysisBanner' +import EmptyStateCard from '../components/EmptyStateCard' import { AnalyticsSkeleton } from '../components/Orgexplorerskeletons' const TOOLTIP_STYLE = { @@ -23,6 +25,7 @@ const TOOLTIP_STYLE = { } export default function AnalyticsPage() { + const navigate = useNavigate() const { model, issuesData, runAudit, govLoading, runAdvanceAnalytics, advanceAnalyticsLoading, advanceAnalyticsComplete, runFullAnalytics, pullsData, auditComplete, loading, runGovernanceAnalysis, pat } = useApp() const [granularity, setGranularity] = useState('monthly') @@ -55,7 +58,19 @@ export default function AnalyticsPage() { const advancedMetrics = useAdvancedMetrics(filteredPulls) if(loading) return - if (!model) return null + if (!model) { + return ( +
+ } + title="No Organization Analyzed" + description="Explore an organization on the home page to view velocity and trend analytics." + buttonText="Go to Home" + onButtonClick={() => navigate('/')} + /> +
+ ) + } const acceptanceChart = [ { diff --git a/src/pages/ContributorsPage.jsx b/src/pages/ContributorsPage.jsx index e89fd97..bf0921a 100644 --- a/src/pages/ContributorsPage.jsx +++ b/src/pages/ContributorsPage.jsx @@ -58,7 +58,19 @@ export default function ContributorsPage() { const visible = sorted.slice(0, shown) if(loading) return - if (!model) return null + if (!model) { + return ( +
+ } + title="No Organization Analyzed" + description="Explore an organization on the home page to view contributor data." + buttonText="Go to Home" + onButtonClick={() => navigate('/')} + /> +
+ ) + } const topActive = contributors.slice(0, 10).filter(c => c.freshness > 50).length const freshPct = contributors.length ? Math.round(topActive / Math.min(10, contributors.length) * 100) : 0 diff --git a/src/pages/GovernancePage.jsx b/src/pages/GovernancePage.jsx index 7a209f6..2926755 100644 --- a/src/pages/GovernancePage.jsx +++ b/src/pages/GovernancePage.jsx @@ -1,8 +1,10 @@ import React, { useState, useMemo } from 'react' -import { FiRefreshCw, FiExternalLink } from 'react-icons/fi' +import { useNavigate } from 'react-router-dom' +import { FiRefreshCw, FiExternalLink, FiDatabase } from 'react-icons/fi' import { useApp } from '../context/AppContext' import { C, PageTitle, EmptyOk } from '../components/UI' import AnalysisBanner from '../components/AnalysisBanner' +import EmptyStateCard from '../components/EmptyStateCard' import { GovernanceSkeleton } from '../components/Orgexplorerskeletons' const TABS = [ @@ -42,16 +44,17 @@ const getStatus = ratio => { } export default function GovernancePage() { - const { model, issuesData, runAudit, govLoading, auditComplete, loading, runGovernanceAnalysis,staleRepoStats } = useApp() + const navigate = useNavigate() + const { model, issuesData, runAudit, govLoading, auditComplete, loading, runGovernanceAnalysis, staleRepoStats } = useApp() const [tab, setTab] = useState('dead') const ITEMS_PER_PAGE = 10 const [stalePage, setStalePage] = useState(1) - const totalPages = Math.ceil(staleRepoStats.length / ITEMS_PER_PAGE) + const totalPages = Math.ceil((staleRepoStats?.length || 0) / ITEMS_PER_PAGE) const paginatedStaleRepos = useMemo(() => { const start = (stalePage - 1) * ITEMS_PER_PAGE - return staleRepoStats.slice(start, start + ITEMS_PER_PAGE) + return (staleRepoStats || []).slice(start, start + ITEMS_PER_PAGE) }, [staleRepoStats, stalePage]) // Flatten all issues and tag with repo/org const allIssues = useMemo(() => { @@ -63,8 +66,20 @@ export default function GovernancePage() { return arr }, [issuesData]) - if(loading) return - if (!model) return null + if (loading) return + if (!model) { + return ( +
+ } + title="No Organization Analyzed" + description="Explore an organization on the home page to view governance audit insights." + buttonText="Go to Home" + onButtonClick={() => navigate('/')} + /> +
+ ) + } const hasAudit = Object.keys(issuesData || {}).length > 0 const daysSince = d => Math.floor((Date.now() - new Date(d)) / 86_400_000) diff --git a/src/pages/NetworkPage.jsx b/src/pages/NetworkPage.jsx index eb26e57..39bd361 100644 --- a/src/pages/NetworkPage.jsx +++ b/src/pages/NetworkPage.jsx @@ -169,10 +169,20 @@ export default function NetworkPage() { }, [model, showRepos, showContribs]) const navigate = useNavigate() - if(loading) return - // 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 + if (loading) return + if (!model) { + return ( +
+ } + title="No Organization Analyzed" + description="Explore an organization on the home page to view network graph relationships." + buttonText="Go to Home" + onButtonClick={() => navigate('/')} + /> +
+ ) + } return (
diff --git a/src/pages/OverviewPage.jsx b/src/pages/OverviewPage.jsx index cd32def..89769f3 100644 --- a/src/pages/OverviewPage.jsx +++ b/src/pages/OverviewPage.jsx @@ -1,11 +1,12 @@ import React, { useEffect, useState, useRef } from 'react' import { useNavigate } from 'react-router-dom' -import { FiExternalLink, FiShare2, FiArrowRight } from 'react-icons/fi' +import { FiExternalLink, FiShare2, FiArrowRight, FiDatabase } from 'react-icons/fi' import { useApp } from '../context/AppContext' import { C, StatCard, HealthBar } from '../components/UI' import SocialShareButton from '../components/SocialShareButton'; import { AiOutlineInfoCircle } from "react-icons/ai"; import AnalysisBanner from '../components/AnalysisBanner' +import EmptyStateCard from '../components/EmptyStateCard' import { OverviewSkeleton } from '../components/Orgexplorerskeletons' import {formatNumber} from '../utils/formatNumber' import { useTheme } from '../context/ThemeContext' @@ -33,7 +34,19 @@ export default function OverviewPage() { }, []) if(loading) return - if (!model) return null + if (!model) { + return ( +
+ } + title="No Organization Analyzed" + description="Explore an organization on the home page to view overview analytics." + buttonText="Go to Home" + onButtonClick={() => navigate('/')} + /> +
+ ) + } const { totalRepos } = model const isMulti = orgs.length > 1 diff --git a/src/pages/RepositoriesPage.jsx b/src/pages/RepositoriesPage.jsx index f5884f4..ac2ad82 100644 --- a/src/pages/RepositoriesPage.jsx +++ b/src/pages/RepositoriesPage.jsx @@ -55,7 +55,19 @@ export default function RepositoriesPage() { const visible = sorted.slice(0, shown) if(loading) return - if (!model) return null + if (!model) { + return ( +
+ } + title="No Organization Analyzed" + description="Explore an organization on the home page to view repository data." + buttonText="Go to Home" + onButtonClick={() => navigate('/')} + /> +
+ ) + } const TABLE_COLS = [ ['name', 'Repository'],