mirror of
https://github.com/Zproger/Aurum.git
synced 2026-10-13 02:56:39 +07:00
feat: crypto portfolio analytics (history chart, allocation, P&L, cost basis)
Adds the analytics half of the CoinMarketCap reference: a value-over-time chart (GET /crypto/history, forward-filled from AssetValuation like Net Worth's own series but scoped to crypto assets), an allocation breakdown by coin, and stat tiles for all-time profit, total invested (cost basis), and best/worst performer. Deliberately no "24h" range — history is only as dense as the sync cadence (button or once/day), so a 24h chart would be one or two points, not a smooth intraday line. Cost basis / all-time profit / best-worst performer aren't separate endpoints: they're trivial to derive client-side from fields already in GET /crypto/holdings, so no redundant backend logic. Allocation card follows the dataviz skill's categorical rule: top 7 coins by value get a fixed-order color from the app's already-validated 8-slot ramp, an 8th+ folds into "Other" rather than generating a new hue. 82/82 backend tests passing (3 new for /crypto/history). Verified live against the real CoinGecko API — and caught a real holding the user had already added directly through the UI between messages, which the smoke test correctly left untouched while cleaning up only its own test data. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01855PiW5Wd8n4aRW4n9kByY
This commit is contained in:
23
DOCS.md
23
DOCS.md
@@ -536,6 +536,7 @@ of the whole request failing.
|
|||||||
| `GET` | `/crypto/holdings/{asset_id}/transactions` | Full buy/sell history for one holding, newest first. |
|
| `GET` | `/crypto/holdings/{asset_id}/transactions` | Full buy/sell history for one holding, newest first. |
|
||||||
| `DELETE` | `/crypto/transactions/{transaction_id}` | Remove one transaction; quantity/avg buy price/value are recomputed from what's left. |
|
| `DELETE` | `/crypto/transactions/{transaction_id}` | Remove one transaction; quantity/avg buy price/value are recomputed from what's left. |
|
||||||
| `GET` | `/crypto/search` | `?q=` — search CoinGecko for a coin to add (name/ticker, returns its `coingecko_id` + logo). |
|
| `GET` | `/crypto/search` | `?q=` — search CoinGecko for a coin to add (name/ticker, returns its `coingecko_id` + logo). |
|
||||||
|
| `GET` | `/crypto/history` | `?range=7d\|30d\|90d\|all` (default `30d`) — total crypto holdings value over time, for the portfolio chart. No `24h` — resolution is only as dense as the sync cadence above. |
|
||||||
|
|
||||||
**Create body** (`POST /crypto/holdings`):
|
**Create body** (`POST /crypto/holdings`):
|
||||||
|
|
||||||
@@ -600,6 +601,28 @@ was down right when it was added) — distinct from a real `0`. `avg_buy_price`/
|
|||||||
zero (nothing left to have a cost basis). `error_key` is `"unreachable"` when a sync attempt couldn't
|
zero (nothing left to have a cost basis). `error_key` is `"unreachable"` when a sync attempt couldn't
|
||||||
reach CoinGecko (existing values are kept as-is), or `null` otherwise.
|
reach CoinGecko (existing values are kept as-is), or `null` otherwise.
|
||||||
|
|
||||||
|
**`GET /crypto/history` response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"range": "30d",
|
||||||
|
"current": "9727.68",
|
||||||
|
"change_amount": "1240.16",
|
||||||
|
"change_percent": 14.6,
|
||||||
|
"series": [
|
||||||
|
{ "date": "2026-08-01", "value": "8487.52" },
|
||||||
|
{ "date": "2026-08-02", "value": "8487.52" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
One point per calendar day, forward-filled from whatever `AssetValuation` snapshots actually exist
|
||||||
|
(same technique as `/net-worth/summary`'s own `series`, just scoped to crypto-class assets) — a day
|
||||||
|
with no sync simply repeats the last known total rather than leaving a gap. Total invested (cost
|
||||||
|
basis), all-time profit/loss, and best/worst performer aren't separate endpoints — they're trivial to
|
||||||
|
derive client-side by summing/comparing the `value`/`cost_basis`/`profit_loss_percent` fields already
|
||||||
|
in every `GET /crypto/holdings` response.
|
||||||
|
|
||||||
## Dashboard, Cash Flow & Reports
|
## Dashboard, Cash Flow & Reports
|
||||||
|
|
||||||
Read-only aggregation endpoints — the numbers behind Aurum's charts. Useful for pulling summary data
|
Read-only aggregation endpoints — the numbers behind Aurum's charts. Useful for pulling summary data
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from app.api.deps import get_session
|
from app.api.deps import get_session
|
||||||
from app.schemas.crypto import (
|
from app.schemas.crypto import (
|
||||||
|
CryptoHistoryResponse,
|
||||||
CryptoHoldingCreate,
|
CryptoHoldingCreate,
|
||||||
CryptoHoldingRead,
|
CryptoHoldingRead,
|
||||||
CryptoSearchResult,
|
CryptoSearchResult,
|
||||||
@@ -11,9 +12,11 @@ from app.schemas.crypto import (
|
|||||||
CryptoTransactionRead,
|
CryptoTransactionRead,
|
||||||
)
|
)
|
||||||
from app.services.crypto_service import (
|
from app.services.crypto_service import (
|
||||||
|
CRYPTO_RANGE_DAYS,
|
||||||
add_transaction,
|
add_transaction,
|
||||||
create_holding,
|
create_holding,
|
||||||
delete_transaction,
|
delete_transaction,
|
||||||
|
get_crypto_history,
|
||||||
list_transactions,
|
list_transactions,
|
||||||
refresh_prices,
|
refresh_prices,
|
||||||
search_coins,
|
search_coins,
|
||||||
@@ -68,3 +71,15 @@ async def delete_transaction_route(transaction_id: int, session: AsyncSession =
|
|||||||
@router.get("/search", response_model=list[CryptoSearchResult])
|
@router.get("/search", response_model=list[CryptoSearchResult])
|
||||||
async def search_coins_route(q: str = Query(min_length=1, max_length=100)) -> list[CryptoSearchResult]:
|
async def search_coins_route(q: str = Query(min_length=1, max_length=100)) -> list[CryptoSearchResult]:
|
||||||
return await search_coins(q)
|
return await search_coins(q)
|
||||||
|
|
||||||
|
|
||||||
|
_HISTORY_RANGES = sorted(set(CRYPTO_RANGE_DAYS) | {"all"})
|
||||||
|
_HISTORY_RANGE_PATTERN = f"^({'|'.join(_HISTORY_RANGES)})$"
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/history", response_model=CryptoHistoryResponse)
|
||||||
|
async def read_crypto_history(
|
||||||
|
range: str = Query(default="30d", pattern=_HISTORY_RANGE_PATTERN),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> CryptoHistoryResponse:
|
||||||
|
return await get_crypto_history(session, range)
|
||||||
|
|||||||
@@ -93,3 +93,21 @@ class CryptoSearchResult(BaseModel):
|
|||||||
symbol: str
|
symbol: str
|
||||||
name: str
|
name: str
|
||||||
thumb_url: str | None
|
thumb_url: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class CryptoHistoryPoint(BaseModel):
|
||||||
|
date: date_
|
||||||
|
value: Decimal
|
||||||
|
|
||||||
|
|
||||||
|
class CryptoHistoryResponse(BaseModel):
|
||||||
|
"""Total crypto holdings value over time — same "cumulative point
|
||||||
|
events, forward-filled" shape as NetWorthSummary's own series, but
|
||||||
|
scoped to crypto-class assets only (see services/crypto_service.py's
|
||||||
|
get_crypto_history). Powers the History chart on the Crypto tab."""
|
||||||
|
|
||||||
|
range: str
|
||||||
|
current: Decimal
|
||||||
|
change_amount: Decimal
|
||||||
|
change_percent: float | None
|
||||||
|
series: list[CryptoHistoryPoint]
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ reuses the last cached price.
|
|||||||
from datetime import date as date_
|
from datetime import date as date_
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
from itertools import groupby
|
||||||
from typing import Literal, NamedTuple
|
from typing import Literal, NamedTuple
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@@ -36,6 +37,8 @@ from app.models.asset import Asset, AssetValuation
|
|||||||
from app.models.crypto import CryptoHolding, CryptoSyncState, CryptoTransaction
|
from app.models.crypto import CryptoHolding, CryptoSyncState, CryptoTransaction
|
||||||
from app.models.enums import AssetClass, CapitalRole, CryptoTransactionType, RiskLevel
|
from app.models.enums import AssetClass, CapitalRole, CryptoTransactionType, RiskLevel
|
||||||
from app.schemas.crypto import (
|
from app.schemas.crypto import (
|
||||||
|
CryptoHistoryPoint,
|
||||||
|
CryptoHistoryResponse,
|
||||||
CryptoHoldingCreate,
|
CryptoHoldingCreate,
|
||||||
CryptoHoldingRead,
|
CryptoHoldingRead,
|
||||||
CryptoSearchResult,
|
CryptoSearchResult,
|
||||||
@@ -51,6 +54,12 @@ COINGECKO_BASE_URL = "https://api.coingecko.com/api/v3"
|
|||||||
# reason (see module docstring on why there's no background poller at all).
|
# reason (see module docstring on why there's no background poller at all).
|
||||||
AUTO_REFRESH_INTERVAL = timedelta(hours=24)
|
AUTO_REFRESH_INTERVAL = timedelta(hours=24)
|
||||||
|
|
||||||
|
# Deliberately no "24h" option, unlike Net Worth's own RANGE_DAYS — our price
|
||||||
|
# history is only ever as dense as our sync cadence (at most a few points a
|
||||||
|
# day, see AUTO_REFRESH_INTERVAL above), so a 24h chart would show one or
|
||||||
|
# two points, not the smooth intraday line a denser data source could draw.
|
||||||
|
CRYPTO_RANGE_DAYS: dict[str, int] = {"7d": 7, "30d": 30, "90d": 90}
|
||||||
|
|
||||||
_EAGER = (selectinload(CryptoHolding.transactions),)
|
_EAGER = (selectinload(CryptoHolding.transactions),)
|
||||||
|
|
||||||
|
|
||||||
@@ -390,3 +399,63 @@ async def delete_transaction(session: AsyncSession, transaction_id: int) -> None
|
|||||||
await _upsert_valuation(session, asset_id, quantity * holding.last_price, date_.today())
|
await _upsert_valuation(session, asset_id, quantity * holding.last_price, date_.today())
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_crypto_history(session: AsyncSession, range_key: str) -> CryptoHistoryResponse:
|
||||||
|
"""Total crypto holdings value over time, for the History chart on the
|
||||||
|
Crypto tab. Same "cumulative point events, forward-filled" approach as
|
||||||
|
net_worth_service.py's own asset series — duplicated here rather than
|
||||||
|
imported, so this can't destabilize the already-tested Net Worth engine,
|
||||||
|
and scoped to crypto-class assets only (a deleted holding's
|
||||||
|
AssetValuation rows are gone via cascade, so its history naturally
|
||||||
|
drops out here too, same as it already does for Net Worth)."""
|
||||||
|
today = date_.today()
|
||||||
|
|
||||||
|
crypto_asset_ids = set(
|
||||||
|
(await session.execute(select(Asset.id).where(Asset.asset_class == AssetClass.CRYPTO))).scalars().all()
|
||||||
|
)
|
||||||
|
if not crypto_asset_ids:
|
||||||
|
return CryptoHistoryResponse(range=range_key, current=Decimal("0"), change_amount=Decimal("0"), change_percent=None, series=[])
|
||||||
|
|
||||||
|
valuations = (
|
||||||
|
await session.execute(
|
||||||
|
select(AssetValuation.asset_id, AssetValuation.as_of_date, AssetValuation.value)
|
||||||
|
.where(AssetValuation.asset_id.in_(crypto_asset_ids))
|
||||||
|
.order_by(AssetValuation.as_of_date)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
if not valuations:
|
||||||
|
return CryptoHistoryResponse(range=range_key, current=Decimal("0"), change_amount=Decimal("0"), change_percent=None, series=[])
|
||||||
|
|
||||||
|
current_by_asset: dict[int, Decimal] = {}
|
||||||
|
events: list[tuple[date_, Decimal]] = []
|
||||||
|
for day, group in groupby(valuations, key=lambda row: row[1]):
|
||||||
|
for asset_id, _, value in group:
|
||||||
|
current_by_asset[asset_id] = value
|
||||||
|
events.append((day, sum(current_by_asset.values(), Decimal("0"))))
|
||||||
|
|
||||||
|
start = today - timedelta(days=CRYPTO_RANGE_DAYS[range_key] - 1) if range_key in CRYPTO_RANGE_DAYS else events[0][0]
|
||||||
|
|
||||||
|
points: list[CryptoHistoryPoint] = []
|
||||||
|
idx, n = 0, len(events)
|
||||||
|
running = Decimal("0")
|
||||||
|
day = start
|
||||||
|
while day <= today:
|
||||||
|
while idx < n and events[idx][0] <= day:
|
||||||
|
running = events[idx][1]
|
||||||
|
idx += 1
|
||||||
|
points.append(CryptoHistoryPoint(date=day, value=running))
|
||||||
|
day += timedelta(days=1)
|
||||||
|
|
||||||
|
current = points[-1].value if points else Decimal("0")
|
||||||
|
start_value = points[0].value if points else Decimal("0")
|
||||||
|
change_amount = current - start_value
|
||||||
|
change_percent = float(change_amount / start_value * 100) if start_value else None
|
||||||
|
|
||||||
|
return CryptoHistoryResponse(
|
||||||
|
range=range_key,
|
||||||
|
current=current,
|
||||||
|
change_amount=change_amount,
|
||||||
|
change_percent=change_percent,
|
||||||
|
series=points,
|
||||||
|
)
|
||||||
|
|||||||
@@ -271,3 +271,32 @@ async def test_backup_roundtrip_preserves_holding_and_transaction_log(client: As
|
|||||||
assert money(restored["quantity"]) == money("2")
|
assert money(restored["quantity"]) == money("2")
|
||||||
assert money(restored["avg_buy_price"]) == money("50000")
|
assert money(restored["avg_buy_price"]) == money("50000")
|
||||||
assert money(restored["value"]) == money("100000") # last_price survived too (2 * 50000)
|
assert money(restored["value"]) == money("100000") # last_price survived too (2 * 50000)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_history_reflects_current_total_crypto_value(client: AsyncClient, monkeypatch):
|
||||||
|
monkeypatch.setattr(crypto_service, "_fetch_market_data", _fake_fetch({"bitcoin": _point("50000")}))
|
||||||
|
await _add_bitcoin(client, "1", "40000")
|
||||||
|
|
||||||
|
resp = await client.get("/crypto/history?range=30d")
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert money(body["current"]) == money("50000")
|
||||||
|
assert len(body["series"]) == 30
|
||||||
|
assert money(body["series"][-1]["value"]) == money("50000")
|
||||||
|
assert money(body["series"][0]["value"]) == money("0") # nothing existed 30 days ago
|
||||||
|
|
||||||
|
|
||||||
|
async def test_history_is_empty_series_with_no_holdings(client: AsyncClient):
|
||||||
|
resp = await client.get("/crypto/history?range=7d")
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["series"] == []
|
||||||
|
assert money(body["current"]) == money("0")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_history_rejects_unknown_range(client: AsyncClient):
|
||||||
|
resp = await client.get("/crypto/history?range=24h")
|
||||||
|
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { api } from "@/api/client";
|
import { api } from "@/api/client";
|
||||||
import type {
|
import type {
|
||||||
|
CryptoHistoryResponse,
|
||||||
CryptoHolding,
|
CryptoHolding,
|
||||||
CryptoHoldingCreateInput,
|
CryptoHoldingCreateInput,
|
||||||
|
CryptoRange,
|
||||||
CryptoSearchResult,
|
CryptoSearchResult,
|
||||||
CryptoSyncResult,
|
CryptoSyncResult,
|
||||||
CryptoTransaction,
|
CryptoTransaction,
|
||||||
@@ -45,3 +47,7 @@ export function deleteCryptoHolding(assetId: number) {
|
|||||||
export function searchCryptoCoins(query: string) {
|
export function searchCryptoCoins(query: string) {
|
||||||
return api.get<CryptoSearchResult[]>(`/crypto/search?q=${encodeURIComponent(query)}`);
|
return api.get<CryptoSearchResult[]>(`/crypto/search?q=${encodeURIComponent(query)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function fetchCryptoHistory(range: CryptoRange) {
|
||||||
|
return api.get<CryptoHistoryResponse>(`/crypto/history?range=${range}`);
|
||||||
|
}
|
||||||
|
|||||||
119
frontend/src/components/crypto/CryptoAllocationCard.tsx
Normal file
119
frontend/src/components/crypto/CryptoAllocationCard.tsx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import { Bitcoin } from "lucide-react";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||||
|
import { formatCurrency } from "@/lib/format";
|
||||||
|
import { useTranslation } from "@/lib/i18n";
|
||||||
|
import type { CryptoHolding } from "@/types";
|
||||||
|
|
||||||
|
interface CryptoAllocationCardProps {
|
||||||
|
holdings: CryptoHolding[];
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fixed categorical order, same 8-slot ramp the rest of the app already
|
||||||
|
// validated (see net_worth_service.py's _CLASS_META comment) — a coin never
|
||||||
|
// gets a freshly generated hue. Only the first 7 get their own color; an 8th+
|
||||||
|
// coin folds into "Other" (--series-other) rather than cycling back through
|
||||||
|
// the ramp, per the dataviz skill's categorical-color rule.
|
||||||
|
const SERIES_COLORS = [
|
||||||
|
"var(--series-1)",
|
||||||
|
"var(--series-2)",
|
||||||
|
"var(--series-3)",
|
||||||
|
"var(--series-4)",
|
||||||
|
"var(--series-5)",
|
||||||
|
"var(--series-6)",
|
||||||
|
"var(--series-7)",
|
||||||
|
];
|
||||||
|
const OTHER_COLOR = "var(--series-other)";
|
||||||
|
const MAX_SLICES = SERIES_COLORS.length;
|
||||||
|
|
||||||
|
interface Slice {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
symbol: string | null;
|
||||||
|
thumbUrl: string | null;
|
||||||
|
amount: number;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSlices(holdings: CryptoHolding[]): Slice[] {
|
||||||
|
const priced = holdings
|
||||||
|
.filter((h) => h.value !== null && Number(h.value) > 0)
|
||||||
|
.map((h) => ({ key: String(h.asset_id), name: h.name, symbol: h.symbol, thumbUrl: h.thumb_url, amount: Number(h.value) }))
|
||||||
|
.sort((a, b) => b.amount - a.amount);
|
||||||
|
|
||||||
|
const top = priced.slice(0, MAX_SLICES).map((item, index) => ({ ...item, color: SERIES_COLORS[index] }));
|
||||||
|
const rest = priced.slice(MAX_SLICES);
|
||||||
|
if (rest.length === 0) return top;
|
||||||
|
|
||||||
|
const otherTotal = rest.reduce((sum, item) => sum + item.amount, 0);
|
||||||
|
return [...top, { key: "other", name: "Other", symbol: null, thumbUrl: null, amount: otherTotal, color: OTHER_COLOR }];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CryptoAllocationCard({ holdings, isLoading }: CryptoAllocationCardProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const slices = buildSlices(holdings);
|
||||||
|
const total = slices.reduce((sum, slice) => sum + slice.amount, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{t("crypto.allocation.title")}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{isLoading ? (
|
||||||
|
<p className="py-10 text-center text-sm text-text-muted">{t("common.loading")}</p>
|
||||||
|
) : slices.length === 0 ? (
|
||||||
|
<p className="py-10 text-center text-sm text-text-muted">{t("crypto.empty")}</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex h-2.5 w-full overflow-hidden rounded-full bg-surface-2">
|
||||||
|
{slices.map((slice) => (
|
||||||
|
<div
|
||||||
|
key={slice.key}
|
||||||
|
style={{ width: `${(slice.amount / total) * 100}%`, backgroundColor: slice.color }}
|
||||||
|
className="h-full first:rounded-l-full last:rounded-r-full"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="mt-4 divide-y divide-gridline border-t border-gridline">
|
||||||
|
{slices.map((slice) => {
|
||||||
|
const percent = (slice.amount / total) * 100;
|
||||||
|
return (
|
||||||
|
<li key={slice.key} className="flex items-center gap-3 py-2.5 first:pt-3">
|
||||||
|
{slice.thumbUrl ? (
|
||||||
|
<img src={slice.thumbUrl} alt="" className="h-7 w-7 shrink-0 rounded-full" />
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full"
|
||||||
|
style={{ backgroundColor: `${slice.color}26` }}
|
||||||
|
>
|
||||||
|
<Bitcoin size={14} style={{ color: slice.color }} />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="min-w-0 flex-1 truncate text-sm text-text-primary">
|
||||||
|
{slice.key === "other" ? t("crypto.allocation.other") : slice.name}
|
||||||
|
{slice.symbol && <span className="text-text-muted"> · {slice.symbol}</span>}
|
||||||
|
</span>
|
||||||
|
<span className="hidden w-24 shrink-0 items-center gap-2 sm:flex">
|
||||||
|
<span className="h-1.5 flex-1 overflow-hidden rounded-full bg-surface-2">
|
||||||
|
<span className="block h-full rounded-full" style={{ width: `${percent}%`, backgroundColor: slice.color }} />
|
||||||
|
</span>
|
||||||
|
<span className="w-9 shrink-0 text-right text-xs text-text-muted tabular-nums">{percent.toFixed(0)}%</span>
|
||||||
|
</span>
|
||||||
|
<span className="w-14 shrink-0 text-right text-xs text-text-muted tabular-nums sm:hidden">
|
||||||
|
{percent.toFixed(0)}%
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 text-sm font-medium tabular-nums text-text-primary">
|
||||||
|
{formatCurrency(slice.amount)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
108
frontend/src/components/crypto/CryptoHistoryChart.tsx
Normal file
108
frontend/src/components/crypto/CryptoHistoryChart.tsx
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Area, AreaChart, ResponsiveContainer, Tooltip, YAxis } from "recharts";
|
||||||
|
import { ArrowDown, ArrowUp, Eye, EyeOff } from "lucide-react";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||||
|
import { CryptoRangeSelector } from "@/components/crypto/CryptoRangeSelector";
|
||||||
|
import { formatCurrency, formatSignedCurrency, getIntlLocale } from "@/lib/format";
|
||||||
|
import { useTranslation } from "@/lib/i18n";
|
||||||
|
import type { CryptoHistoryResponse, CryptoRange } from "@/types";
|
||||||
|
|
||||||
|
interface CryptoHistoryChartProps {
|
||||||
|
history: CryptoHistoryResponse | undefined;
|
||||||
|
isLoading: boolean;
|
||||||
|
range: CryptoRange;
|
||||||
|
onRangeChange: (range: CryptoRange) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAxisDate(iso: string): string {
|
||||||
|
return new Intl.DateTimeFormat(getIntlLocale(), { month: "short", day: "numeric" }).format(new Date(`${iso}T00:00:00`));
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChartTooltip({ active, payload }: { active?: boolean; payload?: Array<{ payload: { date: string; value: number } }> }) {
|
||||||
|
if (!active || !payload?.length) return null;
|
||||||
|
const point = payload[0].payload;
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-border bg-surface-1 px-3 py-2 text-sm shadow-md">
|
||||||
|
<p className="text-text-muted">{formatAxisDate(point.date)}</p>
|
||||||
|
<p className="font-medium text-text-primary">{formatCurrency(point.value)}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The Crypto tab's headline chart — same recipe as NetWorthChart, plus an
|
||||||
|
* eye toggle to mask the big number (screen-over-someone's-shoulder
|
||||||
|
* privacy, doesn't hide anything else on the page). */
|
||||||
|
export function CryptoHistoryChart({ history, isLoading, range, onRangeChange }: CryptoHistoryChartProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [hidden, setHidden] = useState(false);
|
||||||
|
const isPositive = history ? Number(history.change_amount) >= 0 : true;
|
||||||
|
const trendColor = isPositive ? "var(--success)" : "var(--danger)";
|
||||||
|
const chartData = history?.series.map((point) => ({ date: point.date, value: Number(point.value) })) ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex-col items-stretch gap-3 sm:flex-row sm:items-start">
|
||||||
|
<div>
|
||||||
|
<CardTitle>{t("nav.crypto")}</CardTitle>
|
||||||
|
<p className="mt-1.5 flex items-center gap-2 text-2xl font-semibold tabular-nums text-text-primary sm:text-[28px]">
|
||||||
|
{isLoading ? "…" : hidden ? "••••••" : formatCurrency(history?.current ?? 0)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={hidden ? t("crypto.chart.show") : t("crypto.chart.hide")}
|
||||||
|
onClick={() => setHidden((prev) => !prev)}
|
||||||
|
className="text-text-muted hover:text-text-primary"
|
||||||
|
>
|
||||||
|
{hidden ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
{history && !hidden && (
|
||||||
|
<p className="mt-1 flex items-center gap-1 text-sm font-medium" style={{ color: trendColor }}>
|
||||||
|
{isPositive ? <ArrowUp size={14} /> : <ArrowDown size={14} />}
|
||||||
|
{formatSignedCurrency(history.change_amount)}
|
||||||
|
{history.change_percent !== null && ` (${isPositive ? "+" : ""}${history.change_percent.toFixed(1)}%)`}
|
||||||
|
<span className="font-normal text-text-muted">{t("netWorth.periodSuffix")}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<CryptoRangeSelector value={range} onChange={onRangeChange} />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="h-56 w-full sm:h-64">
|
||||||
|
{isLoading || chartData.length === 0 ? (
|
||||||
|
<div className="flex h-full items-center justify-center text-sm text-text-muted">
|
||||||
|
{isLoading ? t("common.loading") : t("netWorth.noChartData")}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<AreaChart data={chartData} margin={{ top: 8, right: 4, bottom: 0, left: 4 }}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="cryptoHistoryFill" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor={trendColor} stopOpacity={0.18} />
|
||||||
|
<stop offset="100%" stopColor={trendColor} stopOpacity={0} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<YAxis hide domain={["auto", "auto"]} />
|
||||||
|
<Tooltip content={<ChartTooltip />} cursor={{ stroke: "var(--gridline)", strokeWidth: 1 }} />
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="value"
|
||||||
|
stroke={trendColor}
|
||||||
|
strokeWidth={2}
|
||||||
|
fill="url(#cryptoHistoryFill)"
|
||||||
|
isAnimationActive={false}
|
||||||
|
activeDot={{ r: 4, strokeWidth: 0 }}
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{chartData.length > 1 && (
|
||||||
|
<div className="mt-2 flex justify-between text-xs text-text-muted">
|
||||||
|
<span>{formatAxisDate(chartData[0].date)}</span>
|
||||||
|
<span>{formatAxisDate(chartData[chartData.length - 1].date)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
frontend/src/components/crypto/CryptoRangeSelector.tsx
Normal file
20
frontend/src/components/crypto/CryptoRangeSelector.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { PillSelector } from "@/components/layout/PillSelector";
|
||||||
|
import { useTranslation } from "@/lib/i18n";
|
||||||
|
import type { CryptoRange } from "@/types";
|
||||||
|
|
||||||
|
interface CryptoRangeSelectorProps {
|
||||||
|
value: CryptoRange;
|
||||||
|
onChange: (value: CryptoRange) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CryptoRangeSelector({ value, onChange }: CryptoRangeSelectorProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const ranges: Array<{ value: CryptoRange; label: string }> = [
|
||||||
|
{ value: "7d", label: "7D" },
|
||||||
|
{ value: "30d", label: "30D" },
|
||||||
|
{ value: "90d", label: "90D" },
|
||||||
|
{ value: "all", label: t("common.allShort") },
|
||||||
|
];
|
||||||
|
|
||||||
|
return <PillSelector options={ranges} value={value} onChange={onChange} />;
|
||||||
|
}
|
||||||
117
frontend/src/components/crypto/CryptoStatsRow.tsx
Normal file
117
frontend/src/components/crypto/CryptoStatsRow.tsx
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { Card, CardContent } from "@/components/ui/Card";
|
||||||
|
import { formatCurrency } from "@/lib/format";
|
||||||
|
import { useTranslation } from "@/lib/i18n";
|
||||||
|
import type { CryptoHolding } from "@/types";
|
||||||
|
|
||||||
|
interface CryptoStatsRowProps {
|
||||||
|
holdings: CryptoHolding[];
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Totals {
|
||||||
|
totalValue: number;
|
||||||
|
totalCostBasis: number;
|
||||||
|
totalProfitLoss: number;
|
||||||
|
totalProfitLossPercent: number | null;
|
||||||
|
best: CryptoHolding | null;
|
||||||
|
worst: CryptoHolding | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeTotals(holdings: CryptoHolding[]): Totals {
|
||||||
|
let totalValue = 0;
|
||||||
|
let totalCostBasis = 0;
|
||||||
|
let best: CryptoHolding | null = null;
|
||||||
|
let worst: CryptoHolding | null = null;
|
||||||
|
|
||||||
|
for (const holding of holdings) {
|
||||||
|
if (holding.value !== null) totalValue += Number(holding.value);
|
||||||
|
if (holding.cost_basis !== null) totalCostBasis += Number(holding.cost_basis);
|
||||||
|
if (holding.profit_loss_percent === null) continue;
|
||||||
|
if (best === null || holding.profit_loss_percent > best.profit_loss_percent!) best = holding;
|
||||||
|
if (worst === null || holding.profit_loss_percent < worst.profit_loss_percent!) worst = holding;
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalProfitLoss = totalValue - totalCostBasis;
|
||||||
|
const totalProfitLossPercent = totalCostBasis > 0 ? (totalProfitLoss / totalCostBasis) * 100 : null;
|
||||||
|
|
||||||
|
return { totalValue, totalCostBasis, totalProfitLoss, totalProfitLossPercent, best, worst };
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatTile({ label, children }: { label: string; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-3.5 sm:p-4">
|
||||||
|
<p className="text-xs uppercase tracking-wide text-text-muted">{label}</p>
|
||||||
|
<div className="mt-1.5">{children}</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PerformerTile({ label, holding }: { label: string; holding: CryptoHolding | null }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
if (holding === null || holding.profit_loss_percent === null) {
|
||||||
|
return (
|
||||||
|
<StatTile label={label}>
|
||||||
|
<p className="text-lg font-semibold text-text-muted">—</p>
|
||||||
|
</StatTile>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const percent = holding.profit_loss_percent;
|
||||||
|
const color = percent >= 0 ? "var(--success)" : "var(--danger)";
|
||||||
|
return (
|
||||||
|
<StatTile label={label}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{holding.thumb_url ? (
|
||||||
|
<img src={holding.thumb_url} alt="" className="h-5 w-5 shrink-0 rounded-full" />
|
||||||
|
) : (
|
||||||
|
<span className="h-5 w-5 shrink-0 rounded-full bg-surface-2" />
|
||||||
|
)}
|
||||||
|
<p className="truncate text-lg font-semibold text-text-primary">{holding.symbol}</p>
|
||||||
|
</div>
|
||||||
|
<p className="mt-0.5 text-sm font-medium tabular-nums" style={{ color }}>
|
||||||
|
{percent >= 0 ? "+" : ""}
|
||||||
|
{percent.toFixed(2)}%
|
||||||
|
</p>
|
||||||
|
<p className="mt-0.5 text-xs text-text-muted">{t("crypto.stats.since", { name: holding.name })}</p>
|
||||||
|
</StatTile>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CryptoStatsRow({ holdings, isLoading }: CryptoStatsRowProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <p className="py-4 text-center text-sm text-text-muted">{t("common.loading")}</p>;
|
||||||
|
}
|
||||||
|
if (holdings.length === 0) return null;
|
||||||
|
|
||||||
|
const totals = computeTotals(holdings);
|
||||||
|
const profitColor = totals.totalProfitLoss >= 0 ? "var(--success)" : "var(--danger)";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
|
<StatTile label={t("crypto.stats.allTimeProfit")}>
|
||||||
|
<p className="text-lg font-semibold tabular-nums" style={{ color: profitColor }}>
|
||||||
|
{totals.totalProfitLoss >= 0 ? "+" : ""}
|
||||||
|
{formatCurrency(totals.totalProfitLoss)}
|
||||||
|
</p>
|
||||||
|
{totals.totalProfitLossPercent !== null && (
|
||||||
|
<p className="mt-0.5 text-sm font-medium tabular-nums" style={{ color: profitColor }}>
|
||||||
|
{totals.totalProfitLossPercent >= 0 ? "+" : ""}
|
||||||
|
{totals.totalProfitLossPercent.toFixed(2)}%
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</StatTile>
|
||||||
|
|
||||||
|
<StatTile label={t("crypto.stats.costBasis")}>
|
||||||
|
<p className="text-lg font-semibold tabular-nums text-text-primary">{formatCurrency(totals.totalCostBasis)}</p>
|
||||||
|
<p className="mt-0.5 text-xs text-text-muted">{t("crypto.stats.costBasisHint")}</p>
|
||||||
|
</StatTile>
|
||||||
|
|
||||||
|
<PerformerTile label={t("crypto.stats.bestPerformer")} holding={totals.best} />
|
||||||
|
<PerformerTile label={t("crypto.stats.worstPerformer")} holding={totals.worst} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,17 +4,19 @@ import {
|
|||||||
createCryptoHolding,
|
createCryptoHolding,
|
||||||
deleteCryptoHolding,
|
deleteCryptoHolding,
|
||||||
deleteCryptoTransaction,
|
deleteCryptoTransaction,
|
||||||
|
fetchCryptoHistory,
|
||||||
fetchCryptoHoldings,
|
fetchCryptoHoldings,
|
||||||
fetchCryptoTransactions,
|
fetchCryptoTransactions,
|
||||||
refreshCryptoPrices,
|
refreshCryptoPrices,
|
||||||
} from "@/api/crypto";
|
} from "@/api/crypto";
|
||||||
import type { CryptoHoldingCreateInput, CryptoTransactionInput } from "@/types";
|
import type { CryptoHoldingCreateInput, CryptoRange, CryptoTransactionInput } from "@/types";
|
||||||
|
|
||||||
function useInvalidateCrypto() {
|
function useInvalidateCrypto() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return () => {
|
return () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["crypto-holdings"] });
|
queryClient.invalidateQueries({ queryKey: ["crypto-holdings"] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["crypto-transactions"] });
|
queryClient.invalidateQueries({ queryKey: ["crypto-transactions"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["crypto-history"] });
|
||||||
// A crypto holding is a normal Asset under the hood — Net Worth's own
|
// A crypto holding is a normal Asset under the hood — Net Worth's own
|
||||||
// numbers change the moment one is added/traded/repriced/removed.
|
// numbers change the moment one is added/traded/repriced/removed.
|
||||||
queryClient.invalidateQueries({ queryKey: ["net-worth-summary"] });
|
queryClient.invalidateQueries({ queryKey: ["net-worth-summary"] });
|
||||||
@@ -25,6 +27,10 @@ export function useCryptoHoldings() {
|
|||||||
return useQuery({ queryKey: ["crypto-holdings"], queryFn: fetchCryptoHoldings });
|
return useQuery({ queryKey: ["crypto-holdings"], queryFn: fetchCryptoHoldings });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useCryptoHistory(range: CryptoRange) {
|
||||||
|
return useQuery({ queryKey: ["crypto-history", range], queryFn: () => fetchCryptoHistory(range) });
|
||||||
|
}
|
||||||
|
|
||||||
export function useCryptoTransactions(assetId: number | null) {
|
export function useCryptoTransactions(assetId: number | null) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["crypto-transactions", assetId],
|
queryKey: ["crypto-transactions", assetId],
|
||||||
|
|||||||
@@ -146,6 +146,17 @@ const ru = {
|
|||||||
"crypto.history.empty": "Пока нет ни одной сделки.",
|
"crypto.history.empty": "Пока нет ни одной сделки.",
|
||||||
"crypto.history.confirmDelete": "Удалить эту сделку? Количество и средняя цена будут пересчитаны.",
|
"crypto.history.confirmDelete": "Удалить эту сделку? Количество и средняя цена будут пересчитаны.",
|
||||||
"crypto.history.viewButton": "Смотреть историю",
|
"crypto.history.viewButton": "Смотреть историю",
|
||||||
|
"crypto.holdingsTitle": "Активы",
|
||||||
|
"crypto.chart.show": "Показать сумму",
|
||||||
|
"crypto.chart.hide": "Скрыть сумму",
|
||||||
|
"crypto.allocation.title": "Распределение по монетам",
|
||||||
|
"crypto.allocation.other": "Другое",
|
||||||
|
"crypto.stats.allTimeProfit": "Прибыль за всё время",
|
||||||
|
"crypto.stats.costBasis": "Вложено всего",
|
||||||
|
"crypto.stats.costBasisHint": "сумма покупок по факту",
|
||||||
|
"crypto.stats.bestPerformer": "Лучший рост",
|
||||||
|
"crypto.stats.worstPerformer": "Худшее падение",
|
||||||
|
"crypto.stats.since": "с момента покупки {{name}}",
|
||||||
|
|
||||||
"roi.calculator.title": "Калькулятор ROI",
|
"roi.calculator.title": "Калькулятор ROI",
|
||||||
"roi.calculator.investmentLabel": "Сумма инвестиции",
|
"roi.calculator.investmentLabel": "Сумма инвестиции",
|
||||||
@@ -594,6 +605,17 @@ const en: Record<keyof typeof ru, string> = {
|
|||||||
"crypto.history.empty": "No trades yet.",
|
"crypto.history.empty": "No trades yet.",
|
||||||
"crypto.history.confirmDelete": "Delete this trade? Quantity and average price will be recalculated.",
|
"crypto.history.confirmDelete": "Delete this trade? Quantity and average price will be recalculated.",
|
||||||
"crypto.history.viewButton": "View history",
|
"crypto.history.viewButton": "View history",
|
||||||
|
"crypto.holdingsTitle": "Holdings",
|
||||||
|
"crypto.chart.show": "Show amount",
|
||||||
|
"crypto.chart.hide": "Hide amount",
|
||||||
|
"crypto.allocation.title": "Allocation by coin",
|
||||||
|
"crypto.allocation.other": "Other",
|
||||||
|
"crypto.stats.allTimeProfit": "All-time profit",
|
||||||
|
"crypto.stats.costBasis": "Total invested",
|
||||||
|
"crypto.stats.costBasisHint": "actual amount spent buying",
|
||||||
|
"crypto.stats.bestPerformer": "Best Performer",
|
||||||
|
"crypto.stats.worstPerformer": "Worst Performer",
|
||||||
|
"crypto.stats.since": "since buying {{name}}",
|
||||||
|
|
||||||
"roi.calculator.title": "ROI Calculator",
|
"roi.calculator.title": "ROI Calculator",
|
||||||
"roi.calculator.investmentLabel": "Investment amount",
|
"roi.calculator.investmentLabel": "Investment amount",
|
||||||
|
|||||||
@@ -3,13 +3,16 @@ import { Plus, RefreshCw } from "lucide-react";
|
|||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||||
import { CryptoAddModal } from "@/components/crypto/CryptoAddModal";
|
import { CryptoAddModal } from "@/components/crypto/CryptoAddModal";
|
||||||
|
import { CryptoAllocationCard } from "@/components/crypto/CryptoAllocationCard";
|
||||||
|
import { CryptoHistoryChart } from "@/components/crypto/CryptoHistoryChart";
|
||||||
import { CryptoHoldingsTable } from "@/components/crypto/CryptoHoldingsTable";
|
import { CryptoHoldingsTable } from "@/components/crypto/CryptoHoldingsTable";
|
||||||
|
import { CryptoStatsRow } from "@/components/crypto/CryptoStatsRow";
|
||||||
import { CryptoTransactionHistoryModal } from "@/components/crypto/CryptoTransactionHistoryModal";
|
import { CryptoTransactionHistoryModal } from "@/components/crypto/CryptoTransactionHistoryModal";
|
||||||
import { CryptoTransactionModal } from "@/components/crypto/CryptoTransactionModal";
|
import { CryptoTransactionModal } from "@/components/crypto/CryptoTransactionModal";
|
||||||
import { useCryptoHoldings, useDeleteCryptoHolding, useRefreshCryptoPrices } from "@/hooks/useCrypto";
|
import { useCryptoHistory, useCryptoHoldings, useDeleteCryptoHolding, useRefreshCryptoPrices } from "@/hooks/useCrypto";
|
||||||
import { formatCurrency, getIntlLocale } from "@/lib/format";
|
import { getIntlLocale } from "@/lib/format";
|
||||||
import { useTranslation } from "@/lib/i18n";
|
import { useTranslation } from "@/lib/i18n";
|
||||||
import type { CryptoHolding } from "@/types";
|
import type { CryptoHolding, CryptoRange } from "@/types";
|
||||||
|
|
||||||
function formatSyncedAt(iso: string): string {
|
function formatSyncedAt(iso: string): string {
|
||||||
return new Intl.DateTimeFormat(getIntlLocale(), { dateStyle: "medium", timeStyle: "short" }).format(new Date(iso));
|
return new Intl.DateTimeFormat(getIntlLocale(), { dateStyle: "medium", timeStyle: "short" }).format(new Date(iso));
|
||||||
@@ -17,7 +20,9 @@ function formatSyncedAt(iso: string): string {
|
|||||||
|
|
||||||
export function CryptoPage() {
|
export function CryptoPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const [range, setRange] = useState<CryptoRange>("30d");
|
||||||
const { data, isLoading } = useCryptoHoldings();
|
const { data, isLoading } = useCryptoHoldings();
|
||||||
|
const { data: history, isLoading: isHistoryLoading } = useCryptoHistory(range);
|
||||||
const refresh = useRefreshCryptoPrices();
|
const refresh = useRefreshCryptoPrices();
|
||||||
const deleteHolding = useDeleteCryptoHolding();
|
const deleteHolding = useDeleteCryptoHolding();
|
||||||
|
|
||||||
@@ -26,7 +31,6 @@ export function CryptoPage() {
|
|||||||
const [historyHolding, setHistoryHolding] = useState<CryptoHolding | null>(null);
|
const [historyHolding, setHistoryHolding] = useState<CryptoHolding | null>(null);
|
||||||
|
|
||||||
const holdings = data?.holdings ?? [];
|
const holdings = data?.holdings ?? [];
|
||||||
const totalValue = holdings.reduce((sum, holding) => sum + (holding.value !== null ? Number(holding.value) : 0), 0);
|
|
||||||
|
|
||||||
function handleDelete(holding: CryptoHolding) {
|
function handleDelete(holding: CryptoHolding) {
|
||||||
if (window.confirm(t("crypto.confirmDelete", { name: holding.name }))) {
|
if (window.confirm(t("crypto.confirmDelete", { name: holding.name }))) {
|
||||||
@@ -36,9 +40,22 @@ export function CryptoPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
|
<CryptoHistoryChart history={history} isLoading={isHistoryLoading} range={range} onRangeChange={setRange} />
|
||||||
|
|
||||||
|
<CryptoStatsRow holdings={holdings} isLoading={isLoading} />
|
||||||
|
|
||||||
|
<CryptoAllocationCard holdings={holdings} isLoading={isLoading} />
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex-col items-stretch gap-3 sm:flex-row sm:items-center">
|
<CardHeader className="flex-col items-stretch gap-3 sm:flex-row sm:items-center">
|
||||||
<CardTitle>{t("nav.crypto")}</CardTitle>
|
<div>
|
||||||
|
<CardTitle>{t("crypto.holdingsTitle")}</CardTitle>
|
||||||
|
<p className="mt-0.5 text-xs text-text-muted">
|
||||||
|
{data?.last_synced_at
|
||||||
|
? t("crypto.lastSynced", { time: formatSyncedAt(data.last_synced_at) })
|
||||||
|
: t("crypto.neverSynced")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button variant="secondary" onClick={() => refresh.mutate()} disabled={refresh.isPending}>
|
<Button variant="secondary" onClick={() => refresh.mutate()} disabled={refresh.isPending}>
|
||||||
<RefreshCw size={16} className={refresh.isPending ? "animate-spin" : undefined} />
|
<RefreshCw size={16} className={refresh.isPending ? "animate-spin" : undefined} />
|
||||||
@@ -55,20 +72,7 @@ export function CryptoPage() {
|
|||||||
<p className="py-10 text-center text-sm text-text-muted">{t("common.loading")}</p>
|
<p className="py-10 text-center text-sm text-text-muted">{t("common.loading")}</p>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="mb-4 flex flex-wrap items-baseline justify-between gap-2 border-b border-border pb-4">
|
|
||||||
<div>
|
|
||||||
<p className="text-xs uppercase tracking-wide text-text-muted">{t("crypto.totalValueLabel")}</p>
|
|
||||||
<p className="text-2xl font-semibold tabular-nums text-text-primary">{formatCurrency(totalValue)}</p>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-text-muted">
|
|
||||||
{data?.last_synced_at
|
|
||||||
? t("crypto.lastSynced", { time: formatSyncedAt(data.last_synced_at) })
|
|
||||||
: t("crypto.neverSynced")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{data?.error_key && <p className="mb-3 text-sm text-danger">{t(`crypto.syncError.${data.error_key}`)}</p>}
|
{data?.error_key && <p className="mb-3 text-sm text-danger">{t(`crypto.syncError.${data.error_key}`)}</p>}
|
||||||
|
|
||||||
<CryptoHoldingsTable
|
<CryptoHoldingsTable
|
||||||
items={holdings}
|
items={holdings}
|
||||||
onTrade={setTradingHolding}
|
onTrade={setTradingHolding}
|
||||||
|
|||||||
@@ -443,6 +443,24 @@ export interface CryptoSearchResult {
|
|||||||
thumb_url: string | null;
|
thumb_url: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// No "24h" — the price history is only as dense as the sync cadence (see
|
||||||
|
// services/crypto_service.py's AUTO_REFRESH_INTERVAL), so a 24h chart would
|
||||||
|
// be one or two points, not a smooth intraday line.
|
||||||
|
export type CryptoRange = "7d" | "30d" | "90d" | "all";
|
||||||
|
|
||||||
|
export interface CryptoHistoryPoint {
|
||||||
|
date: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CryptoHistoryResponse {
|
||||||
|
range: CryptoRange;
|
||||||
|
current: string;
|
||||||
|
change_amount: string;
|
||||||
|
change_percent: number | null;
|
||||||
|
series: CryptoHistoryPoint[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface AppSettings {
|
export interface AppSettings {
|
||||||
currency: string;
|
currency: string;
|
||||||
negative_cash_flow_threshold_months: number;
|
negative_cash_flow_threshold_months: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user