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:
@@ -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}
|
||||
@@ -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([
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user