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
+2 -1
View File
@@ -2,7 +2,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from .database import engine, Base
from .routes import users, purchases, stats
from .routes import users, purchases, stats, history
Base.metadata.create_all(bind=engine)
@@ -19,6 +19,7 @@ app.add_middleware(
app.include_router(users.router)
app.include_router(purchases.router)
app.include_router(stats.router)
app.include_router(history.router)
@app.get("/")
@@ -0,0 +1,41 @@
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from datetime import datetime, timezone
from ..database import get_db
from .. import models
from ..dependencies import get_current_user
from ..services.btc import get_btc_history_eur
router = APIRouter()
@router.get("/history")
def get_history(
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
raw = get_btc_history_eur()
# Convert timestamps and deduplicate (keep last price per date)
seen = {}
for ts, price in raw:
date = datetime.fromtimestamp(ts / 1000, tz=timezone.utc).strftime("%Y-%m-%d")
seen[date] = round(price, 2)
prices = [{"date": d, "price": p} for d, p in seen.items()]
purchases_db = db.query(models.Purchase).filter(
models.Purchase.user_id == current_user.id
).all()
purchases = [
{
"date": p.created_at.strftime("%Y-%m-%d"),
"amount_eur": round(p.amount_eur, 2),
"price_eur": round(p.price_eur, 2),
}
for p in purchases_db
]
return {"prices": prices, "purchases": purchases}
+13
View File
@@ -1,6 +1,19 @@
import requests
def get_btc_history_eur() -> list:
try:
resp = requests.get(
"https://api.coingecko.com/api/v3/coins/bitcoin/market_chart",
params={"vs_currency": "eur", "days": "365", "interval": "daily"},
timeout=15,
)
resp.raise_for_status()
return resp.json().get("prices", []) # [[timestamp_ms, price], ...]
except Exception:
return []
def get_btc_price_eur() -> float:
try:
resp = requests.get(
@@ -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>