diff --git a/frontend/src/components/crypto/CryptoAllocationBody.tsx b/frontend/src/components/crypto/CryptoAllocationBody.tsx
new file mode 100644
index 0000000..15f29df
--- /dev/null
+++ b/frontend/src/components/crypto/CryptoAllocationBody.tsx
@@ -0,0 +1,148 @@
+import { Bitcoin } from "lucide-react";
+import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from "recharts";
+import { formatCurrency, maskAmount } from "@/lib/format";
+import { useTranslation } from "@/lib/i18n";
+import type { CryptoHolding } from "@/types";
+
+interface CryptoAllocationBodyProps {
+ holdings: CryptoHolding[];
+ isLoading: boolean;
+ hidden: 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 }];
+}
+
+function DonutTooltip({
+ active,
+ payload,
+ hidden,
+ otherLabel,
+}: {
+ active?: boolean;
+ payload?: Array<{ payload: Slice & { percent: number } }>;
+ hidden: boolean;
+ otherLabel: string;
+}) {
+ if (!active || !payload?.length) return null;
+ const slice = payload[0].payload;
+ return (
+
+
{slice.key === "other" ? otherLabel : slice.name}
+
+ {maskAmount(formatCurrency(slice.amount), hidden)} · {slice.percent.toFixed(1)}%
+
+
+ );
+}
+
+/** The "Allocation" tab's content inside CryptoOverviewCard — a donut (part-
+ * to-whole across few categories is exactly the case a donut is fine for)
+ * plus a list that also carries the $ amount, which the donut/legend alone
+ * can't — the user explicitly wants both percent and sum per coin, not just
+ * percent the way CoinMarketCap's own version shows it. */
+export function CryptoAllocationBody({ holdings, isLoading, hidden }: CryptoAllocationBodyProps) {
+ const { t } = useTranslation();
+
+ if (isLoading) {
+ return {t("common.loading")}
;
+ }
+ const slices = buildSlices(holdings);
+ if (slices.length === 0) {
+ return {t("crypto.empty")}
;
+ }
+
+ const total = slices.reduce((sum, slice) => sum + slice.amount, 0);
+ const donutData = slices.map((slice) => ({ ...slice, percent: (slice.amount / total) * 100 }));
+
+ return (
+
+
+
+
+ 1 ? 2 : 0}
+ stroke="var(--surface-1)"
+ strokeWidth={2}
+ isAnimationActive={false}
+ >
+ {donutData.map((slice) => (
+ |
+ ))}
+
+ } />
+
+
+
+
+
+ {donutData.map((slice) => (
+
+ {slice.thumbUrl ? (
+
+ ) : (
+
+
+
+ )}
+
+ {slice.key === "other" ? t("crypto.allocation.other") : slice.name}
+ {slice.symbol && · {slice.symbol} }
+
+
+ {slice.percent.toFixed(1)}%
+
+
+ {maskAmount(formatCurrency(slice.amount), hidden)}
+
+
+ ))}
+
+
+ );
+}
diff --git a/frontend/src/components/crypto/CryptoAllocationCard.tsx b/frontend/src/components/crypto/CryptoAllocationCard.tsx
deleted file mode 100644
index 2a276f0..0000000
--- a/frontend/src/components/crypto/CryptoAllocationCard.tsx
+++ /dev/null
@@ -1,120 +0,0 @@
-import { Bitcoin } from "lucide-react";
-import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
-import { formatCurrency, maskAmount } from "@/lib/format";
-import { useTranslation } from "@/lib/i18n";
-import type { CryptoHolding } from "@/types";
-
-interface CryptoAllocationCardProps {
- holdings: CryptoHolding[];
- isLoading: boolean;
- hidden: 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, hidden }: 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)}%
-
-
- {maskAmount(formatCurrency(slice.amount), hidden)}
-
-
- );
- })}
-
- >
- )}
-
-
- );
-}
diff --git a/frontend/src/components/crypto/CryptoHistoryChart.tsx b/frontend/src/components/crypto/CryptoHistoryChart.tsx
deleted file mode 100644
index abab181..0000000
--- a/frontend/src/components/crypto/CryptoHistoryChart.tsx
+++ /dev/null
@@ -1,124 +0,0 @@
-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, maskAmount } 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;
- hidden: boolean;
- onToggleHidden: () => 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,
- hidden,
-}: {
- active?: boolean;
- payload?: Array<{ payload: { date: string; value: number } }>;
- hidden: boolean;
-}) {
- if (!active || !payload?.length) return null;
- const point = payload[0].payload;
- return (
-
-
{formatAxisDate(point.date)}
-
{maskAmount(formatCurrency(point.value), hidden)}
-
- );
-}
-
-/** The Crypto tab's headline chart — same recipe as NetWorthChart, plus an
- * eye toggle. `hidden` is owned by CryptoPage and passed to every other
- * money-displaying section of the tab too — this is just where the toggle
- * button itself lives. */
-export function CryptoHistoryChart({
- history,
- isLoading,
- range,
- onRangeChange,
- hidden,
- onToggleHidden,
-}: CryptoHistoryChartProps) {
- const { t } = useTranslation();
- 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 ? "…" : maskAmount(formatCurrency(history?.current ?? 0), hidden)}
-
- {hidden ? : }
-
-
- {history && (
-
- {isPositive ? : }
- {maskAmount(formatSignedCurrency(history.change_amount), hidden)}
- {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/CryptoHistoryChartBody.tsx b/frontend/src/components/crypto/CryptoHistoryChartBody.tsx
new file mode 100644
index 0000000..ab8f6bd
--- /dev/null
+++ b/frontend/src/components/crypto/CryptoHistoryChartBody.tsx
@@ -0,0 +1,83 @@
+import { Area, AreaChart, ResponsiveContainer, Tooltip, YAxis } from "recharts";
+import { formatCurrency, getIntlLocale, maskAmount } from "@/lib/format";
+import { useTranslation } from "@/lib/i18n";
+import type { CryptoHistoryResponse } from "@/types";
+
+interface CryptoHistoryChartBodyProps {
+ history: CryptoHistoryResponse | undefined;
+ isLoading: boolean;
+ hidden: boolean;
+}
+
+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,
+ hidden,
+}: {
+ active?: boolean;
+ payload?: Array<{ payload: { date: string; value: number } }>;
+ hidden: boolean;
+}) {
+ if (!active || !payload?.length) return null;
+ const point = payload[0].payload;
+ return (
+
+
{formatAxisDate(point.date)}
+
{maskAmount(formatCurrency(point.value), hidden)}
+
+ );
+}
+
+/** The "History" tab's content inside CryptoOverviewCard — same recipe as
+ * NetWorthChart's area chart, just without its own Card/header (the total
+ * number, eye toggle, and range pills live in the shared header instead). */
+export function CryptoHistoryChartBody({ history, isLoading, hidden }: CryptoHistoryChartBodyProps) {
+ const { t } = useTranslation();
+ 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 (
+ <>
+
+ {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/CryptoOverviewCard.tsx b/frontend/src/components/crypto/CryptoOverviewCard.tsx
new file mode 100644
index 0000000..68a26e7
--- /dev/null
+++ b/frontend/src/components/crypto/CryptoOverviewCard.tsx
@@ -0,0 +1,96 @@
+import { useState } from "react";
+import { ArrowDown, ArrowUp, Eye, EyeOff } from "lucide-react";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
+import { CryptoAllocationBody } from "@/components/crypto/CryptoAllocationBody";
+import { CryptoHistoryChartBody } from "@/components/crypto/CryptoHistoryChartBody";
+import { CryptoRangeSelector } from "@/components/crypto/CryptoRangeSelector";
+import { PillSelector } from "@/components/layout/PillSelector";
+import { formatCurrency, formatSignedCurrency, maskAmount } from "@/lib/format";
+import { useTranslation } from "@/lib/i18n";
+import type { CryptoHistoryResponse, CryptoHolding, CryptoRange } from "@/types";
+
+type OverviewTab = "history" | "allocation";
+
+interface CryptoOverviewCardProps {
+ totalValue: number;
+ history: CryptoHistoryResponse | undefined;
+ isHistoryLoading: boolean;
+ range: CryptoRange;
+ onRangeChange: (range: CryptoRange) => void;
+ holdings: CryptoHolding[];
+ isHoldingsLoading: boolean;
+ hidden: boolean;
+ onToggleHidden: () => void;
+}
+
+/** One card, two tabs — "History" (the value-over-time chart) and
+ * "Allocation" (a donut broken down by coin), same CoinMarketCap layout the
+ * user asked for: switching views instead of stacking two separate cards,
+ * so the tab keeps its footprint on the page instead of doubling it. The
+ * total/eye toggle/change line stay in the shared header regardless of
+ * which tab is active — only the content panel below switches. */
+export function CryptoOverviewCard({
+ totalValue,
+ history,
+ isHistoryLoading,
+ range,
+ onRangeChange,
+ holdings,
+ isHoldingsLoading,
+ hidden,
+ onToggleHidden,
+}: CryptoOverviewCardProps) {
+ const { t } = useTranslation();
+ const [tab, setTab] = useState("history");
+
+ const isPositive = history ? Number(history.change_amount) >= 0 : true;
+ const trendColor = isPositive ? "var(--success)" : "var(--danger)";
+
+ return (
+
+
+
+
{t("nav.crypto")}
+
+ {maskAmount(formatCurrency(totalValue), hidden)}
+
+ {hidden ? : }
+
+
+ {history && (
+
+ {isPositive ? : }
+ {maskAmount(formatSignedCurrency(history.change_amount), hidden)}
+ {history.change_percent !== null && ` (${isPositive ? "+" : ""}${history.change_percent.toFixed(1)}%)`}
+ {t("netWorth.periodSuffix")}
+
+ )}
+
+
+
+
+ {tab === "history" &&
}
+
+
+
+ {tab === "history" ? (
+
+ ) : (
+
+ )}
+
+
+ );
+}
diff --git a/frontend/src/lib/i18n.ts b/frontend/src/lib/i18n.ts
index 0c4ec9a..68db599 100644
--- a/frontend/src/lib/i18n.ts
+++ b/frontend/src/lib/i18n.ts
@@ -150,6 +150,8 @@ const ru = {
"crypto.chart.show": "Показать сумму",
"crypto.chart.hide": "Скрыть сумму",
"crypto.allocation.title": "Распределение по монетам",
+ "crypto.overview.history": "История",
+ "crypto.overview.allocation": "Аллокация",
"crypto.allocation.other": "Другое",
"crypto.stats.allTimeProfit": "Прибыль за всё время",
"crypto.stats.costBasis": "Вложено всего",
@@ -609,6 +611,8 @@ const en: Record = {
"crypto.chart.show": "Show amount",
"crypto.chart.hide": "Hide amount",
"crypto.allocation.title": "Allocation by coin",
+ "crypto.overview.history": "History",
+ "crypto.overview.allocation": "Allocation",
"crypto.allocation.other": "Other",
"crypto.stats.allTimeProfit": "All-time profit",
"crypto.stats.costBasis": "Total invested",
diff --git a/frontend/src/pages/CryptoPage.tsx b/frontend/src/pages/CryptoPage.tsx
index 98bce12..403fa5f 100644
--- a/frontend/src/pages/CryptoPage.tsx
+++ b/frontend/src/pages/CryptoPage.tsx
@@ -3,9 +3,8 @@ 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 { CryptoOverviewCard } from "@/components/crypto/CryptoOverviewCard";
import { CryptoStatsRow } from "@/components/crypto/CryptoStatsRow";
import { CryptoTransactionHistoryModal } from "@/components/crypto/CryptoTransactionHistoryModal";
import { CryptoTransactionModal } from "@/components/crypto/CryptoTransactionModal";
@@ -36,6 +35,7 @@ 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 }))) {
@@ -45,19 +45,20 @@ export function CryptoPage() {
return (
-
setHidden((prev) => !prev)}
/>
-
-