This commit is contained in:
2026-04-02 01:04:59 +08:00
parent 96711ffb35
commit 13d5343ffc
11 changed files with 1365 additions and 147 deletions

137
web/static/css/style.css Normal file
View File

@@ -0,0 +1,137 @@
:root {
--primary-color: #1a73e8;
--secondary-color: #5f6368;
--success-color: #34a853;
--danger-color: #ea4335;
--warning-color: #fbbc04;
--info-color: #4285f4;
--dark-bg: #202124;
--light-bg: #f8f9fa;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background-color: #f5f5f5;
}
.navbar {
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.card {
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0,0,0,0.12);
transition: box-shadow 0.3s ease;
}
.card:hover {
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
}
.card-header {
border-radius: 8px 8px 0 0 !important;
}
#chart-placeholder {
display: none;
}
#dataChart {
display: none;
}
#dataChart.active {
display: block;
}
.table {
font-size: 0.9rem;
}
.table th {
position: sticky;
top: 0;
z-index: 1;
}
.pagination {
margin: 0;
}
.spinner-border-sm {
width: 1rem;
height: 1rem;
border-width: 0.15em;
}
.series-item {
padding: 8px 12px;
cursor: pointer;
border-radius: 4px;
transition: background-color 0.2s;
}
.series-item:hover {
background-color: #e8f0fe;
}
.series-item.active {
background-color: #1a73e8;
color: white;
}
.category-item {
padding: 6px 10px;
cursor: pointer;
font-size: 0.9rem;
border-radius: 4px;
transition: all 0.2s;
}
.category-item:hover {
background-color: #e8f5e9;
}
.release-item {
padding: 8px;
border-bottom: 1px solid #eee;
cursor: pointer;
}
.release-item:hover {
background-color: #f8f9fa;
}
.release-item:last-child {
border-bottom: none;
}
.release-date {
font-size: 0.8rem;
color: #666;
}
#current-time {
font-family: monospace;
font-size: 0.9rem;
}
.btn-outline-light:hover {
background-color: rgba(255,255,255,0.2);
}
footer {
margin-top: 2rem;
}
.form-control:focus, .form-select:focus {
border-color: #1a73e8;
box-shadow: 0 0 0 0.2rem rgba(26, 115, 232, 0.25);
}
.toast-container {
position: fixed;
bottom: 20px;
right: 20px;
z-index: 1050;
}

385
web/static/js/app.js Normal file
View File

@@ -0,0 +1,385 @@
const API_BASE = '/api/v1';
let chart = null;
let currentSeriesData = [];
let currentPage = 1;
const itemsPerPage = 20;
const popularSeries = ['GNPCA', 'GDP', 'UNRATE', 'CPIAUCSL', 'FEDFUNDS', 'M2SL', 'SP500', 'EXUSEU'];
function init() {
updateTime();
setInterval(updateTime, 1000);
loadPopularSeries();
loadCategories();
loadReleases();
initChart();
}
function updateTime() {
const now = new Date();
document.getElementById('current-time').textContent = now.toLocaleString();
}
function initChart() {
const ctx = document.getElementById('dataChart').getContext('2d');
chart = new Chart(ctx, {
type: 'line',
data: {
labels: [],
datasets: [{
label: 'Value',
data: [],
borderColor: '#1a73e8',
backgroundColor: 'rgba(26, 115, 232, 0.1)',
fill: true,
tension: 0.3,
pointRadius: 3,
pointHoverRadius: 6
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: true,
position: 'top'
},
tooltip: {
mode: 'index',
intersect: false
}
},
scales: {
x: {
ticks: {
maxRotation: 45,
minRotation: 45
}
},
y: {
beginAtZero: false
}
},
interaction: {
mode: 'nearest',
axis: 'x',
intersect: false
}
}
});
}
async function searchSeries() {
const searchText = document.getElementById('search-input').value.trim();
if (!searchText) {
alert('Please enter a search term');
return;
}
try {
const response = await axios.get(`${API_BASE}/series/search`, {
params: { search_text: searchText, limit: 20 }
});
if (response.data && response.data.seriess && response.data.seriess.length > 0) {
displaySearchResults(response.data.seriess);
} else {
alert('No results found');
}
} catch (error) {
console.error('Search error:', error);
alert('Search failed: ' + (error.response?.data?.error_message || error.message));
}
}
function displaySearchResults(results) {
const select = document.getElementById('series-select');
select.innerHTML = '<option value="">-- Search Results --</option>';
results.forEach(series => {
const option = document.createElement('option');
option.value = series.id;
option.textContent = `${series.id} - ${series.title}`;
select.appendChild(option);
});
if (results.length > 0) {
select.value = results[0].id;
loadSeriesData();
}
}
async function loadPopularSeries() {
const select = document.getElementById('series-select');
select.innerHTML = '<option value="">-- Popular Series --</option>';
popularSeries.forEach(seriesId => {
const option = document.createElement('option');
option.value = seriesId;
option.textContent = seriesId;
select.appendChild(option);
});
if (popularSeries.length > 0) {
select.value = popularSeries[0];
loadSeriesData();
}
}
async function loadSeriesData() {
const seriesId = document.getElementById('series-select').value;
if (!seriesId) {
document.getElementById('chart-placeholder').style.display = 'block';
document.getElementById('dataChart').style.display = 'none';
return;
}
try {
const [seriesRes, obsRes] = await Promise.all([
axios.get(`${API_BASE}/series`, { params: { series_id: seriesId } }),
axios.get(`${API_BASE}/series/observations`, { params: { series_id: seriesId, limit: 1000 } })
]);
const series = seriesRes.data.series;
const observations = obsRes.data.observations || [];
displaySeriesInfo(series);
displayChart(observations, seriesId);
currentSeriesData = observations;
renderTable(1);
updatePagination();
document.getElementById('chart-placeholder').style.display = 'none';
document.getElementById('dataChart').style.display = 'block';
} catch (error) {
console.error('Load series error:', error);
alert('Failed to load series: ' + (error.response?.data?.error_message || error.message));
}
}
function displaySeriesInfo(series) {
const info = document.getElementById('series-info');
info.innerHTML = `
<div class="row">
<div class="col-md-6">
<p><strong>Series ID:</strong> ${series.id}</p>
<p><strong>Title:</strong> ${series.title}</p>
<p><strong>Frequency:</strong> ${series.frequency}</p>
</div>
<div class="col-md-6">
<p><strong>Units:</strong> ${series.units}</p>
<p><strong>Seasonal Adjustment:</strong> ${series.seasonal_adjustment}</p>
<p><strong>Last Updated:</strong> ${series.last_updated}</p>
</div>
</div>
${series.notes ? `<p class="mt-2"><strong>Notes:</strong> ${series.notes}</p>` : ''}
`;
document.getElementById('chart-title').textContent = series.title || series.id;
}
function displayChart(observations, seriesId) {
const labels = observations.map(o => o.date).reverse();
const data = observations.map(o => parseFloat(o.value) || null).reverse();
chart.data.labels = labels;
chart.data.datasets[0].data = data;
chart.data.datasets[0].label = seriesId;
chart.update();
}
function renderTable(page) {
currentPage = page;
const start = (page - 1) * itemsPerPage;
const end = start + itemsPerPage;
const pageData = currentSeriesData.slice(start, end);
const tbody = document.getElementById('data-table-body');
if (pageData.length === 0) {
tbody.innerHTML = '<tr><td colspan="2" class="text-center text-muted">No data available</td></tr>';
return;
}
tbody.innerHTML = pageData.map(obs => `
<tr>
<td>${obs.date}</td>
<td>${obs.value || 'N/A'}</td>
</tr>
`).join('');
}
function updatePagination() {
const totalPages = Math.ceil(currentSeriesData.length / itemsPerPage);
const pagination = document.getElementById('pagination');
if (totalPages <= 1) {
pagination.innerHTML = '';
return;
}
let html = '';
if (currentPage > 1) {
html += `<li class="page-item"><a class="page-link" href="#" onclick="renderTable(${currentPage - 1})">Prev</a></li>`;
}
for (let i = 1; i <= totalPages; i++) {
if (i === currentPage) {
html += `<li class="page-item active"><a class="page-link" href="#">${i}</a></li>`;
} else if (i === 1 || i === totalPages || (i >= currentPage - 2 && i <= currentPage + 2)) {
html += `<li class="page-item"><a class="page-link" href="#" onclick="renderTable(${i})">${i}</a></li>`;
} else if (i === currentPage - 3 || i === currentPage + 3) {
html += `<li class="page-item disabled"><span class="page-link">...</span></li>`;
}
}
if (currentPage < totalPages) {
html += `<li class="page-item"><a class="page-link" href="#" onclick="renderTable(${currentPage + 1})">Next</a></li>`;
}
pagination.innerHTML = html;
}
async function loadCategories() {
try {
const response = await axios.get(`${API_BASE}/category/children`, {
params: { category_id: 0 }
});
const list = document.getElementById('categories-list');
if (response.data && response.data.categories) {
list.innerHTML = response.data.categories.map(cat => `
<div class="category-item" onclick="loadCategorySeries(${cat.id}, '${cat.name}')">
${cat.name}
</div>
`).join('');
}
} catch (error) {
console.error('Load categories error:', error);
}
}
async function loadCategorySeries(categoryId, categoryName) {
try {
const response = await axios.get(`${API_BASE}/category/series`, {
params: { category_id: categoryId, limit: 10 }
});
const select = document.getElementById('series-select');
select.innerHTML = `<option value="">-- ${categoryName} --</option>`;
if (response.data && response.data.seriess) {
response.data.seriess.forEach(series => {
const option = document.createElement('option');
option.value = series.id;
option.textContent = `${series.id} - ${series.title}`;
select.appendChild(option);
});
if (response.data.seriess.length > 0) {
select.value = response.data.seriess[0].id;
loadSeriesData();
}
}
} catch (error) {
console.error('Load category series error:', error);
}
}
async function loadReleases() {
try {
const response = await axios.get(`${API_BASE}/releases/dates`, {
params: { limit: 10, start_date: getDateDaysAgo(30) }
});
const list = document.getElementById('releases-list');
if (response.data && response.data.release_dates) {
list.innerHTML = response.data.release_dates.map(rel => `
<div class="release-item" onclick="loadReleaseSeries(${rel.release_id}, '${rel.release_name}')">
<div class="fw-bold">${rel.release_name}</div>
<div class="release-date">${rel.date}</div>
</div>
`).join('');
}
} catch (error) {
console.error('Load releases error:', error);
}
}
async function loadReleaseSeries(releaseId, releaseName) {
try {
const response = await axios.get(`${API_BASE}/release/series`, {
params: { release_id: releaseId, limit: 10 }
});
const select = document.getElementById('series-select');
select.innerHTML = `<option value="">-- ${releaseName} --</option>`;
if (response.data && response.data.seriess) {
response.data.seriess.forEach(series => {
const option = document.createElement('option');
option.value = series.id;
option.textContent = `${series.id} - ${series.title}`;
select.appendChild(option);
});
if (response.data.seriess.length > 0) {
select.value = response.data.seriess[0].id;
loadSeriesData();
}
}
} catch (error) {
console.error('Load release series error:', error);
}
}
function showSeriesModal() {
const modal = new bootstrap.Modal(document.getElementById('addSeriesModal'));
modal.show();
}
function addSeriesFromModal() {
const seriesId = document.getElementById('modal-series-id').value.trim();
if (!seriesId) {
alert('Please enter a series ID');
return;
}
document.getElementById('series-select').value = seriesId;
bootstrap.Modal.getInstance(document.getElementById('addSeriesModal')).hide();
loadSeriesData();
}
function exportData() {
if (!currentSeriesData.length) {
alert('No data to export');
return;
}
let csv = 'Date,Value\n';
currentSeriesData.forEach(obs => {
csv += `${obs.date},${obs.value}\n`;
});
const blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `fred_data_${new Date().toISOString().split('T')[0]}.csv`;
a.click();
URL.revokeObjectURL(url);
}
function getDateDaysAgo(days) {
const date = new Date();
date.setDate(date.getDate() - days);
return date.toISOString().split('T')[0];
}
document.addEventListener('DOMContentLoaded', init);
document.getElementById('search-input').addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
searchSeries();
}
});

183
web/templates/index.html Normal file
View File

@@ -0,0 +1,183 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FRED Economic Data Dashboard</title>
<link rel="stylesheet" href="/static/css/style.css">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
</head>
<body>
<nav class="navbar navbar-dark bg-dark">
<div class="container-fluid">
<a class="navbar-brand" href="/">
<i class="bi bi-graph-up-arrow me-2"></i>
FRED Economic Data Dashboard
</a>
<span class="navbar-text text-light" id="current-time"></span>
</div>
</nav>
<div class="container-fluid mt-4">
<div class="row">
<div class="col-md-3">
<div class="card mb-4">
<div class="card-header bg-primary text-white">
<h5 class="mb-0"><i class="bi bi-search me-2"></i>Search</h5>
</div>
<div class="card-body">
<div class="input-group mb-3">
<input type="text" class="form-control" id="search-input" placeholder="Search series (e.g., GDP)">
<button class="btn btn-primary" onclick="searchSeries()">
<i class="bi bi-search"></i>
</button>
</div>
<div class="d-grid gap-2">
<button class="btn btn-outline-secondary" onclick="loadPopularSeries()">
<i class="bi bi-star me-2"></i>Popular Series
</button>
</div>
</div>
</div>
<div class="card mb-4">
<div class="card-header bg-success text-white">
<h5 class="mb-0"><i class="bi bi-collection me-2"></i>Categories</h5>
</div>
<div class="card-body" id="categories-list" style="max-height: 300px; overflow-y: auto;">
<div class="text-center text-muted">
<div class="spinner-border spinner-border-sm" role="status"></div>
<span class="ms-2">Loading...</span>
</div>
</div>
</div>
<div class="card mb-4">
<div class="card-header bg-info text-white">
<h5 class="mb-0"><i class="bi bi-newspaper me-2"></i>Recent Releases</h5>
</div>
<div class="card-body" id="releases-list" style="max-height: 300px; overflow-y: auto;">
<div class="text-center text-muted">
<div class="spinner-border spinner-border-sm" role="status"></div>
<span class="ms-2">Loading...</span>
</div>
</div>
</div>
</div>
<div class="col-md-9">
<div class="card mb-4">
<div class="card-header bg-dark text-white d-flex justify-content-between align-items-center">
<h5 class="mb-0" id="chart-title">Select a series to view data</h5>
<div>
<button class="btn btn-sm btn-outline-light" onclick="exportData()" title="Export Data">
<i class="bi bi-download"></i>
</button>
</div>
</div>
<div class="card-body">
<div id="series-selector" class="mb-3">
<div class="row">
<div class="col-md-8">
<select class="form-select" id="series-select" onchange="loadSeriesData()">
<option value="">-- Select a Series --</option>
</select>
</div>
<div class="col-md-4">
<button class="btn btn-primary w-100" onclick="showSeriesModal()">
<i class="bi bi-plus-circle me-2"></i>Add Series
</button>
</div>
</div>
</div>
<div style="height: 400px;">
<canvas id="dataChart"></canvas>
</div>
<div id="chart-placeholder" class="text-center text-muted py-5">
<i class="bi bi-graph-up" style="font-size: 4rem;"></i>
<p class="mt-3">Select or search for a series to visualize data</p>
</div>
</div>
</div>
<div class="card mb-4">
<div class="card-header bg-secondary text-white">
<h5 class="mb-0"><i class="bi bi-table me-2"></i>Data Table</h5>
</div>
<div class="card-body">
<div class="table-responsive" style="max-height: 400px; overflow-y: auto;">
<table class="table table-striped table-hover" id="data-table">
<thead class="table-dark">
<tr>
<th>Date</th>
<th>Value</th>
</tr>
</thead>
<tbody id="data-table-body">
<tr>
<td colspan="2" class="text-center text-muted">No data available</td>
</tr>
</tbody>
</table>
</div>
<div class="mt-3">
<nav>
<ul class="pagination justify-content-center" id="pagination"></ul>
</nav>
</div>
</div>
</div>
<div class="card">
<div class="card-header bg-warning text-dark">
<h5 class="mb-0"><i class="bi bi-info-circle me-2"></i>Series Information</h5>
</div>
<div class="card-body" id="series-info">
<p class="text-muted">Select a series to view details</p>
</div>
</div>
</div>
</div>
</div>
<div class="modal fade" id="addSeriesModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Add Series</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Series ID</label>
<input type="text" class="form-control" id="modal-series-id" placeholder="e.g., GNPCA, GDP">
</div>
<div class="mb-3">
<label class="form-label">Observation Limit</label>
<select class="form-select" id="modal-limit">
<option value="10">Last 10</option>
<option value="50" selected>Last 50</option>
<option value="100">Last 100</option>
<option value="1000">All</option>
</select>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" onclick="addSeriesFromModal()">Load</button>
</div>
</div>
</div>
</div>
<footer class="bg-dark text-white text-center py-3 mt-4">
<p class="mb-0">FRED Economic Data Dashboard | Data provided by Federal Reserve Bank of St. Louis</p>
</footer>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script src="/static/js/app.js"></script>
</body>
</html>