Compare commits

...

24 Commits

Author SHA1 Message Date
Jonathan 15358c05c3 Merge branch 'development' 2026-04-14 19:58:46 +02:00
Jonathan 59f833d7fd Fix candle chart staleness and show live price on today's candle
- Refresh candles on every /candles request instead of only at startup
- Patch today's candle close/high/low with the live BTC price intraday
- Synthesise today's candle from live price if CoinGecko hasn't published it yet
- Display current BTC price next to the chart title

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 19:55:01 +02:00
Jonathan d5197cde14 Update README to reflect all features added since initial docs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 20:43:06 +02:00
Jonathan 9c0db31580 Merge branch 'development' 2026-04-06 20:32:49 +02:00
Jonathan d90ac5365a Add price prediction feature to dashboard
Enter a hypothetical BTC price to instantly see predicted portfolio value,
predicted P&L, and difference vs current value — all calculated client-side.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 20:32:25 +02:00
Jonathan ce9547a623 Merge development into main
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 20:20:08 +02:00
Jonathan 672f5b74a4 Fix production deployment: replace serve with nginx reverse proxy
Frontend container now uses nginx to serve static files and proxy
/api/* requests to the backend container internally, eliminating
the hardcoded localhost:8000 build-time URL that caused "Network
error" on any non-local server. CORS origins are also configurable
via ALLOWED_ORIGINS env var.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 20:18:38 +02:00
Jonathan a2ca82062e Cache last known BTC price and show stale warning in UI
When the CoinGecko API fails, fall back to the last successful price
instead of 0.0, and surface a warning indicator on the price card.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 20:01:30 +02:00
Jonathan 5bb67d6663 Add purchase date picker and sells feature
- Purchase form now includes a date picker (defaults to today)
- New Sell model, CRUD endpoints (/sells), and stats integration
- AddSell and SellList components added to dashboard
- Portfolio chart updated to reflect sells over time

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 19:52:24 +02:00
Jonathan 33656c4512 Merge development: security hardening, login fix, chart improvements
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 19:31:11 +02:00
Jonathan 5cf3726f59 Improve portfolio chart with historical price-based data points
Chart now plots weekly data points from first purchase to today using
candle/history price data, giving an accurate view of portfolio value
over time rather than just at purchase dates.

Backend seeds up to 365 days of daily close prices from CoinGecko as
synthetic OHLC candles, refreshing stale entries older than 31 days.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 19:30:48 +02:00
Jonathan ce227e47d6 Fix login blocked by registration password validation
Split UserCreate into UserCreate (with min_length constraints) and
UserLogin (no constraints) so existing short passwords can still log in.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 18:49:43 +01:00
Jonathan 11b020907d Revert backend Dockerfile to run as root
Non-root appuser can't write to the host-mounted SQLite data volume —
chown in the image layer doesn't carry over to runtime volume mounts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 18:45:41 +01:00
Jonathan 4da23c9def Revert bcrypt version back to 3.2.2
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 18:44:22 +01:00
Jonathan 85455f3271 Security hardening: secrets, validation, Docker, and error handling
- Add root .gitignore to prevent btc_wallet.py (with RPC credentials) from being committed
- Load JWT SECRET_KEY from environment variable instead of hardcoded value
- Restrict CORS to explicit methods/headers instead of wildcards
- Add Pydantic Field validation (gt=0) to purchase amounts and user credentials
- Add logging to all silent exception handlers in btc.py
- Run backend and frontend Docker containers as non-root appuser
- Add .dockerignore for both backend and frontend
- Pass SECRET_KEY env var through docker-compose; add healthchecks to both services
- Update bcrypt from pinned 3.2.2 to >=4.0.0
- Capture error objects in frontend catch blocks; check admin delete response

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 18:40:41 +01:00
Jonathan 4616accc63 Merge branch 'development' 2026-03-24 20:28:59 +01:00
Jonathan a0692501b3 Use en-GB locale for dd/mm/yyyy date format in purchases list
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 20:28:51 +01:00
Jonathan cb28979208 Merge feature/btc-history-chart: BTC candlestick chart with local OHLC storage
Resolves conflicts: combined admin + candles routers in main.py,
fixed docker-compose port to 3001:3001, restored admin styles in Dashboard.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 20:24:50 +01:00
Jonathan 79b565cfb6 Add BTC candlestick chart with local OHLC storage
- Store daily BTC OHLC candles in SQLite to avoid hitting CoinGecko on every load
- Seed with 30 days of daily candles on first boot (free tier gives daily granularity for days<=30)
- Auto-detect and replace coarse legacy candle data on startup
- Daily refresh adds new candles on each container restart
- New GET /candles endpoint (supports ?days=365 and ?days=all)
- Switch BTCHistoryChart to BTCCandlestickChart using lightweight-charts (TradingView)
- Purchase markers with nearest-candle matching and multi-purchase merging
- Fullscreen mode showing all stored candles
- Fix frontend port to 3001 and pass REACT_APP_API_URL as build arg

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 20:23:46 +01:00
Jonathan befbe12bcf Merge branch 'development' 2026-03-24 19:39:41 +01:00
Jonathan 0803d86e38 Add admin role with user management (create/delete users)
First registered user becomes admin automatically. Admins see a
"Manage Users" button in the dashboard header that opens a new
/admin page for listing, creating, and deleting users. Backend
enforces admin-only access on /admin/* routes. Startup migration
adds the is_admin column to existing SQLite databases.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 19:26:10 +01:00
Jonathan aedc6a8a17 Merge branch 'development' 2026-03-24 19:18:56 +01:00
Jonathan c1371e9c72 Extend portfolio chart to always show today's data point
Previously the chart stopped at the last purchase date. Now a final
point for today is appended so current portfolio value vs total
invested is always visible.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 19:18:27 +01:00
Jonathan e93b9dfa53 Add git workflow rules to CLAUDE.md
Never work on main directly; always switch back to development after
merging to main.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 19:10:20 +01:00
32 changed files with 1383 additions and 106 deletions
+2
View File
@@ -0,0 +1,2 @@
# Local wallet scripts with credentials — never commit
btc_wallet.py
+6
View File
@@ -70,3 +70,9 @@ Dashboard has a chart toggle (Both / Portfolio / 1-Year BTC) using tab state in
- `purchases`: id, amount_eur, price_eur, created_at, user_id (FK)
Database persists via Docker volume at `/app/data/btc_portfolio.db`.
## Git Workflow
- **Never work directly on `main`.** All development happens on `development` (or a feature branch).
- After any merge/push to `main`, always switch back to `development` immediately.
- `main` is only touched to merge in completed work from `development`.
+55 -35
View File
@@ -1,29 +1,32 @@
# BTC Portfolio Tracker
A full-stack Bitcoin portfolio tracker with live EUR pricing, purchase history, and interactive charts.
A full-stack Bitcoin portfolio tracker with live EUR pricing, purchase/sell history, candlestick charts, and price predictions.
---
## Features
- **Live BTC price** — fetched from CoinGecko in EUR
- **Purchase tracking** — log BTC buys with amount (EUR) and price per BTC
- **Portfolio stats** — total invested, current value, profit/loss
- **Interactive charts** — portfolio value over time and 1-year BTC price history
- **Edit & delete** — manage purchases with inline editing
- **Live BTC price** — fetched from CoinGecko in EUR, with cached fallback and stale warning
- **Purchase tracking** — log BTC buys with amount (EUR), price per BTC, and a custom date
- **Sell tracking** — log BTC sells with BTC amount, price per BTC, and a custom date
- **Portfolio stats** — total invested, current value, profit/loss, net BTC held
- **Price prediction** — enter a target BTC price to see projected portfolio value and P&L
- **Interactive charts** — portfolio value over time and BTC candlestick chart (OHLC stored locally)
- **Edit & delete** — inline editing and deletion for both purchases and sells
- **Admin panel** — admin users can create, list, and delete accounts
- **JWT authentication** — secure per-user portfolios
---
## Tech Stack
| Layer | Technology |
|----------|-------------------------------------|
| Frontend | React 18, Chart.js, dark theme |
| Backend | FastAPI, SQLAlchemy, SQLite |
| Auth | JWT (HS256, 24h expiry) + bcrypt |
| Pricing | CoinGecko API (EUR) |
| Deploy | Docker + Docker Compose |
| Layer | Technology |
|----------|-----------------------------------------|
| Frontend | React 18, Chart.js, dark theme |
| Backend | FastAPI, SQLAlchemy, SQLite |
| Auth | JWT (HS256, 24h expiry) + bcrypt |
| Pricing | CoinGecko API (EUR) |
| Deploy | Docker + Docker Compose + nginx |
---
@@ -65,42 +68,55 @@ btc-portfolio/
├── backend/
│ └── app/
│ ├── main.py # FastAPI app + CORS
│ ├── models.py # User & Purchase ORM models
│ ├── models.py # User, Purchase, Sell, OHLCCandle ORM models
│ ├── auth.py # JWT + bcrypt
│ ├── dependencies.py # Auth dependency injection
│ ├── routes/
│ │ ├── users.py # POST /register, POST /login
│ │ ├── purchases.py # CRUD /purchases
│ │ ├── sells.py # CRUD /sells
│ │ ├── stats.py # GET /stats
│ │ ── history.py # GET /history (365-day BTC prices)
│ │ ── history.py # GET /history (365-day BTC prices)
│ │ ├── candles.py # GET /candles (OHLC data + purchases overlay)
│ │ └── admin.py # GET/POST/DELETE /admin/users
│ └── services/
│ └── btc.py # CoinGecko integration
└── frontend/
└── src/
├── App.js # Routing
├── pages/
│ └── Dashboard.js # Main view
│ └── Dashboard.js # Main view: stats, predictions, charts, tables
└── components/
├── AddPurchase.js # Purchase form
├── PurchaseList.js # Purchase table (edit/delete)
├── PortfolioChart.js # Invested vs current value
── BTCHistoryChart.js # 1-year BTC price history
├── AddPurchase.js # Purchase form (amount EUR, price, date)
├── PurchaseList.js # Purchase table (inline edit/delete)
├── AddSell.js # Sell form (BTC amount, price, date)
── SellList.js # Sell table (inline edit/delete)
├── PortfolioChart.js # Invested vs current value over time
└── BTCCandlestickChart.js # OHLC candlestick chart with purchase markers
```
---
## API Endpoints
| Method | Endpoint | Description | Auth |
|--------|-------------------|------------------------------|------|
| POST | `/register` | Create account | No |
| POST | `/login` | Get JWT token | No |
| GET | `/purchases` | List user purchases | Yes |
| POST | `/purchases` | Add a purchase | Yes |
| PUT | `/purchases/{id}` | Edit a purchase | Yes |
| DELETE | `/purchases/{id}` | Delete a purchase | Yes |
| GET | `/stats` | Portfolio stats (P&L) | Yes |
| GET | `/history` | 365-day BTC price history | Yes |
| Method | Endpoint | Description | Auth |
|--------|------------------------|------------------------------------|-------|
| POST | `/register` | Create account | No |
| POST | `/login` | Get JWT token | No |
| GET | `/purchases` | List user purchases | Yes |
| POST | `/purchases` | Add a purchase | Yes |
| PUT | `/purchases/{id}` | Edit a purchase | Yes |
| DELETE | `/purchases/{id}` | Delete a purchase | Yes |
| GET | `/sells` | List user sells | Yes |
| POST | `/sells` | Add a sell | Yes |
| PUT | `/sells/{sell_id}` | Edit a sell | Yes |
| DELETE | `/sells/{sell_id}` | Delete a sell | Yes |
| GET | `/stats` | Portfolio stats (P&L, net BTC) | Yes |
| GET | `/history` | 365-day BTC price history | Yes |
| GET | `/candles` | OHLC candles + purchase overlay | Yes |
| GET | `/admin/users` | List all users (admin only) | Admin |
| POST | `/admin/users` | Create a user (admin only) | Admin |
| DELETE | `/admin/users/{id}` | Delete a user (admin only) | Admin |
---
@@ -108,15 +124,19 @@ btc-portfolio/
SQLite, stored at `/app/data/btc_portfolio.db` (persisted via Docker volume).
| Table | Columns |
|-------------|------------------------------------------------------|
| `users` | id, username (unique), password (bcrypt hash) |
| `purchases` | id, amount_eur, price_eur, created_at, user_id (FK) |
| Table | Columns |
|----------------|---------------------------------------------------------------|
| `users` | id, username (unique), password (bcrypt hash), is_admin |
| `purchases` | id, amount_eur, price_eur, created_at, user_id (FK) |
| `sells` | id, btc_amount, price_eur, created_at, user_id (FK) |
| `ohlc_candles` | id, date (unique, YYYY-MM-DD), open, high, low, close |
---
## Notes
- The `SECRET_KEY` in `auth.py` is hardcoded — use an environment variable in production.
- CoinGecko requests are unauthenticated; failures return `0.0` gracefully.
- CoinGecko requests are unauthenticated; failures fall back to the last cached price with a UI warning.
- OHLC candle data is fetched from CoinGecko and stored locally to reduce API calls.
- CORS is restricted to `localhost:3000` by default.
- The frontend is served via nginx in the Docker production setup.
+7
View File
@@ -0,0 +1,7 @@
.git
__pycache__
*.pyc
*.pyo
.env
*.egg-info
.pytest_cache
+2 -1
View File
@@ -1,8 +1,9 @@
import os
from datetime import datetime, timedelta
from jose import JWTError, jwt
from passlib.context import CryptContext
SECRET_KEY = "change-me-in-production-use-a-long-random-string"
SECRET_KEY = os.environ.get("SECRET_KEY", "dev-insecure-key-change-me")
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 # 1 day
@@ -27,3 +27,9 @@ def get_current_user(
if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
return user
def get_current_admin(current_user: models.User = Depends(get_current_user)) -> models.User:
if not current_user.is_admin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
return current_user
+32 -6
View File
@@ -2,28 +2,54 @@ import os
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy import text
from .database import engine, Base
from .routes import users, purchases, stats, history
from .database import engine, Base, SessionLocal
from .routes import users, purchases, stats, history, admin, candles, sells
Base.metadata.create_all(bind=engine)
app = FastAPI(title="BTC Portfolio API")
origins = os.getenv("ALLOWED_ORIGINS", "http://localhost:3000,http://localhost:3001").split(",")
_raw_origins = os.environ.get(
"ALLOWED_ORIGINS",
"http://localhost:3000,http://localhost:3001",
)
allowed_origins = [o.strip() for o in _raw_origins.split(",") if o.strip()]
app.add_middleware(
CORSMiddleware,
allow_origins=origins,
allow_origins=allowed_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allow_headers=["Content-Type", "Authorization"],
)
app.include_router(users.router)
app.include_router(purchases.router)
app.include_router(stats.router)
app.include_router(history.router)
app.include_router(admin.router, prefix="/admin")
app.include_router(candles.router)
app.include_router(sells.router)
@app.on_event("startup")
def startup():
# Schema migration: add is_admin column if missing
with engine.connect() as conn:
cols = [r[1] for r in conn.execute(text("PRAGMA table_info(users)"))]
if "is_admin" not in cols:
conn.execute(text("ALTER TABLE users ADD COLUMN is_admin BOOLEAN DEFAULT 0"))
conn.commit()
# Seed / refresh BTC candle data
from .services.candles import refresh_latest_candles
db = SessionLocal()
try:
refresh_latest_candles(db)
finally:
db.close()
@app.get("/")
+26 -1
View File
@@ -1,4 +1,4 @@
from sqlalchemy import Column, Integer, String, Float, ForeignKey, DateTime
from sqlalchemy import Column, Integer, String, Float, ForeignKey, DateTime, Boolean
from sqlalchemy.orm import relationship
from datetime import datetime
from .database import Base
@@ -10,8 +10,10 @@ class User(Base):
id = Column(Integer, primary_key=True, index=True)
username = Column(String, unique=True, index=True, nullable=False)
password = Column(String, nullable=False)
is_admin = Column(Boolean, default=False, nullable=False, server_default='0')
purchases = relationship("Purchase", back_populates="owner", cascade="all, delete")
sells = relationship("Sell", back_populates="owner", cascade="all, delete")
class Purchase(Base):
@@ -24,3 +26,26 @@ class Purchase(Base):
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
owner = relationship("User", back_populates="purchases")
class Sell(Base):
__tablename__ = "sells"
id = Column(Integer, primary_key=True, index=True)
btc_amount = Column(Float, nullable=False)
price_eur = Column(Float, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
owner = relationship("User", back_populates="sells")
class OHLCCandle(Base):
__tablename__ = "ohlc_candles"
id = Column(Integer, primary_key=True, index=True)
date = Column(String, unique=True, index=True, nullable=False) # "YYYY-MM-DD" UTC
open = Column(Float, nullable=False)
high = Column(Float, nullable=False)
low = Column(Float, nullable=False)
close = Column(Float, nullable=False)
+57
View File
@@ -0,0 +1,57 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from pydantic import BaseModel
from typing import List
from ..database import get_db
from .. import models
from ..auth import hash_password
from ..dependencies import get_current_admin
router = APIRouter()
class UserOut(BaseModel):
id: int
username: str
is_admin: bool
class Config:
from_attributes = True
class UserCreate(BaseModel):
username: str
password: str
is_admin: bool = False
@router.get("/users", response_model=List[UserOut])
def list_users(db: Session = Depends(get_db), _: models.User = Depends(get_current_admin)):
return db.query(models.User).all()
@router.post("/users", response_model=UserOut, status_code=status.HTTP_201_CREATED)
def create_user(user_in: UserCreate, db: Session = Depends(get_db), _: models.User = Depends(get_current_admin)):
if db.query(models.User).filter(models.User.username == user_in.username).first():
raise HTTPException(status_code=400, detail="Username already taken")
user = models.User(
username=user_in.username,
password=hash_password(user_in.password),
is_admin=user_in.is_admin,
)
db.add(user)
db.commit()
db.refresh(user)
return user
@router.delete("/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_user(user_id: int, db: Session = Depends(get_db), current_admin: models.User = Depends(get_current_admin)):
if user_id == current_admin.id:
raise HTTPException(status_code=400, detail="Cannot delete your own account")
user = db.query(models.User).filter(models.User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
db.delete(user)
db.commit()
@@ -0,0 +1,79 @@
from fastapi import APIRouter, Depends, Query
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.candles import refresh_latest_candles
from ..services.btc import get_btc_price_eur
router = APIRouter()
@router.get("/candles")
def get_candles(
days: str = Query(default="365"),
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
# Refresh candles on every request (no-op if DB is already current)
refresh_latest_candles(db)
query = db.query(models.OHLCCandle).order_by(models.OHLCCandle.date.asc())
if days != "all":
try:
limit = int(days)
except ValueError:
limit = 365
# Fetch the most recent `limit` rows
total = query.count()
if total > limit:
query = query.offset(total - limit)
candles_db = query.all()
candles = [
{
"date": c.date,
"open": round(c.open, 2),
"high": round(c.high, 2),
"low": round(c.low, 2),
"close": round(c.close, 2),
}
for c in candles_db
]
# Patch today's candle with the live price so it tracks intraday movement
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
live_price, _ = get_btc_price_eur()
if live_price and candles:
last = candles[-1]
if last["date"] == today:
last["close"] = round(live_price, 2)
last["high"] = round(max(last["high"], live_price), 2)
last["low"] = round(min(last["low"], live_price), 2)
elif last["date"] < today:
# No candle for today yet — create a synthetic one from live price
prev_close = last["close"]
candles.append({
"date": today,
"open": prev_close,
"high": round(max(prev_close, live_price), 2),
"low": round(min(prev_close, live_price), 2),
"close": round(live_price, 2),
})
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 {"candles": candles, "purchases": purchases}
@@ -1,7 +1,7 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from pydantic import BaseModel
from typing import List
from pydantic import BaseModel, Field
from typing import List, Optional
from datetime import datetime
from ..database import get_db
@@ -12,13 +12,14 @@ router = APIRouter()
class PurchaseCreate(BaseModel):
amount_eur: float
price_eur: float
amount_eur: float = Field(gt=0, le=10_000_000)
price_eur: float = Field(gt=0, le=10_000_000)
created_at: Optional[datetime] = None
class PurchaseUpdate(BaseModel):
amount_eur: float
price_eur: float
amount_eur: float = Field(gt=0, le=10_000_000)
price_eur: float = Field(gt=0, le=10_000_000)
created_at: datetime
@@ -54,6 +55,7 @@ def add_purchase(
purchase = models.Purchase(
amount_eur=purchase_in.amount_eur,
price_eur=purchase_in.price_eur,
created_at=purchase_in.created_at or datetime.utcnow(),
user_id=current_user.id,
)
db.add(purchase)
+101
View File
@@ -0,0 +1,101 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from pydantic import BaseModel, Field
from typing import List, Optional
from datetime import datetime
from ..database import get_db
from .. import models
from ..dependencies import get_current_user
router = APIRouter()
class SellCreate(BaseModel):
btc_amount: float = Field(gt=0, le=21_000_000)
price_eur: float = Field(gt=0, le=10_000_000)
created_at: Optional[datetime] = None
class SellUpdate(BaseModel):
btc_amount: float = Field(gt=0, le=21_000_000)
price_eur: float = Field(gt=0, le=10_000_000)
created_at: datetime
class SellOut(BaseModel):
id: int
btc_amount: float
price_eur: float
created_at: datetime
class Config:
from_attributes = True
@router.get("/sells", response_model=List[SellOut])
def list_sells(
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
return (
db.query(models.Sell)
.filter(models.Sell.user_id == current_user.id)
.order_by(models.Sell.created_at)
.all()
)
@router.post("/sells", response_model=SellOut, status_code=status.HTTP_201_CREATED)
def add_sell(
sell_in: SellCreate,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
sell = models.Sell(
btc_amount=sell_in.btc_amount,
price_eur=sell_in.price_eur,
created_at=sell_in.created_at or datetime.utcnow(),
user_id=current_user.id,
)
db.add(sell)
db.commit()
db.refresh(sell)
return sell
@router.put("/sells/{sell_id}", response_model=SellOut)
def update_sell(
sell_id: int,
sell_in: SellUpdate,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
sell = db.query(models.Sell).filter(
models.Sell.id == sell_id,
models.Sell.user_id == current_user.id,
).first()
if not sell:
raise HTTPException(status_code=404, detail="Sell not found")
sell.btc_amount = sell_in.btc_amount
sell.price_eur = sell_in.price_eur
sell.created_at = sell_in.created_at
db.commit()
db.refresh(sell)
return sell
@router.delete("/sells/{sell_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_sell(
sell_id: int,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
sell = db.query(models.Sell).filter(
models.Sell.id == sell_id,
models.Sell.user_id == current_user.id,
).first()
if not sell:
raise HTTPException(status_code=404, detail="Sell not found")
db.delete(sell)
db.commit()
+15 -7
View File
@@ -15,19 +15,27 @@ def get_stats(
current_user: models.User = Depends(get_current_user),
):
purchases = db.query(models.Purchase).filter(models.Purchase.user_id == current_user.id).all()
sells = db.query(models.Sell).filter(models.Sell.user_id == current_user.id).all()
total_invested = sum(p.amount_eur for p in purchases)
total_btc = sum(p.amount_eur / p.price_eur for p in purchases) if purchases else 0.0
average_price = total_invested / total_btc if total_btc > 0 else 0.0
current_price = get_btc_price_eur()
portfolio_value = total_btc * current_price
profit_loss = portfolio_value - total_invested
total_btc_bought = sum(p.amount_eur / p.price_eur for p in purchases) if purchases else 0.0
total_btc_sold = sum(s.btc_amount for s in sells)
proceeds_eur = sum(s.btc_amount * s.price_eur for s in sells)
net_btc = total_btc_bought - total_btc_sold
net_invested = total_invested - proceeds_eur
average_price = net_invested / net_btc if net_btc > 0 else 0.0
current_price, price_is_cached = get_btc_price_eur()
portfolio_value = net_btc * current_price
profit_loss = portfolio_value - net_invested
return {
"total_invested": round(total_invested, 2),
"total_btc": round(total_btc, 8),
"total_invested": round(net_invested, 2),
"total_btc": round(net_btc, 8),
"average_price": round(average_price, 2),
"current_price": round(current_price, 2),
"price_is_cached": price_is_cached,
"portfolio_value": round(portfolio_value, 2),
"profit_loss": round(profit_loss, 2),
}
+11 -3
View File
@@ -1,6 +1,6 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from pydantic import BaseModel
from pydantic import BaseModel, Field
from ..database import get_db
from .. import models
@@ -10,6 +10,11 @@ router = APIRouter()
class UserCreate(BaseModel):
username: str = Field(min_length=3, max_length=50)
password: str = Field(min_length=8)
class UserLogin(BaseModel):
username: str
password: str
@@ -17,6 +22,7 @@ class UserCreate(BaseModel):
class Token(BaseModel):
access_token: str
token_type: str
is_admin: bool
@router.post("/register", status_code=status.HTTP_201_CREATED)
@@ -24,9 +30,11 @@ def register(user_in: UserCreate, db: Session = Depends(get_db)):
existing = db.query(models.User).filter(models.User.username == user_in.username).first()
if existing:
raise HTTPException(status_code=400, detail="Username already taken")
no_users_yet = db.query(models.User).first() is None
user = models.User(
username=user_in.username,
password=hash_password(user_in.password),
is_admin=no_users_yet,
)
db.add(user)
db.commit()
@@ -34,9 +42,9 @@ def register(user_in: UserCreate, db: Session = Depends(get_db)):
@router.post("/login", response_model=Token)
def login(user_in: UserCreate, db: Session = Depends(get_db)):
def login(user_in: UserLogin, db: Session = Depends(get_db)):
user = db.query(models.User).filter(models.User.username == user_in.username).first()
if not user or not verify_password(user_in.password, user.password):
raise HTTPException(status_code=401, detail="Invalid credentials")
token = create_access_token({"sub": user.username})
return {"access_token": token, "token_type": "bearer"}
return {"access_token": token, "token_type": "bearer", "is_admin": user.is_admin}
+53 -5
View File
@@ -1,4 +1,8 @@
import logging
import requests
from datetime import datetime, timezone
logger = logging.getLogger(__name__)
def get_btc_history_eur() -> list:
@@ -10,11 +14,52 @@ def get_btc_history_eur() -> list:
)
resp.raise_for_status()
return resp.json().get("prices", []) # [[timestamp_ms, price], ...]
except Exception:
except Exception as e:
logger.error(f"Failed to fetch BTC history: {e}")
return []
def get_btc_price_eur() -> float:
def get_btc_ohlc_eur(days: int) -> list:
"""Fetch OHLC candles from CoinGecko. Returns [[ts_ms, open, high, low, close], ...]."""
try:
resp = requests.get(
"https://api.coingecko.com/api/v3/coins/bitcoin/ohlc",
params={"vs_currency": "eur", "days": str(days)},
timeout=20,
)
resp.raise_for_status()
return resp.json() # [[timestamp_ms, open, high, low, close], ...]
except Exception as e:
logger.error(f"Failed to fetch BTC OHLC: {e}")
return []
def aggregate_to_daily(raw: list) -> dict:
"""Collapse intraday OHLC rows into one candle per UTC calendar date.
Returns {date_str: {open, high, low, close}} using first open, max high,
min low, last close per day.
"""
by_date: dict = {}
for row in raw:
ts_ms, o, h, l, c = row
date = datetime.fromtimestamp(ts_ms / 1000, tz=timezone.utc).strftime("%Y-%m-%d")
if date not in by_date:
by_date[date] = {"open": o, "high": h, "low": l, "close": c}
else:
existing = by_date[date]
existing["high"] = max(existing["high"], h)
existing["low"] = min(existing["low"], l)
existing["close"] = c # last close wins
return by_date
_last_known_price: float = 0.0
def get_btc_price_eur() -> tuple[float, bool]:
"""Returns (price, is_cached). is_cached=True when using a stale fallback."""
global _last_known_price
try:
resp = requests.get(
"https://api.coingecko.com/api/v3/simple/price",
@@ -22,6 +67,9 @@ def get_btc_price_eur() -> float:
timeout=10,
)
resp.raise_for_status()
return float(resp.json()["bitcoin"]["eur"])
except Exception:
return 0.0
price = float(resp.json()["bitcoin"]["eur"])
_last_known_price = price
return price, False
except Exception as e:
logger.error(f"Failed to fetch BTC price: {e}")
return _last_known_price, True
@@ -0,0 +1,117 @@
import logging
from datetime import datetime, timezone, timedelta, date as dt_date
from sqlalchemy.orm import Session
from ..models import OHLCCandle
from .btc import get_btc_ohlc_eur, aggregate_to_daily, get_btc_history_eur
logger = logging.getLogger(__name__)
def seed_candles(db: Session) -> None:
"""Fetch 30 days of daily OHLC candles from CoinGecko and store them.
Free tier gives 4-hour bars for days<=30, which aggregate cleanly to daily candles.
days>30 drops to 4-day granularity (unusable for a daily chart).
"""
raw = get_btc_ohlc_eur(days=30)
if not raw:
logger.warning("Candle seed: CoinGecko returned no data — will retry on next startup.")
return
daily = aggregate_to_daily(raw)
rows = [
OHLCCandle(date=date, open=v["open"], high=v["high"], low=v["low"], close=v["close"])
for date, v in sorted(daily.items())
]
for row in rows:
db.merge(row)
db.commit()
logger.info("Candle seed: stored %d daily candles (%s%s).", len(rows), min(daily.keys()), max(daily.keys()))
def seed_historical_prices(db: Session) -> None:
"""Backfill up to 365 days of daily close prices from CoinGecko market_chart.
Uses previous day's close as each day's open to produce red/green candles.
Clears entries older than 31 days on each run so the data stays fresh.
Real OHLC entries (last 30 days) are never touched.
"""
raw = get_btc_history_eur()
if not raw:
logger.warning("Historical price seed: CoinGecko returned no data.")
return
prices = {}
for ts_ms, price in raw:
date = datetime.fromtimestamp(ts_ms / 1000, tz=timezone.utc).strftime("%Y-%m-%d")
prices[date] = price
# Remove stale historical entries (older than 31 days) so they get re-seeded with current data
cutoff = (datetime.now(tz=timezone.utc) - timedelta(days=31)).strftime("%Y-%m-%d")
db.query(OHLCCandle).filter(OHLCCandle.date < cutoff).delete()
db.commit()
existing = {c.date for c in db.query(OHLCCandle).all()}
new_rows = []
prev_close = None
for date, close in sorted(prices.items()):
if date in existing:
prev_close = close
continue
open_ = prev_close if prev_close is not None else close
high = max(open_, close)
low = min(open_, close)
new_rows.append(OHLCCandle(date=date, open=open_, high=high, low=low, close=close))
prev_close = close
if new_rows:
db.add_all(new_rows)
db.commit()
logger.info("Historical price seed: stored %d daily entries (%s%s).", len(new_rows), new_rows[0].date, new_rows[-1].date)
def refresh_latest_candles(db: Session) -> None:
"""Add any missing candles up to today. Seeds the DB if empty.
Also detects and replaces coarse (>2-day gap) legacy data from a previous days=365 seed.
"""
# Always backfill historical prices for dates not yet in DB (no-op once populated)
seed_historical_prices(db)
# Sparse-data detection: if existing candles have >2-day gaps, wipe and re-seed
first_two = db.query(OHLCCandle).order_by(OHLCCandle.date.asc()).limit(2).all()
if len(first_two) == 2:
d1 = dt_date.fromisoformat(first_two[0].date)
d2 = dt_date.fromisoformat(first_two[1].date)
if (d2 - d1).days > 2:
logger.warning("Candle refresh: detected coarse candle data (gap=%d days). Wiping and re-seeding with daily candles.", (d2 - d1).days)
db.query(OHLCCandle).delete()
db.commit()
seed_candles(db)
return
latest = db.query(OHLCCandle).order_by(OHLCCandle.date.desc()).first()
if latest is None:
seed_candles(db)
return
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
if latest.date >= today:
return # Already up to date
raw = get_btc_ohlc_eur(days=7)
if not raw:
logger.warning("Candle refresh: CoinGecko returned no data.")
return
daily = aggregate_to_daily(raw)
new_dates = [d for d in daily if d > latest.date]
if not new_dates:
return
for date in new_dates:
v = daily[date]
db.merge(OHLCCandle(date=date, open=v["open"], high=v["high"], low=v["low"], close=v["close"]))
db.commit()
logger.info("Candle refresh: upserted %d candle(s) up to %s.", len(new_dates), max(new_dates))
+16 -2
View File
@@ -7,15 +7,29 @@ services:
- ./data:/app/data
environment:
- DATABASE_URL=sqlite:////app/data/btc_portfolio.db
- SECRET_KEY=${SECRET_KEY:-dev-insecure-key-change-me}
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-http://localhost:3000,http://localhost:3001}
restart: unless-stopped
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/')"]
interval: 30s
timeout: 10s
retries: 3
start_period: 15s
frontend:
build:
context: ./frontend
args:
- REACT_APP_API_URL=http://localhost:8000
- REACT_APP_API_URL=/api
ports:
- "3001:3000"
- "3001:80"
depends_on:
- backend
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:80/"]
interval: 30s
timeout: 10s
retries: 3
start_period: 20s
+6
View File
@@ -0,0 +1,6 @@
.git
node_modules
build
.env
.env.local
npm-debug.log
+6 -7
View File
@@ -4,13 +4,12 @@ WORKDIR /app
COPY package.json ./
RUN npm install
COPY . .
ARG REACT_APP_API_URL=http://localhost:8000
ARG REACT_APP_API_URL=/api
ENV REACT_APP_API_URL=$REACT_APP_API_URL
RUN npm run build
FROM node:18-alpine
RUN npm install -g serve
WORKDIR /app
COPY --from=build /app/build ./build
EXPOSE 3000
CMD ["serve", "-s", "build", "-l", "3000"]
FROM nginx:alpine
COPY --from=build /app/build /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+20
View File
@@ -0,0 +1,20 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location /api/ {
proxy_pass http://backend:8000/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
try_files $uri $uri/ /index.html;
}
}
+2 -1
View File
@@ -8,7 +8,8 @@
"react-router-dom": "^6.22.0",
"react-scripts": "5.0.1",
"chart.js": "^4.4.0",
"react-chartjs-2": "^5.2.0"
"react-chartjs-2": "^5.2.0",
"lightweight-charts": "^4.2.0"
},
"scripts": {
"start": "react-scripts start",
+8
View File
@@ -3,11 +3,18 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import Login from './pages/Login';
import Register from './pages/Register';
import Dashboard from './pages/Dashboard';
import AdminPage from './pages/AdminPage';
function PrivateRoute({ children }) {
return localStorage.getItem('token') ? children : <Navigate to="/login" />;
}
function AdminRoute({ children }) {
if (!localStorage.getItem('token')) return <Navigate to="/login" />;
if (localStorage.getItem('is_admin') !== 'true') return <Navigate to="/" />;
return children;
}
export default function App() {
return (
<BrowserRouter>
@@ -15,6 +22,7 @@ export default function App() {
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route path="/" element={<PrivateRoute><Dashboard /></PrivateRoute>} />
<Route path="/admin" element={<AdminRoute><AdminPage /></AdminRoute>} />
<Route path="*" element={<Navigate to="/" />} />
</Routes>
</BrowserRouter>
@@ -12,6 +12,7 @@ const styles = {
};
export default function AddPurchase({ onAdded }) {
const [purchaseDate, setPurchaseDate] = useState(new Date().toISOString().split('T')[0]);
const [amountEur, setAmountEur] = useState('');
const [priceEur, setPriceEur] = useState('');
const [error, setError] = useState('');
@@ -30,16 +31,19 @@ export default function AddPurchase({ onAdded }) {
body: JSON.stringify({
amount_eur: parseFloat(amountEur),
price_eur: parseFloat(priceEur),
created_at: new Date(purchaseDate + 'T12:00:00').toISOString(),
}),
});
if (!res.ok) {
setError('Failed to add purchase');
return;
}
setPurchaseDate(new Date().toISOString().split('T')[0]);
setAmountEur('');
setPriceEur('');
onAdded();
} catch {
} catch (err) {
console.error('AddPurchase network error:', err);
setError('Network error');
}
};
@@ -49,6 +53,13 @@ export default function AddPurchase({ onAdded }) {
<div style={styles.title}>Add Purchase</div>
<form onSubmit={handleSubmit}>
<div style={styles.row}>
<input
style={styles.input}
type="date"
value={purchaseDate}
onChange={e => setPurchaseDate(e.target.value)}
required
/>
<input
style={styles.input}
type="number"
@@ -0,0 +1,87 @@
import React, { useState } from 'react';
const API = process.env.REACT_APP_API_URL || 'http://localhost:8000';
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' },
row: { display: 'flex', gap: '0.75rem', flexWrap: 'wrap' },
input: { flex: 1, minWidth: '140px', padding: '0.65rem', background: '#2a2a2a', border: '1px solid #444', borderRadius: '8px', color: '#e0e0e0', fontSize: '1rem' },
button: { padding: '0.65rem 1.5rem', background: '#f7931a', color: '#000', border: 'none', borderRadius: '8px', fontWeight: 700, cursor: 'pointer' },
error: { color: '#ff6b6b', marginTop: '0.5rem', fontSize: '0.9rem' },
};
export default function AddSell({ onAdded }) {
const [sellDate, setSellDate] = useState(new Date().toISOString().split('T')[0]);
const [btcAmount, setBtcAmount] = useState('');
const [priceEur, setPriceEur] = useState('');
const [error, setError] = useState('');
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
const token = localStorage.getItem('token');
try {
const res = await fetch(`${API}/sells`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
btc_amount: parseFloat(btcAmount),
price_eur: parseFloat(priceEur),
created_at: new Date(sellDate + 'T12:00:00').toISOString(),
}),
});
if (!res.ok) {
setError('Failed to add sell');
return;
}
setSellDate(new Date().toISOString().split('T')[0]);
setBtcAmount('');
setPriceEur('');
onAdded();
} catch (err) {
console.error('AddSell network error:', err);
setError('Network error');
}
};
return (
<div style={styles.card}>
<div style={styles.title}>Add Sell</div>
<form onSubmit={handleSubmit}>
<div style={styles.row}>
<input
style={styles.input}
type="date"
value={sellDate}
onChange={e => setSellDate(e.target.value)}
required
/>
<input
style={styles.input}
type="number"
step="any"
placeholder="BTC Amount"
value={btcAmount}
onChange={e => setBtcAmount(e.target.value)}
required
/>
<input
style={styles.input}
type="number"
step="any"
placeholder="BTC Price (EUR)"
value={priceEur}
onChange={e => setPriceEur(e.target.value)}
required
/>
<button style={styles.button} type="submit">Add</button>
</div>
{error && <div style={styles.error}>{error}</div>}
</form>
</div>
);
}
@@ -0,0 +1,193 @@
import React, { useRef, useEffect } from 'react';
import { createChart, LineStyle } from 'lightweight-charts';
const cardStyle = {
background: '#1a1a1a',
padding: '1.5rem',
borderRadius: '12px',
border: '1px solid #333',
marginBottom: '1.5rem',
};
const fullscreenStyle = {
position: 'fixed',
top: 0, left: 0, right: 0, bottom: 0,
zIndex: 9999,
background: '#0d0d0d',
padding: '1.5rem',
display: 'flex',
flexDirection: 'column',
};
const headerStyle = {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '1rem',
};
const titleStyle = { fontSize: '1.1rem', fontWeight: 700, color: '#f7931a' };
const btnStyle = {
background: 'none',
border: '1px solid #555',
color: '#aaa',
borderRadius: '6px',
padding: '0.4rem 1rem',
cursor: 'pointer',
fontSize: '0.85rem',
marginLeft: '0.5rem',
};
export default function BTCCandlestickChart({ candles, purchases, stats, fullscreen, onToggleFullscreen, livePrice }) {
const containerRef = useRef(null);
const chartRef = useRef(null);
const candleSeriesRef = useRef(null);
// Build/rebuild chart when candles data or fullscreen changes
useEffect(() => {
if (!containerRef.current || !candles || candles.length === 0) return;
const container = containerRef.current;
const { width, height } = container.getBoundingClientRect();
const chart = createChart(container, {
width: width || 800,
height: fullscreen ? height - 20 : 350,
layout: {
background: { color: fullscreen ? '#0d0d0d' : '#1a1a1a' },
textColor: '#ccc',
},
grid: {
vertLines: { color: '#2a2a2a' },
horzLines: { color: '#2a2a2a' },
},
crosshair: { mode: 1 },
rightPriceScale: { borderColor: '#333' },
timeScale: { borderColor: '#333', timeVisible: false },
});
chartRef.current = chart;
// Candlestick series
const candleSeries = chart.addCandlestickSeries({
upColor: '#6bff8e',
downColor: '#ff6b6b',
borderUpColor: '#6bff8e',
borderDownColor: '#ff6b6b',
wickUpColor: '#6bff8e',
wickDownColor: '#ff6b6b',
});
candleSeriesRef.current = candleSeries;
const candleData = candles.map(c => ({
time: c.date,
open: c.open,
high: c.high,
low: c.low,
close: c.close,
}));
candleSeries.setData(candleData);
// Average buy price line
const avgPrice = stats?.average_price ?? 0;
if (avgPrice > 0) {
const avgSeries = chart.addLineSeries({
color: '#4fc3f7',
lineWidth: 1,
lineStyle: LineStyle.Dashed,
priceLineVisible: false,
lastValueVisible: false,
title: `Avg Buy €${avgPrice.toLocaleString()}`,
});
avgSeries.setData(candles.map(c => ({ time: c.date, value: avgPrice })));
}
// Purchase markers — use nearest-candle lookup so purchases always show
if (purchases && purchases.length > 0) {
const sortedDates = candles.map(c => c.date).sort();
const nearestDate = (target) => {
let closest = sortedDates[0];
for (const d of sortedDates) {
if (d <= target) closest = d;
else break;
}
return closest;
};
// Merge multiple purchases on the same candle into one marker
const markerMap = {};
for (const p of purchases) {
const d = nearestDate(p.date);
if (!markerMap[d]) {
markerMap[d] = { time: d, position: 'belowBar', color: '#f7931a', shape: 'arrowUp', text: `${p.amount_eur.toLocaleString()}` };
} else {
markerMap[d].text += ` +€${p.amount_eur.toLocaleString()}`;
}
}
const markers = Object.values(markerMap).sort((a, b) => a.time.localeCompare(b.time));
if (markers.length > 0) candleSeries.setMarkers(markers);
}
chart.timeScale().fitContent();
// Responsive resize
const observer = new ResizeObserver(entries => {
const entry = entries[0];
if (!entry) return;
chart.applyOptions({
width: entry.contentRect.width,
height: fullscreen ? entry.contentRect.height - 20 : 350,
});
});
observer.observe(container);
return () => {
observer.disconnect();
chart.remove();
chartRef.current = null;
candleSeriesRef.current = null;
};
}, [candles, purchases, stats, fullscreen]);
const handleSave = () => {
if (!chartRef.current) return;
const canvas = chartRef.current.takeScreenshot();
const a = document.createElement('a');
a.href = canvas.toDataURL('image/png');
a.download = 'btc-candles.png';
a.click();
};
const containerWrapStyle = fullscreen
? { flex: 1, minHeight: 0 }
: { width: '100%' };
return (
<div style={fullscreen ? fullscreenStyle : cardStyle}>
<div style={headerStyle}>
<div style={titleStyle}>
BTC Candles (EUR)
{livePrice != null && (
<span style={{ fontSize: '0.9rem', fontWeight: 400, color: '#ccc', marginLeft: '0.75rem' }}>
{livePrice.toLocaleString()}
</span>
)}
</div>
<div>
<button style={btnStyle} onClick={handleSave}>Save as PNG</button>
<button style={btnStyle} onClick={onToggleFullscreen}>
{fullscreen ? 'Exit Fullscreen' : 'Fullscreen'}
</button>
</div>
</div>
{(!candles || candles.length === 0) ? (
<div style={{ color: '#666', padding: '1rem 0' }}>Loading price data</div>
) : (
<div ref={containerRef} style={containerWrapStyle} />
)}
</div>
);
}
@@ -19,32 +19,93 @@ const styles = {
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 PortfolioChart({ purchases, stats }) {
function toDateKey(date) {
return date.toISOString().split('T')[0];
}
function priceOn(date, priceMap, currentPrice, isToday, sortedPurchases) {
if (isToday) return currentPrice || 0;
// Try candle history (walk back up to 7 days)
for (let i = 0; i <= 7; i++) {
const d = new Date(date);
d.setDate(d.getDate() - i);
const p = priceMap[toDateKey(d)];
if (p) return p;
}
// Fall back to most recent purchase price up to this date
let fallback = null;
for (const p of sortedPurchases) {
const pd = new Date(p.created_at);
pd.setHours(0, 0, 0, 0);
if (pd <= date) fallback = p.price_eur;
}
return fallback;
}
export default function PortfolioChart({ purchases, sells, stats, btcHistory }) {
const chartRef = useRef(null);
if (!purchases || purchases.length === 0) return null;
// Build cumulative data points sorted by date
const sorted = [...purchases].sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
const sortedSells = [...(sells || [])].sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
const today = new Date();
today.setHours(0, 0, 0, 0);
// Build price lookup from candle history
const priceMap = {};
(btcHistory || []).forEach(({ date, close }) => { priceMap[date] = close; });
const firstDate = new Date(sorted[0].created_at);
firstDate.setHours(0, 0, 0, 0);
// Generate biweekly dates from first purchase to today
const dates = [];
const cursor = new Date(firstDate);
while (cursor <= today) {
dates.push(new Date(cursor));
cursor.setDate(cursor.getDate() + 7);
}
if (toDateKey(dates[dates.length - 1]) !== toDateKey(today)) {
dates.push(new Date(today));
}
let cumInvested = 0;
let cumBtc = 0;
const labels = [];
const portfolioValues = [];
const investedValues = [];
sorted.forEach((p, i) => {
cumInvested += p.amount_eur;
cumBtc += p.amount_eur / p.price_eur;
const currentVal = cumBtc * (stats?.current_price || p.price_eur);
labels.push(new Date(p.created_at).toLocaleDateString());
portfolioValues.push(parseFloat(currentVal.toFixed(2)));
dates.forEach(date => {
const isToday = toDateKey(date) === toDateKey(today);
const price = priceOn(date, priceMap, stats?.current_price, isToday, sorted);
if (price === null) return; // no price data available, skip
// Cumulative BTC and invested up to this date
let cumBtc = 0;
let cumInvested = 0;
sorted.forEach(p => {
const pDate = new Date(p.created_at);
pDate.setHours(0, 0, 0, 0);
if (pDate <= date) {
cumBtc += p.amount_eur / p.price_eur;
cumInvested += p.amount_eur;
}
});
sortedSells.forEach(s => {
const sDate = new Date(s.created_at);
sDate.setHours(0, 0, 0, 0);
if (sDate <= date) {
cumBtc -= s.btc_amount;
cumInvested -= s.btc_amount * s.price_eur;
}
});
if (cumBtc === 0) return; // no purchases yet at this date
labels.push(date.toLocaleDateString('en-GB'));
portfolioValues.push(parseFloat((cumBtc * price).toFixed(2)));
investedValues.push(parseFloat(cumInvested.toFixed(2)));
});
const currentPrice = stats?.current_price || 0;
const breakEvenLine = labels.map(() => stats?.average_price || 0);
const data = {
labels,
datasets: [
@@ -101,7 +101,7 @@ export default function PurchaseList({ purchases, onChanged }) {
</tr>
) : (
<tr key={p.id}>
<td style={styles.td}>{new Date(p.created_at).toLocaleDateString()}</td>
<td style={styles.td}>{new Date(p.created_at).toLocaleDateString('en-GB')}</td>
<td style={styles.td}>{p.amount_eur.toLocaleString()}</td>
<td style={styles.td}>{p.price_eur.toLocaleString()}</td>
<td style={styles.td}>{(p.amount_eur / p.price_eur).toFixed(6)}</td>
@@ -0,0 +1,119 @@
import React, { useState } from 'react';
const API = process.env.REACT_APP_API_URL || 'http://localhost:8000';
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' },
table: { width: '100%', borderCollapse: 'collapse' },
th: { textAlign: 'left', padding: '0.5rem 0.75rem', borderBottom: '1px solid #333', color: '#888', fontSize: '0.85rem' },
td: { padding: '0.6rem 0.75rem', borderBottom: '1px solid #222', fontSize: '0.95rem' },
input: { background: '#2a2a2a', border: '1px solid #555', borderRadius: '6px', color: '#e0e0e0', padding: '0.3rem 0.5rem', fontSize: '0.9rem', width: '100%' },
editBtn: { background: 'none', border: '1px solid #555', color: '#4fc3f7', borderRadius: '6px', padding: '0.25rem 0.6rem', cursor: 'pointer', fontSize: '0.85rem', marginRight: '0.4rem' },
saveBtn: { background: 'none', border: '1px solid #6bff8e', color: '#6bff8e', borderRadius: '6px', padding: '0.25rem 0.6rem', cursor: 'pointer', fontSize: '0.85rem', marginRight: '0.4rem' },
cancelBtn: { background: 'none', border: '1px solid #555', color: '#aaa', borderRadius: '6px', padding: '0.25rem 0.6rem', cursor: 'pointer', fontSize: '0.85rem', marginRight: '0.4rem' },
deleteBtn: { background: 'none', border: '1px solid #555', color: '#ff6b6b', borderRadius: '6px', padding: '0.25rem 0.6rem', cursor: 'pointer', fontSize: '0.85rem' },
empty: { color: '#555', textAlign: 'center', padding: '1rem' },
};
function toDateInputValue(isoString) {
return isoString ? isoString.slice(0, 10) : '';
}
export default function SellList({ sells, onChanged }) {
const [editingId, setEditingId] = useState(null);
const [editForm, setEditForm] = useState({});
const token = () => localStorage.getItem('token');
const startEdit = (s) => {
setEditingId(s.id);
setEditForm({
btc_amount: s.btc_amount,
price_eur: s.price_eur,
created_at: toDateInputValue(s.created_at),
});
};
const cancelEdit = () => setEditingId(null);
const handleSave = async (id) => {
const res = await fetch(`${API}/sells/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token()}` },
body: JSON.stringify({
btc_amount: parseFloat(editForm.btc_amount),
price_eur: parseFloat(editForm.price_eur),
created_at: new Date(editForm.created_at + 'T12:00:00').toISOString(),
}),
});
if (res.ok) {
setEditingId(null);
onChanged();
}
};
const handleDelete = async (id) => {
await fetch(`${API}/sells/${id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token()}` },
});
onChanged();
};
const set = (field) => (e) => setEditForm(f => ({ ...f, [field]: e.target.value }));
return (
<div style={styles.card}>
<div style={styles.title}>Sells</div>
{sells.length === 0 ? (
<div style={styles.empty}>No sells yet.</div>
) : (
<table style={styles.table}>
<thead>
<tr>
<th style={styles.th}>Date</th>
<th style={styles.th}>BTC Amount</th>
<th style={styles.th}>Price (/BTC)</th>
<th style={styles.th}>Value ()</th>
<th style={styles.th}></th>
</tr>
</thead>
<tbody>
{sells.map(s => editingId === s.id ? (
<tr key={s.id}>
<td style={styles.td}>
<input style={styles.input} type="date" value={editForm.created_at} onChange={set('created_at')} />
</td>
<td style={styles.td}>
<input style={styles.input} type="number" step="any" value={editForm.btc_amount} onChange={set('btc_amount')} />
</td>
<td style={styles.td}>
<input style={styles.input} type="number" step="any" value={editForm.price_eur} onChange={set('price_eur')} />
</td>
<td style={styles.td}>
{(parseFloat(editForm.btc_amount) * parseFloat(editForm.price_eur) || 0).toLocaleString()}
</td>
<td style={styles.td}>
<button style={styles.saveBtn} onClick={() => handleSave(s.id)}>Save</button>
<button style={styles.cancelBtn} onClick={cancelEdit}>Cancel</button>
</td>
</tr>
) : (
<tr key={s.id}>
<td style={styles.td}>{new Date(s.created_at).toLocaleDateString('en-GB')}</td>
<td style={styles.td}>{s.btc_amount.toFixed(8)}</td>
<td style={styles.td}>{s.price_eur.toLocaleString()}</td>
<td style={styles.td}>{(s.btc_amount * s.price_eur).toLocaleString()}</td>
<td style={styles.td}>
<button style={styles.editBtn} onClick={() => startEdit(s)}>Edit</button>
<button style={styles.deleteBtn} onClick={() => handleDelete(s.id)}>Delete</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
);
}
@@ -0,0 +1,137 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
const API = process.env.REACT_APP_API_URL || 'http://localhost:8000';
const styles = {
app: { maxWidth: '900px', margin: '0 auto', padding: '1.5rem' },
header: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem' },
title: { fontSize: '1.4rem', fontWeight: 700, color: '#f7931a' },
backBtn: { background: 'none', border: '1px solid #555', color: '#aaa', borderRadius: '8px', padding: '0.4rem 1rem', cursor: 'pointer' },
card: { background: '#1a1a1a', padding: '1.5rem', borderRadius: '12px', border: '1px solid #333', marginBottom: '1.5rem' },
sectionTitle: { fontSize: '1rem', fontWeight: 700, color: '#f7931a', marginBottom: '1rem' },
table: { width: '100%', borderCollapse: 'collapse' },
th: { textAlign: 'left', color: '#888', fontSize: '0.8rem', paddingBottom: '0.5rem', borderBottom: '1px solid #333' },
td: { padding: '0.6rem 0', borderBottom: '1px solid #222', color: '#e0e0e0', fontSize: '0.95rem' },
adminBadge: { background: 'rgba(247,147,26,0.15)', color: '#f7931a', borderRadius: '4px', padding: '0.1rem 0.5rem', fontSize: '0.75rem', marginLeft: '0.5rem' },
deleteBtn: { background: 'none', border: '1px solid #555', color: '#ff6b6b', borderRadius: '6px', padding: '0.25rem 0.75rem', cursor: 'pointer', fontSize: '0.85rem' },
form: { display: 'flex', flexDirection: 'column', gap: '0.75rem' },
row: { display: 'flex', gap: '0.75rem', flexWrap: 'wrap' },
input: { flex: 1, minWidth: '140px', padding: '0.6rem 0.75rem', background: '#2a2a2a', border: '1px solid #444', borderRadius: '8px', color: '#e0e0e0', fontSize: '0.95rem' },
checkLabel: { display: 'flex', alignItems: 'center', gap: '0.4rem', color: '#aaa', fontSize: '0.9rem', cursor: 'pointer' },
submitBtn: { alignSelf: 'flex-start', padding: '0.6rem 1.5rem', background: '#f7931a', color: '#000', border: 'none', borderRadius: '8px', fontWeight: 700, cursor: 'pointer' },
error: { color: '#ff6b6b', fontSize: '0.9rem' },
success: { color: '#6bff8e', fontSize: '0.9rem' },
};
export default function AdminPage() {
const [users, setUsers] = useState([]);
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [isAdmin, setIsAdmin] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const navigate = useNavigate();
const authHeaders = () => ({
Authorization: `Bearer ${localStorage.getItem('token')}`,
'Content-Type': 'application/json',
});
const fetchUsers = useCallback(async () => {
const res = await fetch(`${API}/admin/users`, { headers: authHeaders() });
if (res.status === 401 || res.status === 403) { navigate('/'); return; }
setUsers(await res.json());
}, [navigate]);
useEffect(() => { fetchUsers(); }, [fetchUsers]);
const handleCreate = async (e) => {
e.preventDefault();
setError(''); setSuccess('');
const res = await fetch(`${API}/admin/users`, {
method: 'POST',
headers: authHeaders(),
body: JSON.stringify({ username, password, is_admin: isAdmin }),
});
if (!res.ok) {
const data = await res.json();
setError(data.detail || 'Failed to create user');
return;
}
setSuccess(`User "${username}" created.`);
setUsername(''); setPassword(''); setIsAdmin(false);
fetchUsers();
};
const handleDelete = async (id, name) => {
if (!window.confirm(`Delete user "${name}"? This also deletes all their purchases.`)) return;
const res = await fetch(`${API}/admin/users/${id}`, { method: 'DELETE', headers: authHeaders() });
if (!res.ok) {
const data = await res.json().catch(() => ({}));
setError(data.detail || 'Failed to delete user');
return;
}
fetchUsers();
};
const currentUsername = (() => {
try {
const token = localStorage.getItem('token');
return JSON.parse(atob(token.split('.')[1])).sub;
} catch { return null; }
})();
return (
<div style={styles.app}>
<div style={styles.header}>
<div style={styles.title}>User Management</div>
<button style={styles.backBtn} onClick={() => navigate('/')}>Back to Dashboard</button>
</div>
<div style={styles.card}>
<div style={styles.sectionTitle}>All Users</div>
<table style={styles.table}>
<thead>
<tr>
<th style={styles.th}>Username</th>
<th style={styles.th}>Actions</th>
</tr>
</thead>
<tbody>
{users.map(u => (
<tr key={u.id}>
<td style={styles.td}>
{u.username}
{u.is_admin && <span style={styles.adminBadge}>admin</span>}
</td>
<td style={styles.td}>
{u.username !== currentUsername && (
<button style={styles.deleteBtn} onClick={() => handleDelete(u.id, u.username)}>Delete</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
<div style={styles.card}>
<div style={styles.sectionTitle}>Create User</div>
<form style={styles.form} onSubmit={handleCreate}>
{error && <div style={styles.error}>{error}</div>}
{success && <div style={styles.success}>{success}</div>}
<div style={styles.row}>
<input style={styles.input} placeholder="Username" value={username} onChange={e => setUsername(e.target.value)} required />
<input style={styles.input} type="password" placeholder="Password" value={password} onChange={e => setPassword(e.target.value)} required />
</div>
<label style={styles.checkLabel}>
<input type="checkbox" checked={isAdmin} onChange={e => setIsAdmin(e.target.checked)} />
Admin
</label>
<button style={styles.submitBtn} type="submit">Create User</button>
</form>
</div>
</div>
);
}
+120 -15
View File
@@ -1,9 +1,11 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigate, Link } from 'react-router-dom';
import AddPurchase from '../components/AddPurchase';
import PurchaseList from '../components/PurchaseList';
import AddSell from '../components/AddSell';
import SellList from '../components/SellList';
import PortfolioChart from '../components/PortfolioChart';
import BTCHistoryChart from '../components/BTCHistoryChart';
import BTCCandlestickChart from '../components/BTCCandlestickChart';
const API = process.env.REACT_APP_API_URL || 'http://localhost:8000';
@@ -12,7 +14,16 @@ const styles = {
header: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem' },
logo: { fontSize: '1.4rem', fontWeight: 700, color: '#f7931a' },
logoutBtn: { background: 'none', border: '1px solid #555', color: '#aaa', borderRadius: '8px', padding: '0.4rem 1rem', cursor: 'pointer' },
adminBtn: { background: 'none', border: '1px solid #f7931a', color: '#f7931a', borderRadius: '8px', padding: '0.4rem 1rem', cursor: 'pointer', textDecoration: 'none', fontSize: '1rem' },
headerBtns: { display: 'flex', gap: '0.5rem', alignItems: 'center' },
statsGrid: { display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '1rem', marginBottom: '1.5rem' },
predictionSection: { background: '#1a1a1a', border: '1px solid #333', borderRadius: '12px', padding: '1rem', marginBottom: '1.5rem' },
predictionHeader: { color: '#888', fontSize: '0.8rem', marginBottom: '0.75rem' },
predictionRow: { display: 'flex', gap: '1rem', alignItems: 'flex-start', flexWrap: 'wrap' },
predictionInputWrap: { display: 'flex', flexDirection: 'column', gap: '0.3rem' },
predictionLabel: { color: '#888', fontSize: '0.8rem' },
predictionInput: { background: '#111', border: '1px solid #444', borderRadius: '8px', color: '#fff', padding: '0.55rem 0.75rem', fontSize: '1rem', width: '160px', outline: 'none' },
predictionCards: { display: 'flex', gap: '1rem', flex: 1, flexWrap: 'wrap' },
statCard: { background: '#1a1a1a', padding: '1rem', borderRadius: '12px', border: '1px solid #333' },
statLabel: { color: '#888', fontSize: '0.8rem', marginBottom: '0.3rem' },
statValue: { fontSize: '1.2rem', fontWeight: 700 },
@@ -24,11 +35,19 @@ const styles = {
tabActive: { padding: '0.45rem 1.1rem', borderRadius: '8px', border: '1px solid #f7931a', background: 'rgba(247,147,26,0.1)', color: '#f7931a', cursor: 'pointer', fontSize: '0.9rem', fontWeight: 700 },
};
function StatCard({ label, value, highlight }) {
function StatCard({ label, value, highlight, warning }) {
return (
<div style={styles.statCard}>
<div style={styles.statLabel}>{label}</div>
<div style={{ ...styles.statLabel, display: 'flex', alignItems: 'center', gap: '0.3rem' }}>
{label}
{warning && (
<span title={warning} style={{ color: '#f7931a', cursor: 'default', fontSize: '0.85rem' }}></span>
)}
</div>
<div style={{ ...styles.statValue, ...(highlight ? styles[highlight] : {}) }}>{value}</div>
{warning && (
<div style={{ color: '#888', fontSize: '0.7rem', marginTop: '0.25rem' }}>{warning}</div>
)}
</div>
);
}
@@ -36,8 +55,12 @@ function StatCard({ label, value, highlight }) {
export default function Dashboard() {
const [stats, setStats] = useState(null);
const [purchases, setPurchases] = useState([]);
const [history, setHistory] = useState(null);
const [sells, setSells] = useState([]);
const [candles, setCandles] = useState(null);
const [candlesAll, setCandlesAll] = useState(null);
const [fullscreenChart, setFullscreenChart] = useState(false);
const [chartView, setChartView] = useState('both'); // 'both' | 'portfolio' | 'history'
const [predictionPrice, setPredictionPrice] = useState('');
const navigate = useNavigate();
const authHeaders = () => ({
@@ -46,10 +69,11 @@ export default function Dashboard() {
const fetchData = useCallback(async () => {
try {
const [statsRes, purchasesRes, historyRes] = await Promise.all([
fetch(`${API}/stats`, { headers: authHeaders() }),
fetch(`${API}/purchases`, { headers: authHeaders() }),
fetch(`${API}/history`, { headers: authHeaders() }),
const [statsRes, purchasesRes, sellsRes, candlesRes] = await Promise.all([
fetch(`${API}/stats`, { headers: authHeaders() }),
fetch(`${API}/purchases`, { headers: authHeaders() }),
fetch(`${API}/sells`, { headers: authHeaders() }),
fetch(`${API}/candles?days=365`, { headers: authHeaders() }),
]);
if (statsRes.status === 401) {
localStorage.removeItem('token');
@@ -58,30 +82,60 @@ export default function Dashboard() {
}
setStats(await statsRes.json());
setPurchases(await purchasesRes.json());
setHistory(await historyRes.json());
setSells(await sellsRes.json());
setCandles(await candlesRes.json());
} catch {
// silently fail — network may be unavailable
}
}, [navigate]);
const fetchAllCandles = useCallback(async () => {
try {
const res = await fetch(`${API}/candles?days=all`, { headers: authHeaders() });
if (res.ok) setCandlesAll(await res.json());
} catch {
// silently fail
}
}, []);
useEffect(() => {
fetchData();
}, [fetchData]);
const isAdmin = localStorage.getItem('is_admin') === 'true';
const handleLogout = () => {
localStorage.removeItem('token');
localStorage.removeItem('is_admin');
navigate('/login');
};
const handleToggleFullscreen = useCallback(() => {
if (!fullscreenChart && !candlesAll) fetchAllCandles();
setFullscreenChart(f => !f);
}, [fullscreenChart, candlesAll, fetchAllCandles]);
const predPrice = parseFloat(predictionPrice);
const predValid = stats && predictionPrice !== '' && predPrice > 0;
const predValue = predValid ? +(stats.total_btc * predPrice).toFixed(2) : null;
const predPL = predValid ? +(predValue - stats.total_invested).toFixed(2) : null;
const predVsCurrent = predValid ? +(predValue - stats.portfolio_value).toFixed(2) : null;
const plHighlight = stats
? stats.profit_loss >= 0 ? 'positive' : 'negative'
: 'neutral';
// Fullscreen uses all-candles data once loaded, otherwise falls back to 365-day set
const activeCandles = (fullscreenChart && candlesAll) ? candlesAll : candles;
return (
<div style={styles.app}>
<div style={styles.header}>
<div style={styles.logo}> BTC Portfolio</div>
<button style={styles.logoutBtn} onClick={handleLogout}>Logout</button>
<div style={styles.headerBtns}>
{isAdmin && <Link to="/admin" style={styles.adminBtn}>Manage Users</Link>}
<button style={styles.logoutBtn} onClick={handleLogout}>Logout</button>
</div>
</div>
{stats && (
@@ -89,7 +143,11 @@ export default function Dashboard() {
<StatCard label="Total Invested" value={`${stats.total_invested.toLocaleString()}`} />
<StatCard label="Avg Buy Price" value={`${stats.average_price.toLocaleString()}`} />
<StatCard label="Total BTC" value={`${stats.total_btc}`} highlight="neutral" />
<StatCard label="Current BTC Price" value={`${stats.current_price.toLocaleString()}`} />
<StatCard
label="Current BTC Price"
value={`${stats.current_price.toLocaleString()}`}
warning={stats.price_is_cached ? 'Price may be outdated — live fetch failed' : undefined}
/>
<StatCard label="Portfolio Value" value={`${stats.portfolio_value.toLocaleString()}`} />
<StatCard
label="Profit / Loss"
@@ -99,8 +157,44 @@ export default function Dashboard() {
</div>
)}
<div style={styles.predictionSection}>
<div style={styles.predictionHeader}>Price Prediction</div>
<div style={styles.predictionRow}>
<div style={styles.predictionInputWrap}>
<label style={styles.predictionLabel}>BTC Price ()</label>
<input
type="number"
min="0"
placeholder="e.g. 100000"
value={predictionPrice}
onChange={e => setPredictionPrice(e.target.value)}
style={styles.predictionInput}
/>
</div>
{predValid && (
<div style={styles.predictionCards}>
<StatCard
label="Predicted Value"
value={`${predValue.toLocaleString()}`}
highlight="neutral"
/>
<StatCard
label="Predicted P&L"
value={`${predPL >= 0 ? '+' : ''}${predPL.toLocaleString()}`}
highlight={predPL >= 0 ? 'positive' : 'negative'}
/>
<StatCard
label="vs. Current"
value={`${predVsCurrent >= 0 ? '+' : ''}${predVsCurrent.toLocaleString()}`}
highlight={predVsCurrent >= 0 ? 'positive' : 'negative'}
/>
</div>
)}
</div>
</div>
<div style={styles.tabs}>
{[['both', 'Both'], ['portfolio', 'Portfolio'], ['history', '1-Year BTC']].map(([key, label]) => (
{[['both', 'Both'], ['portfolio', 'Portfolio'], ['history', 'BTC Candles']].map(([key, label]) => (
<button
key={key}
style={chartView === key ? styles.tabActive : styles.tab}
@@ -108,10 +202,21 @@ export default function Dashboard() {
>{label}</button>
))}
</div>
{(chartView === 'both' || chartView === 'portfolio') && <PortfolioChart purchases={purchases} stats={stats} />}
{(chartView === 'both' || chartView === 'history') && <BTCHistoryChart history={history} stats={stats} />}
{(chartView === 'both' || chartView === 'portfolio') && <PortfolioChart purchases={purchases} sells={sells} stats={stats} btcHistory={candles?.candles ?? []} />}
{(chartView === 'both' || chartView === 'history') && (
<BTCCandlestickChart
candles={activeCandles?.candles ?? null}
purchases={activeCandles?.purchases ?? purchases}
stats={stats}
livePrice={stats?.current_price ?? null}
fullscreen={fullscreenChart}
onToggleFullscreen={handleToggleFullscreen}
/>
)}
<AddPurchase onAdded={fetchData} />
<PurchaseList purchases={purchases} onChanged={fetchData} />
<AddSell onAdded={fetchData} />
<SellList sells={sells} onChanged={fetchData} />
</div>
);
}
+3 -1
View File
@@ -35,8 +35,10 @@ export default function Login() {
}
const data = await res.json();
localStorage.setItem('token', data.access_token);
localStorage.setItem('is_admin', data.is_admin ? 'true' : 'false');
navigate('/');
} catch {
} catch (err) {
console.error('Login network error:', err);
setError('Network error');
}
};
+2 -1
View File
@@ -38,7 +38,8 @@ export default function Register() {
}
setSuccess('Account created! Redirecting...');
setTimeout(() => navigate('/login'), 1500);
} catch {
} catch (err) {
console.error('Register network error:', err);
setError('Network error');
}
};