Files
aurum/frontend/src/pages/CryptoPage.tsx
ZProger 7e4508d1de feat: add a Crypto tab with live CoinGecko prices synced into Net Worth
Track individual coins (identity + quantity) in a dedicated tab, backed by
CoinGecko's Demo API (AURUM_COINGECKO_API_KEY). A holding is just another
Asset under the hood (asset_class=crypto) with its value written as a
regular AssetValuation — the existing Net Worth aggregation needs zero
changes to pick it up.

Prices never refresh in the background (no scheduler in this stack, same
reasoning as recurring transactions): a "Refresh prices" button forces a
sync, and GET /crypto/holdings lazily re-syncs at most once every 24h.
CoinGecko outages leave existing values untouched (error_key: "unreachable")
instead of failing the request. Backup/restore now carries CryptoHolding
rows too, so a restore doesn't silently strip a holding's coin identity.

Backend: 75/75 tests passing (9 new), including a real identity-map
staleness bug the tests caught in the raw-Core-upsert + ORM re-read pattern,
fixed via populate_existing=True.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01855PiW5Wd8n4aRW4n9kByY
2026-08-30 03:58:04 +03:00

79 lines
3.5 KiB
TypeScript

import { useState } from "react";
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 { CryptoEditModal } from "@/components/crypto/CryptoEditModal";
import { CryptoHoldingsList } from "@/components/crypto/CryptoHoldingsList";
import { useCryptoHoldings, useDeleteCryptoHolding, useRefreshCryptoPrices } from "@/hooks/useCrypto";
import { formatCurrency, getIntlLocale } from "@/lib/format";
import { useTranslation } from "@/lib/i18n";
import type { CryptoHolding } from "@/types";
function formatSyncedAt(iso: string): string {
return new Intl.DateTimeFormat(getIntlLocale(), { dateStyle: "medium", timeStyle: "short" }).format(new Date(iso));
}
export function CryptoPage() {
const { t } = useTranslation();
const { data, isLoading } = useCryptoHoldings();
const refresh = useRefreshCryptoPrices();
const deleteHolding = useDeleteCryptoHolding();
const [addOpen, setAddOpen] = useState(false);
const [editingHolding, setEditingHolding] = useState<CryptoHolding | null>(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 }))) {
deleteHolding.mutate(holding.asset_id);
}
}
return (
<div className="space-y-5">
<Card>
<CardHeader className="flex-col items-stretch gap-3 sm:flex-row sm:items-center">
<CardTitle>{t("nav.crypto")}</CardTitle>
<div className="flex flex-wrap gap-2">
<Button variant="secondary" onClick={() => refresh.mutate()} disabled={refresh.isPending}>
<RefreshCw size={16} className={refresh.isPending ? "animate-spin" : undefined} />
{refresh.isPending ? t("crypto.refreshing") : t("crypto.refreshButton")}
</Button>
<Button onClick={() => setAddOpen(true)}>
<Plus size={16} />
{t("crypto.addButton")}
</Button>
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<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>}
<CryptoHoldingsList items={holdings} onEdit={setEditingHolding} onDelete={handleDelete} />
</>
)}
</CardContent>
</Card>
<CryptoAddModal open={addOpen} onClose={() => setAddOpen(false)} />
<CryptoEditModal open={editingHolding !== null} onClose={() => setEditingHolding(null)} holding={editingHolding} />
</div>
);
}