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