diff --git a/DOCS.md b/DOCS.md index 76e4299..4148f25 100644 --- a/DOCS.md +++ b/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. | | `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/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`): @@ -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 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 Read-only aggregation endpoints — the numbers behind Aurum's charts. Useful for pulling summary data diff --git a/backend/app/api/routes/crypto.py b/backend/app/api/routes/crypto.py index 256d423..456290b 100644 --- a/backend/app/api/routes/crypto.py +++ b/backend/app/api/routes/crypto.py @@ -3,6 +3,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.api.deps import get_session from app.schemas.crypto import ( + CryptoHistoryResponse, CryptoHoldingCreate, CryptoHoldingRead, CryptoSearchResult, @@ -11,9 +12,11 @@ from app.schemas.crypto import ( CryptoTransactionRead, ) from app.services.crypto_service import ( + CRYPTO_RANGE_DAYS, add_transaction, create_holding, delete_transaction, + get_crypto_history, list_transactions, refresh_prices, search_coins, @@ -68,3 +71,15 @@ async def delete_transaction_route(transaction_id: int, session: AsyncSession = @router.get("/search", response_model=list[CryptoSearchResult]) async def search_coins_route(q: str = Query(min_length=1, max_length=100)) -> list[CryptoSearchResult]: 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) diff --git a/backend/app/schemas/crypto.py b/backend/app/schemas/crypto.py index c3fa8bc..d06c9fb 100644 --- a/backend/app/schemas/crypto.py +++ b/backend/app/schemas/crypto.py @@ -93,3 +93,21 @@ class CryptoSearchResult(BaseModel): symbol: str name: str 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] diff --git a/backend/app/services/crypto_service.py b/backend/app/services/crypto_service.py index ce0da97..f4dedb6 100644 --- a/backend/app/services/crypto_service.py +++ b/backend/app/services/crypto_service.py @@ -22,6 +22,7 @@ reuses the last cached price. from datetime import date as date_ from datetime import datetime, timedelta, timezone from decimal import Decimal +from itertools import groupby from typing import Literal, NamedTuple import httpx @@ -36,6 +37,8 @@ from app.models.asset import Asset, AssetValuation from app.models.crypto import CryptoHolding, CryptoSyncState, CryptoTransaction from app.models.enums import AssetClass, CapitalRole, CryptoTransactionType, RiskLevel from app.schemas.crypto import ( + CryptoHistoryPoint, + CryptoHistoryResponse, CryptoHoldingCreate, CryptoHoldingRead, 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). 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),) @@ -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 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, + ) diff --git a/backend/tests/test_crypto.py b/backend/tests/test_crypto.py index 8980869..b6c688b 100644 --- a/backend/tests/test_crypto.py +++ b/backend/tests/test_crypto.py @@ -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["avg_buy_price"]) == money("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 diff --git a/frontend/src/api/crypto.ts b/frontend/src/api/crypto.ts index 03738c9..5db3603 100644 --- a/frontend/src/api/crypto.ts +++ b/frontend/src/api/crypto.ts @@ -1,7 +1,9 @@ import { api } from "@/api/client"; import type { + CryptoHistoryResponse, CryptoHolding, CryptoHoldingCreateInput, + CryptoRange, CryptoSearchResult, CryptoSyncResult, CryptoTransaction, @@ -45,3 +47,7 @@ export function deleteCryptoHolding(assetId: number) { export function searchCryptoCoins(query: string) { return api.get(`/crypto/search?q=${encodeURIComponent(query)}`); } + +export function fetchCryptoHistory(range: CryptoRange) { + return api.get(`/crypto/history?range=${range}`); +} diff --git a/frontend/src/components/crypto/CryptoAllocationCard.tsx b/frontend/src/components/crypto/CryptoAllocationCard.tsx new file mode 100644 index 0000000..74e0d90 --- /dev/null +++ b/frontend/src/components/crypto/CryptoAllocationCard.tsx @@ -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 ( + + + {t("crypto.allocation.title")} + + + {isLoading ? ( +

{t("common.loading")}

+ ) : slices.length === 0 ? ( +

{t("crypto.empty")}

+ ) : ( + <> +
+ {slices.map((slice) => ( +
+ ))} +
+ +
    + {slices.map((slice) => { + const percent = (slice.amount / total) * 100; + return ( +
  • + {slice.thumbUrl ? ( + + ) : ( + + + + )} + + {slice.key === "other" ? t("crypto.allocation.other") : slice.name} + {slice.symbol && · {slice.symbol}} + + + + + + {percent.toFixed(0)}% + + + {percent.toFixed(0)}% + + + {formatCurrency(slice.amount)} + +
  • + ); + })} +
+ + )} + + + ); +} diff --git a/frontend/src/components/crypto/CryptoHistoryChart.tsx b/frontend/src/components/crypto/CryptoHistoryChart.tsx new file mode 100644 index 0000000..86c498e --- /dev/null +++ b/frontend/src/components/crypto/CryptoHistoryChart.tsx @@ -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 ( +
+

{formatAxisDate(point.date)}

+

{formatCurrency(point.value)}

+
+ ); +} + +/** 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 ( + + +
+ {t("nav.crypto")} +

+ {isLoading ? "…" : hidden ? "••••••" : formatCurrency(history?.current ?? 0)} + +

+ {history && !hidden && ( +

+ {isPositive ? : } + {formatSignedCurrency(history.change_amount)} + {history.change_percent !== null && ` (${isPositive ? "+" : ""}${history.change_percent.toFixed(1)}%)`} + {t("netWorth.periodSuffix")} +

+ )} +
+ +
+ +
+ {isLoading || chartData.length === 0 ? ( +
+ {isLoading ? t("common.loading") : t("netWorth.noChartData")} +
+ ) : ( + + + + + + + + + + } cursor={{ stroke: "var(--gridline)", strokeWidth: 1 }} /> + + + + )} +
+ {chartData.length > 1 && ( +
+ {formatAxisDate(chartData[0].date)} + {formatAxisDate(chartData[chartData.length - 1].date)} +
+ )} +
+
+ ); +} diff --git a/frontend/src/components/crypto/CryptoRangeSelector.tsx b/frontend/src/components/crypto/CryptoRangeSelector.tsx new file mode 100644 index 0000000..71ac707 --- /dev/null +++ b/frontend/src/components/crypto/CryptoRangeSelector.tsx @@ -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 ; +} diff --git a/frontend/src/components/crypto/CryptoStatsRow.tsx b/frontend/src/components/crypto/CryptoStatsRow.tsx new file mode 100644 index 0000000..1ec3599 --- /dev/null +++ b/frontend/src/components/crypto/CryptoStatsRow.tsx @@ -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 ( + + +

{label}

+
{children}
+
+
+ ); +} + +function PerformerTile({ label, holding }: { label: string; holding: CryptoHolding | null }) { + const { t } = useTranslation(); + if (holding === null || holding.profit_loss_percent === null) { + return ( + +

—

+
+ ); + } + const percent = holding.profit_loss_percent; + const color = percent >= 0 ? "var(--success)" : "var(--danger)"; + return ( + +
+ {holding.thumb_url ? ( + + ) : ( + + )} +

{holding.symbol}

+
+

+ {percent >= 0 ? "+" : ""} + {percent.toFixed(2)}% +

+

{t("crypto.stats.since", { name: holding.name })}

+
+ ); +} + +export function CryptoStatsRow({ holdings, isLoading }: CryptoStatsRowProps) { + const { t } = useTranslation(); + + if (isLoading) { + return

{t("common.loading")}

; + } + if (holdings.length === 0) return null; + + const totals = computeTotals(holdings); + const profitColor = totals.totalProfitLoss >= 0 ? "var(--success)" : "var(--danger)"; + + return ( +
+ +

+ {totals.totalProfitLoss >= 0 ? "+" : ""} + {formatCurrency(totals.totalProfitLoss)} +

+ {totals.totalProfitLossPercent !== null && ( +

+ {totals.totalProfitLossPercent >= 0 ? "+" : ""} + {totals.totalProfitLossPercent.toFixed(2)}% +

+ )} +
+ + +

{formatCurrency(totals.totalCostBasis)}

+

{t("crypto.stats.costBasisHint")}

+
+ + + +
+ ); +} diff --git a/frontend/src/hooks/useCrypto.ts b/frontend/src/hooks/useCrypto.ts index 736b053..57bcfcf 100644 --- a/frontend/src/hooks/useCrypto.ts +++ b/frontend/src/hooks/useCrypto.ts @@ -4,17 +4,19 @@ import { createCryptoHolding, deleteCryptoHolding, deleteCryptoTransaction, + fetchCryptoHistory, fetchCryptoHoldings, fetchCryptoTransactions, refreshCryptoPrices, } from "@/api/crypto"; -import type { CryptoHoldingCreateInput, CryptoTransactionInput } from "@/types"; +import type { CryptoHoldingCreateInput, CryptoRange, CryptoTransactionInput } from "@/types"; function useInvalidateCrypto() { const queryClient = useQueryClient(); return () => { queryClient.invalidateQueries({ queryKey: ["crypto-holdings"] }); queryClient.invalidateQueries({ queryKey: ["crypto-transactions"] }); + queryClient.invalidateQueries({ queryKey: ["crypto-history"] }); // A crypto holding is a normal Asset under the hood — Net Worth's own // numbers change the moment one is added/traded/repriced/removed. queryClient.invalidateQueries({ queryKey: ["net-worth-summary"] }); @@ -25,6 +27,10 @@ export function useCryptoHoldings() { 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) { return useQuery({ queryKey: ["crypto-transactions", assetId], diff --git a/frontend/src/lib/i18n.ts b/frontend/src/lib/i18n.ts index 0c65126..0c4ec9a 100644 --- a/frontend/src/lib/i18n.ts +++ b/frontend/src/lib/i18n.ts @@ -146,6 +146,17 @@ const ru = { "crypto.history.empty": "Пока нет ни одной сделки.", "crypto.history.confirmDelete": "Удалить эту сделку? Количество и средняя цена будут пересчитаны.", "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.investmentLabel": "Сумма инвестиции", @@ -594,6 +605,17 @@ const en: Record = { "crypto.history.empty": "No trades yet.", "crypto.history.confirmDelete": "Delete this trade? Quantity and average price will be recalculated.", "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.investmentLabel": "Investment amount", diff --git a/frontend/src/pages/CryptoPage.tsx b/frontend/src/pages/CryptoPage.tsx index 565b406..c5d934b 100644 --- a/frontend/src/pages/CryptoPage.tsx +++ b/frontend/src/pages/CryptoPage.tsx @@ -3,13 +3,16 @@ import { Plus, RefreshCw } from "lucide-react"; import { Button } from "@/components/ui/Button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card"; 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 { CryptoStatsRow } from "@/components/crypto/CryptoStatsRow"; import { CryptoTransactionHistoryModal } from "@/components/crypto/CryptoTransactionHistoryModal"; import { CryptoTransactionModal } from "@/components/crypto/CryptoTransactionModal"; -import { useCryptoHoldings, useDeleteCryptoHolding, useRefreshCryptoPrices } from "@/hooks/useCrypto"; -import { formatCurrency, getIntlLocale } from "@/lib/format"; +import { useCryptoHistory, useCryptoHoldings, useDeleteCryptoHolding, useRefreshCryptoPrices } from "@/hooks/useCrypto"; +import { getIntlLocale } from "@/lib/format"; import { useTranslation } from "@/lib/i18n"; -import type { CryptoHolding } from "@/types"; +import type { CryptoHolding, CryptoRange } from "@/types"; function formatSyncedAt(iso: string): string { 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() { const { t } = useTranslation(); + const [range, setRange] = useState("30d"); const { data, isLoading } = useCryptoHoldings(); + const { data: history, isLoading: isHistoryLoading } = useCryptoHistory(range); const refresh = useRefreshCryptoPrices(); const deleteHolding = useDeleteCryptoHolding(); @@ -26,7 +31,6 @@ export function CryptoPage() { const [historyHolding, setHistoryHolding] = useState(null); const holdings = data?.holdings ?? []; - const totalValue = holdings.reduce((sum, holding) => sum + (holding.value !== null ? Number(holding.value) : 0), 0); function handleDelete(holding: CryptoHolding) { if (window.confirm(t("crypto.confirmDelete", { name: holding.name }))) { @@ -36,9 +40,22 @@ export function CryptoPage() { return (
+ + + + + + - {t("nav.crypto")} +
+ {t("crypto.holdingsTitle")} +

+ {data?.last_synced_at + ? t("crypto.lastSynced", { time: formatSyncedAt(data.last_synced_at) }) + : t("crypto.neverSynced")} +

+