Add 1-year BTC daily price history chart

New GET /history endpoint fetches 365 days of BTC/EUR data from
CoinGecko, deduplicates by date, and joins the user's purchases.
BTCHistoryChart component renders the price line with orange dot
markers on purchase dates and a dashed cyan avg buy price line.
Tooltip shows purchase details on marked dates.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jonathan
2026-03-23 23:18:40 +01:00
parent cb7bbf393c
commit 541b5f57d2
5 changed files with 184 additions and 3 deletions
@@ -0,0 +1,121 @@
import React, { useRef } from 'react';
import {
Chart as ChartJS,
LineElement,
PointElement,
LinearScale,
CategoryScale,
Tooltip,
Legend,
Filler,
} from 'chart.js';
import { Line } from 'react-chartjs-2';
ChartJS.register(LineElement, PointElement, LinearScale, CategoryScale, Tooltip, Legend, Filler);
const styles = {
card: { background: '#1a1a1a', padding: '1.5rem', borderRadius: '12px', border: '1px solid #333', marginBottom: '1.5rem' },
title: { fontSize: '1.1rem', fontWeight: 700, marginBottom: '1rem', color: '#f7931a' },
loading: { color: '#666', padding: '1rem 0' },
saveBtn: { marginTop: '0.75rem', background: 'none', border: '1px solid #555', color: '#aaa', borderRadius: '6px', padding: '0.4rem 1rem', cursor: 'pointer', fontSize: '0.85rem' },
};
export default function BTCHistoryChart({ history, stats }) {
const chartRef = useRef(null);
if (!history || history.prices.length === 0) {
return (
<div style={styles.card}>
<div style={styles.title}>1-Year BTC Price (EUR)</div>
<div style={styles.loading}>Loading price history</div>
</div>
);
}
const { prices, purchases } = history;
const averagePrice = stats?.average_price ?? 0;
const purchaseDateSet = new Set(purchases.map(p => p.date));
const pointRadii = prices.map(p => purchaseDateSet.has(p.date) ? 7 : 0);
const pointColors = prices.map(p => purchaseDateSet.has(p.date) ? '#f7931a' : 'transparent');
const pointBorderColors = prices.map(p => purchaseDateSet.has(p.date) ? '#fff' : 'transparent');
const data = {
labels: prices.map(p => p.date),
datasets: [
{
label: 'BTC Price (€)',
data: prices.map(p => p.price),
borderColor: '#f7931a',
backgroundColor: 'rgba(247,147,26,0.08)',
fill: true,
tension: 0.2,
pointRadius: pointRadii,
pointBackgroundColor: pointColors,
pointBorderColor: pointBorderColors,
pointBorderWidth: 2,
pointHoverRadius: 6,
},
...(averagePrice > 0 ? [{
label: `Avg Buy Price (€${averagePrice.toLocaleString()})`,
data: prices.map(() => averagePrice),
borderColor: '#4fc3f7',
backgroundColor: 'transparent',
borderDash: [8, 4],
pointRadius: 0,
pointHoverRadius: 0,
tension: 0,
}] : []),
],
};
const options = {
responsive: true,
plugins: {
legend: { labels: { color: '#aaa' } },
tooltip: {
mode: 'index',
intersect: false,
callbacks: {
afterBody: (items) => {
const date = items[0]?.label;
const purchase = purchases.find(p => p.date === date);
if (purchase) {
return [`Purchased: €${purchase.amount_eur.toLocaleString()} @ €${purchase.price_eur.toLocaleString()}`];
}
return [];
},
},
},
},
scales: {
x: {
ticks: { color: '#666', maxTicksLimit: 12, maxRotation: 0 },
grid: { color: '#2a2a2a' },
},
y: {
ticks: { color: '#666' },
grid: { color: '#2a2a2a' },
},
},
};
const handleSave = () => {
const chart = chartRef.current;
if (!chart) return;
const a = document.createElement('a');
a.href = chart.toBase64Image();
a.download = 'btc-history.png';
a.click();
};
return (
<div style={styles.card}>
<div style={styles.title}>1-Year BTC Price (EUR)</div>
<Line ref={chartRef} data={data} options={options} />
<br />
<button style={styles.saveBtn} onClick={handleSave}>Save as PNG</button>
</div>
);
}
@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom';
import AddPurchase from '../components/AddPurchase';
import PurchaseList from '../components/PurchaseList';
import PortfolioChart from '../components/PortfolioChart';
import BTCHistoryChart from '../components/BTCHistoryChart';
const API = process.env.REACT_APP_API_URL || 'http://localhost:8000';
@@ -32,6 +33,7 @@ function StatCard({ label, value, highlight }) {
export default function Dashboard() {
const [stats, setStats] = useState(null);
const [purchases, setPurchases] = useState([]);
const [history, setHistory] = useState(null);
const navigate = useNavigate();
const authHeaders = () => ({
@@ -40,9 +42,10 @@ export default function Dashboard() {
const fetchData = useCallback(async () => {
try {
const [statsRes, purchasesRes] = await Promise.all([
fetch(`${API}/stats`, { headers: authHeaders() }),
const [statsRes, purchasesRes, historyRes] = await Promise.all([
fetch(`${API}/stats`, { headers: authHeaders() }),
fetch(`${API}/purchases`, { headers: authHeaders() }),
fetch(`${API}/history`, { headers: authHeaders() }),
]);
if (statsRes.status === 401) {
localStorage.removeItem('token');
@@ -51,6 +54,7 @@ export default function Dashboard() {
}
setStats(await statsRes.json());
setPurchases(await purchasesRes.json());
setHistory(await historyRes.json());
} catch {
// silently fail — network may be unavailable
}
@@ -92,6 +96,7 @@ export default function Dashboard() {
)}
<PortfolioChart purchases={purchases} stats={stats} />
<BTCHistoryChart history={history} stats={stats} />
<AddPurchase onAdded={fetchData} />
<PurchaseList purchases={purchases} onChanged={fetchData} />
</div>