5cf3726f59
Chart now plots weekly data points from first purchase to today using candle/history price data, giving an accurate view of portfolio value over time rather than just at purchase dates. Backend seeds up to 365 days of daily close prices from CoinGecko as synthetic OHLC candles, refreshing stale entries older than 31 days. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
153 lines
6.4 KiB
JavaScript
153 lines
6.4 KiB
JavaScript
import React, { useState, useEffect, useCallback } from 'react';
|
|
import { useNavigate, Link } from 'react-router-dom';
|
|
import AddPurchase from '../components/AddPurchase';
|
|
import PurchaseList from '../components/PurchaseList';
|
|
import PortfolioChart from '../components/PortfolioChart';
|
|
import BTCCandlestickChart from '../components/BTCCandlestickChart';
|
|
|
|
const API = process.env.REACT_APP_API_URL || 'http://localhost:8000';
|
|
|
|
const styles = {
|
|
app: { maxWidth: '900px', margin: '0 auto', padding: '1.5rem' },
|
|
header: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem' },
|
|
logo: { fontSize: '1.4rem', fontWeight: 700, color: '#f7931a' },
|
|
logoutBtn: { background: 'none', border: '1px solid #555', color: '#aaa', borderRadius: '8px', padding: '0.4rem 1rem', cursor: 'pointer' },
|
|
adminBtn: { background: 'none', border: '1px solid #f7931a', color: '#f7931a', borderRadius: '8px', padding: '0.4rem 1rem', cursor: 'pointer', textDecoration: 'none', fontSize: '1rem' },
|
|
headerBtns: { display: 'flex', gap: '0.5rem', alignItems: 'center' },
|
|
statsGrid: { display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '1rem', marginBottom: '1.5rem' },
|
|
statCard: { background: '#1a1a1a', padding: '1rem', borderRadius: '12px', border: '1px solid #333' },
|
|
statLabel: { color: '#888', fontSize: '0.8rem', marginBottom: '0.3rem' },
|
|
statValue: { fontSize: '1.2rem', fontWeight: 700 },
|
|
positive: { color: '#6bff8e' },
|
|
negative: { color: '#ff6b6b' },
|
|
neutral: { color: '#f7931a' },
|
|
tabs: { display: 'flex', gap: '0.5rem', marginBottom: '1rem' },
|
|
tab: { padding: '0.45rem 1.1rem', borderRadius: '8px', border: '1px solid #444', background: 'none', color: '#888', cursor: 'pointer', fontSize: '0.9rem' },
|
|
tabActive: { padding: '0.45rem 1.1rem', borderRadius: '8px', border: '1px solid #f7931a', background: 'rgba(247,147,26,0.1)', color: '#f7931a', cursor: 'pointer', fontSize: '0.9rem', fontWeight: 700 },
|
|
};
|
|
|
|
function StatCard({ label, value, highlight }) {
|
|
return (
|
|
<div style={styles.statCard}>
|
|
<div style={styles.statLabel}>{label}</div>
|
|
<div style={{ ...styles.statValue, ...(highlight ? styles[highlight] : {}) }}>{value}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function Dashboard() {
|
|
const [stats, setStats] = useState(null);
|
|
const [purchases, setPurchases] = useState([]);
|
|
const [candles, setCandles] = useState(null);
|
|
const [candlesAll, setCandlesAll] = useState(null);
|
|
const [fullscreenChart, setFullscreenChart] = useState(false);
|
|
const [chartView, setChartView] = useState('both'); // 'both' | 'portfolio' | 'history'
|
|
const navigate = useNavigate();
|
|
|
|
const authHeaders = () => ({
|
|
Authorization: `Bearer ${localStorage.getItem('token')}`,
|
|
});
|
|
|
|
const fetchData = useCallback(async () => {
|
|
try {
|
|
const [statsRes, purchasesRes, candlesRes] = await Promise.all([
|
|
fetch(`${API}/stats`, { headers: authHeaders() }),
|
|
fetch(`${API}/purchases`, { headers: authHeaders() }),
|
|
fetch(`${API}/candles?days=365`, { headers: authHeaders() }),
|
|
]);
|
|
if (statsRes.status === 401) {
|
|
localStorage.removeItem('token');
|
|
navigate('/login');
|
|
return;
|
|
}
|
|
setStats(await statsRes.json());
|
|
setPurchases(await purchasesRes.json());
|
|
setCandles(await candlesRes.json());
|
|
} catch {
|
|
// silently fail — network may be unavailable
|
|
}
|
|
}, [navigate]);
|
|
|
|
const fetchAllCandles = useCallback(async () => {
|
|
try {
|
|
const res = await fetch(`${API}/candles?days=all`, { headers: authHeaders() });
|
|
if (res.ok) setCandlesAll(await res.json());
|
|
} catch {
|
|
// silently fail
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
fetchData();
|
|
}, [fetchData]);
|
|
|
|
const isAdmin = localStorage.getItem('is_admin') === 'true';
|
|
|
|
const handleLogout = () => {
|
|
localStorage.removeItem('token');
|
|
localStorage.removeItem('is_admin');
|
|
navigate('/login');
|
|
};
|
|
|
|
const handleToggleFullscreen = useCallback(() => {
|
|
if (!fullscreenChart && !candlesAll) fetchAllCandles();
|
|
setFullscreenChart(f => !f);
|
|
}, [fullscreenChart, candlesAll, fetchAllCandles]);
|
|
|
|
const plHighlight = stats
|
|
? stats.profit_loss >= 0 ? 'positive' : 'negative'
|
|
: 'neutral';
|
|
|
|
// Fullscreen uses all-candles data once loaded, otherwise falls back to 365-day set
|
|
const activeCandles = (fullscreenChart && candlesAll) ? candlesAll : candles;
|
|
|
|
return (
|
|
<div style={styles.app}>
|
|
<div style={styles.header}>
|
|
<div style={styles.logo}>₿ BTC Portfolio</div>
|
|
<div style={styles.headerBtns}>
|
|
{isAdmin && <Link to="/admin" style={styles.adminBtn}>Manage Users</Link>}
|
|
<button style={styles.logoutBtn} onClick={handleLogout}>Logout</button>
|
|
</div>
|
|
</div>
|
|
|
|
{stats && (
|
|
<div style={styles.statsGrid}>
|
|
<StatCard label="Total Invested" value={`€${stats.total_invested.toLocaleString()}`} />
|
|
<StatCard label="Avg Buy Price" value={`€${stats.average_price.toLocaleString()}`} />
|
|
<StatCard label="Total BTC" value={`₿${stats.total_btc}`} highlight="neutral" />
|
|
<StatCard label="Current BTC Price" value={`€${stats.current_price.toLocaleString()}`} />
|
|
<StatCard label="Portfolio Value" value={`€${stats.portfolio_value.toLocaleString()}`} />
|
|
<StatCard
|
|
label="Profit / Loss"
|
|
value={`${stats.profit_loss >= 0 ? '+' : ''}€${stats.profit_loss.toLocaleString()}`}
|
|
highlight={plHighlight}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<div style={styles.tabs}>
|
|
{[['both', 'Both'], ['portfolio', 'Portfolio'], ['history', 'BTC Candles']].map(([key, label]) => (
|
|
<button
|
|
key={key}
|
|
style={chartView === key ? styles.tabActive : styles.tab}
|
|
onClick={() => setChartView(key)}
|
|
>{label}</button>
|
|
))}
|
|
</div>
|
|
{(chartView === 'both' || chartView === 'portfolio') && <PortfolioChart purchases={purchases} stats={stats} btcHistory={candles?.candles ?? []} />}
|
|
{(chartView === 'both' || chartView === 'history') && (
|
|
<BTCCandlestickChart
|
|
candles={activeCandles?.candles ?? null}
|
|
purchases={activeCandles?.purchases ?? purchases}
|
|
stats={stats}
|
|
fullscreen={fullscreenChart}
|
|
onToggleFullscreen={handleToggleFullscreen}
|
|
/>
|
|
)}
|
|
<AddPurchase onAdded={fetchData} />
|
|
<PurchaseList purchases={purchases} onChanged={fetchData} />
|
|
</div>
|
|
);
|
|
}
|