diff --git a/btc-portfolio/backend/app/main.py b/btc-portfolio/backend/app/main.py index bafeb7e..2496285 100644 --- a/btc-portfolio/backend/app/main.py +++ b/btc-portfolio/backend/app/main.py @@ -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("/") diff --git a/btc-portfolio/backend/app/routes/history.py b/btc-portfolio/backend/app/routes/history.py new file mode 100644 index 0000000..dcae971 --- /dev/null +++ b/btc-portfolio/backend/app/routes/history.py @@ -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} diff --git a/btc-portfolio/backend/app/services/btc.py b/btc-portfolio/backend/app/services/btc.py index e20acdb..de914f2 100644 --- a/btc-portfolio/backend/app/services/btc.py +++ b/btc-portfolio/backend/app/services/btc.py @@ -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( diff --git a/btc-portfolio/frontend/src/components/BTCHistoryChart.js b/btc-portfolio/frontend/src/components/BTCHistoryChart.js new file mode 100644 index 0000000..7a5b1f5 --- /dev/null +++ b/btc-portfolio/frontend/src/components/BTCHistoryChart.js @@ -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 ( +
+
1-Year BTC Price (EUR)
+
Loading price history…
+
+ ); + } + + 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 ( +
+
1-Year BTC Price (EUR)
+ +
+ +
+ ); +} diff --git a/btc-portfolio/frontend/src/pages/Dashboard.js b/btc-portfolio/frontend/src/pages/Dashboard.js index 7269991..6f25e44 100644 --- a/btc-portfolio/frontend/src/pages/Dashboard.js +++ b/btc-portfolio/frontend/src/pages/Dashboard.js @@ -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() { )} +