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 fastapi.middleware.cors import CORSMiddleware
from .database import engine, Base from .database import engine, Base
from .routes import users, purchases, stats from .routes import users, purchases, stats, history
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
@@ -19,6 +19,7 @@ app.add_middleware(
app.include_router(users.router) app.include_router(users.router)
app.include_router(purchases.router) app.include_router(purchases.router)
app.include_router(stats.router) app.include_router(stats.router)
app.include_router(history.router)
@app.get("/") @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 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: def get_btc_price_eur() -> float:
try: try:
resp = requests.get( 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 AddPurchase from '../components/AddPurchase';
import PurchaseList from '../components/PurchaseList'; import PurchaseList from '../components/PurchaseList';
import PortfolioChart from '../components/PortfolioChart'; import PortfolioChart from '../components/PortfolioChart';
import BTCHistoryChart from '../components/BTCHistoryChart';
const API = process.env.REACT_APP_API_URL || 'http://localhost:8000'; const API = process.env.REACT_APP_API_URL || 'http://localhost:8000';
@@ -32,6 +33,7 @@ function StatCard({ label, value, highlight }) {
export default function Dashboard() { export default function Dashboard() {
const [stats, setStats] = useState(null); const [stats, setStats] = useState(null);
const [purchases, setPurchases] = useState([]); const [purchases, setPurchases] = useState([]);
const [history, setHistory] = useState(null);
const navigate = useNavigate(); const navigate = useNavigate();
const authHeaders = () => ({ const authHeaders = () => ({
@@ -40,9 +42,10 @@ export default function Dashboard() {
const fetchData = useCallback(async () => { const fetchData = useCallback(async () => {
try { try {
const [statsRes, purchasesRes] = await Promise.all([ const [statsRes, purchasesRes, historyRes] = await Promise.all([
fetch(`${API}/stats`, { headers: authHeaders() }), fetch(`${API}/stats`, { headers: authHeaders() }),
fetch(`${API}/purchases`, { headers: authHeaders() }), fetch(`${API}/purchases`, { headers: authHeaders() }),
fetch(`${API}/history`, { headers: authHeaders() }),
]); ]);
if (statsRes.status === 401) { if (statsRes.status === 401) {
localStorage.removeItem('token'); localStorage.removeItem('token');
@@ -51,6 +54,7 @@ export default function Dashboard() {
} }
setStats(await statsRes.json()); setStats(await statsRes.json());
setPurchases(await purchasesRes.json()); setPurchases(await purchasesRes.json());
setHistory(await historyRes.json());
} catch { } catch {
// silently fail — network may be unavailable // silently fail — network may be unavailable
} }
@@ -92,6 +96,7 @@ export default function Dashboard() {
)} )}
<PortfolioChart purchases={purchases} stats={stats} /> <PortfolioChart purchases={purchases} stats={stats} />
<BTCHistoryChart history={history} stats={stats} />
<AddPurchase onAdded={fetchData} /> <AddPurchase onAdded={fetchData} />
<PurchaseList purchases={purchases} onChanged={fetchData} /> <PurchaseList purchases={purchases} onChanged={fetchData} />
</div> </div>