fix: график крипты открывать за всё время, лучший/худший рост — по выбранному периоду

График во вкладке «Криптовалюта» теперь по умолчанию показывает всю
историю, а не 30 дней — короткое окно у портфеля, растущего годами,
часто попадало на локальную просадку и выглядело демотивирующе.

Плитки «Лучший рост» и «Худшее падение» раньше всегда считались за
24 часа. Теперь они следуют за диапазоном, выбранным у графика
(7д/30д/90д/всё время): для 7д/30д используются закэшированные данные
CoinGecko, для «всё время» — метрика за 1 год (дальше бесплатный
тариф CoinGecko историю не отдаёт), а для 90д — отдельный запрос по
каждой монете, который выполняется только когда пользователь реально
выбрал этот период.
This commit is contained in:
ZProger
2026-09-04 02:18:31 +03:00
parent aefd6cc035
commit cd125e9e45
15 changed files with 404 additions and 33 deletions

View File

@@ -1,7 +1,7 @@
{
"name": "aurum-frontend",
"private": true,
"version": "1.1.2",
"version": "1.1.3",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -3,6 +3,7 @@ import type {
CryptoHistoryResponse,
CryptoHolding,
CryptoHoldingCreateInput,
CryptoPerformanceResponse,
CryptoPortfolio,
CryptoPortfolioInput,
CryptoRange,
@@ -78,3 +79,8 @@ export function fetchCryptoHistory(range: CryptoRange, portfolioId?: number | nu
const query = portfolioId != null ? `&portfolio_id=${portfolioId}` : "";
return api.get<CryptoHistoryResponse>(`/crypto/history?range=${range}${query}`);
}
export function fetchCrypto90dPerformance(portfolioId?: number | null) {
const query = portfolioId != null ? `?portfolio_id=${portfolioId}` : "";
return api.get<CryptoPerformanceResponse>(`/crypto/performance/90d${query}`);
}

View File

@@ -2,12 +2,21 @@ import type { ReactNode } from "react";
import { Card, CardContent } from "@/components/ui/Card";
import { formatCurrency, maskAmount } from "@/lib/format";
import { useTranslation } from "@/lib/i18n";
import type { CryptoHolding } from "@/types";
import type { CryptoHolding, CryptoRange } from "@/types";
interface CryptoStatsRowProps {
holdings: CryptoHolding[];
isLoading: boolean;
hidden: boolean;
// Which window Best/Worst Performer measures price move over — kept in
// sync with the History chart's own range selector one level up, so
// switching one switches both (see CryptoPage).
range: CryptoRange;
// Real per-coin 90d % change, fetched on demand only while range==="90d"
// (see services/crypto_service.py's get_90d_performance) — undefined
// while that fetch is still in flight.
performance90d?: Map<number, number | null>;
isPerformance90dLoading: boolean;
}
interface Totals {
@@ -15,28 +24,49 @@ interface Totals {
totalCostBasis: number;
totalProfitLoss: number;
totalProfitLossPercent: number | null;
best: CryptoHolding | null;
worst: CryptoHolding | null;
best: { holding: CryptoHolding; percent: number } | null;
worst: { holding: CryptoHolding; percent: number } | null;
}
function computeTotals(holdings: CryptoHolding[]): Totals {
// Best/Worst Performer compare coins against *each other* by market price
// move over the selected window, not against what the user personally paid
// — a coin bought yesterday at the top can still be "best performer" here
// if it's simply up the most of anything held today, independent of entry
// price/timing.
function changePercentFor(
holding: CryptoHolding,
range: CryptoRange,
performance90d: Map<number, number | null> | undefined
): number | null {
switch (range) {
case "7d":
return holding.price_change_7d !== null ? Number(holding.price_change_7d) : null;
case "30d":
return holding.price_change_30d !== null ? Number(holding.price_change_30d) : null;
case "90d":
return performance90d?.get(holding.asset_id) ?? null;
case "all":
return holding.price_change_1y !== null ? Number(holding.price_change_1y) : null;
}
}
function computeTotals(
holdings: CryptoHolding[],
range: CryptoRange,
performance90d: Map<number, number | null> | undefined
): Totals {
let totalValue = 0;
let totalCostBasis = 0;
let best: CryptoHolding | null = null;
let worst: CryptoHolding | null = null;
let best: Totals["best"] = null;
let worst: Totals["worst"] = null;
for (const holding of holdings) {
if (holding.value !== null) totalValue += Number(holding.value);
if (holding.cost_basis !== null) totalCostBasis += Number(holding.cost_basis);
// Best/Worst Performer compare coins against *each other* by market
// price move (24h % change), not against what the user personally paid
// — a coin someone bought yesterday at the top can still be "best
// performer" here if it's simply up the most of anything they hold
// today, independent of their own entry price/timing.
if (holding.price_change_24h === null) continue;
const change = Number(holding.price_change_24h);
if (best === null || change > Number(best.price_change_24h)) best = holding;
if (worst === null || change < Number(worst.price_change_24h)) worst = holding;
const percent = changePercentFor(holding, range, performance90d);
if (percent === null) continue;
if (best === null || percent > best.percent) best = { holding, percent };
if (worst === null || percent < worst.percent) worst = { holding, percent };
}
const totalProfitLoss = totalValue - totalCostBasis;
@@ -56,16 +86,37 @@ function StatTile({ label, children }: { label: string; children: ReactNode }) {
);
}
function PerformerTile({ label, holding }: { label: string; holding: CryptoHolding | null }) {
// Same labels CryptoRangeSelector shows on its own pills — "all" is the
// only one actually translated there too.
const RANGE_LABELS: Record<Exclude<CryptoRange, "all">, string> = { "7d": "7D", "30d": "30D", "90d": "90D" };
function PerformerTile({
label,
entry,
rangeLabel,
isLoading,
}: {
label: string;
entry: Totals["best"];
rangeLabel: string;
isLoading: boolean;
}) {
const { t } = useTranslation();
if (holding === null || holding.price_change_24h === null) {
if (isLoading) {
return (
<StatTile label={label}>
<p className="text-lg font-semibold text-text-muted">{t("common.loading")}</p>
</StatTile>
);
}
if (entry === null) {
return (
<StatTile label={label}>
<p className="text-lg font-semibold text-text-muted">—</p>
</StatTile>
);
}
const percent = Number(holding.price_change_24h);
const { holding, percent } = entry;
const color = percent >= 0 ? "var(--success)" : "var(--danger)";
return (
<StatTile label={label}>
@@ -81,12 +132,19 @@ function PerformerTile({ label, holding }: { label: string; holding: CryptoHoldi
{percent >= 0 ? "+" : ""}
{percent.toFixed(2)}%
</p>
<p className="mt-0.5 text-xs text-text-muted">{t("crypto.stats.change24h")}</p>
<p className="mt-0.5 text-xs text-text-muted">{t("crypto.stats.changeLabel", { range: rangeLabel })}</p>
</StatTile>
);
}
export function CryptoStatsRow({ holdings, isLoading, hidden }: CryptoStatsRowProps) {
export function CryptoStatsRow({
holdings,
isLoading,
hidden,
range,
performance90d,
isPerformance90dLoading,
}: CryptoStatsRowProps) {
const { t } = useTranslation();
if (isLoading) {
@@ -94,8 +152,10 @@ export function CryptoStatsRow({ holdings, isLoading, hidden }: CryptoStatsRowPr
}
if (holdings.length === 0) return null;
const totals = computeTotals(holdings);
const totals = computeTotals(holdings, range, performance90d);
const profitColor = totals.totalProfitLoss >= 0 ? "var(--success)" : "var(--danger)";
const rangeLabel = range === "all" ? t("common.allShort") : RANGE_LABELS[range];
const performerLoading = range === "90d" && isPerformance90dLoading;
return (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
@@ -118,8 +178,18 @@ export function CryptoStatsRow({ holdings, isLoading, hidden }: CryptoStatsRowPr
<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} />
<PerformerTile
label={t("crypto.stats.bestPerformer")}
entry={totals.best}
rangeLabel={rangeLabel}
isLoading={performerLoading}
/>
<PerformerTile
label={t("crypto.stats.worstPerformer")}
entry={totals.worst}
rangeLabel={rangeLabel}
isLoading={performerLoading}
/>
</div>
);
}

View File

@@ -6,6 +6,7 @@ import {
deleteCryptoHolding,
deleteCryptoPortfolio,
deleteCryptoTransaction,
fetchCrypto90dPerformance,
fetchCryptoHistory,
fetchCryptoHoldings,
fetchCryptoPortfolios,
@@ -74,6 +75,17 @@ export function useCryptoHistory(range: CryptoRange, portfolioId?: number | null
});
}
// Only fires while the 90d range is actually selected — a real CoinGecko
// call per held coin (see get_90d_performance's docstring), not something
// to run on every Crypto tab visit the way 7d/30d/1y already do for free.
export function useCrypto90dPerformance(range: CryptoRange, portfolioId?: number | null) {
return useQuery({
queryKey: ["crypto-performance-90d", portfolioId ?? null],
queryFn: () => fetchCrypto90dPerformance(portfolioId),
enabled: range === "90d",
});
}
export function useCryptoTransactions(assetId: number | null) {
return useQuery({
queryKey: ["crypto-transactions", assetId],

View File

@@ -161,7 +161,7 @@ const ru = {
"crypto.stats.costBasisHint": "сумма покупок по факту",
"crypto.stats.bestPerformer": "Лучший рост",
"crypto.stats.worstPerformer": "Худшее падение",
"crypto.stats.change24h": "изменение цены за 24ч",
"crypto.stats.changeLabel": "изменение цены за {{range}}",
"crypto.portfolio.all": "Все портфели",
"crypto.portfolio.addLabel": "Добавить портфель",
"crypto.portfolio.archiveLabel": "Архивировать портфель",
@@ -655,7 +655,7 @@ const en: Record<keyof typeof ru, string> = {
"crypto.stats.costBasisHint": "actual amount spent buying",
"crypto.stats.bestPerformer": "Best Performer",
"crypto.stats.worstPerformer": "Worst Performer",
"crypto.stats.change24h": "24h price change",
"crypto.stats.changeLabel": "price change over {{range}}",
"crypto.portfolio.all": "All portfolios",
"crypto.portfolio.addLabel": "Add portfolio",
"crypto.portfolio.archiveLabel": "Archive portfolio",

View File

@@ -11,6 +11,7 @@ import { CryptoStatsRow } from "@/components/crypto/CryptoStatsRow";
import { CryptoTransactionHistoryModal } from "@/components/crypto/CryptoTransactionHistoryModal";
import { CryptoTransactionModal } from "@/components/crypto/CryptoTransactionModal";
import {
useCrypto90dPerformance,
useCryptoHistory,
useCryptoHoldings,
useCryptoPortfolios,
@@ -27,7 +28,9 @@ function formatSyncedAt(iso: string): string {
export function CryptoPage() {
const { t } = useTranslation();
const [range, setRange] = useState<CryptoRange>("30d");
// "all" by default — a 30-day window makes a portfolio that's actually
// grown steadily for years look demotivating whenever it's mid-dip.
const [range, setRange] = useState<CryptoRange>("all");
// Single "hide balance" switch for the whole tab — owned here so every
// money-displaying section (chart, stats, allocation, table, trade
// history) masks together instead of the toggle only affecting whichever
@@ -38,6 +41,7 @@ export function CryptoPage() {
const { data: portfolios } = useCryptoPortfolios();
const { data, isLoading } = useCryptoHoldings(portfolioFilter);
const { data: history, isLoading: isHistoryLoading } = useCryptoHistory(range, portfolioFilter);
const { data: performance90d, isLoading: isPerformance90dLoading } = useCrypto90dPerformance(range, portfolioFilter);
const refresh = useRefreshCryptoPrices();
const deleteHolding = useDeleteCryptoHolding();
@@ -55,6 +59,10 @@ export function CryptoPage() {
const holdings = data?.holdings ?? [];
const totalValue = holdings.reduce((sum, holding) => sum + (holding.value !== null ? Number(holding.value) : 0), 0);
const portfoliosById = useMemo(() => new Map((portfolios ?? []).map((p) => [p.id, p])), [portfolios]);
const performance90dByAsset = useMemo(
() => new Map((performance90d?.items ?? []).map((item) => [item.asset_id, item.price_change_percent])),
[performance90d]
);
function openAddPortfolioModal() {
setEditingPortfolio(null);
@@ -110,7 +118,14 @@ export function CryptoPage() {
onToggleHidden={() => setHidden((prev) => !prev)}
/>
<CryptoStatsRow holdings={holdings} isLoading={isLoading} hidden={hidden} />
<CryptoStatsRow
holdings={holdings}
isLoading={isLoading}
hidden={hidden}
range={range}
performance90d={performance90dByAsset}
isPerformance90dLoading={isPerformance90dLoading}
/>
<Card>
<CardHeader className="flex-col items-stretch gap-3 sm:flex-row sm:items-center">

View File

@@ -453,6 +453,10 @@ export interface CryptoHolding {
price_change_1h: string | null;
price_change_24h: string | null;
price_change_7d: string | null;
price_change_30d: string | null;
// Stands in for "all time" on the Best/Worst Performer stat — CoinGecko's
// free tier caps historical lookback at 365 days regardless.
price_change_1y: string | null;
value: string | null;
cost_basis: string | null;
profit_loss: string | null;
@@ -507,6 +511,18 @@ export interface CryptoSearchResult {
thumb_url: string | null;
}
// Real 90-day % price change per coin, fetched on demand only while the
// 90d range is selected — see services/crypto_service.py's
// get_90d_performance for why this can't ride along with the regular sync.
export interface CryptoPerformancePoint {
asset_id: number;
price_change_percent: number | null;
}
export interface CryptoPerformanceResponse {
items: CryptoPerformancePoint[];
}
// 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.