diff --git a/src/components/RateLimitBanner.jsx b/src/components/RateLimitBanner.jsx index 8f5edb5..b09369d 100644 --- a/src/components/RateLimitBanner.jsx +++ b/src/components/RateLimitBanner.jsx @@ -33,7 +33,10 @@ export default function RateLimitBanner() { )} - RESETS HOURLY + + Used: {rateLimit.used} • Reset at{' '} + {new Date(rateLimit.reset * 1000).toLocaleTimeString()} + ) } diff --git a/src/context/AppContext.jsx b/src/context/AppContext.jsx index ceab84d..19f4adb 100644 --- a/src/context/AppContext.jsx +++ b/src/context/AppContext.jsx @@ -1,19 +1,63 @@ -import { createContext, useContext, useState, useCallback } from 'react' -import { fetchOrg, fetchRepos, fetchContributors, fetchIssues, fetchRateLimit } from '../services/github' +import { createContext, useContext, useState, useCallback, useEffect } from 'react' +import { fetchOrg, fetchRepos, fetchContributors, fetchIssues, } from '../services/github' import { buildAnalyticalModel } from '../services/analytics' const Ctx = createContext(null) +function getStoredRateLimit() { + const stored = localStorage.getItem('oe_rate_limit') + + if (!stored) return null + + try { + const data = JSON.parse(stored) + + if (Date.now() > data.reset * 1000) { + localStorage.removeItem('oe_rate_limit') + return null + } + + return data + } catch { + localStorage.removeItem('oe_rate_limit') + return null + } +} + export function AppProvider({ children }) { - const [pat, setPat] = useState(() => localStorage.getItem('oe_pat') || '') - const [orgs, setOrgs] = useState([]) - const [model, setModel] = useState(null) + const [pat, setPat] = useState(() => localStorage.getItem('oe_pat') || '') + const [orgs, setOrgs] = useState([]) + const [model, setModel] = useState(null) const [issuesData, setIssuesData] = useState({}) - const [rateLimit, setRateLimit] = useState(null) - const [loading, setLoading] = useState(false) - const [loadMsg, setLoadMsg] = useState('') + const [rateLimit, setRateLimit] = useState(getStoredRateLimit) + const [loading, setLoading] = useState(false) + const [loadMsg, setLoadMsg] = useState('') const [govLoading, setGovLoading] = useState(false) - const [error, setError] = useState('') + const [error, setError] = useState('') + + useEffect(() => { + const handler = e => { + setRateLimit(e.detail) + localStorage.setItem('oe_rate_limit', JSON.stringify(e.detail)) + } + + window.addEventListener('rate-limit-update', handler) + + return () => { + window.removeEventListener('rate-limit-update', handler) + } + }, []) + + useEffect(() => { + if (!rateLimit?.reset) return + + const timeout = setTimeout(() => { + localStorage.removeItem('oe_rate_limit') + setRateLimit(null) + }, Math.max(0, rateLimit.reset * 1000 - Date.now())) + + return () => clearTimeout(timeout) + }, [rateLimit]) const savePat = useCallback(token => { setPat(token) @@ -25,7 +69,7 @@ export function AppProvider({ children }) { setLoading(true); setError(''); setModel(null); setOrgs([]); setIssuesData({}) try { setLoadMsg('Fetching organization metadata...') - const orgRes = await Promise.allSettled(orgNames.map(n => fetchOrg(n, pat))) + const orgRes = await Promise.allSettled(orgNames.map(n => fetchOrg(n, pat))) const validOrgs = orgRes.filter(r => r.status === 'fulfilled').map(r => r.value) if (!validOrgs.length) throw new Error('No valid organizations found. Check the names and try again.') setOrgs(validOrgs) @@ -50,11 +94,9 @@ export function AppProvider({ children }) { setLoadMsg('Building analytical data model...') setModel(buildAnalyticalModel(validOrgs, reposPerOrg, contribsPerRepo)) - const rl = await fetchRateLimit(pat) - if (rl) setRateLimit(rl) // Save to recent searches - const prev = JSON.parse(localStorage.getItem('oe_recent') || '[]') + 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))) return true @@ -72,7 +114,7 @@ export function AppProvider({ children }) { const runAudit = useCallback(async () => { if (!model || govLoading) return setGovLoading(true) - const map = {} + const map = {} const repos = model.allRepos.slice(0, 15) // Batches of 5 using Promise.allSettled diff --git a/src/services/github.js b/src/services/github.js index 8b10597..d471146 100644 --- a/src/services/github.js +++ b/src/services/github.js @@ -1,14 +1,14 @@ // IndexedDB Cache (L2) const DB_NAME = 'orgexplorer_cache' -const STORE = 'cache' -const TTL_MS = 3_600_000 // 1 hour +const STORE = 'cache' +const TTL_MS = 3_600_000 // 1 hour function openDB() { return new Promise((resolve, reject) => { const req = indexedDB.open(DB_NAME, 1) req.onupgradeneeded = e => e.target.result.createObjectStore(STORE, { keyPath: 'k' }) - req.onsuccess = e => resolve(e.target.result) - req.onerror = () => reject(req.error) + req.onsuccess = e => resolve(e.target.result) + req.onerror = () => reject(req.error) }) } @@ -34,7 +34,7 @@ export async function cacheSet(key, value) { const tx = db.transaction(STORE, 'readwrite') tx.objectStore(STORE).put({ k: key, v: value, ts: Date.now() }) tx.oncomplete = () => res(true) - tx.onerror = () => res(false) + tx.onerror = () => res(false) }) } catch { return false } } @@ -46,7 +46,7 @@ export async function cacheClear() { const tx = db.transaction(STORE, 'readwrite') tx.objectStore(STORE).clear() tx.oncomplete = () => res(true) - tx.onerror = () => res(false) + tx.onerror = () => res(false) }) } catch { return false } } @@ -61,6 +61,18 @@ async function fetchWithCache(url, pat) { if (pat) headers.Authorization = `token ${pat}` const res = await fetch(url, { headers }) + + window.dispatchEvent( + new CustomEvent('rate-limit-update', { + detail: { + limit: Number(res.headers.get('x-ratelimit-limit')), + remaining: Number(res.headers.get('x-ratelimit-remaining')), + used: Number(res.headers.get('x-ratelimit-used')), + reset: Number(res.headers.get('x-ratelimit-reset')) + } + }) + ) + if (res.status === 403) throw new Error('RATE_LIMIT') if (res.status === 404) throw new Error('NOT_FOUND') if (!res.ok) throw new Error(`HTTP_${res.status}`) @@ -77,7 +89,7 @@ export const fetchOrg = (org, pat) => export async function fetchRepos(org, pat) { const all = [] for (let page = 1; page <= 5; page++) { - const url = `https://api.github.com/orgs/${org}/repos?per_page=100&page=${page}&sort=updated` + const url = `https://api.github.com/orgs/${org}/repos?per_page=100&page=${page}&sort=updated` const data = await fetchWithCache(url, pat) all.push(...data) if (data.length < 100) break @@ -105,7 +117,7 @@ export async function fetchRateLimit(pat) { try { const headers = { Accept: 'application/vnd.github.v3+json' } if (pat) headers.Authorization = `token ${pat}` - const res = await fetch('https://api.github.com/rate_limit', { headers }) + const res = await fetch('https://api.github.com/rate_limit', { headers }) const data = await res.json() return data.rate } catch { return null }