From 7a23ed1ff89b7814a18f515b0910e2470605f6e3 Mon Sep 17 00:00:00 2001 From: ZProger Date: Sun, 30 Aug 2026 04:47:13 +0300 Subject: [PATCH] feat: donut allocation chart + History/Allocation tabs on Crypto tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merges the separate history chart and allocation cards into one CryptoOverviewCard with History/Allocation tab pills, matching CoinMarketCap's layout — switching views instead of stacking two cards keeps the tab's footprint on the page instead of doubling it. The total value, eye toggle, and change-over-range line stay in the shared header regardless of which tab is active. Allocation is now a donut (Recharts Pie, innerRadius 68%, paddingAngle gap between segments per the dataviz skill's mark spec) instead of a plain bar. Unlike CoinMarketCap's own version, the list next to it shows both percent and the $ amount per coin, per explicit request — CMC's reference only shows percent. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01855PiW5Wd8n4aRW4n9kByY --- .../crypto/CryptoAllocationBody.tsx | 148 ++++++++++++++++++ .../crypto/CryptoAllocationCard.tsx | 120 -------------- .../components/crypto/CryptoHistoryChart.tsx | 124 --------------- .../crypto/CryptoHistoryChartBody.tsx | 83 ++++++++++ .../components/crypto/CryptoOverviewCard.tsx | 96 ++++++++++++ frontend/src/lib/i18n.ts | 4 + frontend/src/pages/CryptoPage.tsx | 13 +- 7 files changed, 338 insertions(+), 250 deletions(-) create mode 100644 frontend/src/components/crypto/CryptoAllocationBody.tsx delete mode 100644 frontend/src/components/crypto/CryptoAllocationCard.tsx delete mode 100644 frontend/src/components/crypto/CryptoHistoryChart.tsx create mode 100644 frontend/src/components/crypto/CryptoHistoryChartBody.tsx create mode 100644 frontend/src/components/crypto/CryptoOverviewCard.tsx 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)} - -

- {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")} -
- ) : ( - - - - - - - - - - - - )} -
- {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")} +
+ ) : ( + + + + + + + + + + + + )} +
+ {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)} + +

+ {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 (
-