You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
516 lines
18 KiB
TypeScript
516 lines
18 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
|
import Layout from '../components/Layout';
|
|
import { fredApi, FredUsageStats, FredSeriesData, FredObservationsData } from '../lib/api';
|
|
|
|
interface QuotaDisplayProps {
|
|
stats: FredUsageStats;
|
|
isLoading: boolean;
|
|
}
|
|
|
|
const QuotaDisplay: React.FC<QuotaDisplayProps> = ({ stats, isLoading }) => {
|
|
if (isLoading) {
|
|
return (
|
|
<div className="bg-white rounded-lg shadow p-6 mb-6">
|
|
<div className="animate-pulse">
|
|
<div className="h-4 bg-gray-200 rounded w-1/4 mb-2"></div>
|
|
<div className="h-8 bg-gray-200 rounded w-1/2"></div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const getQuotaColor = (percentage: number) => {
|
|
if (percentage < 50) return 'bg-green-500';
|
|
if (percentage < 80) return 'bg-yellow-500';
|
|
return 'bg-red-500';
|
|
};
|
|
|
|
const getQuotaTextColor = (percentage: number) => {
|
|
if (percentage < 50) return 'text-green-600';
|
|
if (percentage < 80) return 'text-yellow-600';
|
|
return 'text-red-600';
|
|
};
|
|
|
|
return (
|
|
<div className="bg-white rounded-lg shadow p-6 mb-6">
|
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">📊 FRED API Daily Quota</h3>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-4">
|
|
<div className="bg-gray-50 rounded-lg p-4">
|
|
<div className="text-sm text-gray-500">Used Today</div>
|
|
<div className={`text-2xl font-bold ${getQuotaTextColor(stats.usage_percentage)}`}>
|
|
{stats.used_today}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-gray-50 rounded-lg p-4">
|
|
<div className="text-sm text-gray-500">Remaining</div>
|
|
<div className="text-2xl font-bold text-blue-600">
|
|
{stats.remaining_today}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-gray-50 rounded-lg p-4">
|
|
<div className="text-sm text-gray-500">Daily Limit</div>
|
|
<div className="text-2xl font-bold text-gray-900">
|
|
{stats.daily_limit}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-gray-50 rounded-lg p-4">
|
|
<div className="text-sm text-gray-500">Usage %</div>
|
|
<div className={`text-2xl font-bold ${getQuotaTextColor(stats.usage_percentage)}`}>
|
|
{stats.usage_percentage.toFixed(1)}%
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Progress Bar */}
|
|
<div className="mb-4">
|
|
<div className="flex justify-between text-sm text-gray-600 mb-1">
|
|
<span>API Usage Progress</span>
|
|
<span>{stats.used_today} / {stats.daily_limit}</span>
|
|
</div>
|
|
<div className="w-full bg-gray-200 rounded-full h-2">
|
|
<div
|
|
className={`h-2 rounded-full ${getQuotaColor(stats.usage_percentage)}`}
|
|
style={{ width: `${Math.min(stats.usage_percentage, 100)}%` }}
|
|
></div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Status */}
|
|
<div className="flex items-center gap-2">
|
|
<div className={`w-3 h-3 rounded-full ${stats.can_make_requests ? 'bg-green-500' : 'bg-red-500'}`}></div>
|
|
<span className={`text-sm font-medium ${stats.can_make_requests ? 'text-green-600' : 'text-red-600'}`}>
|
|
{stats.can_make_requests ? 'API Available' : 'Daily Limit Reached'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
interface CacheStatsProps {
|
|
stats: FredUsageStats;
|
|
}
|
|
|
|
const CacheStats: React.FC<CacheStatsProps> = ({ stats }) => {
|
|
return (
|
|
<div className="bg-white rounded-lg shadow p-6 mb-6">
|
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">💾 Database Cache Statistics</h3>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
<div className="bg-blue-50 rounded-lg p-4">
|
|
<div className="text-sm text-blue-600">Cached Series</div>
|
|
<div className="text-2xl font-bold text-blue-900">
|
|
{stats.cache_stats.cached_series.toLocaleString()}
|
|
</div>
|
|
<div className="text-xs text-blue-500 mt-1">Economic data series</div>
|
|
</div>
|
|
|
|
<div className="bg-green-50 rounded-lg p-4">
|
|
<div className="text-sm text-green-600">Cached Observations</div>
|
|
<div className="text-2xl font-bold text-green-900">
|
|
{stats.cache_stats.cached_observations.toLocaleString()}
|
|
</div>
|
|
<div className="text-xs text-green-500 mt-1">Historical data points</div>
|
|
</div>
|
|
|
|
<div className="bg-purple-50 rounded-lg p-4">
|
|
<div className="text-sm text-purple-600">Cache Duration</div>
|
|
<div className="text-2xl font-bold text-purple-900">
|
|
{stats.cache_stats.cache_duration_hours}h
|
|
</div>
|
|
<div className="text-xs text-purple-500 mt-1">Auto-refresh period</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-4 p-4 bg-gray-50 rounded-lg">
|
|
<h4 className="font-medium text-gray-900 mb-2">🔧 Proxy Information</h4>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
|
|
<div>
|
|
<span className="text-gray-600">Mode:</span>
|
|
<span className="ml-2 font-medium">{stats.proxy_info.mode}</span>
|
|
</div>
|
|
<div>
|
|
<span className="text-gray-600">Smart Caching:</span>
|
|
<span className="ml-2 font-medium">{stats.proxy_info.smart_caching ? 'Enabled' : 'Disabled'}</span>
|
|
</div>
|
|
<div>
|
|
<span className="text-gray-600">Permanent Storage:</span>
|
|
<span className="ml-2 font-medium">{stats.proxy_info.permanent_storage ? 'Yes' : 'No'}</span>
|
|
</div>
|
|
<div>
|
|
<span className="text-gray-600">Supported Endpoints:</span>
|
|
<span className="ml-2 font-medium">{stats.proxy_info.supported_endpoints}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
interface EndpointStatsProps {
|
|
stats: FredUsageStats;
|
|
}
|
|
|
|
const EndpointStats: React.FC<EndpointStatsProps> = ({ stats }) => {
|
|
const topEndpoints = stats.endpoint_stats.slice(0, 10);
|
|
|
|
return (
|
|
<div className="bg-white rounded-lg shadow p-6 mb-6">
|
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">🔥 Most Used Endpoints</h3>
|
|
|
|
{topEndpoints.length > 0 ? (
|
|
<div className="space-y-3">
|
|
{topEndpoints.map((endpoint, index) => (
|
|
<div key={endpoint.endpoint} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-6 h-6 bg-blue-100 text-blue-600 rounded-full flex items-center justify-center text-xs font-bold">
|
|
{index + 1}
|
|
</div>
|
|
<code className="text-sm font-mono bg-gray-200 px-2 py-1 rounded">
|
|
{endpoint.endpoint}
|
|
</code>
|
|
</div>
|
|
<div className="text-right">
|
|
<div className="text-lg font-bold text-gray-900">{endpoint.call_count}</div>
|
|
<div className="text-xs text-gray-500">calls</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="text-gray-500 text-center py-8">
|
|
No endpoint usage data available
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
interface PopularSeriesProps {
|
|
series: FredSeriesData[];
|
|
isLoading: boolean;
|
|
onSelectSeries: (seriesId: string) => void;
|
|
}
|
|
|
|
const PopularSeries: React.FC<PopularSeriesProps> = ({ series, isLoading, onSelectSeries }) => {
|
|
if (isLoading) {
|
|
return (
|
|
<div className="bg-white rounded-lg shadow p-6 mb-6">
|
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">📈 Popular Economic Indicators</h3>
|
|
<div className="space-y-3">
|
|
{[...Array(5)].map((_, i) => (
|
|
<div key={i} className="animate-pulse p-4 bg-gray-50 rounded-lg">
|
|
<div className="h-4 bg-gray-200 rounded w-3/4 mb-2"></div>
|
|
<div className="h-3 bg-gray-200 rounded w-1/2"></div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="bg-white rounded-lg shadow p-6 mb-6">
|
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">📈 Popular Economic Indicators</h3>
|
|
|
|
<div className="space-y-2">
|
|
{series.map((seriesData) => (
|
|
<button
|
|
key={seriesData.id}
|
|
onClick={() => onSelectSeries(seriesData.id)}
|
|
className="w-full text-left p-4 bg-gray-50 hover:bg-blue-50 rounded-lg transition-colors border border-transparent hover:border-blue-200"
|
|
>
|
|
<div className="flex justify-between items-start">
|
|
<div className="flex-1">
|
|
<div className="font-medium text-gray-900 flex items-center gap-2">
|
|
<code className="text-sm bg-gray-200 px-2 py-1 rounded">{seriesData.id}</code>
|
|
{seriesData.cached && (
|
|
<span className="text-xs bg-green-100 text-green-600 px-2 py-1 rounded">Cached</span>
|
|
)}
|
|
</div>
|
|
<div className="text-sm text-gray-600 mt-1 line-clamp-2">{seriesData.title}</div>
|
|
<div className="text-xs text-gray-500 mt-1">
|
|
{seriesData.frequency} • {seriesData.units}
|
|
</div>
|
|
</div>
|
|
<div className="text-xs text-gray-400 ml-4">
|
|
Updated: {new Date(seriesData.last_updated).toLocaleDateString()}
|
|
</div>
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
interface SeriesSearchProps {
|
|
onSelectSeries: (seriesId: string) => void;
|
|
}
|
|
|
|
const SeriesSearch: React.FC<SeriesSearchProps> = ({ onSelectSeries }) => {
|
|
const [searchTerm, setSearchTerm] = useState('');
|
|
const [searchResults, setSearchResults] = useState<FredSeriesData[]>([]);
|
|
const [isSearching, setIsSearching] = useState(false);
|
|
|
|
const handleSearch = async () => {
|
|
if (!searchTerm.trim()) return;
|
|
|
|
setIsSearching(true);
|
|
try {
|
|
const results = await fredApi.searchSeries(searchTerm, 15);
|
|
setSearchResults(results);
|
|
} catch (error) {
|
|
console.error('Search failed:', error);
|
|
setSearchResults([]);
|
|
} finally {
|
|
setIsSearching(false);
|
|
}
|
|
};
|
|
|
|
const handleKeyPress = (e: React.KeyboardEvent) => {
|
|
if (e.key === 'Enter') {
|
|
handleSearch();
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="bg-white rounded-lg shadow p-6 mb-6">
|
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">🔍 Search Economic Data</h3>
|
|
|
|
<div className="flex gap-2 mb-4">
|
|
<input
|
|
type="text"
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
onKeyPress={handleKeyPress}
|
|
placeholder="Search for economic indicators (e.g., unemployment, inflation, GDP)"
|
|
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
|
/>
|
|
<button
|
|
onClick={handleSearch}
|
|
disabled={!searchTerm.trim() || isSearching}
|
|
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
{isSearching ? 'Searching...' : 'Search'}
|
|
</button>
|
|
</div>
|
|
|
|
{searchResults.length > 0 && (
|
|
<div className="space-y-2 max-h-96 overflow-y-auto">
|
|
{searchResults.map((seriesData) => (
|
|
<button
|
|
key={seriesData.id}
|
|
onClick={() => onSelectSeries(seriesData.id)}
|
|
className="w-full text-left p-3 bg-gray-50 hover:bg-blue-50 rounded-lg transition-colors border border-transparent hover:border-blue-200"
|
|
>
|
|
<div className="flex justify-between items-start">
|
|
<div className="flex-1">
|
|
<div className="font-medium text-gray-900">
|
|
<code className="text-sm bg-gray-200 px-2 py-1 rounded mr-2">{seriesData.id}</code>
|
|
</div>
|
|
<div className="text-sm text-gray-600 mt-1 line-clamp-2">{seriesData.title}</div>
|
|
<div className="text-xs text-gray-500 mt-1">
|
|
{seriesData.frequency} • {seriesData.units}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{searchTerm && searchResults.length === 0 && !isSearching && (
|
|
<div className="text-gray-500 text-center py-4">
|
|
No results found for "{searchTerm}"
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
interface SeriesDataDisplayProps {
|
|
seriesId: string;
|
|
observations: FredObservationsData | null;
|
|
isLoading: boolean;
|
|
onClose: () => void;
|
|
}
|
|
|
|
const SeriesDataDisplay: React.FC<SeriesDataDisplayProps> = ({ seriesId, observations, isLoading, onClose }) => {
|
|
if (isLoading) {
|
|
return (
|
|
<div className="bg-white rounded-lg shadow p-6">
|
|
<div className="flex justify-between items-center mb-4">
|
|
<div className="h-6 bg-gray-200 rounded w-1/3 animate-pulse"></div>
|
|
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">✕</button>
|
|
</div>
|
|
<div className="space-y-2">
|
|
{[...Array(10)].map((_, i) => (
|
|
<div key={i} className="flex justify-between items-center p-2 animate-pulse">
|
|
<div className="h-4 bg-gray-200 rounded w-24"></div>
|
|
<div className="h-4 bg-gray-200 rounded w-16"></div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!observations) return null;
|
|
|
|
return (
|
|
<div className="bg-white rounded-lg shadow p-6">
|
|
<div className="flex justify-between items-center mb-4">
|
|
<h3 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
|
|
📊 <code className="text-sm bg-gray-200 px-2 py-1 rounded">{seriesId}</code>
|
|
{observations.cached && (
|
|
<span className="text-xs bg-green-100 text-green-600 px-2 py-1 rounded">From Cache</span>
|
|
)}
|
|
</h3>
|
|
<button
|
|
onClick={onClose}
|
|
className="text-gray-400 hover:text-gray-600 text-xl"
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
|
|
<div className="mb-4 text-sm text-gray-600">
|
|
{observations.count} observations • Last 20 shown
|
|
{observations.cached_at && (
|
|
<span className="ml-2">• Cached: {new Date(observations.cached_at).toLocaleString()}</span>
|
|
)}
|
|
</div>
|
|
|
|
<div className="max-h-96 overflow-y-auto">
|
|
<table className="w-full text-sm">
|
|
<thead className="bg-gray-50 sticky top-0">
|
|
<tr>
|
|
<th className="px-4 py-2 text-left">Date</th>
|
|
<th className="px-4 py-2 text-right">Value</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{observations.observations.slice(0, 20).map((obs, index) => (
|
|
<tr key={index} className="border-t border-gray-100">
|
|
<td className="px-4 py-2 text-gray-900">{obs.date}</td>
|
|
<td className="px-4 py-2 text-right font-mono">
|
|
{obs.value === '.' ? 'N/A' : parseFloat(obs.value).toLocaleString()}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const FredPage: React.FC = () => {
|
|
const [stats, setStats] = useState<FredUsageStats | null>(null);
|
|
const [popularSeries, setPopularSeries] = useState<FredSeriesData[]>([]);
|
|
const [selectedSeriesId, setSelectedSeriesId] = useState<string | null>(null);
|
|
const [seriesObservations, setSeriesObservations] = useState<FredObservationsData | null>(null);
|
|
const [isLoadingStats, setIsLoadingStats] = useState(true);
|
|
const [isLoadingPopular, setIsLoadingPopular] = useState(true);
|
|
const [isLoadingObservations, setIsLoadingObservations] = useState(false);
|
|
|
|
// Load initial data
|
|
useEffect(() => {
|
|
const loadInitialData = async () => {
|
|
try {
|
|
// Load usage stats
|
|
const statsData = await fredApi.getUsageStats(7, true);
|
|
setStats(statsData);
|
|
setIsLoadingStats(false);
|
|
|
|
// Load popular series
|
|
const popularData = await fredApi.getPopularSeries();
|
|
setPopularSeries(popularData);
|
|
setIsLoadingPopular(false);
|
|
} catch (error) {
|
|
console.error('Failed to load initial data:', error);
|
|
setIsLoadingStats(false);
|
|
setIsLoadingPopular(false);
|
|
}
|
|
};
|
|
|
|
loadInitialData();
|
|
}, []);
|
|
|
|
// Load series observations when selected
|
|
const handleSelectSeries = async (seriesId: string) => {
|
|
setSelectedSeriesId(seriesId);
|
|
setIsLoadingObservations(true);
|
|
|
|
try {
|
|
const observationsData = await fredApi.getSeriesObservations(seriesId, 50);
|
|
setSeriesObservations(observationsData);
|
|
} catch (error) {
|
|
console.error('Failed to load series observations:', error);
|
|
setSeriesObservations(null);
|
|
} finally {
|
|
setIsLoadingObservations(false);
|
|
}
|
|
};
|
|
|
|
const handleCloseSeriesData = () => {
|
|
setSelectedSeriesId(null);
|
|
setSeriesObservations(null);
|
|
};
|
|
|
|
return (
|
|
<Layout>
|
|
<div className="space-y-6">
|
|
<div className="flex justify-between items-center">
|
|
<h1 className="text-2xl font-bold text-gray-900">🏦 FRED Economic Data</h1>
|
|
<div className="text-sm text-gray-500">
|
|
Federal Reserve Economic Data (FRED) API Access
|
|
</div>
|
|
</div>
|
|
|
|
{/* Quota Display */}
|
|
{stats && <QuotaDisplay stats={stats} isLoading={isLoadingStats} />}
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
{/* Left Column */}
|
|
<div>
|
|
{/* Popular Series */}
|
|
<PopularSeries
|
|
series={popularSeries}
|
|
isLoading={isLoadingPopular}
|
|
onSelectSeries={handleSelectSeries}
|
|
/>
|
|
|
|
{/* Search */}
|
|
<SeriesSearch onSelectSeries={handleSelectSeries} />
|
|
</div>
|
|
|
|
{/* Right Column */}
|
|
<div>
|
|
{/* Cache Stats */}
|
|
{stats && <CacheStats stats={stats} />}
|
|
|
|
{/* Endpoint Stats */}
|
|
{stats && <EndpointStats stats={stats} />}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Series Data Display */}
|
|
{selectedSeriesId && (
|
|
<SeriesDataDisplay
|
|
seriesId={selectedSeriesId}
|
|
observations={seriesObservations}
|
|
isLoading={isLoadingObservations}
|
|
onClose={handleCloseSeriesData}
|
|
/>
|
|
)}
|
|
</div>
|
|
</Layout>
|
|
);
|
|
};
|
|
|
|
export default FredPage; |