From 16e41dbae9dbc5fcb11e17836766f7e8687fb374 Mon Sep 17 00:00:00 2001 From: Jitendra Rewar Date: Mon, 31 Aug 2026 00:01:11 +0530 Subject: [PATCH 1/3] fix: restore active organization state on page refresh and add empty state fallbacks --- src/context/AppContext.jsx | 18 +++++++++++++++++- src/pages/AnalyticsPage.jsx | 19 +++++++++++++++++-- src/pages/ContributorsPage.jsx | 14 +++++++++++++- src/pages/GovernancePage.jsx | 27 +++++++++++++++++++++------ src/pages/NetworkPage.jsx | 15 ++++++++++++++- src/pages/OverviewPage.jsx | 17 +++++++++++++++-- src/pages/RepositoriesPage.jsx | 14 +++++++++++++- 7 files changed, 110 insertions(+), 14 deletions(-) diff --git a/src/context/AppContext.jsx b/src/context/AppContext.jsx index a013159..217d8cc 100644 --- a/src/context/AppContext.jsx +++ b/src/context/AppContext.jsx @@ -40,7 +40,20 @@ export function AppProvider({ children }) { const [advanceAnalyticsComplete, setAdvanceAnalyticsComplete] = useState(false) const [isComplete, setIsComplete] = useState(false) const [auditComplete, setAuditComplete] = useState(false) - const [lastOrgNames, setLastOrgNames] = useState([]) + const [lastOrgNames, setLastOrgNames] = useState(() => { + try { + const stored = localStorage.getItem('oe_active_orgs') + return stored ? JSON.parse(stored) : [] + } catch { + return [] + } + }) + + useEffect(() => { + if (lastOrgNames.length > 0 && !model && !loading) { + explore(lastOrgNames) + } + }, []) useEffect(() => { const handler = e => { @@ -87,6 +100,9 @@ export function AppProvider({ children }) { setOrgs([]); setIssuesData({}); setLastOrgNames(orgNames); + if (orgNames?.length) { + localStorage.setItem('oe_active_orgs', JSON.stringify(orgNames)) + } setAuditComplete(false); setAdvanceAnalyticsComplete(false); try { 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 105e30f..39bd361 100644 --- a/src/pages/NetworkPage.jsx +++ b/src/pages/NetworkPage.jsx @@ -169,7 +169,20 @@ export default function NetworkPage() { }, [model, showRepos, showContribs]) const navigate = useNavigate() - if(loading) return + 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 12eec18..d961cad 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' @@ -31,7 +32,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'], From a03f5b4b7cf824442422d83afd8e62e2db78cb43 Mon Sep 17 00:00:00 2001 From: Jitendra Rewar Date: Mon, 31 Aug 2026 20:57:31 +0530 Subject: [PATCH 2/3] fix: validate active orgs storage restoration and isolate localStorage writes --- src/context/AppContext.jsx | 33 +++++++++---- src/context/AppContext.test.jsx | 85 +++++++++++++++++++++++++++++++++ 2 files changed, 109 insertions(+), 9 deletions(-) create mode 100644 src/context/AppContext.test.jsx diff --git a/src/context/AppContext.jsx b/src/context/AppContext.jsx index ad74145..cb7f735 100644 --- a/src/context/AppContext.jsx +++ b/src/context/AppContext.jsx @@ -44,7 +44,12 @@ export function AppProvider({ children }) { const [lastOrgNames, setLastOrgNames] = useState(() => { try { const stored = localStorage.getItem('oe_active_orgs') - return stored ? JSON.parse(stored) : [] + 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 [] } @@ -59,7 +64,9 @@ export function AppProvider({ children }) { 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) @@ -73,7 +80,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())) @@ -90,7 +99,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 @@ -102,7 +113,9 @@ export function AppProvider({ children }) { setIssuesData({}); setLastOrgNames(orgNames); if (orgNames?.length) { - localStorage.setItem('oe_active_orgs', JSON.stringify(orgNames)) + try { + localStorage.setItem('oe_active_orgs', JSON.stringify(orgNames)) + } catch {} } setAuditComplete(false); setAdvanceAnalyticsComplete(false); @@ -144,10 +157,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('') + }) +}) From e496a0c94189fc60aff00e9d2bc76b22a1ab2744 Mon Sep 17 00:00:00 2001 From: Jitendra Rewar Date: Mon, 31 Aug 2026 21:16:53 +0530 Subject: [PATCH 3/3] fix: integrate hydrating state and IndexedDB caching with active orgs restoration --- src/context/AppContext.jsx | 54 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 52 insertions(+), 2 deletions(-) diff --git a/src/context/AppContext.jsx b/src/context/AppContext.jsx index cb7f735..b6236c2 100644 --- a/src/context/AppContext.jsx +++ b/src/context/AppContext.jsx @@ -55,11 +55,61 @@ export function AppProvider({ children }) { } }) + const [hydrating, setHydrating] = useState(true) + const restoredFromCache = useRef(false) + + // Restore the last analysis on startup from IndexedDB useEffect(() => { - if (lastOrgNames.length > 0 && !model && !loading) { + 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) + if (cached.lastOrgNames?.length) { + 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 + useEffect(() => { + if (hydrating || !model) return + + 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(() => { + if (!hydrating && lastOrgNames.length > 0 && !model && !loading) { explore(lastOrgNames) } - }, []) + }, [hydrating]) useEffect(() => { const handler = e => {